Compare commits

...
431 Commits
Author SHA1 Message Date
zeekayandClaude Opus 4.8 6f82f001c4 feat(analytics): hosted /v1/event.js injects the site's browser pixels
CI/CD / image (push) Blocked by required conditions
CI/CD / rollout (push) Blocked by required conditions
CI/CD / reach (push) Blocked by required conditions
CI/CD / fanout (push) Blocked by required conditions
CI/CD / receipt (push) Blocked by required conditions
Hanzo CI/CD / cicd (push) In progress
CI/CD / containment (push) Successful in 3m58s
CI/CD / gate (push) In progress
The hosted tag is now the per-site twin of track.js: on load it fetches
/v1/tags (dual-resolved per site by its key or host) and injects the
connected browser pixels (GA/Meta/TikTok/X) first-party, firing each event
to the native pixel — translated through the same taxonomy the server CAPI
uses and stamped with a shared event_id carried on the /v1/event row — so
the browser pixel and the server-side CAPI deduplicate. 11/11 behavioral
checks. The one-liner now does the whole loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 05:48:26 -07:00
zeekayandhanzo-dev 35543313ce commerce v1.50.28 — the allotment could be granted twice
CI/CD / containment (push) Successful in 4m38s
Hanzo CI/CD / cicd (push) Failing after 6m56s
CI/CD / gate (push) Failing after 6m56s
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.50.28 is the one that matters: billing/allotment.Grant read "already granted
for this period?" and then wrote a transaction with a GENERATED id, so two
concurrent schedulers both read "no" and both created a row. The user received
the month's included credit TWICE, as real spendable balance. Three comments
asserted this was impossible, all resting on datastore.RunInTransaction — whose
body is "// For now, just run the function directly". There was no transaction.

Fixed by deriving the storage id from (user, period, mode), so concurrent writers
land on one row and the balance, a SUM over rows, moves once. Also fixed a second
bug the test found: the precheck filtered (DestinationId, Tags) but not Test,
while GrantedCents does — so a live grant silently suppressed the test-mode grant
for the same user and month.

Also riding along: v1.50.24 TON + XRPL deposit readers (XRPL matching a POOLED
address plus a per-intent destination tag, since XRPL's non-refundable reserve
makes one address per payer wasteful), and v1.50.27 XRPL tag minting, whose
uniqueness comes from a single INSERT … ON CONFLICT DO UPDATE … RETURNING —
measured against real Postgres to beat an explicit READ COMMITTED transaction.

Crypto remains gated: cryptoDepositsCanBeCredited is still a compile-time false
and no CRYPTO_DEPOSIT_* is configured, so the readers are inert.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 05:37:47 -07:00
zeekay a9575db39f cloud.Door: an internal caller reaches the agent door directly, not via the edge
Hanzo CI/CD / cicd (push) Successful in 17s
CI/CD / gate (push) Successful in 17s
CI/CD / containment (push) Successful in 1m35s
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 tool the Slack agent called answered "X-Org-Id required" or "sign in to
use", while tools/list and describe worked — and the plugin's own request log
said org=hanzo user=hanzo/z@hanzo.ai for the very request that refused. That is
what made it look impossible.

It is not. The run holds a principal it resolved SERVER-SIDE — the org a
workspace is installed in, the person the run is attributed to — and no bearer
to replay for it. The door forwarded that into the subsystem's EDGE door, where
SanitizeIdentity deletes every authority header and re-mints one only from a
verified credential. Right for a stranger, wrong for a sibling: the statement
was gone by the time the op read it, so principal.OrgOf refused the org that had
ridden along. The log was not disagreeing with the op — zip reports the caller
as the request BEGINS, so the log held an EARLIER fact than the handler did.

cloud.Door publishes the door on the internal Plane instead: the caller is read
off the request actually being served, and decided by the SAME predicate a
routed request gets — an org with no validated user is still refused, here as
there. What makes it safe is the ADDRESS, not an extra check: Plane listens on
the app's own socket and is never mounted on the edge router, so there is no
path to it from the internet, exactly as there is none to a route nobody
registered.

plugin/o11y/main.go needed the call spelled out because it is hand-written —
cloud.Listen adds it for every generated app main, and without it this app's
tools were the only ones in the fleet an inside caller could not reach.

door_test.go drives it from both directions against one subsystem.

Verified before landing: builds, and fleet + cmd/cloud + apps/o11y + the root
package all pass. The published surface does not move — openapi.yaml and
public.yaml re-weave to the same 1782/18.
2026-08-06 23:58:24 -07:00
zeekay 49e3953982 describe: regenerate the document the per-site tag work moved
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
Three commits added the per-site browser-tag surface — a `tags` map on the
project update and read, /v1/tags re-scoped from the ORG's pixels to the SITE's,
iam's enableCodeSignin — and none of them ran `make describe`. So the routes
moved and openapi.yaml did not, and app-contract has been red on main ever
since.

That gate is not decoration: openapi.yaml is the file the SDK repos pull, so a
route missing from it is a route no generated client can reach, and hanzoai/cli
derives its whole command tree from this document. `/v1/tags` was live, served
and reachable by nothing.

It is also what has been holding the release train. Every car declares needs: on
the gate, so nothing has shipped since — including the metrics tenant fix, which
is why production is still serving a build from before it.

No behaviour here. `make -f mk/fleet.mk check` regenerates every per-app subset
and weaves the golden; this is that output, committed.
2026-08-06 23:53:47 -07:00
zeekay b55f1048fd o11y: the gate stops asking for an outage it already fixed
CI/CD / containment (push) Successful in 2m43s
Hanzo CI/CD / cicd (push) Failing after 10m18s
CI/CD / gate (push) Failing after 10m18s
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 tests pinned "no cloud.Bridge in front => 403", and that stopped being true
when principal.ValidatedFrom and OrgFrom gained their fallback to zip's caller.
They have been red on main ever since, and because every car of the release
train declares needs: on the test gate, nothing has shipped: the deployed build
is sha-35209c422c0b while main carries the metrics tenant fix three commits
later. A stale test is holding a security fix out of production.

What the 403 was pinning was an OUTAGE, not a boundary. A typed op takes a
context and its decoded input, so it sees its caller only if something parked
one — and the Bridge is not in front in two real places: o11y as its own binary
(a context value does not cross the host<->plugin socket) and MCP's tools/call,
which invokes an op directly so no route middleware runs at all. Both answered
403 to callers the host had already validated. The fallback fixed that.

So the tests now assert what is true and keep the teeth where the teeth belong:
a request carrying NO principal is still refused, on both shapes, and that
assertion is stated as the one that must never flip.

The fallback widens the door and not the trust because cloud installs
SanitizeIdentity app-wide and it DELETES every client-sent authority header
before a handler reads one. That reasoning covers cloud's own handlers and
stops there — it does NOT extend to a subsystem cloud proxies to, which is
exactly where the real defect was, and the comment says so rather than leaving
the next reader to over-generalise it.
2026-08-06 23:36:45 -07:00
zeekay 889ebc350b o11y: hand metrics the org rule instead of letting it invent one
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
hanzoai/metrics resolved its own tenant from X-Org-Id with a brand fallback and
no authentication, so /v1/{metrics,logs,traces}/* answered whatever org the
caller named. Verified live against api.hanzo.ai with no credential: a write
under an invented org returned {"written":1} and the matching query read it
back, while the same query without the header saw nothing — the header WAS the
boundary.

metrics cannot import cloud, which is why it grew its own resolver in the first
place. So the dependency inverts: metrics declares Deps.Org and cloud supplies
principal.Org, the one predicate that already requires a validated principal
beside the org claim. One rule, applied by the module that cannot see it.

Pin moves v1.110.2 -> v1.110.6, which also picks up the three intervening
releases (zip v1.18.1, go 1.26.5, CI removal) — deps and build only, no route
behaviour. The published contract is unchanged at 1782/18 paths: this is a
change in who may call, not in what is served.
2026-08-06 23:33:16 -07:00
zeekay 6561437af3 slack replies are translated into the language slack reads
Slack does not read Markdown, it reads mrkdwn, and the two share enough
punctuation to look identical until they render: **bold** keeps its
asterisks, ### prints a hash, [text](url) shows its brackets. Every model
writes Markdown — it is what they are trained on — so the translation
belongs at the edge rather than in a system-prompt rule a model drops by
the third turn.

Applied at the two seams model prose leaves through (the reply closure and
SendSlackAt), NOT inside slackChatPost: Block Kit callers here already
write mrkdwn by hand, and `*bold*` is mrkdwn bold and Markdown italic, so
a blanket pass at the bottom would rewrite every one into `_bold_`.

Code is exempt. Inside a fence or a span, `**x**` is text the reader is
meant to see, and translating a sample is worse than the bug being fixed.
2026-08-06 23:33:16 -07:00
zeekayandClaude Opus 4.8 7d09341e70 feat(projects): set a site's browser tags via the project update
CI/CD / containment (push) Successful in 3m7s
Hanzo CI/CD / cicd (push) Failing after 14m2s
CI/CD / gate (push) Failing after 14m7s
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
Extends PATCH /v1/projects/:slug to accept `tags` (platform → non-secret
pixel id), sanitized + stored on Project.Tags, and returns them on the
project view. This is how a site's GA/Meta/TikTok/X pixel ids are set —
consumed by /v1/tags (client injection) and the server CAPI. The secret
(CAPI token) stays sealed via POST /v1/destinations; this is ids only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 23:20:38 -07:00
zeekayandClaude Opus 4.8 17c31fd8ca feat(tags): per-site tag config — /v1/tags dual-resolves the project
A project IS a site, so browser tags belong on it: adds Project.Tags
(platform → non-secret pixel id) to the projects store (additive column +
JSON codec). New projects.TagsFor(key, host) dual-resolves the site — by
the publishable key when it's a per-site project key (ResolveKey), else by
the request host (ResolveHost) for an org-level key. /v1/tags now reads
that instead of per-org config, so hanzo.ai and hanzo.chat inject different
pixels under one org. Client half; the fan-out goes per-site next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 23:20:38 -07:00
zeekayandClaude Opus 4.8 7cf9eb986a fix(destinations): dedup on the browser tag's event_id when present
Translate now keys the Conversion EventID on properties.event_id (which
track.js stamps into the event and fires on the browser pixel) when set,
falling back to messageId. This is what makes the browser pixel and the
server-side CAPI deduplicate instead of double-counting a conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 23:20:37 -07:00
hanzo-dev 1ebda0ab16 merge: forge main into the tracker design-system re-embed
CI/CD / containment (push) Successful in 5m7s
Hanzo CI/CD / cicd (push) Failing after 38m32s
CI/CD / gate (push) Failing after 53m11s
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
2026-08-06 22:11:27 -07:00
hanzo-dev 7cca2637ec tracker, meet: re-embed the SPAs on the estate's design system
Built from hanzoai/admin@a4495ce, where the tracker stopped carrying 583
lines of git.hanzo.ai's own stylesheet and moved onto @hanzo/gui primitives
and @hanzogui/admin tokens — the substrate the console already paints with.
The chrome was always right; everything under it belonged to another product.

Also in these bundles: the brand registry now sends Zoo to zoolabs.id, and
useFetch bounds a read at 45s — longer than this binary's own 30s budget, on
purpose, so a client abort can never kill the request that warms the cache.

apps/tracker/ui and apps/meet/ui embed_test.go pin the base; the drift and
dist-integrity gates pass on the new bytes.
2026-08-06 22:11:13 -07:00
hanzo-dev c663f04e6e forge: size the inventory window by what a refresh costs, not by the clock
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
Re-reading the repository list for the 250-repo org is fifty requests to
the endpoint measured degrading from 13s to 22s under load. A one-minute
window put that on the forge every minute, per actor, to learn that nothing
had changed — a cache whose refresh is heavier than the thing it spares.

Nothing a board renders goes stale for it: issues are not cached, so cards,
columns and assignees stay the forge's current answer, and a new repository
that has work on it appears immediately because the list is built from
issues. Only an EMPTY repository created in the last few minutes waits.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:58:35 -07:00
hanzo-dev bacbe30e52 tracker: read the org that holds the work, and never wait forever for 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
The board sat on loading skeletons with an empty browser console. Two
independent defects produced that one symptom, and each hid the other.

THE ORG WAS WRONG. The IAM tenant is `hanzo`; its work lives on the forge
under `hanzoai`, the name the estate uses wherever a namespace is written
down. A near-empty `hanzo` org also exists, so asking by name answered 200
with an empty list — a wrong answer wearing a healthy one's clothes.
Measured on git.hanzo.ai: `hanzo` is 64 repos and 0 issues, `hanzoai` is
250 repos and the actual work; hanzo/cloud is 404 where hanzoai/cloud is
200. forgeOwners maps the two, as a declared value rather than a branch
inside the resolver, applied to the VALIDATED principal's org and never to
anything a caller sent. It does not touch the Sudo actor, so a wrong entry
can show an empty board but cannot widen what anyone may read.

NOTHING BOUNDED THE READ. The forge client's timeout bounds one request
while a board read makes many, so a slow forge was an unbounded wait: no
response, no error, and a page that looks like it is still working. Every
forge-backed op now runs under one budget, applied in one place, and a
timeout answers 504 with its reason — never an empty list, which renders as
"you have no work" and is indistinguishable from a correct answer.

The board list no longer waits on the repository inventory. That endpoint
charges per repository RETURNED — ~21s for one of five pages of the 250-repo
org — while issues-search answers the whole org in ~1.5s and carries the
repository, labels, milestone and assignees inline. The list is built from
issues; the inventory is read only when already warm, and completes the list
with the boards that have no work on them yet. A board addressed by name is
one direct repository read rather than a scan of the org.

Behind that, the two list endpoints are read through a per-(actor, org)
cache. The key carries the ACTOR because the forge answers as the sudoed
user: keyed by org alone it would serve one user another's visible set,
which is a disclosure dressed as a cache hit. Stale entries are served while
one refresh runs behind them, so only a genuinely cold cache blocks, and a
credential rotation no longer discards a warm list. Where the inventory is
still needed it pages CONCURRENTLY: the forge's per-repo cost serialises
inside a request but parallelises across requests, taking the same 64 repos
from 20.7s to 2.7s.

Tests: 74 across the two packages, forge under -race. The regression tests
are a wedged forge answering 504 rather than hanging, the board asking
`hanzoai` when the principal says `hanzo`, the list not blocking on a slow
inventory, and the cache refusing to serve one actor's repositories to
another. TestLiveOwner checks each mapping target actually holds work —
no stub can catch a namesake, because a stub answers what it was told.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:56:19 -07:00
hanzo-dev a5ec8df325 merge: inc/main into the brand-registry fix
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
2026-08-06 21:42:13 -07:00
hanzo-dev 99216affcc brand: one registry, and a gate that reads what we actually ship
brand.go said zoo's issuer is https://zoolabs.id. Four other places said
https://zoo.id — a host with NO DNS RECORD, unregistered, that the live Zoo
IAM has never stamped:

  - the tracker bundle embedded here and live at tracker.hanzo.ai
  - the meet bundle, same
  - apps/skills/catalog/zoo/index.json, which is the document an agent READS
    to learn where to get a token for api.zoo.ngo
  - that catalogue's SKILL.md, twice, in prose

Go was right and alone. Every Go test passed the entire time, because the
copies that were wrong are not Go: they are compiled into a bundle and
generated into a catalogue, and nothing compared them to the registry.

The severity is not the outage. A shipped artifact naming an identity host
the validator never vouched for is the precondition for cross-brand token
confusion — the set of origins a surface may send a credential to is meant
to be CLOSED by the registry, and a name anyone could register is a hole in
that closure.

The generator is fixed at the root (hanzoai/openapi skills.py): the issuer
was being read as "the brand's .id host", a rule that looks like it holds
and does not. It is a value per brand now, and the catalogue digests are
recomputed from the served bytes rather than typed.

TestShippedArtifactsNameOnlyRegisteredIssuers is the gate. It reads the
ARTIFACTS — the embedded dist bundles and the catalogue, the exact bytes
go:embed puts in the binary — and fails when any of them names an identity
host brand.Issuers() does not declare. A pin against a hand-written mirror
of the Go table would have passed all along; that is precisely what the TS
side had. Verified by reverting the fix: it fails on both bundles.

TestWhiteLabelByHost now DERIVES every expectation from brand.*, so the
disagreement it was meant to catch is unrepresentable rather than untested.
Both halves of its old table had failed silently: the `issuer` field was
declared and never asserted, and the cross-brand marker "zoo.id" was a
string the registry emits nowhere, so it could not fire. It asserts the
served issuer now, and mutation-testing confirms it fails without the fix.

dist_integrity_test.go takes the two facts about committed build artifacts
that no per-app embed_test.go owns: no git conflict marker (one reached
production, past every gate, because nothing in CI renders an SPA), and
every hashed chunk index.html names is actually present.
2026-08-06 21:41:29 -07:00
hanzo-dev 7e7d41b029 deliver IAM verification codes in-process, for every tenant
Grafted, IAM and notify share a process, so the org travels as an ARGUMENT --
which is the whole reason this exists beside the standalone iam's HTTP client.
notify's HTTP surface derives the sending tenant from the validated principal
and never from a header, so a service credential can only send as its own org;
that is wrong for a binary answering for every white-label identity host, and an
unpinned client would push lux and zoo codes through hanzo's Twilio while
reporting success. notify.Send takes the org explicitly, precisely for callers
carrying no request principal, so in-process delivery serves every tenant with
no credential to mint, hold or rotate and no cross-tenant hole in the public
surface.

Binding it lights four things at once, because email sign-in, SMS sign-in and
the email and SMS second factors all read one predicate that reports on the
bound sender. Until this line runs IAM correctly hides all four rather than
advertising a method it cannot finish.

Requires iam v1.34.29, which exports the seam: it lives in internal/oidc and was
therefore unreachable from a host that grafts IAM.
2026-08-06 21:38:39 -07:00
zeekay 7cd7306ea5 openapi: re-weave the golden — websearch's operationId had drifted
CI/CD / containment (push) Successful in 3m8s
Hanzo CI/CD / cicd (push) Failing after 50m40s
CI/CD / gate (push) Failing after 51m45s
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
TestFleetIsTheWeaveOfItsApps was already red on forge/main before this merge,
and the drift was one line: /v1/websearch declares search_web in source while
the woven document still said post_v1_websearch. That identifier is what the
SDK cuts a method name from, so the golden was publishing a name the fleet had
stopped serving under.

`make describe` regenerates all 121 app subsets to no change — the subsets were
current the whole time. Only the weave lagged, which is exactly the gap the
gate exists to catch.
2026-08-06 21:36:02 -07:00
zeekay 0a3ebc2167 Merge inc2/main: the sqlite tags stop being load-bearing
forge and inc2 had diverged 3/1 with no overlapping files — forge carried the
cicd credential message and the meet prose, inc2 the org fence and the build
that no longer needs the tags to be stated.
2026-08-06 21:28:34 -07:00
zooqueenandhanzo-dev 91305ee65b plain go build works here too — and the sqlite tags stop being load-bearing
cloud pinned base v1.5.15, which had already deleted the compile-time guard, next
to sqlite v0.5.1, which still had the tag-gated cgo backend. That pairing is the
worst of the three: nothing refuses to build, and a cgo binary answers "no such
function: acos" at runtime instead. It has been safe only because the Dockerfile
passes -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" by hand, so the tags
were load-bearing rather than incidental.

base v1.5.17 + sqlite v0.5.2 + csqlite v0.1.2 supply the capability itself: both
backends compile ENABLE_MATH_FUNCTIONS and ENABLE_FTS5 unconditionally, and
sqlite's TestBackendParity asserts they answer the same SQL under CGO_ENABLED=1
and 0. `go build ./...` is now clean here under BOTH, with no tags passed.

The ha rename came with base v1.5.17 (it requires ha v0.2.0, which renamed Fencer
-> Leases and StaticFencer -> StaticLeases). Followed upstream rather than keeping
a local synonym for one thing; the whole surface was two interface assertions and
the re-export block, and internal/org's tests pass under both modes. CASFencer
keeps its name — it is our CAS-backed implementation, not the interface.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:25:43 -07:00
antje 8b704def20 cicd: name the credential when the forge refuses a claim
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 CAS moved to the forge, which answers 409 where github answered 422, and
the catch-all error still said "expected 201 or 422" — it would have printed
the wrong contract for the one status it exists to explain.

It also now separates the two ways a claim is refused. A 401/403 is not a
version collision, it is FORGE_TOKEN lacking write:repository, and this is
the first thing in this workflow to WRITE with that token — every prior use
is a go module fetch or an ls-remote. That makes it the most likely first
failure and the least self-evident, so it says which secret and which scope
rather than leaving a bare status code.
2026-08-06 21:10:35 -07:00
zeekay 32f2150208 merge: forge 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
2026-08-06 21:08:10 -07:00
zeekay 35209c422c merge: github main (commerce v1.50.25) into main 2026-08-06 21:07:11 -07:00
zooqueenandhanzo-dev f494e9e5f8 commerce v1.50.25 — the checkout header gets its mark and its accent
Two things pay.hanzo.ai has been waiting on ride in one bump, and neither is a
pay change: GET /v1/commerce/tenant answers logoUrl and primaryColor from
commerce's brand table, embedded here, so until this moves the checkout draws a
wordmark and a white accent no matter what the SPA does.

- logoUrl: the brand struct always had the field and no brand ever set it, so it
  answered "" forever. Only hanzo carries one -- every other brand's CDN 404s or
  522s, and lux.network's 200 is 112KB of text/html rather than an image.
- primaryColor #808000: olive has been in brandHanzo for a while and prod still
  serves #ffffff, which is the whole reason it never appeared.

Verified against the resolved module rather than the source: the logoURL and the
olive are both on brandHanzo in GOMODCACHE at v1.50.25, and cloud builds clean
against it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:06:20 -07:00
antje 1d692c7467 meet: lift the prose for the two routes that had none
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/meet/zipdoc_gen.go was stale: /v1/meet/session and /v1/meet/getToken
were registered without their lift being regenerated, so zipdoc-check went
red and took the whole gate with it — and the gate is what the image job
needs, so no release could publish while this sat there.

Generated, not written. Note that both routes lift the doc of cloud.Handle
rather than of mint/session, which do carry good prose of their own: zipdoc
resolves the outermost call in `cloud.Handle(s, mint)` and stops there. That
is pre-existing and repo-wide — 24 of 108 zipdoc_gen.go files describe the
adapter — so it is not this commit's to fix, but those two descriptions are
wrong in openapi until the generator learns to unwrap.
2026-08-06 21:05:25 -07:00
zeekay 73c8225230 merge: typed-op readers resolve identity on the MCP 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
2026-08-06 21:03:34 -07:00
zeekay 82aaf8230f the typed-op readers resolve identity on the door that has no route
MCP's tools/call invokes an op directly, so no route middleware runs and
nothing parked principal's org/validated slots. Every op gating on
ValidatedFrom was therefore unreachable as a tool, and every org-scoped op
resolved no tenant: websearch refused "sign in to search the web" for a
request that reached the plugin carrying org=hanzo and a validated user.

The gate had already been moved out of middleware and into the handler so
that every door would reach it — but the fact it read was still parked by
the one door tools/call does not pass through, so it was unsatisfiable
exactly where the move was meant to make it work.

OrgFrom and ValidatedFrom now fall back to zip's caller, which carries
across every door since zip's own caller_mcp_test. That is one more READER
of OrgOf, not a second rule: an org riding along without a validated user
is still refused, so the fallback widens the door and not the trust.

Also names the op search_web. `create_websearch` is what a POST to
/v1/websearch derives, it reads as "make a websearch", and a model picking
from an `op` enum reads the name before any description.
2026-08-06 21:03:02 -07:00
antje b853fa16ab cicd: the release lane runs entirely on the forge, and the queue stops eating itself
CI/CD / containment (push) Successful in 3m35s
Hanzo CI/CD / cicd (push) Failing after 34m38s
CI/CD / gate (push) Failing after 34m54s
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 failures, one cause: this workflow asked the forge for values it does not
set, and read the answers as meaningful.

THE CONCURRENCY GROUP. It was cicd-${{ github.ref }}, and act_runner leaves
the ref empty — head_branch is null and there is no ref field on an ordinary
push to main. So the group rendered as the constant `cicd-` and every run in
the repository shared one slot. Gitea coalesces a concurrency queue to depth
one, which is correct on its own terms, so each arrival cancelled whatever was
queued: pull requests ate releases and releases ate pull requests. The runs
carry started_at 1970-01-01 — never given a runner. Two earlier passes blamed
cancel-in-progress and argued about its truthiness; the flag was never what
acted. Keyed on the event instead, releases queue in one slot and each pull
request gets its own.

THE CLAIM. It was made against github, so a step had to publish the commit
there first, and that step broke the lane twice — once pushing the private
tree to a public repo, once against a private repo the credential could not
see (run 38319: "Repository not found"). The forge answers 201 on create and
409 on collision, which is the same compare-and-swap for a register that now
lives in the git the code is already in. So the publish step is gone, along
with the mirror race it existed to close.

Receipts move with it: a smoke receipt is a push to refs/smoked/<digest>,
which is the same atomic create without needing an endpoint for a ref outside
refs/tags. Release notes likewise. The only github calls left are fanout's
dispatches to downstream SDK repos, which really are there.
2026-08-06 21:02:30 -07:00
zeekay 1d84cca4e5 openapi: declare the published contract, default-deny
CI/CD / containment (push) Successful in 3m7s
Hanzo CI/CD / cicd (push) Failing after 34m52s
CI/CD / gate (push) Failing after 35m0s
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 product reached the public SDK by being served. That is the wrong
default: the surface a customer can call was decided by whoever mounted a
route, and 2325 operations — admin, billing, commerce, o11y, treasury —
were one generator run away from a published client.

openapi.Public(path, method) is the third declaration seam, beside
Describe and Compat. It renders x-public: true, and the weave projects the
SAME composition twice: openapi.yaml is everything the fleet serves and is
what our own clients are cut from; public.yaml beside it is what declared
itself part of the contract. An operation that says nothing is internal, so
nothing reaches a customer by anyone forgetting.

The first declaration is inference and nothing else: 18 operations across
chat, completions, messages, embeddings, rerank, models, and the image,
video and audio doors. Each modality has its OWN door — publishing text
alone would ship a catalog naming zen-image and zen-video that no client
could call.

Two files, one command, one composition. `make check` now covers
public.yaml, so the two documents cannot come to describe two commits.

Also: name /v1/tags on the destinations row. It is destinations' browser
half, a bare noun, so the /v1 remainder answered it — 404 for a path the
document publishes and track.js fetches on every page load.
2026-08-06 21:01:24 -07:00
hanzo-dev 3d0d19acea Merge remote-tracking branch 'inc/main' into sync-homes
CI/CD / containment (push) Successful in 3m7s
Hanzo CI/CD / cicd (push) Failing after 21m17s
CI/CD / gate (push) Failing after 21m42s
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-06 20:24:56 -07:00
hanzo-dev 1df3fa1cac meet: the room mint is a write, and now it is gated like one
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
POST /v1/meet/getToken MINTS A CREDENTIAL to a live conversation, and it was
reachable with nothing but an ambient session cookie. callerToken accepts five
cookie names, validatedPrincipal mints from whatever it finds, and admits
selects the IAM lane on principal.Minted BEFORE it looks at an Authorization
header — so a signed-in tab could mint with no header at all. The deployment's
CORS policy reflects *.hanzo.ai with credentials, and that wildcard covers
hosts serving arbitrary user content, so a page there could mint a join token
for a room it was never admitted to.

meet had no route group at all, which is why there was nowhere for a gate to
live. It now has one, carrying account's RequireCSRF verbatim rather than a
second implementation of the same rule.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 20:23:38 -07:00
hanzo-devandzeekay 845dae123c sandbox: use means a call is in flight, not that one finished
touched fired only AFTER an exec returned, so LastUsedAt answered "when did a
call last FINISH". A sandbox in the middle of a forty-minute run therefore read
as forty minutes idle, and any idle window shorter than the longest legitimate
call would reap the work it was waiting for. The stamp now happens on entry too,
so it answers the question the reaper is actually asking.

This is what has to be true BEFORE the idle window can be shortened. The window
stays an hour here: a single call longer than the window still goes stale
mid-flight, and closing that needs the in-flight call to keep saying so rather
than one more constant.

Why the window WANTS to be short: a disconnected client whose agent has stopped
is holding a pod, and an hour of that per abandoned run is the difference
between how many tenants a node carries. That is the real cost, and it is worth
the in-flight heartbeat rather than a guess.
2026-08-06 20:00:29 -07:00
zeekay 33c369a422 Merge remote-tracking branch 'inc2/main' into sync
CI/CD / containment (push) Successful in 3m8s
Hanzo CI/CD / cicd (push) Successful in 39m12s
CI/CD / gate (push) Successful in 40m16s
CI/CD / image (push) Failing after 1m26s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
2026-08-06 19:51:16 -07:00
zeekay 1320ba8009 destinations: claim /v1/tags, the path it serves
apps/destinations registers, serves and PUBLISHES GET /v1/tags (tags.go:60-61,
destinations.go:171) while its manifest row claimed only /v1/destinations. A
prefix owns its whole subtree, so /v1/tags fell through to ai's bare /v1
remainder and answered ai's 404. api.hanzo.ai/v1/tags returns 404 in production
right now, for an operation the document has been advertising to every SDK
generator — indistinguishable from an unmounted route from the outside, which is
how it survived.

Mounting is half an address: the app says what it will answer, the manifest row
says what the router may hand it. This is the other half.

Full suite at -p 4: ZERO failures, twice. The "baseline" of 3 was two missing
sqlite build tags; the residual 1 was apps/o11y flaking under the default
parallel sweep (passes 3/3 standalone). There is no baseline. It is green.
2026-08-06 19:51:12 -07:00
hanzo-devandzeekay 43e9c351fd sandbox: a run that is working keeps its computer
LastUsedAt already told us the difference between a run that is progressing and
one that is abandoned — it is stamped by every exec and every fs call, and the
idle rule already trusted it to KILL. Trusting it in one direction only was the
bug: activity could shorten a lease and never lengthen it, so a deep-research
run or a long build that was demonstrably alive still died at its 4-hour TTL.
That is the one case where reaping destroys the most work, and it fired on
exactly the runs worth keeping.

A busy sandbox — one within 10 minutes of expiry — now has its lease pushed to
now+1h, bounded by a 24-hour ceiling measured from CREATION. It stays a lease:
the ceiling is absolute, so no amount of activity turns a sandbox into a
permanent resident, and a job still working after a day wants a Deployment
rather than another hour.

The two rules compose rather than compete, and the idle rule still outranks:
untouched for an hour ends a sandbox whatever its lease says. Extension is only
reached by a sandbox that has been touched inside that window.

Store.Extend moves a lease FORWARD only — the `expires_at<?` guard means a stale
caller with an older timestamp cannot pull a lease in and kill a sandbox early.
That is what makes the sweep safe to run every minute, from more than one place.

apps/sandbox builds and its tests pass.
2026-08-06 19:50:08 -07:00
zeekay d9ce990109 Merge remote-tracking branch 'inc2/main' into sync
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
2026-08-06 19:44:32 -07:00
zeekay 477689538a Merge remote-tracking branch 'forge/main' into sync 2026-08-06 19:44:32 -07:00
zeekay 4bbfa596fb close the two gates the merge opened
token gate — apps/destinations/x.go reached for crypto/hmac and was not named.
It is OAuth 1.0a request signing: HMAC-SHA1 over the method+URL+params base
string under consumerSecret&accessSecret, X's contract. It signs an OUTBOUND
call under credentials the tenant configured and mints nothing this deployment
would honour, which is the same shape venue/aws_sigv4.go is allowed under. Named
with the reason, because the gate exists so this list cannot grow in silence.

openapi — the host served 1773 and the document carried 1772. Regenerated from
the code: 1782 paths. Fourth time today git has resolved that GENERATED file to
one side while the routes merged to the union; it is not a merge artifact worth
resolving by hand, only by regenerating.

Verified with the tags the shipped build carries (sqlite_fts5 +
sqlite_math_functions): root, cmd/cloud and openapi all ok.
2026-08-06 19:44:02 -07:00
hanzo-devandzeekay 28a3fbdbba coding: a run leases a real sandbox, and compute is not free
THE RUNNER. The seam had one implementation and it could not run: it POSTed to
bot's /v1/coding-tasks, which invokes the `docker` CLI, and bot-gateway has
neither that binary nor a socket. Every dispatch 503'd while the chain read as
configured.

The defect was never the ISOLATION — that path asked for --runtime=runsc or
kata-runtime, the same boundary a sandbox pod gets. It was asking through a CLI
that is not installed. apps/sandbox asks the apiserver for a Pod carrying
runtimeClassName instead: same boundary, a caller that exists. All three are
registered on the cluster (gvisor, kata-clh, kata-fc) and live pods run gvisor
today, so SANDBOX_RUNTIME_CLASS is a deployment field and never a fork in code.
That matters because a benchmark inverted the expected answer: Firecracker beat
gVisor on BOTH axes (git status 82ms vs 980ms, start 294ms vs 881ms, ~57 MiB
either way).

Every hop is plane.Ask — a typed op over ZAP/UDS. apps/bots' own doc says its
bytes "should move over ZAP" and that the swap is "a change to THIS FILE plus
each caller's one stub"; this skips the migration rather than performing it,
because a plane op IS ZAP. bots keeps its HTTP transport for bot traffic.

The five ops are the same ones the fleet door now publishes to agents
(lease_sandbox, run_in_sandbox, ...). One surface, whether a human drives a
sandbox from chat or a coding run drives one for a task.

COMPUTE IS NOT FREE, and it was. apps/sandbox had ZERO metering — no meter, no
debit, no usage — while a dev lease holds a pod for up to FOUR HOURS. An org
could lease one from Slack the moment sandboxes landed on the door.

A run now passes the prepaid gate BEFORE the lease, because a lease holds a pod
whether or not anyone can pay for it. It asks commerce (finance_authorize, "the
prepaid gate") rather than reading a balance and subtracting here: the gate
already knows about spend caps, which is why Verdict carries NoFunds and
CapSpent as separate bits — one says add money, the other says wait for the
period to roll. Arithmetic in this file would be a second pricing implementation
that can disagree with the first.

15 minutes of funded compute is the bar: long enough that no honest run is
refused for a rounding error, short enough that an unfunded account cannot open
a four-hour hole and walk away.

It fails CLOSED on an unreadable verdict, and plane.go says why in the type
itself: Reason "is an upstream failure the caller must treat as UNKNOWN and fail
closed on, and never read as permission."

The org is stated on a DETACHED context — commerce takes org from the caller's
identity, never an argument, and a Runner has no inbound request to carry one.

STILL MISSING, and it is the other half: the lease is GATED but not METERED.
Nothing debits as it runs. apps/agents already has the pattern
(cloud.NewResourceMeter, meterKind "agent") and a sandbox is a resource with a
duration. Also: maxLiveExec bounds exec at 16/org and dev/desktop have no
equivalent, so a per-query spawn path is still unbounded for one tenant.

go build ./... clean; apps/coding passes.
2026-08-06 19:43:26 -07:00
antje 9d512d5485 the header list stops calling a debited header an attribution hint
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
subScopeHeaders' doc grouped X-App-Id and X-Billing-Account-Id as "caller
attribution hints (no isolation boundary): forwarded as-is on the validated
path". That still describes X-App-Id and has not described the billing account
for some time: sanitizeSubScopes mints it from the validated billing_account
claim and deliberately does NOT restore the client copy, because Payer resolves
who pays from it -- a restored client value would be a caller naming its own
payer.

The function that enforces the rule explains it at length. The list above it said
the opposite, and that is the more dangerous of the two to read first: it
describes the header as deciding nothing, so the edit it invites is forwarding a
copy again.

Comment only, no behavior change.
2026-08-06 19:40:11 -07:00
zeekay c00e1a0fb7 Merge remote-tracking branch 'forge/main' into sync
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
2026-08-06 19:39:10 -07:00
antje 2bb253b6af cicd: the image job refuses tags and PRs, instead of guessing at its own ref
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
act_runner does not populate the ref on these runs — the API returns
head_branch null and no ref field, on ordinary pushes to main, not only on
the API reruns an earlier note blamed. The condition allowed
ref=='refs/heads/main' or '' or null, which reads as though it covers that
and does not: an absent value satisfies none of the three, so the predicate
was false and the job was SKIPPED.

Skipped rather than failed is why this survived every attempt to fix it.
Run 38052 is the shape: gate success, containment success, then image,
rollout, reach, fanout and receipt all skipped — a green run that shipped
nothing and left no error to read. Work went into the gate, which was not
what was failing.

`on:` already decides what reaches this workflow, so the job no longer
re-derives it. It names the two things it must refuse — pull requests, and
tag pushes, since the tag is minted by the claim below and building on one
would publish a second image for a commit that already has one. An absent
ref is not a tag, which is the right answer rather than an accident.
2026-08-06 19:37:48 -07:00
zeekay 627b66714a Merge remote-tracking branch 'inc2/main' into sync 2026-08-06 19:35:07 -07:00
antje b683451cfb deps: hanzoai/ai v1.832.37 — the transcription route says what it does
/v1/audio/transcriptions has carried a full doc comment since it landed, but
comments do not survive compilation: routerdoc lifts each handler's sentence
into a generated table at generate time, and the commit that added the route
never re-ran it. openapi.Table refuses a document in which any served route
says nothing about itself, so describe/ai failed, the gate failed, and the
image job — which needs the gate — never started. Every cloud release since
has been blocked on one missing row.

Nothing about the endpoint changes; it simply publishes the sentence it
already had. plugin/ai/openapi.json is the regenerated projection.
2026-08-06 19:34:24 -07:00
antje 8c56920061 sandbox: the terminal page answers both spellings of its address
A framing host that appends a query to a directory-shaped URL builds
/terminal/?ticket=…, and a host that joins path segments builds /terminal. The
page derives its SOCKET from its own path, so if only one spelling reached it the
other would load and then dial an address that is not there — a blank pane with
nothing in the log to explain it.

Both work already; this is the test that says so, because it is a property of the
router's configuration rather than of anything in this package, and a property
nothing measures is a property that changes without anyone noticing.
2026-08-06 19:34:24 -07:00
antje f6aaf91d45 sandbox: serve the terminal, so an iframe is already a terminal
A socket is not a product. Every host that wants to show a shell — the console's
dock, tabs' panes, whatever is next — would otherwise carry its own emulator, its
own resize arithmetic and its own reconnect affordance, and the day one of them
is wrong is the day one product's terminal is quietly worse than another's. So
the terminal is SERVED, and a host embeds it.

  POST /:id/terminal/ticket   the credential          {ticket, expiresIn, url}
  GET  /:id/terminal          the terminal, as a page  ?ticket=&arg=
  GET  /:id/terminal/ws       the terminal, as a socket ?ticket=&arg=

The page and the socket are two addresses because a ticket is spent ONCE: a page
that redeemed it would hold a credential that no longer opens anything. So the
page is inert and ungated, and the socket is the gate — which is also why the
page needs no auth of its own to be safe.

THE PAGE IS A CONSTANT. Nothing is substituted into it; it reads its own
location, and the socket is this document's path plus /ws carrying this
document's query string unchanged — where the ticket and the session name already
are. A document with no substitution has no injection surface, so the whole class
of "a value from the URL reached the markup" does not exist. xterm ships inline
rather than from an address, because a terminal that fetches its emulator from
somewhere else stops working when the somewhere else does.

It posts {source:"hanzo-term", ready:true} to its parent when the terminal is up,
because a framing host has no other way to tell a live terminal from a page that
failed into a sign-in, and a host that guesses gets a blank pane it cannot
explain.

?arg NAMES A SESSION, which is what lets ONE sandbox hold MANY terminals: the
shell runs under `tmux new -A -s <arg>`, attaching if it exists and creating if
it does not, so a pane reframed finds what it left instead of a fresh empty
shell. The name reaches a command line, so what may be in it is an ALLOWLIST and
not an escape — `-` alone would be read by tmux as a flag — and it is quoted as
well, because one of the two being wrong later should not be enough on its own.
tmux is asked for, never required: an image without it gets the plain shell,
since a missing multiplexer should cost a caller its session names and not its
terminal. Measured on the live image (oci.hanzo.ai/hanzoai/sandbox): tmux, bash
and the hanzo CLI are all there.

WHO MAY FRAME IT is derived from the brand registry rather than listed, because a
list rots — tabs.hanzo.ai is one host of many that will want a terminal, and the
next is a subdomain added by somebody who never finds this file. brand.Domains()
is the enumeration beside ForHost's single-host answer, so adding a brand admits
its hosts everywhere at once. It is defence in depth against a clickjack; the
ticket is still the gate.

And the terminal now keeps its own sandbox alive. The reaper ends a sandbox that
has gone an hour untouched, attention is stamped by exec and fs calls, and a
terminal makes neither — so a session somebody was sitting in read as abandoned
and the pod would have been taken out from under it at the hour mark, mid
command. The heartbeat says the session is alive to both the client and the
reaper, because that is one fact.
2026-08-06 19:34:24 -07:00
a611fd16f0 feat(destinations): native ecommerce translation for TikTok + X
TikTok now renders our line items into its native contents[] (content_id,
content_name, content_category, brand, price, quantity) + content_type,
alongside value/currency — Value-Based Optimization reads product detail,
not just a total. X now carries number_items from the line-item quantities.
Brings both to parity with Meta CAPI / GA4 items[] / Pinterest contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:34:24 -07:00
4aec686b86 feat(destinations): GET /v1/tags — public browser-tag config for the hosted tag
The client half of the one config: an org connects a destination once, and
that single row drives BOTH the server-side CAPI fan-out AND (for platforms
with a browser pixel: GA/Meta/TikTok/X) the tags the hosted tag injects, so
browser tag and server conversion share ids and never drift. Public,
pk--keyed like /v1/event(.js), non-secret ids only, fail-safe to an empty
set so a page never breaks on its tag config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:34:24 -07:00
949560e95e feat(destinations): Google Ads offline conversion import (uploadClickConversions)
Google Ads PROPER, not GA4: uploads real conversions against a configured
conversion action via the Ads API, OAuth2-refreshed, attributed by gclid
and/or enhanced-conversion hashed identifiers (email/phone). Only
conversion-class events with a Google match key are uploaded; traffic and
keyless events are skipped. Composite JSON secret carries the four OAuth2
values; developer-token + login-customer-id headers per the Ads API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:34:24 -07:00
28f8873c89 feat(destinations): wire X (Twitter) Ads — OAuth 1.0a signer
Completes the X adapter: the payload builder was already done, this adds
the OAuth 1.0a HMAC-SHA1 request signer and wires Send. The four OAuth1
parts ride as one composite JSON secret (consumer key/secret + access
token/secret), because the fan-out resolves a single primary credential
per destination — no fan-out change. Signature cross-checked in tests by
independently recomputing the HMAC over the base string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:34:24 -07:00
c0a0e9136b feat(destinations): Pinterest Conversions API adapter (v5)
Adds the server-side Pinterest conversion sink on the shared Destination
interface — closes the Pinterest gap in the fan-out. Bearer-token auth,
SHA-256 advanced matching (em/ph/external_id), the epik click id lifted
and deduped against the browser tag via the shared event_id, ecommerce
contents, value-as-string per Pinterest's v5 contract. Custom events
collapse to the "custom" enum value the API mandates.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 19:34:24 -07:00
antje 14bdf40d53 deps: hanzoai/ai v1.832.37 — the transcription route says what it does
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / gate (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/audio/transcriptions has carried a full doc comment since it landed, but
comments do not survive compilation: routerdoc lifts each handler's sentence
into a generated table at generate time, and the commit that added the route
never re-ran it. openapi.Table refuses a document in which any served route
says nothing about itself, so describe/ai failed, the gate failed, and the
image job — which needs the gate — never started. Every cloud release since
has been blocked on one missing row.

Nothing about the endpoint changes; it simply publishes the sentence it
already had. plugin/ai/openapi.json is the regenerated projection.
2026-08-06 19:34:12 -07:00
antje 3e4d98fb6f sandbox: the terminal page answers both spellings of its address
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 framing host that appends a query to a directory-shaped URL builds
/terminal/?ticket=…, and a host that joins path segments builds /terminal. The
page derives its SOCKET from its own path, so if only one spelling reached it the
other would load and then dial an address that is not there — a blank pane with
nothing in the log to explain it.

Both work already; this is the test that says so, because it is a property of the
router's configuration rather than of anything in this package, and a property
nothing measures is a property that changes without anyone noticing.
2026-08-06 19:10:53 -07:00
antje d721be09e4 sandbox: serve the terminal, so an iframe is already a terminal
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 socket is not a product. Every host that wants to show a shell — the console's
dock, tabs' panes, whatever is next — would otherwise carry its own emulator, its
own resize arithmetic and its own reconnect affordance, and the day one of them
is wrong is the day one product's terminal is quietly worse than another's. So
the terminal is SERVED, and a host embeds it.

  POST /:id/terminal/ticket   the credential          {ticket, expiresIn, url}
  GET  /:id/terminal          the terminal, as a page  ?ticket=&arg=
  GET  /:id/terminal/ws       the terminal, as a socket ?ticket=&arg=

The page and the socket are two addresses because a ticket is spent ONCE: a page
that redeemed it would hold a credential that no longer opens anything. So the
page is inert and ungated, and the socket is the gate — which is also why the
page needs no auth of its own to be safe.

THE PAGE IS A CONSTANT. Nothing is substituted into it; it reads its own
location, and the socket is this document's path plus /ws carrying this
document's query string unchanged — where the ticket and the session name already
are. A document with no substitution has no injection surface, so the whole class
of "a value from the URL reached the markup" does not exist. xterm ships inline
rather than from an address, because a terminal that fetches its emulator from
somewhere else stops working when the somewhere else does.

It posts {source:"hanzo-term", ready:true} to its parent when the terminal is up,
because a framing host has no other way to tell a live terminal from a page that
failed into a sign-in, and a host that guesses gets a blank pane it cannot
explain.

?arg NAMES A SESSION, which is what lets ONE sandbox hold MANY terminals: the
shell runs under `tmux new -A -s <arg>`, attaching if it exists and creating if
it does not, so a pane reframed finds what it left instead of a fresh empty
shell. The name reaches a command line, so what may be in it is an ALLOWLIST and
not an escape — `-` alone would be read by tmux as a flag — and it is quoted as
well, because one of the two being wrong later should not be enough on its own.
tmux is asked for, never required: an image without it gets the plain shell,
since a missing multiplexer should cost a caller its session names and not its
terminal. Measured on the live image (oci.hanzo.ai/hanzoai/sandbox): tmux, bash
and the hanzo CLI are all there.

WHO MAY FRAME IT is derived from the brand registry rather than listed, because a
list rots — tabs.hanzo.ai is one host of many that will want a terminal, and the
next is a subdomain added by somebody who never finds this file. brand.Domains()
is the enumeration beside ForHost's single-host answer, so adding a brand admits
its hosts everywhere at once. It is defence in depth against a clickjack; the
ticket is still the gate.

And the terminal now keeps its own sandbox alive. The reaper ends a sandbox that
has gone an hour untouched, attention is stamped by exec and fs calls, and a
terminal makes neither — so a session somebody was sitting in read as abandoned
and the pod would have been taken out from under it at the hour mark, mid
command. The heartbeat says the session is alive to both the client and the
reaper, because that is one fact.
2026-08-06 19:05:58 -07:00
antje 90c9f2dd15 cicd: the release lane claims its versions in the repo that holds the source
CI/CD / containment (push) Successful in 4m18s
Hanzo CI/CD / cicd (push) Failing after 51m9s
CI/CD / gate (push) Failing after 53m26s
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 image job published every release commit to github.com/hanzoai/cloud —
the PUBLIC repo, the one that carries the Apache-2.0 OSS core — as
refs/heads/forge-head, and claimed refs/tags/v<N> there too. So the register
of release names lived in a different repo from the bytes, and the full
private tree was world-readable at a ref nobody chose.

It surfaced as a stopped pipeline rather than as a leak. GitHub push
protection refused forge-head over two commits carrying deliberately
fake-shaped credentials — the fixtures that prove the secret-shape detector
works — and since those are ancestors of main they ride every push, so no
release has published an image since 21:22 UTC. The unblock URL in the log
would have re-opened the leak and taught the scanner to ignore the one shape
we most want it to catch.

Claiming against the private repo fixes both at once, and restores the
property the lane was missing: the name and the bytes it names come from the
same git. Numbering continues from v1.801.491; 487-490 stay holes, since a
version nothing published is not a release.
2026-08-06 18:55:03 -07:00
antje 2b23d289b4 cicd: the release lane claims its versions in the repo that holds the source
The image job published every release commit to github.com/hanzoai/cloud —
the PUBLIC repo, the one that carries the Apache-2.0 OSS core — as
refs/heads/forge-head, and claimed refs/tags/v<N> there too. So the register
of release names lived in a different repo from the bytes, and the full
private tree was world-readable at a ref nobody chose.

It surfaced as a stopped pipeline rather than as a leak. GitHub push
protection refused forge-head over two commits carrying deliberately
fake-shaped credentials — the fixtures that prove the secret-shape detector
works — and since those are ancestors of main they ride every push, so no
release has published an image since 21:22 UTC. The unblock URL in the log
would have re-opened the leak and taught the scanner to ignore the one shape
we most want it to catch.

Claiming against the private repo fixes both at once, and restores the
property the lane was missing: the name and the bytes it names come from the
same git. Numbering continues from v1.801.491; 487-490 stay holes, since a
version nothing published is not a release.
2026-08-06 18:54:29 -07:00
zeekayandClaude Opus 4.8 b2ce8891e0 feat(destinations): native ecommerce translation for TikTok + X
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
TikTok now renders our line items into its native contents[] (content_id,
content_name, content_category, brand, price, quantity) + content_type,
alongside value/currency — Value-Based Optimization reads product detail,
not just a total. X now carries number_items from the line-item quantities.
Brings both to parity with Meta CAPI / GA4 items[] / Pinterest contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:54:23 -07:00
zeekayandClaude Opus 4.8 33c6640bee feat(destinations): GET /v1/tags — public browser-tag config for the hosted tag
The client half of the one config: an org connects a destination once, and
that single row drives BOTH the server-side CAPI fan-out AND (for platforms
with a browser pixel: GA/Meta/TikTok/X) the tags the hosted tag injects, so
browser tag and server conversion share ids and never drift. Public,
pk--keyed like /v1/event(.js), non-secret ids only, fail-safe to an empty
set so a page never breaks on its tag config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:54:23 -07:00
zeekayandClaude Opus 4.8 9673793c2e feat(destinations): Google Ads offline conversion import (uploadClickConversions)
Google Ads PROPER, not GA4: uploads real conversions against a configured
conversion action via the Ads API, OAuth2-refreshed, attributed by gclid
and/or enhanced-conversion hashed identifiers (email/phone). Only
conversion-class events with a Google match key are uploaded; traffic and
keyless events are skipped. Composite JSON secret carries the four OAuth2
values; developer-token + login-customer-id headers per the Ads API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:54:23 -07:00
zeekayandClaude Opus 4.8 1052d3d1bf feat(destinations): wire X (Twitter) Ads — OAuth 1.0a signer
Completes the X adapter: the payload builder was already done, this adds
the OAuth 1.0a HMAC-SHA1 request signer and wires Send. The four OAuth1
parts ride as one composite JSON secret (consumer key/secret + access
token/secret), because the fan-out resolves a single primary credential
per destination — no fan-out change. Signature cross-checked in tests by
independently recomputing the HMAC over the base string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:54:23 -07:00
zeekayandhanzo-dev 0de31762e7 feat(destinations): Pinterest Conversions API adapter (v5)
Adds the server-side Pinterest conversion sink on the shared Destination
interface — closes the Pinterest gap in the fan-out. Bearer-token auth,
SHA-256 advanced matching (em/ph/external_id), the epik click id lifted
and deduped against the browser tag via the shared event_id, ecommerce
contents, value-as-string per Pinterest's v5 contract. Custom events
collapse to the "custom" enum value the API mandates.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 18:54:23 -07:00
zeekay c33c3d63e9 Merge remote-tracking branch 'forge/main' into sqlite-tags
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
2026-08-06 18:52:37 -07:00
zeekay 9095395c56 Merge remote-tracking branch 'inc2/main' into sqlite-tags 2026-08-06 18:52:06 -07:00
zeekay 0e1accb665 sqlite: test with the tags the shipped build carries
TEST_TAGS said `sqlite_fts5`. The release image builds AND tests with
`libsqlite3 sqlite_fts5 sqlite_math_functions` (Dockerfile:213), and the
Dockerfile's own comment says sqlite_math_functions "is not optional under cgo"
because hanzoai/base's search layer calls the math functions.

So a local `make test` linked a SQLite the shipped one is not, and apps/base,
apps/code and apps/commerce failed on `no such function: acos`. That was read as
"this box's SQLite is old" — including by me, all of today, in every regression
count I reported. It never was. The tag list here and the tag list in the
Dockerfile are two statements of one fact, and they disagreed.

Measured, same box, same commit:

  default cgo                                FAIL x3
  cgo + sqlite_math_functions + fts5         ok, ok, ok
  CGO_ENABLED=0 (pure Go)                    ok, ok, ok

Both engines were fine. Only the tag list was wrong.

Full suite with the corrected tags: ONE failure, apps/meet/ui — a real defect
(the auth module ships twice, so the OIDC code is redeemed once per copy and the
second redemption destroys the session the first established). Its SPA source is
not in this repo; apps/meet/ui carries dist/ only, so the vite resolve.dedupe fix
belongs where the meet SPA is built. Left red on purpose: it is true.

The stale prose above the variable is corrected too — it claimed the image builds
with two tags while the image builds with three.
2026-08-06 18:51:55 -07:00
antje ded824b648 sandbox: measure the terminal's two doors as ROUTES
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
This package has already shipped handlers nothing could reach — Create, List,
Get and Delete existed as exported functions with no routes registered, and
nothing said so. The terminal adds two more doors, so the mounting is a fact with
a test rather than a line somebody remembered to write.

Both refusals happen before a pod or a store is addressed, which is what makes
them measurable anywhere: the ticket door answers the siblings' 403 without a
principal, and the socket door answers 401 for a missing, empty or forged ticket
— identically with a principal and without one, because a principal is not what
opens a terminal. It refuses BEFORE upgrading, since a socket that opens and then
closes tells a browser nothing.
2026-08-06 18:51:31 -07:00
antje 0c495e1a0c sandbox: the terminal, proven on a real pty
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 ticket is decided in memory and the wire is decided in a function, so both
are measured without a cluster. The pseudo-terminal is not: whether the apiserver
gives us a pty on that pod, and whether a shell comes up on it, is a question
only a cluster answers — and if the answer is no, the symptom is a socket that
opens, says nothing and closes.

So this drives the REAL argv through the REAL stream, guarded by SANDBOX_LIVE
like its siblings. It types `echo $((6*7))` and waits for 42, because a pty
echoes what is typed and a marker that appears in the command text would match
the echo rather than the output. It asks the shell, from inside, how wide it is,
and gets back the 100 columns the window sent. Then it types `exit` and requires
the stream to end — a terminal that outlives its shell holds a pod open until the
reaper takes it.

Measured against do-sfo3-hanzo-k8s, hanzo-sandboxes:

  SHELL RUNS: echo $((6*7))  42
  WINDOW IS OURS: 100 columns, as sent
  TERMINAL ENDED: <nil>
  --- PASS: TestLiveTerminalIsARealShell (17.51s)
2026-08-06 18:49:39 -07:00
antje 7fbafa54aa cloud: take the browser terminal into the reconciled lineage
Hanzo CI/CD / cicd (push) Successful in 35s
CI/CD / gate (push) Successful in 35s
CI/CD / containment (push) Successful in 4m41s
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/sandbox/sandbox.go
2026-08-06 18:45:02 -07:00
antje 7ba18a2386 sandbox: the terminal — a shell a browser can actually open
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
CI/CD / gate (push) Canceled after 0s
Every route on /v1/sandboxes was a request/response, so the one thing a sandbox
could not give anybody was a prompt. exec collects what a command produced; a
person at a keyboard needs the other shape of the same channel.

Two routes and one mechanism:

  POST /v1/sandboxes/:id/terminal      a ticket
  GET  /v1/sandboxes/:id/terminal/ws   the terminal

The ticket exists because a browser's WebSocket constructor takes a URL and
nothing else. There is no header to put a bearer in, and the two usual answers
are both wrong: a bearer in the query string is a long-lived credential written
into every access log on the path, and trusting the session cookie makes the
socket a CSRF target, since no same-origin policy applies to a WebSocket. So the
credential is MINTED for the socket — crypto-random, bound to one org and one
sandbox, thirty seconds, spent on presentation and not on success. That is also
why the origin is not checked: the ticket IS the check, and a second gate beside
it would answer a question the first one already closed.

The wire, both directions, once. A text frame is stdin unless it is the one
control object {"resize":{"cols":N,"rows":M}}; a binary frame is always stdin.
Output comes back BINARY — a text frame must be valid UTF-8, a pty emits
arbitrary bytes cut at arbitrary offsets, and a frame carrying half a rune is one
the browser closes the connection over.

runtime.tty is a second shape of the exec channel, not a flag on exec: no
timeout that would insult whoever is typing, nothing buffered, one stream (a pty
merges stderr by construction and the apiserver refuses to open a second on a
TTY session). The shell is `bash -l` falling back to `sh -l`, so nothing on the
image is a precondition for getting a prompt — the hanzo CLI included.

The session ends once, from either end. The socket's read pump cancels the
stream when the far side goes, because EOF on stdin is a hint a pty may ignore;
the exec stream's end closes the socket with a reason instead of leaving it to
time out. The terminal is bounded by the sandbox's own lease, so it cannot
outlive what it is attached to even when the reaper is behind.

Tickets are held in memory and are replica-local — cloud-api runs one replica.
When that changes, this fails loudly with a 401 rather than quietly, which is
the property worth having in a gate.
2026-08-06 18:42:38 -07:00
antje 5131661f59 cloud: reconcile the two gits — the forge's commerce fix and github's fleet verbs are one lineage
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 / fanout (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
The forge and github.com/hanzo-inc had diverged: six commits here that
github lacked (through commerce v1.50.23), four there that the forge
lacked (fleet verbs, sandbox leases) — and the LIVE image was built from
one of theirs. Shipping either tip alone would have regressed the other.
One lineage, so a release can only ever mean one thing again.
2026-08-06 18:40:54 -07:00
hanzo-dev df6b78ffa1 sandbox: an agent can name the computer it leases
The fleet door served 98 tools and `sandboxes` was not among them, so @hanzo
could not lease a sandbox — while the run path underneath was built, deployed and
working. Real gVisor pods in ns hanzo-sandboxes, digest-pinned, hanzo-mcp
answering over stdio in-pod with 29 tools and a live fetch. All of it unnameable.

Cause: every /v1/sandboxes route is a RAW handler, and a raw route is invisible to
every projection zip derives from its typed registry — REST is the only one it
reaches (zip typed.go:6-9 says exactly this). The child answered tools/list
happily with an empty array, so `sandboxes` was absent from the tool list AND
absent from the outage list. Silent absence: the same shape as the stock node:22
that exited 0, and the callee that logged 200 after the caller had hung up.

The five typed ops already existed one file over. expose() registers them on
cloud.Plane() — a DIFFERENT zip.App on a DIFFERENT socket that the door never
asks (plane.go:29-33 states the rule; manifest/mcp.go:68-76 and webui/mcp.go:37-41
both record this exact failure happening to kms). Same handlers, same types, now
also on the server the door does ask. Nothing new is invented, so there is no
second implementation to drift.

Op names are verbs on the product noun, matching the surface that landed today:
lease_sandbox, run_in_sandbox, read_sandbox_file, write_sandbox_file,
end_sandbox. None trips refuse() — no bearer-secret noun, no mutating verb on an
authority object — so the gate is unchanged and the refused set stays 136.

This is the line between "@hanzo can run code" and "@hanzo can lease a computer".

apps/sandbox builds and its tests pass.
2026-08-06 18:31:00 -07:00
antje 91cd1367ae tracker: one coherent bundle, rebuilt — the merge had minted a chimera
CI/CD / containment (push) Successful in 4m4s
Hanzo CI/CD / cicd (push) Failing after 1h2m15s
CI/CD / gate (push) Failing after 1h2m15s
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 embedded dist carried an unresolved merge (conflict markers in
index.html and .sync-stamp — the new invariants gate refused the tree,
correctly) and something worse underneath: vendor-BgRt8uZI.js was a
LINE-MERGED minified chunk importing BOTH generations' ui chunks — a
hashed filename over bytes no build ever produced. No deletion could
make that set coherent, so the whole dist is replaced by a clean build
from the stamped source (hanzoai/admin apps/tracker@8da44d7: tsc clean,
vitest 7/7, vite build) — six files, every relative import resolving
inside the set, verified. A hashed asset that does not match its hash
is worse than a conflict marker; only a rebuild tells the truth.
2026-08-06 17:50:34 -07:00
zeekayandhanzo-dev 864cbca1b1 commerce v1.50.23 — Solana deposits readable, and a live-chain probe
CI/CD / containment (push) Successful in 3m19s
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
Hanzo CI/CD / cicd (push) Failing after 13s
CI/CD / gate (push) Failing after 13s
v1.50.21 adds a probe that drives the deposit watcher against a REAL chain,
which is the one thing 63 unit tests against fakes could not establish. Verified
against Base both ways: the correct USDC contract scans clean, and USDT's
contract labelled "usdc" is REFUSED quoting the on-chain symbol — which is also
the proof that decimals come off the contract rather than a constant.

v1.50.22 (another author) credits top-ups to the signed-identity payer rather
than a client-supplied subject.

v1.50.23 adds the Solana reader. SPL USDC is dollar-pegged, so it needs no price
oracle — which is why Solana came before BTC. The dedup key stopped being
EVM-shaped: chain:txHash:eventIndex, where eventIndex is the log index on EVM
and the token-BALANCE record index on Solana. It credits post − pre rather than
the instruction amount, because under a Token-2022 transfer fee those differ and
only the delta is what actually arrived.

Nothing here can take money: cryptoDepositsCanBeCredited is still false, and an
unconfigured watcher is disabled. GetCryptoOptions additionally intersects
"creditable" with "an address can be minted", so Solana stays out of the picker
until the custody fleet can mint an Ed25519 key.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 17:46:04 -07:00
zeekayandzeekay c4e79aa053 sandbox: a caller may not spend our pull secret on another org's images
CI/CD / containment (push) Successful in 3m44s
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
Hanzo CI/CD / cicd (push) Failing after 12s
CI/CD / gate (push) Failing after 12s
BYO images already worked — Spec.Image goes straight into the pod spec — and
nothing checked it. Our registry pull secret is attached to the `sandbox`
ServiceAccount and is FLEET-WIDE, so a caller naming
`oci.hanzo.ai/<someone-else>/private` had our credential fetch another tenant's
private image for them. Nothing in the request was forged; the field was simply
never read for what it implied.

The rule is the one the registry already uses. Our registry is org-namespaced as
<host>/<org>/<app>, so on OUR hosts the first path segment must BE the caller's
org — or `hanzoai`, which is the platform's own sandbox images and is the same
bytes imageFor would have chosen anyway. Anywhere else needs no credential of
ours, so it needs no permission from us: a public image is the caller's own
business, and the pod's securityContext contains it either way.

That makes BYO images a real feature rather than an accident. An org pushes to
its own namespace and names it on create.

RUNTIME IS PER SANDBOX NOW. It was one deployment-wide env var read at startup,
so the same task could not be run on two runtimes and compared without a
rollout, and a caller could not choose. Spec.RuntimeClass overrides it, against a
CLOSED set — an unknown runtimeClassName is a pod that never schedules, so a
typo would otherwise become a sandbox stuck Pending with nothing to read.

The row deliberately does NOT record which runtime a sandbox got: the pod is the
source of truth for what it is actually running, and a second copy could only go
stale.
2026-08-06 17:35:49 -07:00
hanzo-devandzeekay ab29588949 sandbox: an exec sandbox's /mnt/data is a mount, not the image's read-only dir
The code interpreter tells the model to persist artifacts in /mnt/data. In a
deployed `exec` sandbox that directory was whatever the image shipped —
root:root 0755 — and the pod runs as runAsUser 1000, so every such write failed
with EACCES. The one directory the tool exists to fill was the one directory it
could not write.

Measured in a live sandbox rather than inferred:

    $ kubectl -n hanzo-sandboxes exec m-643df8b317a6052579035359 -- sh -c '...'
    id: uid=1000(sandbox) gid=1000(sandbox) groups=1000(sandbox)
    drwxr-xr-x 1 root root 40 /mnt/data
    mkdir: Permission denied

Only `dev` sandboxes ever got a mount at their workdir, because only they have a
project PVC. `exec` fell through the `if m.Volume != ""` and got nothing. It
survived because nothing checks: a run that prints its answer looks perfectly
successful, and only a run that SAVES something notices — as a traceback the
model apologises for rather than as an error anyone sees.

emptyDir, not a PVC: a code-interpreter session is exactly as long-lived as its
pod, which is what emptyDir already means. It is also what makes the mount
writable — the kubelet chowns an emptyDir to the pod's fsGroup, and the
securityContext already sets fsGroup 1000 — so the fix is the mount, not a chown
in the image. Bounded by SANDBOX_WORKDIR_SIZE (2Gi) because a container's
ephemeral-storage limit does not cover emptyDir usage on every runtime.
2026-08-06 17:35:48 -07:00
antje e6374961a7 meet: the previous build's chunks leave with the build that replaced them
CI/CD / containment (push) Successful in 3m4s
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
Hanzo CI/CD / cicd (push) Failing after 17s
CI/CD / gate (push) Failing after 18s
The re-embed at 86de6b94e copied the new bundle beside the old one
instead of over it, so dist carried two generations of the ui chunk —
and TestTheAuthModuleIsBundledOnce counted the auth module once per
generation and went red, holding every release since. The three files
the current index.html cannot reach (the old entry, its ui chunk, its
css — they reference only each other) are removed; the live five are
untouched.
2026-08-06 17:14:47 -07:00
zeekay 9052430f9f coding: a sandbox run is a computer, so the wire says what kind
The runtime already grew `tool`, an optional repo and `desktop`; this is the
cloud half of that contract.

`tool` (dev|claude|codex|python|node) and `desktop` are carried, not
interpreted. Cloud ships a NAME; the runtime owns the name→argv table, so
adding a tool is one edit over there and none here. `desktop` selects an image
variant — a tag — and nothing on this side branches on it.

THE REPO IS OPTIONAL, AND THE CREDENTIAL LIVES INSIDE IT. Every git field is
`omitempty` and `credential` is a POINTER: a value type always marshals, so a
repo-less run would still ship a blank `credential` object and the runtime could
not tell an absent grant from an empty one. A caller that supplies a credential
with no repo is REFUSED rather than quietly trimmed — silently dropping a secret
hides the bug that minted it, and the runtime says the same thing at its own
boundary, so both ends agree.

`Secret` is now true only when the body actually carries a credential. It was
unconditional, which meant the cleartext guard — a rule written to protect a git
token — refused every research and bare-exec run that has no token to protect.

SANDBOX_URL names where a run goes. A sandbox is not the bot: coding, deep
research and bare exec all want a computer to run something in, and none of them
wants the service that runs Slack channels. BOT_GATEWAY_URL stays correct for
bot traffic and is accepted here for one release, then deleted.

The transport learns none of this. `Call.Base` is a DESTINATION, which is a
transport concern; the caller resolves the address because resolving it inside
transport.go would mean that file learning what a run is — the exact line its
header draws. requireSecure now checks the base the call will ACTUALLY use;
checking the default while the bytes went elsewhere was a guard on the wrong hop.
2026-08-06 17:01:54 -07:00
antje 8ae00313e0 deps: hanzoai/ai v1.832.36 — the usage dedup stops filtering through its own aliases
Hanzo CI/CD / cicd (push) Failing after 17s
CI/CD / gate (push) Failing after 18s
CI/CD / containment (push) Successful in 4m56s
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 console overview's 'usage totals: code 184' card was ClickHouse
refusing WHERE timestamp beside any(timestamp) AS timestamp in the
dedup subquery. v1.832.36 moves the predicate to an inner plain scan.
2026-08-06 16:49:11 -07:00
zeekay 4117fc1486 fleet: an operation is named for what it does, and says so in one line
The `projects` tool offered 37 operations spelled
`get_v1_projects_by_slug_deployments`, with no prose beside any of them.
Those are ROUTES — zip derives an operation id from method and path when
nobody declares one — so a model's first job was to reverse-engineer a URL
back into an intention, its second was to call describe to find out what the
intention took, and only its third was to act. Three round trips to use a
menu written in URL, and the assistant reads as stupid for the first two.

phrase() reads the route back as a verb on an object. The SHAPE decides it,
not a dictionary: a mutating method with a singular segment sitting after a
parameter is the author's own verb (deploy_project), a path that ends in a
parameter acts on one row (get_project, delete_project_domain), a GET of a
plural tail is a list and keeps it plural (list_project_deployments). PUT
and PATCH get different words — `set` replaces, `update` merges — because
they are different operations at one address. A DECLARED id is already a
verb on an object and is left alone, so o11y and iam are untouched.

	get_v1_projects_by_slug_deployments              list_project_deployments
	post_v1_projects_by_slug_domains_by_host_verify  verify_project_domain
	post_v1_projects_by_slug_deploy                  deploy_project
	delete_v1_projects_by_slug                       delete_project

THE REFUSED SET IS UNCHANGED, measured rather than reasoned. Naming happens
AFTER the gate and only there: refuse() judges the child's own id exactly as
it always did, fleet/surface.go is byte-identical, and the refused list is
the same element for element over the fleet's 2,440 declared operations —
211 — checked against the same rule compiled from HEAD and from this tree.
rank() reads the route for the same reason, so the order is unchanged too.

The mapping back is exact because offer() REFUSES to guess: a phrase two
operations would share, or one that is already some operation's own id, is
not used and both keep their ids. 182 of 2,229 do, nearly all of them the
fleet's own duplicates — one handler at /tasks and /v1/tasks, post_v1_agent
beside post_v1_agents in another subsystem. An operation's id also still
routes, and that is forced rather than kind: describe hands back the owning
subsystem's descriptor bytes verbatim, and those carry the owner's name.

And the enum now carries a line of the operation's own documentation beside
the product operations, which is the half that removes the describe round
trip for the calls an agent actually makes. It is rationed to productStems
because the bytes say so, over the 2,229 offered ops:

	routes, as they shipped           63,370
	verb phrases                      50,151   a phrase is SHORTER than a route
	+ a summary on the 143 ranked     64,081   +711 against the routes  <- shipped
	+ a summary on ALL of them       255,488   four times over

So the whole change costs 711 bytes: the naming pays for the prose. The
console tail stays bare and named well enough to recognise, with describe
one call away, because the point of grouping was 977 KB down to 71 and four
times the enum puts most of it back.

Proved on real children over real sockets under the real door:
TestACallByThePUBLISHEDNameReachesTheSameHandler stands up a child whose ids
zip DERIVES from its routes, then shows deploy_project and
post_v1_projects_by_slug_deploy reaching one handler with one reply, and
TestAColdDoorDispatchesAPublishedNameOnTheFirstCall covers the path that
caught a real bug in the first draft — a door that has answered no
tools/list holds neither table, so resolving before discovering answered
"unknown tool" for an operation it publishes. find() does both in one
lookup.

apps/agents/door.go stops requiring a descriptor's name to equal the op it
asked for. The door publishes the phrase and the descriptor carries the id,
so that check would have rejected 1,730 of 2,229 operations for being
correctly named; the seam's real guarantee — the model is offered exactly
what it will call — is kept by naming the offer with what came out of the
enum.
2026-08-06 16:49:01 -07:00
zeekay 9957aa1a8b fleet: a tool is named for what it is, and the server is the namespace
The door's tools shipped this morning as `hanzo_<app>` plus `hanzo_describe`.
The MCP server IS Hanzo — a client reaches these names through it and through
nothing else — so the prefix disambiguated nothing from nothing, and charged a
token for it on every one of 118 tools on every turn. Gone, in one change, with
no aliases: `hanzo_git` -> `git`, `hanzo_websearch` -> `websearch`,
`hanzo_describe` -> `describe`. Clients re-discover on connect, which is why
this is cheap now and would not have been in a week.

The prefix was also doing load-bearing work nobody had written down: composed()
read it to tell one of the door's own tools from an operation a child declared.
A convention holding up dispatch is a convention someone will break thinking it
is cosmetic — so that test is now an exact membership check against the app set
the door was mounted over (Door.composed). A child's operation id is
`<method>_<path>` or a declared PascalCase verb; an app name is neither, so
nothing a subsystem serves can be mistaken for an envelope.

Dropping the prefix puts the door's own tools in one namespace with the app
names, which creates exactly one new way to be wrong: an app called `describe`
would publish a second tool under that name and be silently unreachable.
TestNoSubsystemIsCalledDescribe reads the manifest and fails the build if one
ever appears — a fact about the app list, checked where it is decidable.

THE REFUSED SET IS UNCHANGED, and this was measured rather than reasoned:
refuse() reads CHILD operation ids and never saw the door's own names. Over the
fleet's own corpus (plugin/*/openapi.json, 117 subsystems, 2,440 ops) the
refused and kept lists are byte-for-byte identical before and after —
211 refused, 2,229 kept.

Measured through the real door, real children, real sockets:
  the head of the surface  describe ai agent agents code lsp product
                           provisioning git deploy exec projects …
  118 tools, 106,282 bytes, 2,229 operations offered, 211 withheld

e2e/mcp-door.sh asked whether a tool name started with `hanzo_` to decide it was
a subsystem and not a flat operation. It now asks whether the tool CARRIES an
`op` enum, which is the thing itself rather than a convention about it.
2026-08-06 16:46:32 -07:00
zeekay 49a5ecca1a Merge remote-tracking branch 'inc2/main' into sync-lines
Hanzo CI/CD / cicd (push) Failing after 15s
CI/CD / gate (push) Failing after 15s
CI/CD / containment (push) Successful in 2m17s
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
2026-08-06 16:24:21 -07:00
zeekayandhanzo-dev fca758d361 ask: the advisor asks the processes that hold the answers
/v1/ask is described as agentic research across all of our data. It had one
contributor, and that one could not work: the advisor ships as its own plugin
binary (plugin/ask/main.go mounts ask.Mount and nothing else), so the read it
made for its figures — an httptest replay against its OWN router — could only
ever reach /v1/ask. books runs in a different process. The replay 404'd, the
gather failed, and every money question in production was answered by the
"I can answer questions about your finances" fallback with an empty figures
array, while books sat healthy one socket away. Measured on api.hanzo.ai:
"what is my MRR and how long is my runway?" -> {"figures":[],"domain":""}.

So the seam was not merely empty, it was unreachable, and filling it with more
in-process replays would have produced more contributors that classify a
question and then answer nothing. Every domain is now a PLANE call, which is
the one thing that crosses a process boundary.

One shape for all of them: plane.FiguresOut, a domain's headline figures for
the caller's org, already formatted by the domain that owns the number. One op
each — books_figures, projects_figures, git_figures — because the ANSWER is
each app's own (only books knows what a dollar of revenue is) while the shape
is shared, so the advisor needs no per-domain branch and a new domain is a new
op and one line, never a router edit. apps/ask keeps ONE contributor type; the
three domains are values of it.

TENANCY. plane.FiguresIn is an empty struct. There is no org argument to
validate because there is no org argument: zip forwards the gateway's own
assertion off the in-flight request and each op reads cloud.Who(ctx).Org,
refusing anonymous rather than defaulting it. The advisor hands the peer
cloud.As(c, "") and not c.Context() — an UNTYPED handler is not given the
request on its context, so the peers would have seen "nobody is calling" and
correctly refused every question ever asked.

Verified in the production topology: four plugin binaries, four processes, four
sockets, real IAM-validated tokens. "how many repositories do I have and what
changed recently?" answers domain=git, sources=[git/figures], Repositories 3,
Code stored 412 B, most recently advisor-core — byte-identical to what the git
process reports for itself at /v1/git/usage. The same token switched to an org
with no repos answers 0, and a non-admin principal's forged X-Org-Id is ignored
entirely.

Skipped: o11y (its read needs ClickHouse and an allowlisted product slug — no
plane read op exists and I could not honestly verify one), knowledge (no plane
ops, needs a Qdrant this repo does not deploy) and index (org-scoped and ready,
but a query needs a UID and nothing indexes an org's code under a nameable one).
Two working domains beat five that classify and return nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 16:23:36 -07:00
zeekay 730724f688 sandbox: put digestFor back — this main had lost the only pin that cannot move
hanzo-inc main was RED and had silently lost a mechanism. `go test ./apps/sandbox/`
failed two cases here and passed on the forge, which is the tell:

    imageFor("dev") = "sandbox:dev-2026.6.7", want "sandbox:2026.6.7-dev"
    a digest wins over any tag: got "sandbox:dev-2026.6.7", want "sandbox@sha256:2baf7ede"

b028ab0f rewrote imageFor from a base that predated dbcb9040, so it did not change
one line — it reinstated an older file over newer work and took two things out
with it:

  the ORDER      back to `<class>-<version>`, so it asks for `dev-2026.6.7` while
                 the registry holds `2026.6.7-dev`. Every version-pinned pull 404s.
  digestFor      DELETED. `SANDBOX_IMAGE_DIGEST_<CLASS>` stops being read at all.

The second one is the serious half and it is silent in the worst way. The live
deployment sets all three digests, and universe records why: they are the only
name that cannot be rewritten under a running fleet. A binary without digestFor
IGNORES them and falls through to SANDBOX_IMAGE_TAG=latest — a moving tag,
which is the exact class of name that a `crane copy` put stock node:22 onto in
the first place. The deployment would look correctly pinned and would not be.
Every signal stays green; the only tell is a `whoami`.

Its commit message says "apps/sandbox builds and its tests pass". On the tree it
was written against, that is true. On this one it is not, and that gap IS the
bug — the same shape as the defect it was fixing.

This restores apps/sandbox/runtime.go from forge/main, which CD reads, and
nothing else. One file, by path. The two mains now agree on this function.

That also settles the fallback: forge has `<class>-unset`, this had `<class>-latest`.
I had independently written `-latest` too and it is the weaker answer. `-latest`
is still a MOVING tag, so it fails OPEN into whatever was pushed last; `-unset`
is published by nothing and fails CLOSED. The argument for `-latest` rested on
the bare tags being unrepairable — but they were repaired: a pod requesting
`:dev-latest` today resolves to sha256:4e5deb07, the same digest universe pins,
and inside it whoami is `sandbox`, /etc/sandbox-version is 1.0.0 and the OS is
Ubuntu 26.04. Not node:22. For a component that runs untrusted code, a fallback
reached only by misconfiguration should refuse, not improvise.

TestImageForNeverComposesTheBareClassTag passes under BOTH answers, which is why
it was written as an invariant rather than a table row.
2026-08-06 16:22:35 -07:00
zeekay ca31fdf159 Merge remote-tracking branch 'inc2/main' into sync-lines
# Conflicts:
#	apps/agents/builtin_test.go
2026-08-06 16:22:18 -07:00
zeekay 13d88fda56 Merge remote-tracking branch 'forge/main' into sync-lines
Hanzo CI/CD / cicd (push) Failing after 16s
CI/CD / gate (push) Failing after 17s
CI/CD / containment (push) Successful in 2m36s
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
2026-08-06 16:19:59 -07:00
zeekay 4938b9149e close the four gates inc2's commits opened
Merging inc2 onto forge surfaced four failures. All four were the gates working;
none was merge damage.

principal.ProjectFrom / WithProject — apps/crawl's scopeOf reached for
cloud.Request, the pinned escape hatch, to read two facts: org and project.
OrgFrom already existed; Project had no ctx counterpart, so the hatch was the
only way across the typed-op seam. It has one now, and crawl reads the context
like everything else.

WithProject parks UNCONDITIONALLY, which is the one asymmetry with WithOrg and
is deliberate: an org is an AUTHORITY, so an unvalidated request must park
nothing rather than an empty tenant a query would honour. A project is a
NARROWING — every consumer ANDs it with an org that already gates — so gating it
here would state the authority twice and let the two statements disagree.

agents/builtin_test — the test asserted the default assistant carries no tools;
the code now offers it the whole door. The CODE is right: an empty offer reads
to a model as "there is nothing here", and the assistant was reporting it could
not reach the cloud while the door served 88 tools one socket away. The test
sentence was the stale half, so it now says what is true.

floor tracker 10 -> 9 — GET and DELETE on /v1/tracker/projects/{key}/issues/{num}
stopped registering, and that is deliberate: tracker moved to reading the forge
(dd6cd6de, 7d42283f), so tracker.go registers the list route and no longer the
two by-number ones. A milestones op arrived, hence -1 net. Lowered here, next to
the reason, which is what the ratchet asks for when a deletion is real.

openapi.yaml regenerated: 1772 paths / 2442 operations, measured by the
generator rather than merged as text — the third time today git resolved the
generated document to one side while the routes merged to the union.

go build 0. Full suite 4 red: apps/base, apps/code, apps/commerce (the
environmental three — this box's SQLite has no acos/fts5) plus apps/o11y, which
passes standalone and is ok on main; it flakes under the parallel sweep.
2026-08-06 16:19:55 -07:00
hanzo-dev b028ab0f07 sandbox: stop asking for a tag that is permanently a stock node image
Every sandbox in production ran docker.io/library/node:22. imageFor's default
returned the BARE class tag — <image>:dev / :exec / :desktop — and all three are
sha256:0557ac14, byte-identical to upstream node:22.

Forensics: a Job `sandbox-image-seed` ran `crane copy docker.io/library/node:22`
onto them at 20:08 UTC 2026-08-06, from a locally built +dirty crane rather than
CI's pinned 0.20.2. A second burst at 20:41 also clobbered exec-latest,
dev-latest and desktop-latest — those three were republished by the sha-8af2c8a
build at 21:39-21:42 and are correct again. The bare three are not, and never
will be: NO LANE EMITS A BARE CLASS TAG. hanzoai/ci and bot both publish
sha-<short>-amd64-<class>, <class>-latest and <version>-<class>. A tag nothing
can create is a tag nothing can repair.

The failure was SILENT, which is the expensive part: node:22 starts, reads EOF,
exits 0. A run that did nothing and reported success — the same shape as the
Slack bug that cost a day, where the callee logged 200 while the caller had
already hung up.

Default is now <class>-latest, a real published tag. It is not the strongest
form — SANDBOX_IMAGE_TAG_<CLASS> pins a version and a deployment that cares
should set it — but an unpinned tag that is CORRECT beats a pinned-looking one
that is a stock node image. The docstring's preference for a pinned version was
never satisfiable by a name carrying no version at all.

apps/sandbox builds and its tests pass, including the one that referenced the
bare tag — so nothing was pinning that behaviour.
2026-08-06 16:18:47 -07:00
zeekay 6b23f290b4 Merge forge main into hanzo-inc main
Carries the sandbox image and network work onto this remote: the unset-tag
refusal (Red's -unset), the bare-tag invariant, and everything else already on
the forge. The two mains had diverged by one commit each way; the forge is the
one CD reads.
2026-08-06 16:14:56 -07:00
zeekay 6dfb79e49a sandbox: the bare tag is refused as an invariant, not as one table row
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
I arrived at this line independently and with a WEAKER answer: `<class>-latest`,
because that is a name hanzoai/ci actually publishes. Red got here first with
`-unset` and Red is right. `-latest` is still a MOVING tag, and a moving tag
someone can overwrite out of band is precisely what put stock node:22 under all
six of these names in the first place; resolving to it fails OPEN, into an image
that starts, reads EOF and exits 0 as root. `-unset` fails closed. Their line
stands unchanged.

What is added is the same rule as an invariant over every class and every tag
state, rather than the one `dev`-with-no-tag row that proves the fix. The bare
form is not one wrong answer among many — it is the ONE spelling in this
function that resolves to something in the registry which is not ours, and three
different roads reach it: an empty SANDBOX_IMAGE_TAG, an empty
SANDBOX_IMAGE_TAG_<CLASS>, or a later edit that reorders the concatenation and
drops a separator. A table covers the roads someone thought of.

bot's imageFor now asserts the identical invariant on its own side. Two
consumers, one rule, written twice because a Go service and a TypeScript one
share no code — only a registry.
2026-08-06 16:14:26 -07:00
zeekayandzeekay a30a6b16e4 sandbox: an unset tag must not boot a root shell
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 bare `exec`, `dev` and `desktop` tags all exist in the registry and all three
resolve to ONE digest: stock node:22. No toolchain, no agent, and User is unset,
so it runs as root. imageFor fell back to exactly that spelling whenever the tag
was empty — so one unset env var silently swapped a hardened sandbox for a root
shell, and every signal stayed green. The pod runs, the API answers, and the only
tell is `whoami`.

Nothing publishes `-unset`, so the pull now fails with a name that explains
itself. A loud stop beats a silent downgrade; the whole point of this subsystem
is that the thing running someone else's code is not root.

Found by RED, which noticed that fixing `{class}-latest` and leaving `{class}`
bare left the gun loaded rather than unloaded.
2026-08-06 16:11:01 -07:00
hanzo-dev 4840b92d0b flags: one refusal, and it names both ways in
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
2026-08-06 16:06:25 -07:00
hanzo-dev 13ce86d43f merge: take main forward
# Conflicts:
#	apps/flags/routes.go
#	apps/tracker/ui/dist/.sync-stamp
#	apps/tracker/ui/dist/assets/vendor-BgRt8uZI.js
#	apps/tracker/ui/dist/assets/vendor-Cxl82NwH.js
#	apps/tracker/ui/dist/assets/vendor-DfmjNf5r.js
#	apps/tracker/ui/dist/index.html
2026-08-06 16:04:05 -07:00
hanzo-dev 3c24993000 flags: a project key names its tenant for a read, and can never author
The verdict surface took a principal or nothing, so an SDK holding a project
key -- the credential the wire was designed around -- had no way in and read
'X-Org-Id required' for a header it cannot mint. A key now resolves its org
through the ONE IAM seam the event door already uses, fails CLOSED on an
unresolvable key rather than falling back to the host, and is refused at every
write: reading a verdict and changing what everyone reads are different powers.
Authorship keys on whether a validated identity named the tenant, not on the
actor email, which a principal need not carry.
2026-08-06 16:02:17 -07:00
hanzo-dev 9950d27d97 merge: take main forward 2026-08-06 16:02:08 -07:00
antje ddd81cdb9b deps: hanzoai/ai v1.832.35 — the gateway learns to transcribe
CI/CD / containment (push) Successful in 3m18s
Hanzo CI/CD / cicd (push) Failing after 28m2s
CI/CD / gate (push) Failing after 29m16s
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
Brings stt: an OpenAI-compatible /v1/audio/transcriptions provider, so
cloud can front the in-cluster speech service (whisper on CPU) the same
way it fronts every other model plane.
2026-08-06 15:44:58 -07:00
zeekay f8e1941202 agents: the chat tier keeps its value and loses its fiction
The Slack front door ran on a literal `enso` behind a BRIDGE_AGENT_MODEL knob,
justified in its own doc as "the auto-routing SKU that selects per query in the
gateway's own catalog". That is not what enso is.

enso is one fixed route entry: deepseek-v4-pro, 1M ctx, reasoning: medium
(zen-svc catalog-enso.yaml, and the live enso-catalog ConfigMap it is served
from). One rung, no ladder, no per-query selection, no escalation. Three code
comments in three repos and the Slack App Home menu all asserted otherwise, so
the front door's tier had never been chosen — it had been inherited from a
property nothing in the system has.

So I measured it instead, paired and interleaved against the live enso service so
upstream load drift cannot flatter either side. n=42 full turns, the real
assistant instructions, the real describe-then-call protocol:

    enso        p50 3489 ms   p90  8000 ms   1.71 model round-trips/turn
    enso-flash  p50 3905 ms   p90 17269 ms   1.71 model round-trips/turn
    paired diff 1512 ms median in enso's favour, t=-3.29 — significant.

The tier is RIGHT and the stated reason was invented. Round count is identical;
the difference is generation rate. On a tool-shaped turn enso-flash emits at
11.7 tok/s against enso's 19.1 for the same ~75-token answer, so "flash" is
quicker only when the answer is short enough for terseness to beat rate — a
greeting, not a question about the fleet. "what did we deploy today?" ran
5,386 ms on enso and 15,122 ms on enso-flash.

I very nearly shipped the opposite. A single-call benchmark says flash wins
(p50 1648 vs 3250 ms, t=2.15) and it is the wrong benchmark: it measures the one
turn shape — a greeting — where terseness dominates, and the Slack assistant is
a tool-driving agent. Two runs of the full turn disagreed with each other before
n was large enough to separate the effect from the upstream's variance, which is
the real reason a turn is sometimes slow: p90 is 2-3x p50 for both tiers.

What changes:
  - the tier and its evidence move to cloud.ChatModel, in the file that owns
    model policy, beside DefaultModel and FallbackModel. It is deliberately NOT
    DefaultModel: that one serves one-shot text, this one drives tools, and the
    tiers do not rank the same on those jobs.
  - BRIDGE_AGENT_MODEL is gone. No deployment ever set it, and a second place can
    only disagree with the first.
  - App Home stops selling `enso` as "Picks the right model for each message" and
    stops calling enso-flash "Fastest, for quick questions" — it is not fastest on
    the questions people ask. It now names the tiers and marks the default, read
    from cloud.ChatModel rather than from the menu's own first row.
  - builtin_test pinned "the default carries no tools" against code that had
    already been given the whole door, so the package's tests were red.

The App Home pin still wins over the default, and is still limited to the three
SKUs the menu offers.

Carries one line of an in-flight fleet.Describe rename in onbehalf.go that landed
in the shared worktree mid-edit; reverting it would have dropped that work.

(cherry picked from commit fce13e455d7c15157d4c6f62b7184d34c917db13)
2026-08-06 15:14:02 -07:00
hanzo-dev 86de6b94e6 tracker, meet: rebuild both embedded SPAs on the hardened client
CI/CD / containment (push) Successful in 1m24s
Hanzo CI/CD / cicd (push) Failing after 27m1s
CI/CD / gate (push) Failing after 27m8s
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 meet client mints its LiveKit join token on a bearer-only host, where
cloud's session cookie never arrives, so POST /v1/meet/getToken has to carry
the Authorization header the session read already carries. Without it every
room join answers 401 and the lobby lists rooms nobody can enter.

The tracker client picks up the same-origin predicate, the token-resolved
org header, the hostname-derived brand registry and the tenant-keyed cache.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 15:01:56 -07:00
zeekay f908ac9213 Merge remote-tracking branch 'inc2/main' into sync-lines
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
2026-08-06 14:58:47 -07:00
zeekay 181969ac0f Merge remote-tracking branch 'forge/main' into feat/plugin-solo-builds
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
2026-08-06 14:57:56 -07:00
zeekay 155e9503ec dockerignore: the context stops carrying a copy of ourselves
`COPY . .` was shipping 1087 MB. /bin/ was already excluded, but nothing that
mattered lived there:

  hanzo   586 MB   untracked
  o11y    126 MB   untracked
  host     26 MB   untracked
  .claude  ~92 MB  gitignored — agent worktrees, i.e. second checkouts of this
                   same repository, cache-keyed into the layer so every agent
                   run invalidated it

All build litter; none of it tracked, so nothing in the build can want it.
Measured after: 152 MB.

NOT excluded, deliberately: `sandboxes` (50 MB), which IS tracked. COPY . . may
legitimately carry it, and a build output somebody committed is a question for
that commit — not something to drop from the image behind their back while
chasing a number.

The .claude/ line is the same defect the zipdoc gate hit, in a second place: an
agent worktree is a whole other checkout, and any tool that walks the tree
without excluding it reads our own copy as our own source.
2026-08-06 14:57:38 -07:00
blueandhanzo-dev dd6cd6de25 tracker: the forge serves this brand's principals, and only this brand's
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
One cloud binary answers every brand's API host, and its validator trusts EVERY
white-label issuer — trustedIssuers unions BrandIssuers, deliberately, because
api.hanzo.ai and api.lux.network are the same process. So a lux.id- or
zoo.ngo-issued token is genuinely valid on the hanzo deployment.

The forge, though, is resolved from the DEPLOYMENT's own domain (brand.Sibling
→ git.hanzo.ai), never from the principal's. So a token vouched by another
brand's IAM arrived with an attested org and an attested username, and both of
this surface's controls then did exactly what they were built to do: the org
scoped the query, and the username was Sudo'd against git.hanzo.ai. But "alice"
on this forge is a DIFFERENT HUMAN from lux.id's alice, and the forge answered
with that person's private issues.

Two individually-sound controls composed into a cross-brand private-repo read,
because neither of them asked WHO VOUCHED. The vouching brand must be this
deployment's own, so that is now checked — once, in the one resolver every read,
every write and the milestone rollup passes through, rather than six times on the
routes.

An absent vouching brand still passes: that is an hk-/sk- key minted by this
deployment's own IAM, which is by construction this brand, and principal.Brand
publishes "no brand" as "no second fact to compare" rather than as a brand.
Normalised on both sides, because a case difference must not decide a tenancy
question. Same pair, read the same way, as apps/tenant.

Tests: a lux-vouched principal is refused on all six routes and nothing reaches
the forge; the deployment's own brand passes, in any case; an unbranded
own-IAM principal passes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 14:33:19 -07:00
zeekay ec5e4876ec the web the fleet could already reach is a web the agent can now call
The assistant was asked what the weather was and had no path to the internet.
It was right: the fleet's MCP door carried 88 grouped tools and none of them
was websearch or crawl. Not because the capability was missing — apps/websearch
is a working keyless meta-search and apps/crawl a working fetch-and-extract,
both in-process, both serving over HTTP the whole time — but because ONLY TYPED
OPS PROJECT. A raw handler appends nothing to zip's op registry, and that
registry is the single value every projection reads: the route, the OpenAPI
operation, the SDK method, the CLI command and the MCP tool. A subsystem of raw
routes serves perfectly and is invisible to the agent.

websearch gains its NATIVE door. Its two existing routes are foreign-protocol
adapters — LibreChat's frozen searxng and firecrawl contracts — and neither can
be a typed op: one is registered with All and zip has no typed All, the other
answers 200 to a malformed body on purpose. Those are facts about the ADAPTERS,
and the package doc had read them as facts about web search itself. POST
/v1/websearch runs the SAME metaSearch over the SAME engines and answers the
SAME envelope, at an address the registry can hold.

crawl's POST /v1/crawl becomes a typed op with NOTHING moved on the wire. Its
two documented blockers were real and both were carried rather than dropped:
the 400 with `{"success":false,"error":"missing url"}` is stated by the ANSWER
through zip's StatusCoder, so the document publishes 400 with that schema; and
the 1 MiB bound and body-tolerance are facts about BYTES, which a typed op
never sees, so they are asked in middleware where the bytes still are.
TestCrawlWireSurvivedTyping is the measurement — same three inputs, same three
answers, byte for byte.

Both gates moved into the HANDLER, because a tools/call reaches a typed op with
no route and therefore no middleware: a gate that lived only in middleware would
be no gate at all for the two projections this exists to create.

fleet/reachable_test.go is the proof, and it stubs nothing: each subsystem
composed the way cloud.Serve composes a plugin child, on its own unix socket,
under the real composed door — so refuse() runs for real and an operation that
passes is one an agent can reach. post_v1_websearch, post_v1_crawl and
post_v1_exec all project. Their names are in surface_internal_test.go's
survivors table, which is the one place the rule is asserted; no second gate
site was added.
2026-08-06 14:06:28 -07:00
blueandhanzo-dev 7d42283fdd tracker: the board reads the forge, and the forge says who is asking
CI/CD / containment (push) Successful in 3m39s
Hanzo CI/CD / cicd (push) Failing after 1h1m51s
CI/CD / gate (push) Failing after 1h1m51s
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 work items on /v1/tracker were a SQLite table beside a github.com feeder,
while the estate files, labels and closes its issues on git.hanzo.ai. Two stores
under one prefix are two answers to what the state of a piece of work is, and
they disagreed the first time anyone touched the forge directly, which is every
day. The forge is now the store: every read is a read OF it, every write a write
TO it, and nothing here caches or mirrors a row.

A board is a repository, a column is a LABEL, and a card is an issue. Reading
the column off a label is what makes the board and the forge web UI the same
object seen twice — relabel in either and the card moves in both. So the
repository lifecycle is NOT on this surface: creating, renaming and deleting a
board are forge operations under forge permissions, and a second door onto them
here would be a weaker guard on the same object. Those three answer 405 naming
the forge, which is a different fact from 404.

Milestones are repo-scoped upstream and there is no org-level list, so the org
view is a server-side fan-out over the repositories the caller can see —
bounded, and failing whole rather than returning a partial rollup that reads as
complete.

TWO INDEPENDENT CONTROLS, because neither is trusted to be sufficient and this
forge really does host private orgs. The org comes from the validated principal
(principal.OrgFrom) and never from a path, query or body. Then every call is
made with Forgejo Sudo as the caller's own IAM username, which DROPS PRIVILEGE
to that user — measured against the live forge: the machine token reads
hanzo-private/patents (200), the same token sudoed as a non-member gets 404,
byte-identical to anonymous. So a bug in the first control cannot leak a private
repository on its own, and a write is attributed to the HUMAN rather than to a
shared bot.

One credential, held in KMS at orgs/hanzo/deploy/FORGE_TRACKER_TOKEN@prod —
never an env file, never a browser-side PAT. A separate secret from the universe
pin token on purpose: one credential per capability, so a compromise of the
tracker cannot deploy. Resolved lazily with a TTL so rotation is live without a
restart, invalidated when the forge rejects it, and fail-closed at every step —
an anonymous client would quietly serve public repos and read as "your board is
empty" rather than "this deployment is misconfigured". The forge host is
brand.Sibling of the deployment's own API host, so a white-labelled deployment
cannot read another brand's forge.

Also closes two tenancy defects found beside this work:

plane.AgentPRIn carried an Org the agent-PR seam read off the wire and passed
straight into the per-tenant store selector, so a caller on the plane could file
a work item onto ANOTHER tenant's board by naming it. Its sibling on the same
socket, IssueIn, has never had one. The field is gone, the handler reads
cloud.Who(ctx), the caller carries the org in the ENVELOPE (which is re-checked
by the same OrgOf rule as the HTTP boundary), and a reflection test now fails if
an org-shaped field returns to either input.

The audit trail's Home field means "a platform SuperAdmin acted inside another
tenant". Its predicate was home != effective, which WAS impersonation back when
a SuperAdmin was the only principal who could act outside their home org. Since
membership-based org switching, any ordinary member of two orgs trips it the
moment they work in their second one — telling auditors that routine work was an
admin impersonation, and burying the real events in volume. The predicate is now
the fact it always meant: home is the reserved admin org (authz.AdminOrg, the
issuer's constant).

Tests: 22 on the forge client (fail-closed with no actor, credential never in an
error, bounded fan-out, pagination that terminates against an endless forge, a
compile-time refusal of tenancy in the filter), 12 on the surface (cross-tenant
read, no-principal refusal, CSRF-refused writes never reaching the forge,
attribution of a move). The retired SQLite HTTP surface's tests go with it; the
two security properties they held — the CSRF gate and the per-IAM-project store
isolation, which still backs the plane doors — are re-pinned against what
survives. forge/live_test.go exercises the real forge, skipped unless
FORGE_LIVE_TOKEN is set, because a stub can only confirm we built what we
believed and not that what we believed is true.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 13:55:06 -07:00
zeekayandzeekay dbcb9040a0 sandbox: resolve the image the publisher actually wrote, and let a digest pin 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
TWO defects, both silent, both found by trying to start a real sandbox.

THE TAG ORDER WAS BACKWARDS. imageFor composed <class>-<version> and asked for
`dev-2026.6.7`; the registry holds `2026.6.7-dev`. So the default path 404'd on
an image sitting right there, and the only reason anything ever ran was a test
that overrode the image entirely. The publisher wins this argument: hanzoai/ci
appends a per-image tag-suffix to the version, so the whole fleet is
<version>-<suffix> and a consumer spelling it the other way is simply wrong.

A VERSION TAG IS NOT A PIN HERE. The sandbox image ships from hanzoai/bot under
BOT's package.json version, last bumped 2026-06-07. A rebuild today — commit
d1904514f4, "box: uv ships at the root" — republished `2026.6.7-dev` from source
two months newer. A tag that gets rewritten is not a pin, and one that LOOKS
pinned is worse than `latest`, which at least admits what it is.

So SANDBOX_IMAGE_DIGEST_<CLASS> is honoured ahead of any tag. `repo@sha256:…`
names bytes, and bytes do not change under a running fleet.

PER CLASS, not one variable. A single SANDBOX_IMAGE_DIGEST would have handed
every class the exec image while every log line still read correctly — the same
shape as the tag bug above. image_test.go pins that case specifically, along with
the order, because both of these are invisible until a pod fails to pull.
2026-08-06 13:51:40 -07:00
hanzo-dev 7881feacfc merge: take main forward
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
2026-08-06 13:48:45 -07:00
hanzo-dev a8da2c68f7 flags: one evaluator, two spellings, and a refusal that names its own cure
The SDK's leaf and the plane's root reach the same evaluator; the comment
said whose protocol it was rather than what the line does. The refusal said
'X-Org-Id required' to callers that can never send one — a project key does
not carry a tenant on this surface — so it now says what would satisfy it.
2026-08-06 13:48:44 -07:00
zeekay 76223d8df2 sandbox: fsGroup, or a sandbox that cannot write to its own disk
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 image ends `USER sandbox` (uid 1000). A mounted volume — the emptyDir at the
exec class's workdir, or a project PVC — arrives owned root:root 0755, so uid
1000 takes EPERM on its first write. The pod is Running, the API answers, every
health signal is green, and the agent cannot save a file.

Reproduced before fixing, in both shapes:
    uid=1000(node) gid=1000(node)
    drwxr-xr-x 2 0 0 /mnt/data     touch: Permission denied
    drwxr-xr-x 3 0 0 /work         touch: Permission denied

A Dockerfile `chown -R sandbox /work` cannot fix it — the mount happens after
the image layer and shadows it. fsGroup is the only mechanism that reaches a
volume: the kubelet chowns it to that GID and adds it as a supplemental group.

runAsUser/runAsGroup are stated rather than inherited from the image, because
the two have to agree and the one checkable from outside the image should be the
one that says so.

The image's own comments already asserted this existed — "the pod runs
runAsNonRoot with runAsUser 1000, so the uid is fixed by the securityContext."
Nothing fixed it. The live proof missed it because a stock node:22 runs as root,
so the one path exercised was the one that happened to survive. That is the
whole hazard of proving a mechanism with a substitute image.
2026-08-06 13:43:07 -07:00
hanzo-dev 57b2e41fb7 agents: the default assistant is offered the door it was told it had
The assistant reported it "can't query your projects directly" and named the
exact tools it would have used — hanzo_projects after hanzo_describe. It was
right, and the two halves of the system disagreed:

  instructions  "your tools are grouped hanzo_<subsystem>; call hanzo_describe"
  door.go:93    if org == "" || len(want) == 0 { return nil }

want comes from callableTools(a), which iterates a.Tools — and the builtin
agent's Tools was empty, deliberately, from when the tool loop decided what to
offer. So the model was taught a protocol and handed nothing to practise it on,
while the door served 88 tools one socket away.

ToolsAll ("*") says "whatever the fleet serves", resolved per run. It has to be
STATED rather than implied: an agent that declares nothing still gets nothing,
because a user-defined agent's tool list is its authority. But the default
assistant cannot enumerate a surface discovered at runtime — the whole point of
grouping was that the set changes when a subsystem ships, so any list written
here would be stale by the next deploy.

The wildcard offers the door's tools AS GROUPED, not the ops flattened back out:
1,189 flat tools were 977 KB (~244k tokens) merely to list, and the same
operations grouped are 88 tools in 63 KB. Flattening here would hand back every
byte the grouping saved, and would contradict the prose the model is reading.

Also merges main (242 commits) into the security branch. plane.go's conflict was
two additive type blocks at one offset — both kept. The two generated files were
regenerated rather than hand-merged.
2026-08-06 13:41:57 -07:00
hanzo-dev ae49297880 tracker: rebuild the embedded board on the hardened client
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
Red re-reviewed the chrome and found four client-side holes. All are fixed in
hanzoai/admin apps/tracker@577bb4c; this is the rebuilt bundle. No Go changed.

What is different in the bytes:

  - ONE parser-based same-origin predicate gates the bearer. The old check
    short-circuited on `startsWith('/')` without parsing, and seven root-relative
    shapes — including ones carrying a raw TAB, LF or CR — fold to a foreign
    authority under the parser fetch itself uses. Those shapes took the Bearer
    and X-Org-Id off-origin. The base client attached the bearer with no check at
    all.

  - X-Org-Id is resolved against the token that carries it. The selection lives
    in localStorage (permanent, shared by every user of a browser profile) and
    the token in sessionStorage (dies with the tab); nothing reconciled them, so
    the second person to use a machine sent the first person's org and got their
    OWN rows back displayed under it. cloud already refused to honour it — this
    stops it being sent, drawn, or kept.

  - The IAM base comes from a brand registry (hanzo -> hanzo.id, lux -> lux.id,
    zoo -> zoo.id) mirroring cloud's own, selected by brand rather than by an
    arbitrary URL.

Audited on the shipped chunks: React only (0 hits for svelte/vue/solid/preact/
angular/htmx/alpine), 0 credential surface, 0 origin-derived IAM base.

Still not verified here: `go build` and `go test ./apps/tracker/...` — this box
has no Go toolchain. embed_test.go's assertions were checked by hand against the
new dist/ (base /tracker/, /v1/tracker baked in, every asset index.html names is
present, .sync-stamp still dot-prefixed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 13:10:51 -07:00
zeekay f648c17f85 sandbox: prove the surface a client calls, not the runtime under 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 / receipt (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
live_test.go proves a POD runs code. It calls r.exec, r.stop and r.purge
directly, so it never issues a request — delete every line of Routes() and it
still passes. What ships is not the runtime, it is the seven addresses in front
of it, and nothing was reading those.

So: the same proof, driven only through the routes.

  CREATED over HTTP: id=m_53ce6be390c9eab1903222b7 class=exec status=running
  WROTE FILE over HTTP: {"bytes":63,"path":"/mnt/data/answer.js"}
  EDIT PERSISTS over HTTP: export const answer = 42; // written over .../fs
  EDITED CODE RUNS over HTTP: ANSWER=42
  FAILURE IS DATA over HTTP: exitCode=3
  CROSS-ORG REFUSED over HTTP: GET as other-org -> 404
  LIST CONTAINS IT over HTTP
  DELETE /v1/sandboxes/m_53ce6be390c9eab1903222b7 -> 204

Three of those could not have been caught below the route. A non-zero exit is
DATA on POST /:id/exec — 200 with exitCode 3, not a 500 — and an agent that
cannot read a failing build is no use; that shape is a handler decision and
lives nowhere else. Org isolation is likewise a route fact: the unauthenticated
call is checked FIRST, so a later 201 cannot be explained away by an open door,
and a second org asking for the same id gets 404 rather than the row. The write
lands at /mnt/data because workdirFor answers the class, which is the same
confusion that made the git proof cd into a directory that was never there.

Guarded by SANDBOX_LIVE like its sibling, so it skips where there is no
cluster and runs where there is:

  SANDBOX_LIVE=1 SANDBOX_NAMESPACE=hanzo-sandboxes go test ./apps/sandbox/ -run TestLiveHTTP -v
2026-08-06 13:05:54 -07:00
hanzo-devandzeekay ce5714f685 risk: a money assertion asks whether the debit lands, not how fast
CI/CD / containment (push) Successful in 12m38s
Hanzo CI/CD / cicd (push) Successful in 1h18m48s
CI/CD / gate (push) Successful in 1h18m50s
CI/CD / image (push) Failing after 57s
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
ledger.await gave a background debit 2 seconds to reach the ledger. Alone that
is ample; on the release gate the same process is running the whole fleet's
suite, and TestFeatures_IsPricedFromItsWindow failed there at "only 0 of 1
debits reached the ledger" while passing 20 of 20 runs in isolation.

That is a red money gate that proves nothing about the money — the metering it
exists to defend was never in question — and it is the shape of red that teaches
people to re-run a gate instead of reading it.

The question the helper asks is whether the debit arrives, which has a yes/no
answer that does not depend on the machine's load. Give it 30 seconds. The poll
is unchanged, so the happy path still returns in milliseconds and nothing gets
slower; only a genuine failure takes longer to declare.

Measured: apps/risk green over 6 consecutive runs under 12-way CPU contention,
which reproduced the failure before.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:48:01 -07:00
hanzo-devandzeekay 26f4b356b7 content: a transition leases the item it is about to publish
Two concurrent transitions to `published` posted the item to its channels
twice. The fan-out was already leased; the erasure that defeated it was not.

Transition reads the document, then writes it back with the new status. That
write carries the WHOLE document — UpdateData replaces it, so external_ids
cannot be left out of the map without being deleted — and the snapshot it
writes is read before any fan-out. So: both callers read an empty skip-set, A
publishes and records its external_ids, B's stale snapshot writes that skip-set
back to empty, and B's fan-out (correctly leased, correctly re-reading) finds
nothing to skip and posts the item again. The lease inside Publish could not
see this, because the erasure happened outside it.

Widen the section to match the invariant it was defending: on the one edge that
distributes, Transition takes the item's publish lease across read, edge-check,
status write and fan-out. The loser then reads after the winner recorded — a
no-op edge (CanTransition is true for from==to), re-stamping the same status,
skipping every channel already on record. One post, both callers succeed.

Publish keeps its own acquisition for its own callers and delegates the fan-out
to publishHeld, which states the held lease as a precondition; the lease is not
reentrant, so Transition calls publishHeld directly. A contender that cannot win
the lease inside the wait window is refused with 409 rather than writing a
document it would corrupt.

Measured on apps/content: 2 of 30 runs failed before, 0 of 200 after.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:48:01 -07:00
hanzo-devandzeekay ac2e1d7422 sandbox: the live proof ends the pod it started
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
The cleanup this file promises — "a leaked pod on a shared cluster is somebody
else's problem tomorrow" — was deferring purge, and purge deletes the VOLUME:

    func (r *runtime) purge(ctx context.Context, m Sandbox) error {
        if err := r.ready(); err != nil { return err }
        if m.Volume == "" { return nil }

Neither live test declares a Volume, so purge returned at that second line every
time and deleted nothing. The pod was never anybody's job. Measured on
hanzo-k8s: four sandbox-live-* pods still Running, the oldest twelve minutes,
none carrying a deletionTimestamp — every pod both tests had ever started.

stop is the one that ends the pod, and it is separate from purge on purpose:
the volume holds the only copy of a checkout, so deleting it is opt-in. A test
that owns both wants both. Deferred in the order the product uses them.

Verified — pods gone after the run rather than left Running, and the proofs
still hold on the way out:

    RUNS CODE: SANDBOX-RUNS-CODE v22.23.2
    EDIT PERSISTS / EDITED CODE RUNS: ANSWER=42 / FAILURE IS DATA: exit=3
    GIT PRESENT: git version 2.39.5
    COMMIT MADE: 49523ec the agent committed this
    ok github.com/hanzoai/cloud/apps/sandbox 79.993s
2026-08-06 12:43:25 -07:00
hanzo-dev 67c64cca52 Merge forge main
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
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:40:20 -07:00
hanzo-dev 398ea586b8 Revert the ci pin: naming the immutable tag stopped runs being constructed
Pinning @v1.0.38 — which this file's own comments call for — made the forge
stop constructing a run entirely. No run row, nothing to inspect: the 'dead CI
is not red, it is absent' failure the same comments describe, reached from the
other direction. Absent is worse than red, so this goes back to @v1.

The lane remains broken and was before any of this: gate never gets a runner
while containment succeeds on the same run against an idle fleet, and no
release has minted since v1.801.490. Reverting restores a lane that at least
REPORTS its failure; it does not fix it, and the comment now says so instead of
implying the pin was the answer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:39:52 -07:00
hanzo-devandzeekay d46b85b268 sandbox: the live proof asks the sandbox where its workdir is
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
TestLiveSandboxDoesGit has been RED since exec's workdir split off from dev's.
The script opened with a bare `cd /work` — the `workdir` constant — against a
sandbox declared `Class: "exec"`, and workdirFor("exec") is /mnt/data. Kubernetes
creates a pod's workingDir and nothing else, so /work was never there:

    live_test.go:163: git flow exit=2 stderr="sh: 2: cd: can't cd to /work\n"

So the one test that proves an agent can COMMIT was failing for a directory, and
"COMMIT MADE" has not actually been observed since.

TestLiveSandboxRunsRealCode hid the same mistake instead of failing on it: it
writes with `mkdir -p /work/src`, which CREATES the directory it then proves the
edit survives in. The edit was real and the persistence was real, but both were
happening in a directory the product does not read — confine() resolves every
caller path under workdirFor(class), so nothing served through /v1/sandboxes
would ever have seen that file. A proof that passes in the wrong directory is
weaker than a proof that fails.

Both now ask workdirFor(m.Class), which is the same question runtime.go asks when
it sets workingDir and confine() asks when it resolves a path — one source for
the path instead of a constant that is right for one class and wrong for the
other. Against hanzo-k8s, node:22, ns hanzo-sandboxes:

    RUNS CODE: SANDBOX-RUNS-CODE v22.23.2
    EDIT PERSISTS: export const answer = 42; // edited by the agent
    EDITED CODE RUNS: ANSWER=42
    FAILURE IS DATA: exit=3 stderr=to-stderr
    GIT PRESENT: git version 2.39.5
    COMMIT MADE: a9423dc the agent committed this
    FORGE REACHABLE: exit=0
    ok github.com/hanzoai/cloud/apps/sandbox 64.902s
2026-08-06 12:39:45 -07:00
zeekay c5f8660abb sandbox: pull from oci.hanzo.ai, the name the registry actually has
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
The default was registry.hanzo.ai/hanzoai/sandbox. That host is a DEPRECATED
ALIAS — one Traefik file router serves both names from svc/registry:5000, so it
resolves, which is exactly what makes a stale name survive: nothing breaks, it
just spreads. oci.hanzo.ai is the canonical one.

Measured while confirming this: a probe pod in hanzo-sandboxes sits in
ImagePullBackOff on registry.hanzo.ai/hanzoai/sandbox:exec. The alias is not the
reason — the image does not exist under either name yet — but the next person to
read that error should be sent to the right host.

Two comments carried the old name too, including the one explaining why a
sandbox uses its own ServiceAccount rather than `default` (DOKS re-attaches its
own registry pull secrets to every namespace's default account, so a sandbox
inherited a credential for someone else's registry and asked ours anonymously —
a 401 that reads like a bad password and was no password at all).
2026-08-06 12:38:47 -07:00
zeekay 3f336bfeeb Merge remote-tracking branch 'forge/main' into merge-all
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
2026-08-06 12:32:34 -07:00
hanzo-dev 1782107b79 Merge hanzo-inc into the forge again
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
The mirror moved while the ci pin was being fixed. Merged, not forced: the
forge is canonical but that does not make GitHub's commits disposable.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:28:42 -07:00
hanzo-dev cd82ffe0db cicd: pin the gate to an immutable ci tag, not the v1 alias
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
Every cloud release since v1.801.490 has died in `gate`, and the rule that
prevents it is written in this file directly above the line that broke it:
pin the immutable patch tag, not the rolling `v1` alias.

`gate` is the ONLY job here that is a reusable-workflow call rather than a
literal `runs-on`, and it is the only one that never gets a runner —
runner_id 0, zero steps, ~32 minutes, then failure — while `containment` on
the same run succeeds against an idle 10/10 fleet. Runs 925 and 926 (at
7c50638e) and 928 (the forge merge) all died identically, so this predates
that merge; it is not what the merge introduced.

The alias is not just stale (v1 -> f098b39e vs v1.0.38 -> dfac7dc4) — it is
unpinnable by design: sync-from-github refuses to move a tag that already
exists, so `v1` on the forge can never catch up. Only a NEW immutable tag
syncs, which is exactly why the rule says to name one.

If this does not restore the lane, the next thing to read is why the forge
declines to schedule a called workflow's jobs while scheduling sibling jobs
in the same run.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:28:11 -07:00
hanzo-dev 342388347a sandbox: the account a sandbox runs as is a fact, not a default
podSpec named no ServiceAccount, so every sandbox pod ran as `default` — and
DOKS's registry integration re-attaches its own DigitalOcean pull secrets to
every namespace's `default` account whenever it reconciles. A sandbox therefore
inherited a credential for a registry that is not ours and died asking
registry.hanzo.ai for its image anonymously: a 401 that reads like a bad
password and was in fact no password at all.

`sandbox` is ours and DOKS does not manage it. It is bound to no Role and
carries exactly one thing, the pull secret; the line above it still refuses the
pod a token, so naming an account grants nothing that omitting one withheld.

A constant and not an env var: which accounts exist in the sandbox namespace is
decided by the manifest that creates the namespace, so a second knob here could
only ever disagree with it. Declared in universe infra/k8s/sandboxes/registry.yaml.
2026-08-06 12:27:29 -07:00
hanzo-dev b62869ca97 tracker: the embedded board signs in at hanzo.id, wearing two controls
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (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
CI/CD / containment (push) Canceled after 0s
Rebuilt from hanzoai/admin apps/tracker@e3d5672. The bundle changes what the
page does about identity and chrome; nothing in this package's Go changed.

ONE DOOR. The SPA signed in against `<origin>/v1/iam` — whatever IAM the host
serving it embeds. That is a second identity authority by construction, and
where the embedded store is not the one holding the accounts it fails as a login
screen nobody can get through. It now signs in at hanzo.id, which is the issuer
this binary validates against (brand registry: hanzo -> https://hanzo.id, JWKS
https://hanzo.id/v1/iam/.well-known/jwks). The bundle carries no form and no
password field: it asks IAM to authenticate somebody and holds the bearer.

That is the point of the cutover rather than a detail of it. tracker.hanzo.ai is
Huly today and Huly runs its own login form, so replacing it is only worth doing
if it REDUCES the number of places identity can be established.

TWO CONTROLS. The chrome is the org switcher (left) and the user menu (right)
over the board — no nav rail, no env/version/clock/theme chips.

THE SWITCHER RE-SCOPES. The selection rides as X-Org-Id on every /v1/tracker
call. It is a request, not a claim: SanitizeIdentity already deletes that header
and re-mints it, honouring the value only when the token's signed `orgs` claim
contains it — so the switcher lists exactly that claim, and cannot offer a
selection the server would silently swap for the home org. No change was needed
on this side; the mechanism was already here and tested.

embed.go and the README said the SPA "sends no tenancy of its own", which is now
half true and would read as a promise the page no longer keeps. They say what it
asserts (nothing) and what it asks for (a selection the server validates).

Verified against this bundle in a browser: sign-in offers one button and zero
inputs and leaves for https://hanzo.id/v1/iam/oauth/authorize with S256 PKCE and
client_id=hanzo-cloud; the board renders with exactly the two controls; choosing
acme sends X-Org-Id: acme and returns acme's board. Bundle audit: React only —
zero Svelte/Vue/other — and zero credential surface.

NOT verified here: `go build` and `go test ./apps/tracker/...`. This box has no
Go toolchain. The Go sources are unchanged, and embed_test.go's assertions were
checked by hand against the new dist/ (base /tracker/, /v1/tracker baked in,
every asset index.html names is present, .sync-stamp still dot-prefixed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:27:20 -07:00
zeekay eafbf24a13 Merge remote-tracking branch 'forge/main' into merge-all
# Conflicts:
#	openapi/floor.json
2026-08-06 12:26:39 -07:00
zeekay 32a2f9caf1 Merge remote-tracking branch 'forge/feat/plugin-solo-builds' into merge-all 2026-08-06 12:24:05 -07:00
zeekay d35bc990f9 merge projections: regenerate the document the merge made stale
openapi.yaml is GENERATED. git merged it as text, so the routes merged to the
union (1764) while the document took one side (1759) — five paths served and
unpublished, which every SDK generator would have missed. TestTheServedDocument
IsTheArtifact caught it and named the fix; this is the fix.

fleet/mcp.go kept both halves: Serve publishes the door at an internal address,
signpost answers the framework default with a 308. d.Serve IS on.Post(path,
d.serve), so the abstraction subsumed the raw line rather than racing it.
2026-08-06 12:24:02 -07:00
hanzo-dev 2c8ff45655 the fleet is a set of targets, and every plugin proves it links alone
Every sweep in mk/fleet.mk was a `for` in a shell recipe, and a shell loop does
one thing at a time: 121 independent links, one after another, with the Go
toolchain itself pinned to two compilers. From an empty cache that shape takes
586s. The same builds scheduled across the box take 300s, and 25s warm.

They are static-pattern targets now — one per app, so the set is CLOSED and
`build/nosuchapp` names the app instead of quietly matching. (They are also
explicit rules, which is what makes them work at all: an implicit `build/%:` is
never searched for a target listed in .PHONY, so the phony declaration these
obviously wanted turned every one of them into "nothing to be done".)

`make -k` replaces `set -e`, and that is the part that was actually dangerous.
The old loop stopped at the first failure and never ran the apps behind it,
which reports nothing, and nothing is indistinguishable from passing — it is how
an unbalanced brace in apps/commerce hid ~45 apps that silently never
regenerated. make continues past every failure and names each one itself.

THE EXEMPTIONS WERE EXEMPTIONS FROM DESCRIBING, APPLIED TO BUILDING. kafka needs
a live broker and zen is coresident, so neither can MOUNT alone — and `describe`
was the only sweep that ever touched an app, so neither was ever COMPILED. Both
could have stopped linking on main with every gate green. `binaries` carries no
exemptions, because there is no such thing as an app that cannot be compiled by
itself; `describe` builds those two and skips only the projection. All 121
manifest apps link alone, verified as an exact bijection against ./bin.

`check` CALLS describe instead of restating it. They were one sweep written
twice with different skip messages and different error text, which is what let
them disagree — the bug its own exemption comment already had to be written
about once.

`dist` is that same recipe with the platform spelled into the name:
dist/<app>-<os>-<arch> for every app and every platform, 242 binaries, which is
the triple manifest/release.go resolves a plugin by. CGO_ENABLED=0 there is
load-bearing — a plugin fetched over the network runs on a box we did not build.
Two bugs found writing it: `VAR=x cmd1 && cmd2` sets the env for cmd1 only, so
the floor cross-compiled and everything behind it built for the host; and
`go generate` inherited the cross-target and tried to exec an amd64 zipdoc on
arm64. A generator runs where make runs, always.

SIZING IS ONE RULE, J apps at once with P compilers each, and J*P is what the
box sees. J is bounded by MEMORY read from the cgroup before /proc/meminfo,
because the git-runner pod is 26Gi on 6 CPU while nproc inside it reports the
NODE's cores — sizing off nproc there asks for a dozen concurrent links in a
cgroup that holds eight. The heaviest link measures 1.67 GB resident, not the
6.23 GB the pod's config still cites; that number predates the light host.

mk/go.mk stops imposing -p=2 on SOLO builds, which never had the problem it was
protecting against: one app from an empty cache is 57.6s at -p=2 and 41.4s with
the box. The runner's own injected GOFLAGS still wins, via `?=`.

Two negative results are recorded rather than dropped, because both are the
first thing the next person reaches for:

  * Prebuilding the shared floor is SLOWER. J cold builds each compile the
    587-package root, so warming it first is the obvious fix; at J=10 P=2 it went
    300s -> 339s (the root) -> 327s (all 4149 packages). One process on a
    dependency-shaped graph idles the box longer than the duplication costs.
  * More parallelism is slower past a point: 431s at J=20 P=2 against 300s at
    J=10 P=2, the same work with twice the actions.

hanzo.yml names the binaries: lane it cannot yet declare, and both blockers,
instead of tripping over them: ci's run:/out: lane indexes per RECIPE rather
than per FILE (unreadable to manifest/release.go), and `bucket:` needs an
S3_ADMIN_* credential that is in KMS for no org — declaring it would publish
nothing and red every tag build, which is the state ci's own site: lane shipped
in.
2026-08-06 12:22:26 -07:00
hanzo-devandzeekay 3846c7440b slack: say something immediately, and tell the model its tools have a protocol
Two defects behind "it is SUPER SLOW and it seems stupid". Measured in
production, a turn is:

  slack ack            0-6 ms      (well under Slack's 3s retry threshold)
  plane overhead      ~25 ms       (53,009 vs 52,985)
  model completion    9,955 / 35,893 / 52,985 ms   <- all of it

So the plumbing is not slow; the model takes ten to fifty seconds, and until now
the person saw an EMPTY THREAD for the whole of it. That is indistinguishable
from a dead bot, and it is what the "does nothing" reports actually were.

setStatus is Slack's own affordance and the only one available: there is no SSE
to a Slack client, so the honest vocabulary is a status then a message, never a
token stream. A second placeholder message would be worse — it occupies the
thread with something the reader must skip. Best-effort by construction: a
3-second budget, every error swallowed, because a courtesy that runs BEFORE the
work must never delay the answer it announces. Skipped when there is no thread
rather than faked.

THE MODEL DID NOT KNOW ITS TOOLS HAD A PROTOCOL, and this is why it looked
stupid rather than merely slow. The surface was collapsed from 1,189 flat tools
(977 KB, ~244k tokens just to LIST) to 88 grouped hanzo_<subsystem> tools whose
only argument is an `op` enum of bare names — the schemas are fetched on demand
through hanzo_describe. That trade is a WIN only if the model is told how to
make the fetch. It was not: builtinAgentInstructions was three sentences that
never mentioned tools at all. The model saw 88 tools it could not interpret and
answered from memory, which is exactly the observed behaviour.

The instructions now state the protocol — grouped by subsystem, choose an op
from the enum, call hanzo_describe for a shape you do not know — and say plainly
that answering from memory is wrong here, because the question is about THIS
org's live cloud and no training data contains it.

Kept short on purpose: every sentence is read on every turn and spends context
the user's actual question needs.

go build ./... clean; apps/integrations passes.

(cherry picked from commit 0d4093193e)
2026-08-06 12:14:44 -07:00
hanzo-dev 0d4093193e slack: say something immediately, and tell the model its tools have a protocol
Two defects behind "it is SUPER SLOW and it seems stupid". Measured in
production, a turn is:

  slack ack            0-6 ms      (well under Slack's 3s retry threshold)
  plane overhead      ~25 ms       (53,009 vs 52,985)
  model completion    9,955 / 35,893 / 52,985 ms   <- all of it

So the plumbing is not slow; the model takes ten to fifty seconds, and until now
the person saw an EMPTY THREAD for the whole of it. That is indistinguishable
from a dead bot, and it is what the "does nothing" reports actually were.

setStatus is Slack's own affordance and the only one available: there is no SSE
to a Slack client, so the honest vocabulary is a status then a message, never a
token stream. A second placeholder message would be worse — it occupies the
thread with something the reader must skip. Best-effort by construction: a
3-second budget, every error swallowed, because a courtesy that runs BEFORE the
work must never delay the answer it announces. Skipped when there is no thread
rather than faked.

THE MODEL DID NOT KNOW ITS TOOLS HAD A PROTOCOL, and this is why it looked
stupid rather than merely slow. The surface was collapsed from 1,189 flat tools
(977 KB, ~244k tokens just to LIST) to 88 grouped hanzo_<subsystem> tools whose
only argument is an `op` enum of bare names — the schemas are fetched on demand
through hanzo_describe. That trade is a WIN only if the model is told how to
make the fetch. It was not: builtinAgentInstructions was three sentences that
never mentioned tools at all. The model saw 88 tools it could not interpret and
answered from memory, which is exactly the observed behaviour.

The instructions now state the protocol — grouped by subsystem, choose an op
from the enum, call hanzo_describe for a shape you do not know — and say plainly
that answering from memory is wrong here, because the question is about THIS
org's live cloud and no training data contains it.

Kept short on purpose: every sentence is read on every turn and spends context
the user's actual question needs.

go build ./... clean; apps/integrations passes.
2026-08-06 12:13:54 -07:00
hanzo-dev 2bcf761f2d money: a new org's first billable request funds itself, once, under the screen
The paywall could not be switched on. A brand-new org's wallet is $0, the
automatic starter grant was deleted (41b23f12), and the "$5 free credit" the
catalog advertises was never provisioned -- so enforcing SpendGate would have
402'd every new signup on request #1. This adds the missing rung.

WHERE IT SITS. One call site, in standing()'s proven-unpaid branch, past the
enforcement check: the last point at which an account that has never been
funded can still be told apart from one that will not pay. Both authorities
have already answered, so the rung inherits "no subscription and no credit"
as a finding rather than re-reading it.

WHY THIS IS NOT THE MECHANISM THAT WAS DELETED. The old grant ran as app-wide
middleware on first credential contact and minted into any wallet that did not
exist yet. This one:

  - is not money -- it posts the plan's own advertised term under the shared
    starter-credit tag, which billing/bucket classifies non-cash: spendable on
    metered usage, never refundable, never paid out;
  - is not unbounded -- the amount is credit.StarterCreditCents, the constant
    the catalog's entry plan advertises, and fund() takes no amount at all, so
    no request body reaches it;
  - is not repeatable -- starterRef keys the deposit on the address it credits,
    with no time, nonce or request id, so finance dedups it inside the same
    transaction as the insert;
  - is not unscreened -- every grant is a PRIVILEGED Decide at StageSignup, so
    a flagged signup receives nothing and a scorer that is present and silent
    withholds. That is the bound on "$5 x as many fake signups as can be made";
  - has no switch of its own -- it is unreachable while the paywall is dark, so
    the mint and the refusal it cures are the same flag. That is the structural
    answer to "a disabled money-mint is one flag away from an enabled one".

Two further guards keep one grant to one customer: the shared signup org is
excluded (a login is not an account), and only the caller's home org is funded,
because a founder is a member of every org they create and position -- orgs[0],
minted as X-User-Owner -- is what marks the one that is theirs.

Funding is not an authorization decision: a grant that cannot be posted leaves
the gate to refuse in its own words, with the actionable 402 body.

No switch is flipped here. SwitchPaywallEnforced and enableSignUp stay as they
are; this only makes the rung exist so the flip is a pricing decision.

Every mechanism above is mutation-proven: removing the rung, the ref dedup, the
screen, the Privileged bit, the lifetime-usage leg, the catalog amount, the
dark-gate guard, the home-org guard or the signup-org exclusion each turns a
test red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:11:35 -07:00
hanzo-dev 39c2dc6923 money: a new org's first billable request funds itself, once, under the screen
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
The paywall could not be switched on. A brand-new org's wallet is $0, the
automatic starter grant was deleted (41b23f12), and the "$5 free credit" the
catalog advertises was never provisioned -- so enforcing SpendGate would have
402'd every new signup on request #1. This adds the missing rung.

WHERE IT SITS. One call site, in standing()'s proven-unpaid branch, past the
enforcement check: the last point at which an account that has never been
funded can still be told apart from one that will not pay. Both authorities
have already answered, so the rung inherits "no subscription and no credit"
as a finding rather than re-reading it.

WHY THIS IS NOT THE MECHANISM THAT WAS DELETED. The old grant ran as app-wide
middleware on first credential contact and minted into any wallet that did not
exist yet. This one:

  - is not money -- it posts the plan's own advertised term under the shared
    starter-credit tag, which billing/bucket classifies non-cash: spendable on
    metered usage, never refundable, never paid out;
  - is not unbounded -- the amount is credit.StarterCreditCents, the constant
    the catalog's entry plan advertises, and fund() takes no amount at all, so
    no request body reaches it;
  - is not repeatable -- starterRef keys the deposit on the address it credits,
    with no time, nonce or request id, so finance dedups it inside the same
    transaction as the insert;
  - is not unscreened -- every grant is a PRIVILEGED Decide at StageSignup, so
    a flagged signup receives nothing and a scorer that is present and silent
    withholds. That is the bound on "$5 x as many fake signups as can be made";
  - has no switch of its own -- it is unreachable while the paywall is dark, so
    the mint and the refusal it cures are the same flag. That is the structural
    answer to "a disabled money-mint is one flag away from an enabled one".

Two further guards keep one grant to one customer: the shared signup org is
excluded (a login is not an account), and only the caller's home org is funded,
because a founder is a member of every org they create and position -- orgs[0],
minted as X-User-Owner -- is what marks the one that is theirs.

Funding is not an authorization decision: a grant that cannot be posted leaves
the gate to refuse in its own words, with the actionable 402 body.

No switch is flipped here. SwitchPaywallEnforced and enableSignUp stay as they
are; this only makes the rung exist so the flip is a pricing decision.

Every mechanism above is mutation-proven: removing the rung, the ref dedup, the
screen, the Privileged bit, the lifetime-usage leg, the catalog amount, the
dark-gate guard, the home-org guard or the signup-org exclusion each turns a
test red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:10:57 -07:00
hanzo-devandzeekay 70572332b0 sandbox: the account a sandbox runs as is a fact, not a default
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
podSpec named no ServiceAccount, so every sandbox pod ran as `default` — and
DOKS's registry integration re-attaches its own DigitalOcean pull secrets to
every namespace's `default` account whenever it reconciles. A sandbox therefore
inherited a credential for a registry that is not ours and died asking
registry.hanzo.ai for its image anonymously: a 401 that reads like a bad
password and was in fact no password at all.

`sandbox` is ours and DOKS does not manage it. It is bound to no Role and
carries exactly one thing, the pull secret; the line above it still refuses the
pod a token, so naming an account grants nothing that omitting one withheld.

A constant and not an env var: which accounts exist in the sandbox namespace is
decided by the manifest that creates the namespace, so a second knob here could
only ever disagree with it. Declared in universe infra/k8s/sandboxes/registry.yaml.
2026-08-06 12:10:20 -07:00
hanzo-dev 23a6c6edea commerce: the four billing reads say what they do
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 drift gate stopped on commerce again, this time on the four reads mount.go
registered so the billing app's tabs would stop rendering empty: GET
/v1/billing/transactions, /credit-balance, /accounts and /accounts/{id}/members.
They were routed and described nowhere, and an operation that says nothing about
itself publishes an operationId and no sentence — an SDK method that cannot
explain itself and an MCP tool a model cannot pick.

They are raw by nature — the handlers live in the commerce module, so there is no
doc comment here for zipdoc to lift — so the prose is declared with
openapi.Describe beside the route table, which is where this file already keeps
the other seventy-five.

The prose is written from the handlers and from the chain mount.go gives them,
and it leads with the fact a reader is most likely to get wrong: these handlers
filter on a user or userId parameter, and PinBillingSubject OVERWRITES that key
with the caller's own account.Payer subject before the handler runs. So the
parameter is not the caller's to choose — naming another subject returns your own
rows, not theirs. Saying otherwise, or saying nothing, would leave a generated
client's author believing they had found a way to read another tenant's ledger.
accounts/{id}/members has no subject key to pin and guards itself instead, by
comparing the path segment against the resolved org and answering 403; that is
stated too, because it is the one member of the family whose refusal comes from
somewhere else. Both refusals are 401 unauthenticated rather than 403, because a
browser re-authenticates on the first and merely reports the second.

Regenerated from source: 1759 -> 1768 paths, 2430 -> 2440 operations, billing 39
-> 43. Every number rises; nothing shrank, so the floor moves up rather than
being lowered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:05:14 -07:00
hanzo-dev 7772f7fe80 mcp: one door, one media type — and the ordering suspicion is pinned as innocent
Follow-up to 7e34af0a, closing the two things it left open.

The terminal handler answered with `application/json; charset=utf-8` — this
file's usual spelling — while zip's mounted route answers `application/json`.
An agent could therefore tell WHICH adapter carried its answer, which is the
second door reappearing as a header. Unified to zip's media type, and
TestDoorlessPluginAnswersItsOwnDoor now compares the two paths' Content-Type
directly, so a future divergence fails rather than merely looking untidy.

TestCatchAllNeverShadowsTheDoor pins what was NOT the cause. Registration order
was the obvious suspect and it is innocent: zap-proto/fiber's insertRouteSorted
ranks a longer static literal ahead of the greedy `/*`, so a control route
registered late still sorts ahead of the console catch-all. Nor was it the
zip v1.25.1 -> v1.27.0 bump — generation.go is byte-identical across the two.
The cause was only ever installMCP declining to mount a route for an app with an
empty edge registry. Both dead ends are recorded in the test and in
manifest/mcp.go so the next reader does not re-run the investigation.

manifest/mcp.go's claim is corrected at its source. It said the host's framework
path "is claimed by nobody, which is exactly why the front door has to name it".
The host claims it now, from fleet.Mount, and a plugin answers there with its own
door — so the comment describes the mechanism that exists rather than the one
that was replaced.
2026-08-06 12:01:59 -07:00
zeekayandhanzo-dev 6232358a8e commerce v1.50.20 — the crypto rail's other half
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.50.16 (already live) stopped the rail from handing out addresses nothing
could credit. These four carry the machinery that will eventually let it be
lifted, and all of it ships COLD:

- v1.50.18 the EVM deposit watcher. Exactly-once is a property of the KEY —
  sha256(chain:txHash:logIndex) against a backend that upserts — so re-scans,
  crash retries and N replicas all yield one credit with no leader election.
  Disabled unless CRYPTO_DEPOSIT_* assets are configured, which they are not.
- v1.50.19 GET /v1/billing/crypto/options answers from the watcher's assets
  instead of the MPC processor's mintable chains, so the picker cannot name an
  asset nothing is watching.
- v1.50.20 GenerateAddress returns {Address, ID} and the intent records the
  custody handle, so a credited deposit is one we can also sweep.

The gate (cryptoDepositsCanBeCredited) is still false and is a constant, not
config — nothing in this bump can take money.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:56:55 -07:00
hanzo-dev 86c51b1526 git: a pkt-line payload with an embedded newline is refused
Red re-reviewed the eight-door work and found the ref policy bypassed by the
code added to close its own lowest-severity finding. L2 was "shallow and signed
pushes fail to parse" — an availability nit. The push-certificate parser written
to fix it disagrees with real git about where a certificate's commands are, and
the disagreement is total.

git concatenates every cert pkt-line payload into one buffer and takes the
commands between the FIRST "\n\n" and the signature offset
(builtin/receive-pack.c, queue_commands_from_cert). This parser treats one
pkt-line as one logical line. Embed a "\n\n" inside a header payload and the two
part ways: we parse ZERO commands, git parses one and applies it.

Zero commands WAS the exploit. checkRefPolicy over an empty slice iterates
nothing and returns nil, because an empty command list satisfies every policy —
including a grant confined to one agent ref. The raw bytes are then forwarded to
git receive-pack, which executes the command we never saw.

Verified by Red against git 2.43: a 262-byte certificate whose `pusher` payload
carries "\n\n<old> <new> refs/heads/main\n" makes a grant scoped to
refs/heads/agent/<session> write refs/heads/main — which fires cloud.OnGitPush
and deploys a model-authored, unreviewed commit. No signature and no nonce are
needed; the forge sets no receive.certNonceSeed. Negative control: the same
trunk write as a PLAIN push under the same grant is correctly refused, so the
policy works and the hole is specifically the framing.

TWO GUARDS, either sufficient, so neither is load-bearing alone:

1. parseRefCommandsCaps refuses any pkt-line payload carrying an embedded
   newline. This makes the parser's own one-line assumption TRUE rather than
   assumed. Narrower and safer than teaching it git's concatenation: parity with
   a second implementation must be re-proved every time either side changes,
   whereas a payload with no embedded newline can only be read one way BY BOTH.
   Nothing legitimate is lost — git's send-pack emits one line per pkt-line.

2. checkRefPolicy refuses a grant-bearing push that names no ref. A grant exists
   to write one named ref, so naming none means the frame did not parse the way
   we think it did. For a principal an empty push stays a harmless no-op.

Tests use Red's exact 262-byte exploit and FAIL without the guards — verified by
reverting them and re-running. The negative control (an ordinary single-line
push still parses and yields its one command) is what keeps the fix from being
worse than the bug.

Red's other findings are NOT closed here and are follow-ups, all lower:
writer 9 (corePush -> initBare sets HEAD to an agent branch on a NEW repo,
dead-ending checkHeadRef, principal-only), a check-then-set TOCTOU in corePush
(go-git SetReference is not CAS; the wire path is safe because git does CAS ref
locking), and zapface minting a socket for any credential string.

apps/git shows the same 3 pre-existing failures as baseline; go build ./... clean.
2026-08-06 11:53:37 -07:00
hanzo-dev f4cc9249b8 mcp: the door gate hunts a ROUTE, so importing the protocol stops looking like a rival
TestNoSecondMCPDoorInSource matched any string literal ending in /mcp, which made
`"github.com/zap-proto/mcp"` read as a second door. It is the opposite: webui's
terminal handler imports the PROTOCOL precisely so it can hand a frame to zip's
existing door rather than write an envelope of its own.

The pattern now requires a leading slash, which is what actually distinguishes
the two — a door is a route path, rooted and ending at /mcp; an import path is
neither. The gate still fails on a real second door, and apps/tasks' raw net/http
mux handler is still the reason it reads SOURCE rather than the document.
2026-08-06 11:50:10 -07:00
hanzo-dev 5f7e632dde merge: take main forward under the lenses
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
2026-08-06 11:50:01 -07:00
hanzo-dev 7e34af0a23 mcp: a plugin answers its own door, and the signpost moves to the host that moved it
Every per-app plugin binary answered POST /mcp with 308 to /v1/mcp, and then 404
there. Measured on bin/kms and bin/tasks before this change; the door a subsystem
owns was unreachable over HTTP.

The console's terminal handler sent it. Its reasoning was that a plugin serving
its own door at the framework default matches a real route and never reaches a
terminal handler — so anything arriving there had to be a caller who guessed the
default on a host that moved it. That is false for a whole class of app: zip
mounts the /mcp route only when there is something to expose (installMCP), and a
plugin whose typed ops live on the internal plane has an EMPTY edge registry.
kms is the honest example — its four secret ops are on cloud.Plane() precisely so
no route runs from the edge to a secret — so kms has no route at its own door,
fell through to the console, and was told its door lived at an address only a
host serves.

The cost is not cosmetic. The fleet composes tools/list by asking every child at
FrameworkMCPPath and reading any non-2xx as an outage (fleet/fleet.go ask), so
such a child drops out of the composed list AND is reported down — for the crime
of having no tools. An empty list is the honest answer and it is a 200.

Two changes, each in the place that holds the fact:

The console SERVES the door instead of redirecting it. The door is not the route:
it is zip.App.MCP, a frame in and a frame out, which exists whether or not
anything was mounted over it. Serving zip's own door is not a second surface;
re-implementing one, or redirecting to a door this process does not have, is.

The signpost moves to fleet.Mount, which is the one place that KNOWS the door
moved, because it is the call that registers the new address — and it registers
the signpost only when the two differ. A host still tells a caller who guessed
/mcp where /v1/mcp is; a plugin, which moved nothing, no longer claims it did.

Verified against freshly built binaries, not by reading code:
  bin/kms       POST /mcp initialize  -> 200, protocolVersion 2025-06-18, serverInfo kms
                POST /mcp tools/list  -> 200 {"tools":[]}     (was 308 -> 404)
  bin/blueprint POST /mcp tools/list  -> 200, 2 tools, both carrying lifted prose
                POST /mcp tools/call  -> 200, get_v1_blueprint_health returned live data
2026-08-06 11:48:59 -07:00
hanzo-dev adf4be5067 merge: take main forward under the lenses 2026-08-06 11:47:41 -07:00
hanzo-dev 46c2f1ce53 Merge hanzo-inc/cloud into the canonical forge
CI/CD / containment (push) Successful in 4m15s
Hanzo CI/CD / cicd (push) Failing after 31m56s
CI/CD / gate (push) Failing after 31m56s
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
git.hanzo.ai is canonical for every repo; GitHub is a mirror. These two had
diverged and BOTH sides held real work:

  forge only (8): the platform deploy/git-lane series — b17f9e6a..26cfa8e5
  hanzo-inc only (1): 827f3e66 plane: a call expires on the CALLER's deadline

Production was running 827f3e66 — a commit that existed ONLY on GitHub and not
on the canonical tree. That is the failure mode the one-remote rule exists to
prevent: the deployed sha was unreachable from the repo everything else reads.

Merged rather than force-pushed in either direction, so no commit is lost from
either side.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:43:03 -07:00
hanzo-dev 26cfa8e5db platform: rebase onto forge 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
Three conflicts, all mechanical: forge renamed Mount's router to zapp, moved
fleet.go's openapi.Describe calls out (so that import goes), and switched
runnerBuild to a plain ctx. Kept forge's shape in each and re-applied the
delivery wiring, the org attribution and the namespace import on top. Renamed
this package's test helper keysOf to mapKeys — forge added a keysOf of its own
with a different signature.

Verified by DIFF, not by commit count: this estate has a merge that kept a fix's
commit and dropped its diff, so every security-critical seam was re-checked in
the working tree — seal-by-default, the audit's caller-declared public set, the
tenant- reservation, owner() on both boards, checkFence, the rendered-patch
guard, tenantNamespace's single derivation, per-org build attribution, and the
deleted resource/logs handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:33:06 -07:00
hanzo-dev de97f6dfb7 the projections say what they do, and the one that said nothing is counted
A typed op mints five things from one registration. Three defects here let an
operation reach all five carrying no sentence, and one hand-written file
described a surface nobody generated.

POST /v1/exec published a summary and no description. The handler had a doc
comment; the package had no zipdoc directive, so nothing lifted it — and the op
registered on the cloud.Router parameter, which zipdoc cannot follow to a prefix,
so it refuses to lift even with the directive. openapi.Complete accepts EITHER a
summary or a description, so every gate stayed green over the hole. The
registration moves to the *zip.App (the apps/meet and apps/blueprint pattern);
routes move, the credential middleware stays on the scoped router where the
prefix guard applies to it. Its 15 published fields carry prose now too.

docs/automations-openapi.yaml is deleted. 25 KB of hand-authored OpenAPI over 17
operations of /v1/automations, referenced by nothing and compared by no gate — so
it had drifted the way a second copy always does: it claimed two operations the
fleet does not serve (GET /v1/automations/health, POST /v1/automations/mcp) and
omitted three it does (connectors/{id}/run, flows/{id}/versions,
hooks/{source}/{event}). The document already describes that surface.

The field tranche closes 343 published properties across seven apps — authors
39->0, label 37->0, channels 34->0, prompts 29->0, leaderboard 40->0, campaign
42->6, affiliates 123->10 — and campaign's two result types stop being defined
types over another struct, which published all 30 of their properties bare; the
struct is declared under its published name and the domain name is an alias, so
there is still one shape.

The 16 that remain are not app defects and are recorded as such in LLM.md: zip
keys a promoted field's prose under the type that DECLARES it and looks it up
under the type that PROMOTES it, so no comment written here can reach them.

LLM.md records what the running deployment measures, taken by making the request:
the served document is the committed one at the revision the header names (1735
paths / 2474 ops, identical sets), /v1/commands answers 2448 commands under a
working ETag, POST /v1/mcp lists 88 tools and tools/call returns lifted prose —
and those tools reach only 1189 of 2422 operations, with 134 declared refusals
accounting for a tenth of the gap.
2026-08-06 11:30:38 -07:00
hanzo-dev 3581728ceb platform: the git lane seals by default; a heuristic is the wrong shape for history
Red proved the classifier missed 8 of 8 real credential shapes, each for its own
reason: PGPASSWORD (libpq's own variable, one token to any splitter), *_PW (`pw`
absent), a symbol-rich password (the check returned false the moment it saw a
symbol, so a STRONGER password was MORE likely to be published), KUBECONFIG
(cluster-admin, base64 material, no PEM armour), a base32 MFA seed. Every fix
would have been another special case in a denylist over an unbounded set.

And the three gates were ONE gate: leakedSecret called the same mustSeal the
split called, so a miss passed both and reached git. An independent-looking
backstop that shares its single point of failure is worse than none, because it
is counted as defence.

THE FIX IS THE POLARITY, NOT A BETTER CLASSIFIER. This lane's output is a commit
in a repository replicated to every clone and impossible to unpublish, so what
matters is not how good the guess is but which way it fails. Every env value is
now a KMS reference UNLESS the caller explicitly marks it public. A miss means an
operator cannot read back a config value — a support ticket. The old miss meant a
password in git history — an incident with no rollback. It needs no list, so
there is no shape left to overlook, and it matches what the console already
sends. It also closes the over-seal complaint from the other side: GIT_COMMIT,
IMAGE_DIGEST and TENANT_ID are marked public and stay readable.

The audit now shares NO code with the decision it checks. It parses the RENDERED
bytes and asks a fact — "is this key in the set the caller marked public?" — not
a judgement. The public set is carried on the spec straight from the request and
is NOT derived from spec.Env: an audit that asks "is this key in Env?" of a
document rendered FROM Env answers yes by construction, which is the same
tautology in new clothing. A value reaching the render by any path the caller
did not authorise is refused, whatever it looks like.

V6 — the templatePatch guard scanned for the substring "project", which the very
engine that runs it defeats: `{{ "pro" }}ject:` and "\x70roject:" both set the
key and both evade a scan. It renders the patch with the generator's own funcs
and data and reads the RESULT as YAML. The real fleet patch (dig/syncPolicy)
still passes; anything resolving to a project is refused.

The operator lane keeps shape classification — it writes a database column it
can rewrite — but the named misses are closed there too, since they cost
nothing: substring matching for the names with no separator to tokenise,
`pw` added, and the symbol short-circuit inverted so a symbol-bearing,
space-free string of password length is password-SHAPED rather than exempt.

V2/V4 — evidence, and the containment, recorded at the emission site. The leading
slash is RIGHT: eleven live values files in universe use `secretsPath: /<path>`
and are healthy, and the chart schema requires it; the operator lane's no-slash
form is the outlier, flagged and not changed because it is live. Still
unverified: a CUSTOMER org's projectSlug resolving, since every live example is
platform-tier. Nothing from this lane can reach a pod yet — a declaration lands
on a branch the generator does not read, and modeCommit is refused by checkFence
until universe carries the companion rule. Both gates open deliberately, by a
human, and the first merge is where this resolves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:51 -07:00
hanzo-dev 182367f235 platform: the server decides what a secret is, and git never sees one
A key-NAME regex running in a BROWSER decided which env values were sealed. Two
things are wrong with that and the second is the one that matters: the key is
not the secret, the VALUE is — GCP_SA_JSON names nothing credential-ish and
holds a private key, while STRIPE_SK, SK_LIVE, GH_PAT, PGPASS, SMTP_PASS, HMAC
and TLS_CERT are ordinary names that pattern missed — and a control the caller
can edit is not a control. secretshape.go decides on the SERVER now: issuer
prefixes and PEM armour, connection strings carrying a password, and a narrow
entropy tier for tokens with no recognisable prefix, OR'd with a server-side key
test so DB_PASS=hunter2 is caught too. Both signals, because they fail in
opposite directions. The client flag stays as UX and may only ADD secrecy.

Not default-seal-everything: PORT, NODE_ENV and LOG_LEVEL would become
write-only values an operator cannot read back, and every config line would take
a KMS round trip on the deploy path.

★ THE DECLARE LANE WAS WORSE THAN THE OPERATOR LANE. sealSecretEnv protects a
database column; a values file is committed to universe, replicated to every
clone, and cannot be unpublished — a credential written into one is cleartext in
git history forever. The split now happens BEFORE anything is rendered: secrets
are sealed into KMS at the coordinate the operator lane already uses, and the
file carries a `kmsSecrets` reference plus env valueFrom.secretKeyRef. That is
the chart's own rule, in its own words: "Secrets are REFERENCES, never values —
that is what makes this values file safe to publish." Fail-closed harder here
than in the operator lane: no KMS, no deploy. And a last-line guard re-asks the
question of the BYTES about to be committed, so the decision is proven to have
held all the way to the write rather than assumed.

V1 — reservation extended to the namespaces the cluster actually runs (adnexus,
bootnode, team-go, preview, registry, pars-*) plus `*-system` as a SUFFIX, so the
next operator installed is not claimable in the window before someone extends a
list. Deliberately STATIC rather than derived from the live namespace set: a
derivation is the stronger rule but fails OPEN exactly when the apiserver is
unreachable, which is the moment it is most needed. Wrong only by omission,
never by outage.

V2 — fenceOf saw only spec.template. templatePatch is rendered as TEXT and
merged OVER it, so a patch setting spec.project overrides the fence unseen;
evaluating the merge faithfully means reimplementing the generator, so a patch
that mentions `project` is refused instead. And `env`/`expandenv` are removed
from the function map: they read the PROCESS environment, so the same expression
would render from cloud's environment here and the controller's there — one
expression, two answers, and the deciding one is not ours.

tenantNamespace derives ONCE. It sanitized an input that tenant(s, c) had
already sanitized, and Sanitize is not idempotent — re-folding its own
<fold>-<hash> output appends a second hash. So for every org whose name is not
already a clean label, platform wrote App CRs into tenant-<double> while
apps/deploy and apps/provisioning both scanned tenant-<single>: fails closed,
nothing crosses a boundary, but the org's board is silently empty and its CRs
orphaned. It was the outlier of three copies; the other two already took the
slug. Non-label input now resolves to the inert "unknown" rather than rendering a
malformed namespace, so a caller that skips tenant(s, c) fails closed too.

Deleted the unrouted, unscoped appResource/appTree/appLogs. They resolved a
namespace with no org filter and no redaction, and the typed client already
ships tree/resource/logs methods — wiring them would have been a cross-tenant
unredacted read. resource.go and logs.go go entirely; tree.go keeps buildTree,
which dashboard and detail actually route.

Follow-ups flagged, not slipped in: a SanitizedOrg type in hanzoai/namespace
(26 call sites across three packages, a cross-repo release) is what would make
double application a COMPILE error instead of a guard test; and the legacy
tenant-* namespaces still want migrating onto the bare-org layout.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:51 -07:00
hanzo-dev 0b5a074a89 platform: reserve the legacy tenant family, and ask one question in one place
R1 (HIGH, red) — THE RENAME'S OWN SHADOW. Dropping the `tenant-` prefix from
this API made `tenant-` an ordinary name, but the cluster's live fences are
still spelled with it: universe project-tenants.yaml declares AppProjects
tenant-hanzo|lux|zoo|zen|maxpower, each admitting namespace tenant-<org>. A
clean label, so Sanitize passes it through untouched — an IAM org named
`tenant-maxpower` would have claimed the REAL maxpower tenant's namespace under
maxpower's own fence and read its CD rows. Reserved as a FAMILY, so the next
tenant onboarded under the legacy layout is not claimable in the window before
someone extends a list. Red's PoC (7 tests) is kept and green.

That fix exposed a conflation in my own predicate, and splitting it is the real
change here. `reserved` was answering two questions at once:

  reserved(dir)  — may an org CLAIM this directory? The WRITE question. Wider
                   than platformOwned by exactly the tenant- family: those
                   directories are not the platform's, but they are already
                   somebody's.
  owner(ns)      — which org does this namespace BELONG to, "" for the
                   platform's own? The READ question. It decodes BOTH layouts,
                   because both are live.

Conflated, reserving tenant- would have blanked every legacy tenant's own board.
Two names, two questions, and the boards share the second one.

R3 (MEDIUM, LIVE) — /v1/platform/fleet asked nsOrg, which maps the brand
namespaces onto org "hanzo", so the ORG ADMIN of the brand org was handed the
platform tier — iam, kms, gateway — while the delivery board refused the
identical caller. A per-org isAdmin is never platform-privileged (HIP-0519);
this file's own deploy route already applied that reasoning to RESTARTING one of
these services, and observing them is the same class of act, only quieter, which
is why it survived. Both boards ask owner() now. Two tests asserted the old
behaviour in so many words ("an OrgAdmin of the platform org sees its own org's
whole board"); that expectation WAS the bug and they now assert the refusal.

R2 (MEDIUM) — checkFence refused one known-bad substring, so every OTHER unsafe
template passed: an unconditional `project: hanzo-platform`, a file with no
project, an empty file, garbage. A denylist of one is not a check. It now
asserts the POSITIVE — parses the ApplicationSet, evaluates its project template
for this org with the same engine and options the generator uses (text/template
+ sprig, missingkey=error), and compares to declareProject. Anything it cannot
answer is refused, including a template that reaches past the path for the fence
(the "thing being fenced chooses its fence" defect the ApplicationSet itself
rejects). Red's six templates are the refusal table.

R4 (LOW) — DECIDED: the per-org build ceiling stays SOFT, and the reasoning is
recorded where the check is. It is check-then-act; the only atom available is
the Job name, already spent on idempotency, and a counter object would be a
second source of truth about how many builds are running. The overrun is bounded
by requests in flight and confined to the caller's OWN org. The cluster bound
belongs where bounds are enforced atomically — a ResourceQuota on the build
namespace, applied at admission, which no race can widen.

Follow-up flagged, not done here: retire the legacy tenant-* namespaces and
AppProjects onto the bare-org layout. That is a migration, not a rename — it
moves live workloads — and the reservation holds until it lands.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:51 -07:00
hanzo-dev 39a81d08e3 platform: an org is its name, and reservation is what makes that safe
CTO naming rule: no prefix on an org. The values directory, the destination
namespace, the AppProject and the image path are all `<org>` —
charts/app/values/<org>/<app>.yaml, namespace <org>, project <org>,
ghcr.io/hanzoai/<org>/<app>. One value, four roles, nothing added to it.

The `tenant-` prefix was a naming convention doing a policy's job, and removing
it is only safe once the policy exists, because namespace.Sanitize is the
IDENTITY on a clean label: without a separator, customer directories and the
cluster's own namespaces share one name space, and an IAM org named
`kube-system` resolves to the real `kube-system`. RESERVATION replaces it —
one predicate, asked on read and on write, over the platform's namespace
family: the brands and their environments, the control and delivery planes,
kubernetes' own, and `admin`. A reserved directory is SuperAdmin-only even when
it is the caller's own org.

`tier` is gone with the prefix. An org is its name, so placement is not a field:
`org` is an ACT-AS, defaulting to the caller's own, and naming another requires
SuperAdmin. Both refusals refuse rather than downgrade, so an escape attempt is
never indistinguishable from a normal request.

The fence's real rule lives in universe's ApplicationSet, so it is VERIFIED, not
documented: checkFence reads the live template out of the clone the write
already makes and refuses to put a declaration on main while that template would
fence it wider than this API reports. It clears itself when universe lands the
reservation form — no flag, nothing to remember — and a branch write is exempt
because nothing is generated from a branch. A comment saying "land the companion
change first" is not a control, and neither is a test that fails on a
developer's machine and skips in CI.

RED FINDINGS, all fixed with red's PoC kept and green:

F1 (HIGH, cross-org read) — the write path derived a directory with
namespace.Sanitize(org) while the read path confined with the RAW owner claim.
Sanitize on ONE side of an authorization compare is a collision waiting to be
named: org "Acme" owns "acme-<hash>", so it never matched its own rows, and any
org whose raw name IS that literal string matched them instead — offline
-computable, since the slugger is public code. Both sides are canonicalised now,
in cd.go owns AND in fleet.go scopeNamespaces, which carried the identical
compare and is LIVE on /v1/platform/fleet. Sanitize is injective, so this is
collision-free and not merely symmetric. cdApps had no tests; it now has a table
over every shape of name Sanitize treats differently.

F3 (build DoS) — a declare build was charged to the constant "platform", so one
org looping deploys exhausted a shared ceiling of 3 and locked the fleet out of
building, with no attribution in the Job labels to see it by. launchDirectBuild
takes the org it is charged to; the ceiling is per-org, like /v1/runner's.

F4 — the default host interpolated the RAW owner claim, so any org without a
clean name got "web.Acme.hanzo.app", not a hostname at all. It is built from the
canonical org, the same value that is the directory.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:51 -07:00
hanzo-dev 067ab86de3 platform: a retry of the same deploy is a no-op, not a git error
Every client retries. A branch write based on main committed the same tree at a
different timestamp on the second call, so a different sha, so a non-fast-forward
push and a raw git hint the caller could not act on. A branch write now BASES ON
ITS OWN BRANCH when that ref exists — the push is a fast-forward, and when the
declaration is already exactly this the whole call is a clean no-op reporting the
same ref and review URL.

The env comparison was the same defect one layer up: an existing declaration
refused ANY env, so a retry of a create-with-env could never succeed. It now
compares environments as sets of name=value and refuses only a genuine change.

The test that pins this sleeps a second between the two calls, and that sleep is
the point. Without it both commits land in the same second, git mints the
identical sha, the push is "everything up-to-date" and the bug is invisible —
which is exactly how the first version of this test passed while the bug was live.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:13 -07:00
hanzo-dev 597ece7dea platform: refuse build+commit before spending the build
A commit to main proves the image pullable, and the image a build launched by
the same call would produce does not exist until the Job finishes — so the
combination could only ever fail, after a privileged BuildKit Job had already
been spent on it. Refused at validation instead, naming the two-step flow the
caller wants: deploy (which returns the build's tag), then commit that tag once
the build is green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:13 -07:00
hanzo-dev b17f9e6a60 platform: deploy an app by declaring it, and read what CD did with it
/v1/platform/apps is the delivery surface for the ONE deploy plane. An app is a
values file in hanzoai/universe under charts/app/values/<namespace>/<name>.yaml;
the fleet ApplicationSet renders charts/app against it and cd.hanzo.ai reconciles
the result. Declaring the file is the whole of deploying the app, so the surface
is three moves: build the repository through the existing BuildKit lane, write
the declaration that names the image, and report the Application reconciling it.

Two things a caller may never choose, because both are fences.

The DIRECTORY. The generator derives the Application name, the destination
namespace, the Helm release name and — load-bearing — the AppProject from the
file's own path: tenant-<org> admits ONE namespace, no cluster scope and six
kinds, while hanzo-platform admits namespace * and ClusterRole/ClusterRoleBinding.
A caller that could name its directory could name its fence, so the directory is
derived from the validated owner claim and is not a request field. tier=platform
is the one lever over placement and it is SuperAdmin only.

The IMAGE REPOSITORY. A declaration is what the cluster pulls, so it is derived
per tenant on the same injective path the build lane already uses. There is no
image field to try.

A branch is not a deploy. The generator reads main, so the default mode pushes
deploy/<ns>/<name>/<tag> and deploys nothing; merging the review is the
deliberate act. mode=commit writes main and proves the image pullable first,
because a declaration naming an image the registry cannot serve is an
ImagePullBackOff with no rollback path.

The git seam is pin.go's, not a second one: the same shallow clone, the same KMS
-held token carried as an http.extraHeader rather than in argv, the same
fast-forward-only push retried by re-reading the tip. This composes with the pin
rather than duplicating it — declare ADDS a service deliberately, which is
exactly what resolvePinFile refuses to do, and the pin then moves its tag. An
update here moves that one scalar too and refuses anything that would rewrite a
hand-maintained declaration.

/v1/platform/cd reads the Applications in hanzo-cd. A cluster with no CD answers
an empty plane; a plane that cannot be READ answers 503 and says why, because
those are opposite facts. /v1/platform/ci answers 501 and names what is missing
rather than fabricating an empty run list — this deployment has no forge API
client. Static sites and bucket listing are NOT added: /v1/platform/sites and
/v1/s3/buckets already serve them.

Proved against the real chart, not a fixture: the generated declaration renders
through hanzoai/universe charts/app with its values.schema.json enforced, and a
key the chart does not declare is refused by that schema. A dry run against a
clone holding the real 105-file inventory wrote a scratch branch and left main
byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 11:28:13 -07:00
hanzo-devandzeekay 6dbc3b9d2e git: a run's credential stops being an identity, and the ref policy stands in all eight doors
refpolicy.go claimed receive-pack was "the point every path that changes a ref
passes through and no client can decline to". That was false, and it was not a
detail — it was the entire argument. It guarded ONE of eight ref writers into the
same bare repository, and the other seven took the credential the run already
held.

The cheapest was not the git wire protocol at all. One JSON POST to
/v1/git/repos/:name/push lands a FAST-FORWARD CHILD on the branch a human just
approved: worse than the force-push the wire door refused, because append-only
trips no "the branch was rewritten" signal anywhere. The reviewer approved A and
merges A+B. /repos/:name/mirror was worse still — `+refs/*:refs/*` with --prune
force-overwrites every ref from a source the caller names and DELETES any ref
that source omits. SSH receive-pack piped straight into git with no policy in
front of it, and any org member may enrol a key. Verified by test: before this,
a mirror from an attacker-chosen upstream deleted a reviewed agent branch and
created one of its own.

Underneath all seven was the real problem. The credential was an org-wide `agent`
key sealed in KMS — an ordinary IAM sk-, which IAM resolves to a USER, from which
cloud mints a full org principal, which cloud.Member admits everywhere. The one
process executing untrusted model output held something that opened
GET /v1/kms/secrets (every other secret the org has, including whatever posts to
its Slack) and every org-scoped API in the platform. Custody had not shrunk to a
safe place; it had shrunk to the worst place, holding a skeleton key.

So the credential stops being an identity. A run now asks the forge for a GRANT
(apps/git/grant.go): a bounded permission to drive the pack protocol against ONE
repository, creating ONE ref, until it expires. It authenticates nobody. It is
not a JWT and carries none of APIKeyPrefixes, so validatedPrincipal returns nil,
no X-User-Id is minted, principal.Validated is false, and every cloud.Guard and
every tenantOf refuses it BY DEFAULT — nothing had to be told to say no. The one
exception is resolvePackRepo, so the set of doors a grant opens is the set of
callers of that function, and a principal always wins because the grant is
consulted only where there is none. git owns refs, so git decides who may write
one; the orchestrator is no longer a credential custodian, and there is no
org-wide agent secret left in the world to seal or to leak. The constants that
named one are deleted, not deprecated: a constant naming a secret is an
instruction to seal one.

The policy then moved to all eight writers, enumerated in refpolicy.go beside the
rule so a ninth is a change to the list and not just a new function. Five state
their intent as the same refCommand value the wire door parses and call the same
function. Two cannot be judged command-by-command and are refused structurally
instead: the mirror takes a negative refspec so the machine namespace is outside
its refmap and therefore outside --prune, and HEAD — which no refspec reaches —
goes past checkHeadRef, including the first-push case where a client-less push
would otherwise make a run's branch the repository default, which is what the
deploy reactors gate on. One (tag fetch) was already structurally safe and now
says so.

Also, from the same review:

  - Base was TrimSpace and nothing else while Repo and Org were shape-checked. It
    reaches a `git clone -b <base>` argv on a CUSTOMER'S machine, where a leading
    dash is not a branch but a flag, and --upload-pack= / --config=core.fsmonitor=
    are each arbitrary execution there. BaseRE is git's own branch shape; the
    load-bearing part is that a branch is alnum-led. Project is shaped too.
  - The PR head is pinned to the cloud-issued BranchFor(sessionID). Adopting the
    sandbox's self-reported branch let a compromised one answer `main`, which
    passed VerifyRef (which only asks whether a ref EXISTS) and came out as
    CreatePR{Head: "main"} — a pull request headed at the trunk, filed by us, for
    a run that never had permission to write there. Same on the routed path,
    where the reporter is a customer's machine.
  - A refusal naming many refs was wrapped in ONE side-band packet. A pkt-line
    length is four hex digits, so past ~65516 bytes it renders five and the client
    desynchronises — the report the control exists to deliver became the
    "RPC failed / unexpected disconnect" it exists to avoid. Now chunked, with
    each line bounded independently.
  - A push from a --depth clone (`shallow <oid>` lines) and `git push --signed`
    (a push certificate wrapping the commands) both failed to parse and answered
    400. Fail-closed, but closed on ordinary clients doing ordinary things, which
    is how a control gets switched off. Both parse now, and a certificate's
    commands are still judged — parsing one is not a bypass.
  - A run's budget was unbounded, so a caller could hold one of its org's two pool
    slots for a day and ask the forge to delegate a push for just as long. Capped,
    and the grant's lifetime is now derived from it.

Proved with the real client against the real server, one test per door:
refwriters_wire_test.go drives the real git CLI over the real SSH listener and
over smart-HTTP, and refpolicy_framing_test.go replays wire bytes captured from
git 2.43. A grant clones its repository and pushes its one ref, and is refused —
with git's own report-status, in words the pusher can read — for the trunk, for
another run's branch, for a non-agent ref, for deleting the trunk, and for
appending to its own branch. The legitimate paths are asserted too: an agent
branch still creates, the builder's client-less push to main still lands, and an
ordinary SSH push to main still lands.

The sandbox remains INERT: BOT_GATEWAY_URL and HANZO_CODING_SANDBOX_IMAGE stay
unset until Red has re-reviewed this.
2026-08-06 11:18:13 -07:00
hanzo-dev 827f3e6692 plane: a call expires on the CALLER's deadline, not the transport's
Every @hanzo turn in Slack answered "the agent hit an error handling that". With
the bridge's new non-silent branch the reason finally surfaced:

  zip: call agents_run_on_behalf at /var/lib/cloud/run/agents.sock:
  zaphttp: read response: i/o timeout

zap-proto/http@v0.3.1 sets readTimeout: 30 * time.Second (client.go:72) on every
dialled transport. An agent turn runs a real model completion and enso spends
40s+ on one — production measured 41,153ms, answered 200 by BOTH `ai` and
`agents`. The work succeeded. The caller had already hung up, and the reply was
written to a socket nobody was reading.

That is the worst shape a timeout can have: the expensive work is done and
billed, the callee logs success, and only the caller reports failure — so every
log you would naturally check says the system is healthy. It is why this
survived a day of looking at a healthy `ai`.

The context budget could not save it. The bridge bounds a turn at 110s on a
detached context; that governs the CALL and never reaches the transport's own
SetReadDeadline, which is wall-clock on the connection and shorter. Two
deadlines for one operation, and the smaller one — the one nobody chose — won.

The fix is a registration, not a patch. zip resolves a scheme through
RegisterTransport (transport.go:109) and its default zap Dial is one line, so
re-register the same scheme with the same dialler plus the knob zap-proto/http
exports for exactly this (SetReadTimeout, client.go:81). The stock Serve is
restated verbatim because re-registering replaces BOTH halves — omitting it
would stop every plugin listening.

15 minutes, matching the host's own plugin-start budget, so there is ONE answer
to "how long may an in-flight plane call take". It is a CEILING, never a floor:
every caller still bounds itself, and a caller that gives itself ten seconds
still gets ten. What changes is that it can no longer be cut off BELOW its own
budget by a default it never saw.

Tests pin the property rather than the wall clock (a unit test cannot hold a
socket for 40s): the ceiling must exceed the longest real caller budget,
re-registration must be idempotent, and networkOf must match zip's rule — it is
copied because zip keeps it unexported, and a drifted copy would hand a unix
path to a tcp dialler.
2026-08-06 11:17:14 -07:00
zeekay 7c50638eb1 merge inc2/main: one line again
CI/CD / containment (push) Successful in 1m55s
Hanzo CI/CD / cicd (push) Failing after 6m48s
CI/CD / gate (push) Failing after 6m49s
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 two private lines had diverged 191/18 off a merge-base six hours old.
Production builds from inc2; every push went to forge; neither knew.

The 14 conflicting hunks, and why each resolved the way it did:

- bridgeReply loses its ctx parameter (inc2). Both lines fixed the same
  production bug — the webhook's ctx made zip drop the stated org, so the
  balance gate answered "no org on the call". forge passed a detached ctx in;
  inc2 deleted the parameter so the webhook's cannot be passed at all, and
  states the tenant with cloud.For. Unrepresentable beats discouraged. Its
  body now calls forge's bridgeIdentity, so the link lookup exists once.
- commerce prefixes are the union: forge's /v1/cart and the /v1/billing/topup
  stem, inc2's accounts, credit-balance and transactions. topup/token is
  dropped — the stem owns its subtree. A prefix missing here never reaches
  commerce; it falls to ai's bare /v1 and answers ai's 404.
- zipdoc-check keeps inc2's target (forge repeated the loop twice) with
  forge's body: per-package, because whole-module load extracts differently
  than the generator it polices, and skipping dot-dirs, because an agent
  worktree is a second checkout and the walk read 203 packages where there
  are 104.
- plane.go keeps both new sections — sandbox ops and the coding-run seams are
  orthogonal, no shared names.
- plane_debit_harness_test.go takes forge's, which delegates to
  internal/planetest instead of holding a second copy. Same API. The copy is
  what hid a bind failure: a unix path is 108 bytes and t.TempDir() spells
  the test's name into it.
- metering keeps Actor and the token counts; plane.Usage has the fields, and
  a debit without them can be re-read but not re-derived.
- o11y v1.5.62 over v1.5.61 — upgrade, never downgrade.

go build ./... and go vet clean. Full suite: 3 red (base, code, commerce),
identical to both parents — this box's SQLite lacks acos and fts5.
2026-08-06 11:06:47 -07:00
zeekay 5fd1f4eea5 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
CI/CD / image (push) Successful in 18m7s
CI/CD / gate (push) Successful in 32m51s
CI/CD / containment (push) Successful in 55s
Hanzo CI/CD / cicd (push) Successful in 32m51s
CI/CD / rollout (push) Failing after 11s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 7s
2026-08-06 07:56:06 -07:00
zeekay d09ff7317b exec: the tenant is not a header, and the credential is not a path
Red returned do-not-ship on two CRITICALs that chain to unauthenticated
cross-tenant code execution. Both are proven by apps/exec/auth_test.go, and all
four assertions FAIL when the old behaviour is put back — mutation-checked, not
asserted.

1. THE TENANT CAME OFF A HEADER. callCtx preferred cloud.Who(ctx).Org, which is
   zip.CallerOf, which reads the X-Org-Id REQUEST HEADER (zip caller.go:377) —
   and for a request with no validated bearer, SanitizeIdentity deliberately
   RESTORES the client's own header (middleware_identity.go:455). So
   `X-Org-Id: victim-corp` made storeFor open the victim's SQLite file: the run
   executed in their store and /v1/files + /v1/download read their artifacts
   back out. principal.OrgFrom is the org a VALIDATED principal resolved to and
   nothing else; every other app resolves through it and this one did not.
   plane.go's own note — "an org in the argument is an org the caller chose" —
   is the rule it was breaking.

2. THE CREDENTIAL CHECK WAS A LOWERCASE PREFIX LIST OVER c.Path(). fiber routes
   case-insensitively; cloud.RoutePath exists in this repo for exactly that and
   two other gates already use it. POST /V1/EXEC matched the route and missed
   the list: no key at all ran code, a wrong key read another session's bytes,
   and CODE_EXEC_API_KEY UNSET — the documented fail-closed 503 — still ran code.

Same root, so one fix: authorization stops being inferred from the SPELLING of a
request. The middleware normalizes with RoutePath AND parks two facts on the
context — principal.WithOrg (inherited by the typed op, typed.go:82) and an
unexported `admitted` marker. tenantOf is the ONE tenant decision and refuses a
context carrying neither. The prefix list may still drift; drift is now
fail-CLOSED — a missed path is a 403 on a route that should have worked, never a
route that works without a credential.

AND THE DOOR NO LIST COULD HAVE COVERED. A typed op is also an MCP tool and an
op-plane op, and tools/call invokes it DIRECTLY (zip typed.go:474) — no route, no
middleware. `POST /mcp name=post_v1_exec` with no key ran code. Typing /v1/exec
for its SDK value is what opened that door; the handler-side check closes it,
because those doors park no marker and carry no principal.

3. [HIGH] AN EXEC SANDBOX HAD NO CEILING. Single-attach bounds dev/desktop via
   their project; an exec sandbox has none, and the code tool sends no
   session_id, so every call mints a fresh pod on a 15-minute lease — 40 calls,
   40 pods, each 250m/512Mi/2Gi. The reaper is the FLOOR, not the ceiling: it
   ends leases that are over, bounding the steady state and never the burst.
   maxLiveExec=16 is written in node capacity (8Gi, 4 cores) and refuses with
   429, because the caller's correct response is to wait. Counted with a real
   COUNT(*), not len(List) — List is LIMIT 200, which stops counting exactly
   where refusing starts to matter.

4. [HIGH] TWO CLIENT-SHAPE BUGS, both silent. hanzo.chat primes an attachment as
   {id, session_id, name} (Files/Code/process.js) while @hanzochat/agents spells
   it storage_session_id (tools.d.ts) — reading only the second skipped every
   attached file AND the "not available" note, so a user's CSV was invisible with
   no error. CodeFile.Session() reads both, and a ref with neither is reported.
   Artifacts were COLLECTED recursively (find) and LISTED top-level (ls -1A), so
   a nested artifact appeared in the reply and was missing from /v1/files/{sid},
   which the client reads as expired. One find answers both now.

Also: apps/functions.go's doc no longer cites the deleted CODE_EXEC_UPSTREAM.

Tests: 22 in apps/exec (14 + 5 auth + 3 client-shape), 6 in apps/sandbox. Full
suite 3 red — base, code, commerce — all pre-existing and environmental (this
box's SQLite lacks fts5 and acos). Zero new failures. make check green.

NOT FIXED, and it is a decision rather than a defect: the real chat client sends
no credential at all (EnvVar.CODE_API_KEY undefined, handleTools.js sends no auth
header, the chat pod has no LIBRECHAT_CODE_API_KEY), so the guard 401s every
legitimate request. Fixing the bypasses does not make the feature work; settling
what the client presents does, and the house rule says Hanzo IAM rather than a
second shared secret.
2026-08-06 07:52:18 -07:00
hanzo-devandzeekay cab8ff9d84 image: there is no box daemon, so the build stops trying to compile one
CI/CD / image (push) Successful in 23m6s
CI/CD / gate (push) Successful in 32m2s
CI/CD / containment (push) Successful in 2m11s
Hanzo CI/CD / cicd (push) Successful in 32m1s
CI/CD / rollout (push) Failing after 10s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 8s
Every image built from main has failed, after a green gate, at one line:

  #28 ERROR: process "/bin/sh -c CGO_ENABLED=0 go build -ldflags=\"$GO_LDFLAGS\"
  -o /boxd ./cmd/boxd" did not complete successfully: exit code: 1

cmd/boxd was deleted with the design that needed it — a sandbox is a POD, and
commands reach it over the Kubernetes exec subresource, so there is no daemon
inside to talk to and nothing to ship into it. The Dockerfile stage that built it
outlived the source, and `go build` on a directory that is not there is not a
warning. So the lane could TEST a commit and could not BUILD one, which is the
whole reason a sandbox executor that is merged and proven has never been served:
no image has published since v1.801.476.

This deletes the stage and the COPY that carried its output. The consumer they
existed for — hanzo/bot's Dockerfile.box, `COPY --from=cloud:<pin> /boxd` — has
to move to a pod nothing needs installing into; leaving a stage that cannot
compile does not keep that consumer working, it only stops everything else from
shipping.

Verified against this tree, with the Dockerfile's own tags: ./cmd/cloud,
./plugin/smoke and ./plugin/sandboxes all build, all 121 manifest apps have a
plugin/<app> directory (the loop hard-fails otherwise), and no non-comment line
mentions boxd.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 07:18:34 -07:00
zeekay b9b516ae17 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
CI/CD / image (push) Failing after 12m49s
CI/CD / gate (push) Successful in 27m39s
CI/CD / containment (push) Successful in 3m24s
Hanzo CI/CD / cicd (push) Successful in 27m32s
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:
#	apps/functions/billing_test.go
2026-08-06 07:03:11 -07:00
zeekay a1e44d9d96 coding: a tenant name is shape-checked before it becomes a KMS path
Start required a non-empty org and nothing more. The org is then interpolated
into the KMS reference for the agent credential (credRef) and into the git
namespace, so an org carrying a separator or a `..` segment would address
another tenant's secret.

It arrives already validated from the gateway or the plane. This is the second
lock, placed on the side that would actually be harmed if the first ever failed
— the same rule the git subsystem states for itself (git.go orgRE), spelled
where the concatenation happens.
2026-08-06 06:53:51 -07:00
zeekay 48c2af53f9 a session is a sandbox: exec stops proxying to a Service with no endpoints
apps/exec was a reverse proxy to code-exec.hanzo.svc.cluster.local:8000, and
that Service has had ZERO endpoints for 33 days — /v1/exec answered 503 in
production the whole time. It was not failing to reach the executor; there was
no executor. apps/functions read the same CODE_EXEC_UPSTREAM and was failing
against the same absence.

cloud already runs the one compute primitive. A LibreChat session IS a sandbox
("a code-exec call = a sandbox with a session lease", apps/sandbox's package
doc), so session_id is the sandbox id, upload/download/list are Write/Read/Read
on that sandbox, and exec holds no store, no session table and no lifetime of
its own. The lease ends on the reaper.

THE BLOCKER, AND THE DECOMPLECTION. Every sandbox operation existed ONLY as
`func X(s *Service, c *zip.Ctx) error` — the domain braided into the transport,
so nothing else in the process could use a sandbox. apps/sandbox/api.go is the
domain as a VALUE: Lease, Get, List, Run, Read, Write, End. The HTTP handlers
become adapters (bind, call, JSON) and apps/sandbox/plane.go is a SECOND adapter
over the same functions. Errors are zip errors in the core, so "not yours" is
404 once, decided where the fact is known.

  (Functions taking *Service, not methods: `Service = cloud.Service[state]` is an
  alias for a generic type in package cloud, and Go cannot define a method on a
  non-local type. apps/git's files.go is the same shape for the same reason.)

cloud.Ask AND NOT A GO IMPORT, and that is correctness, not style. Apps ship as
separate plugin binaries (plugin/<app>/main.go "links only its own subsystem"),
so importing apps/sandbox would give exec a SECOND sandbox service — its own
OrgStore on the same per-org SQLite files, its own reaper racing the real one.
plane.Ask already collapses to an in-process dispatch where the fleet fuses two
apps (zip.Serving/zip.Here), so the ONE call is a function call when they are
co-resident and a socket hop when they are not.

THE CONTRACT IS MEASURED, from ~/work/hanzo/chat, not remembered. Download is
TWO segments — /download/{session_id}/{fileId} (crud.js) — not /download/{id}.
Upload answers {message:"success", session_id, files:[{fileId, filename}]} and
the client throws unless `message` is that literal. /files/{sid} answers a BARE
JSON ARRAY of {name, lastModified} whose name is that same two-segment
identifier. And the code tool tells the model to persist artifacts in /mnt/data,
so sandbox gains workdirFor(class): /mnt/data for exec, /work for dev. Listing
/work would have reported no files after every successful run.

/v1/exec/programmatic answers 501 rather than being routed into the interpreter.
It is a DIFFERENT protocol — a run suspended on each tool call and resumed from
a continuation token — and answering it with a Result its parser cannot read is
a wrong answer where a refusal is an actionable one.

TENANCY, and the trap. cloud.For states a caller only on a context with NO
request behind it: zip.CallerOf reads the request's headers first, deliberately,
so no handler can assert an org a caller did not arrive with. A typed op's ctx
HAS a request behind it, so cloud.For(typedCtx, org) silently states nothing and
the peer answers 403. exec.callCtx is the one rule — pass a ctx that already
carries a principal through unchanged (hanzo.chat forwards the user's IAM
bearer), otherwise detach to Background, state the deployment's brand org, and
put the request's cancellation back with context.AfterFunc.

apps/functions moves to the same door, because task 3 (deleting the orphan
Service) would otherwise turn its 502 into a DNS failure. It calls exec.Run —
one operation, one home — and ENDS the lease, because a function invoke is over
when it answers.

THE FLOOR IS LOWERED HERE, next to its reason. exec published 40 operations
because a proxy cannot describe a contract it does not own; it now implements
one, so it publishes 5 — POST /v1/exec is a TYPED op (CodeRun -> CodeResult),
which is the schema, the MCP tool, the CLI command and the SDK method a proxy
could never carry. The four that stay untyped have real reasons: multipart in,
bytes out, a bare array, and a protocol we do not serve. Numbers taken from the
ratchet's own report, not computed:

  paths       1762 -> 1759  (-3)
  operations  2465 -> 2430  (-35)
  download    10 -> 1 · exec 10 -> 2 · files 10 -> 1 · upload 10 -> 1

Deleted: newProxy, defaultUpstream, CODE_EXEC_UPSTREAM, and the ledger of 40
untyped operations. CODE_EXEC_API_KEY stays — it is the credential the chat
presents, and the guard is now middleware because a typed op takes no handler
chain.

Tests: apps/exec 14 (a sandbox peer double on the REAL plane, so an op renamed
or a field moved fails here and not in production), apps/functions green
including TestInvoke_AllowsAndDebitsCallerOrg, which was RED before this change
— metering moved debits onto the plane and the test still had only an HTTP
double.

Measured against HEAD: 12 packages fail before and after; zero new; one fixed.

LLM.md also carries a concurrent agent's insights.hanzo.ai section, present in
the tree and committed rather than dropped.
2026-08-06 06:52:35 -07:00
zeekay f2b5ee2d15 coding: one engine, two doors, and a run that can no longer rewrite what a reviewer read
A coding run could not complete, for four independent reasons, any one of which
was fatal:

  1. The run was spawned on a bare context.Background(). Every seam it touches —
     session, clone URL, ref verify, PR row, and the balance gate behind them —
     authorizes on the CALLER's org and never on an argument, so each answered
     `authorize: no org on the call`. The routed target lookup was worse: it ran
     on the webhook's REQUEST context, where cloud.For is a silent no-op. This is
     the same shape that killed every chat turn until bridgeRunContext fixed it,
     and start.go's runContext is the other half of that fix.
  2. There was no /v1/coding. The app had no way to start a run at all.
  3. The chat surface assembled its own Dispatcher, so Slack and the app would
     have been two engines with two pools and no shared address for a run.
  4. (unchanged here, stated in the handoff) the sandbox hop and its image are
     deployment configuration, and both currently refuse.

The engine now lives in one process — agents, which already holds the session
store, the durable engine and the routed mailbox — and the START travels instead,
exactly as AgentsRunOnBehalf made one brain reachable from every chat platform.
POST /v1/coding and the coding_start plane op are two doors onto the same
coding.Start: same pool, same validation, same credential custody, same detached
tenant-stated context. The chat adapter is an adapter again — parse, authorize,
dispatch, reply — and no longer holds the org's git credential at all, which the
engine now reads from KMS at the one moment it dispatches.

The forge gets the rule that holds when the rest has failed. A run executes
untrusted model output against a real checkout, and the model also READS the
repo, so a README can carry an instruction. Assume the run is hostile and holds
the credential: refs/heads/agent/* is CREATE-ONLY, and the default branch cannot
be deleted by a push. That refuses the bait-and-switch — open a clean PR, let a
human read it, force-push the payload before the merge — structurally, at the one
point every push must pass through, rather than by asking reviewers to be
vigilant. It refuses with git's own report-status, not an HTTP 403, because a
control that can only say "RPC failed" is a control someone switches off.

Proven with the real git CLI against the real server: an agent branch pushes and
re-clones; --force over it, deleting it, and deleting main all come back
`! [remote rejected]` with the reason; feature-branch force-push and trunk
updates are untouched.
2026-08-06 06:48:46 -07:00
zeekay 5ff5314d46 agents: a run could not name itself until after it was over
The run id was minted at the end of executeRun, beside the row it fills in.
That reads naturally and made the run unobservable: every span the run
produced — the step, each tool dispatch, each model call — had already ended
and exported by the time the run had a name, so none of them could carry it,
and neither could the per-token debits the metering decorator makes one round
at a time. The id existed only on the record of a thing that was already over.

Mint it before the work instead. One value is now on the span, on the row and
on the money, which is the whole of "drill into this run":

  - every span (agent.run / agent.step / agent.tool / the gen_ai client span)
    carries hanzo.agent.run_id, so attribution never depends on walking a
    parent chain that sampling or a truncated batch may have broken;
  - the run row carries the trace id, which is the key the run history and the
    span store had no version of — two accounts of one event that could not be
    joined in either direction;
  - types.ChatRequest.RunID rides to metering.Usage.RequestID, the field whose
    stated job is exactly this, so a run's per-token cost is a SUM over ledger
    rows rather than unanswerable. It is the correlation id and NOT Ref: a tool
    loop settles once per round, and pinning the idempotency key to the run
    would dedup every round after the first into the first one's debit.

WHO, not just which tenant. org answered "whose ledger" and nothing answered
"which person" — an actor reached the debit and the session row but was never
written to the run, so a scheduled or on-behalf run had no answer at all.
Runs now record it and the spans carry hanzo.user.

A tool dispatch is readable as a dispatch: the span names the tool, its call
id, the owning subsystem, the round it happened in, and its outcome — set on
every exit, including the happy one, because a status written only on failure
cannot tell "succeeded" from "never finished". So "it called six tools and
failed on the fourth" is a fact you can read instead of infer.

toolSubsystem derives the owner from the operation name rather than looking it
up: the fleet door spells ops <method>_v1_<subsystem>_<rest>, so it is a fact
the value already states, and it answers the same in the fused binary and in a
single-app plugin process — where cloud.SubsystemOf reads a boot-time mount
index that knows only that plugin's own routes and would answer "" for every
sibling's tool.

GET /v1/agents/runs is the org-wide feed the per-agent history could not be:
an operator asking what a tenant's agent plane is doing does not start out
knowing an agent ref, and listing the agents to page each one's history is N+1
round trips to rebuild an ordering the org index already has. The org comes
from the caller's identity; there is deliberately no org field to forge.

scanRun is one function because runCols was named once to stop the projection
drifting and both readers were spelling the column order out by hand anyway.

The tests run a real turn — real handler, real tool loop, real OpenAI-wire
client — and assert on spans that actually reached an exporter, because a span
that is created and never exported is the failure this is about. The provider
is installed once per process and only the sink swaps: OTel's global delegates
ONCE, so a tracer handle taken at package init binds to the first provider and
keeps it, and a test that installed its own and shut it down on cleanup left
every later span-asserting test in the binary seeing nothing.
2026-08-06 06:48:45 -07:00
hanzo-devandzeekay c9731e0d1b metering: the debit crosses the plane, so the tests watch the plane — and it carries who acted
Hanzo CI/CD / cicd (push) Successful in 2m0s
CI/CD / gate (push) Successful in 2m20s
CI/CD / containment (push) Successful in 2m59s
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 usage debit moved off HTTP onto the internal plane (ac09a86d), for a good
reason: metering.Usage.Ref is `json:"-"` so no request body can set the ledger's
idempotency key, and json.Marshal therefore dropped it — every split-deploy debit
reached the ledger anonymous and a sealed retry was charged twice.

Nine packages never moved with it. Each had hand-rolled a usage counter against
POST /v1/billing/usage, so each went on counting an endpoint nothing calls: 20
tests across apps/{agents,cloudflare,content,functions,ml,projects,provisioning,
risk,storage} reported "0 debits" for work that had in fact been billed. That is
the release lane's break, and it predates the sandbox work — bisected to
ac09a86d, which is a metering commit, not a sandbox one. Before it, the same
tests pass.

THE CROSSING WAS DROPPING FIELDS OF ITS OWN. plane.Usage had no Actor and no
token counts, and metering.Usage carries both — so the crossing lost WHO acted
and WHAT WORK the amount priced, and the receiver rebuilt a ledger row without
them. Exactly the failure the Ref fix was written for, one field over. That is
not a test problem and is fixed on the wire: plane.Usage gains Actor,
PromptTokens, CompletionTokens and TotalTokens, the sender sends them, and
commerce's record handler writes them. apps/agents already asserted an actor is
present; it now passes because the actor arrives, not because the assertion left.

ONE PEER, NOT TEN. internal/planetest is commerce's half of the money plane on a
real socket, following internal/iamtest: one fixture for one wire, because the
copies are what drifted. It also ends a bug all the copies shared — they bound
the socket under t.TempDir(), which spells the TEST'S NAME into the path, and a
unix address is a fixed 108-byte field. TestMeterUsage_OneActIsChargedOnceIn-
EitherTopology/split_deploy produced a 106-character address; the bind failed
inside a goroutine where nothing read the error, and the symptom was a debit
that never arrived. Whether it failed at all depended on how long $TMPDIR was,
which is to say on whose machine it ran. The shared peer uses a short directory
and refuses an address it cannot bind instead of failing silently.

Verified on the gates CI runs, with its tags and skips:
  go vet -tags "sqlite_fts5 sqlite_math_functions" ./...      clean
  make -f mk/fleet.mk check    openapi.yaml unchanged — 1762 paths
  go test -tags "..." ./...    the 9 packages green, root green

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 06:39:59 -07:00
zeekayandClaude Opus 4.8 4e09e3d1ea fix(o11y): keyed sentry ingest — accept the org publishable key (bump o11y v1.5.61)
CI/CD / containment (push) Successful in 2m50s
Hanzo CI/CD / cicd (push) Failing after 9m14s
CI/CD / gate (push) Failing after 12m37s
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
Wire the embedded sentry runtime to the ONE binary-wide key resolver: a pk- key
posted to /v1/sentry resolves to its org through the SAME seam /v1/event uses
(sharedKeys), and the DSN's project is auto-provisioned under that org on first
ingest. So sentry.hanzo.ai accepts the exact key that already feeds analytics +
insights — no per-project DSN secret, dark no more.

- auth_apikey.go: export ResolvePublishableKeyOrg (the pk- -> org seam for
  sibling packages that resolve a key out-of-band of the Authorization header).
- apps/o11y/embed.go: implsentry.SetIngestKeyResolver at the o11y mount.
- apps/o11y/o11y.go: migrate the removed o11y.SetHandler(gh) -> SetRuntime(Whole(gh))
  (o11y v1.5.61's mount API), the drop-in "one door" replacement.
- go.mod: github.com/hanzoai/o11y v1.5.58 -> v1.5.61 (carries the sentry change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 06:09:55 -07:00
zeekay 48979adf17 sandbox: a lease that ends
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 part of this already existed and nothing called it. ExpiresAt was written
on every create, LastUsedAt was stamped on every call, Store.Expired was
implemented, stop was implemented — and a sandbox, once created, ran forever.
Measured rather than inferred: a proof pod from a finished test was still
Running 64 minutes later and had to be deleted by hand.

That is one bug wearing two faces. A sandbox that never sleeps is a sandbox that
bills forever, so this is simultaneously the idle-sleep feature and the honest
meter. They are not separate components and building them separately would give
the fleet two clocks that disagree.

TWO TRIGGERS, ONE ACTION:

  ExpiresAt   the LEASE — the caller said how long they wanted it.
  LastUsedAt  ATTENTION — a sandbox nobody has touched for an hour is abandoned
              even with hours of lease left, and holding a pod for a session
              someone walked away from is the entire cost this exists to stop.

Both end the pod and both KEEP THE VOLUME, because purge is a separate opt-in
and ending a lease must never destroy what the tenant made. So the checkout and
the caches survive and the next call for that project gets a fresh pod against
the same disk — "resume is cheap" with no suspended state to define and no
resume route to serve.

A `status: suspended` was the obvious other design and it is worse: Status is
pending|running|error, nothing resumes, and a row in a fourth state that no
route can leave is a dead row wearing a live sandbox's clothes.

Idle is computed here rather than queried, because "untouched for an hour" is a
POLICY and the store holds facts. The constant that defines it lives beside the
code that applies it.

Started from Mount, not from a route and not from a caller. A reaper that
something has to remember to call is the bug it was written to fix.
2026-08-06 05:56:19 -07:00
zeekay 50e99a2848 sandbox: prove it commits, not just that it edits
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 agent that can edit but cannot commit has done nothing durable, so the second
half needed its own proof. Against hanzo-k8s, 62s, stock node:22:

    GIT PRESENT:     git version 2.39.5
    COMMIT MADE:     7fb283f the agent committed this
    FORGE REACHABLE: exit=0

git needs no routes of its own — it is a command, and exec runs commands. The
predecessor had a /git/clone and a /git/push endpoint and a credential file
written to /tmp at 0600, which the code it was sandboxing could poll for during
the push window. None of that survives, because none of it was git.

Identity is set locally rather than globally: a sandbox is per-tenant, and a
global identity is one more thing to reset between leases.

The ls-remote is deliberately NOT a push. It exits 0, which proves the network
reaches the forge — and proves nothing about a credential. Those are different
gaps and conflating them sends someone to fix the wrong one. Pushing as a tenant
still needs a per-org token the sandbox does not yet have.
2026-08-06 05:47:13 -07:00
hanzo-devandzeekay c4769c7179 telemetry-chain: probe the event plane, so stage 3 can fail meaningfully
Stage 3 calls itself the load-bearing check and it could not pass. It read
o11y_traces.o11y_index_v3 and o11y_logs.logs_v2; both databases are gone, so
the query errored, `mins` came back empty, and the script printed "stale --
nothing is draining into the store" and TELEMETRY CHAIN: BROKEN on every run.

A check that always fails reports nothing. Worse, it spends the alarm it exists
to raise: the one run where telemetry really has stopped looks exactly like the
hundred before it.

Traces and logs are event.span and event.log now, and both date themselves with
the same `time` column, so the per-probe expression -- and the special case that
overrode it for traces -- has nothing left to vary and is gone. Measured while
writing this: both signals 0 minutes stale, 3.3M spans and 131.4M logs.
2026-08-06 05:45:56 -07:00
zeekay 8465354e6b ai: a sibling reaches the model API through the router, not the plane socket
Every @hanzo turn answered "the agent hit an error handling that". The tenant
fix landed and was correct — the run reached agents with org=hanzo and passed
the balance gate — and then the model call died one hop lower:

  Post "http://ai/v1/chat/completions": EOF

deps.AI dialed zip.SocketPath("ai") and spoke ordinary HTTP to it. That address
is wrong twice, and each half is independently fatal.

The WIRE is not HTTP. The socket is served by zaphttp.Server — ZAP, a framed
binary protocol. A cleartext request is not slow there, it is unintelligible:
the peer reads a malformed frame and closes. Measured on a healthy pod, every
request over that socket EOF'd — GET /v1/health, GET /v1/models, a bogus path,
POST /v1/chat/completions — and agents.sock and commerce.sock did the same, so
this was never about `ai`.

The SURFACE is not the app's. What binds there is the app's PLANE, the typed-op
door plane.Ask uses; the app's own routes are on a listener it knows nothing
about. Framed correctly as ZAP, ai.sock still answers 404 for /v1/models while
ai's own listener answers 200.

So `ai` never logged the request, because the request never arrived, and the
run came back error-status with a nil error — which is the branch that logged
nothing at all. Hence a day of looking at a healthy `ai`.

The address that does serve it is the fleet ROUTER's own listener: it owns the
route table that sends /v1/* to `ai`, and it owns starting a cold app. Entered
on 127.0.0.1 it never leaves the pod — no DNS, no Service hop, and no trip out
through Cloudflare and back to the pod's own public address, which is what the
configured base URL does. Only the port is read from CLOUD_LISTEN; the host half
a process binds is not an address a client may dial. Measured there:
/v1/models 200, bogus 404, /v1/chat/completions 401 for the Bearer the M2M
client already mints.

There is deliberately no second mechanism left. A raw route reached
process-to-process is not something this fleet offers; ops are. The custom
transport is deleted rather than repaired.

The non-"ok" branch in the bridge now says so, with the run id. It is the branch
a broken inference path lands in and it was the silent one.
2026-08-06 05:40:06 -07:00
hanzo-dev 9d9efed301 o11y v1.5.62: PromQL stops reading a database that does not exist
Every metric the console reads through PromQL failed on the live pod with
"Database o11y_metrics does not exist" -- 96 times in 30 minutes, and each one
renders as an empty chart rather than an error, so service health, HTTP traffic,
ingest, memory, plane throughput and alert delivery all read as silence. The
data was never missing: event.metric holds 145.7M rows and was 3 seconds behind
real time throughout. Only the address was wrong.

o11y v1.5.62 points its PromQL client at event.series/event.metric via the
telemetrymetrics constants, which is where cloud's own gauge reader
(apps/o11y/metricsgauge.go) has been reading all along.

The bump also crosses o11y's runtime seam: SetHandler(http.Handler) became
SetRuntime(Runtime), which resolves a handler PER ADDRESS so a declared op that
the runtime does not serve is a nil rather than a 404 indistinguishable from a
typo. Both of cloud's installs are o11y.Whole -- the embedded runtime is one
router that matches the request's own path, and the proxy fallback has one door
with the far side selecting the route. Neither has anything to resolve per
address, and Whole is that shape stated honestly.
2026-08-06 05:35:39 -07:00
zeekay 3d631516b1 exec: point at the namespace that has the Service
CI/CD / containment (push) Successful in 2m26s
Hanzo CI/CD / cicd (push) Failing after 32m42s
CI/CD / gate (push) Failing after 32m59s
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
I moved this to hanzo-sandboxes on the reasoning that everything running
submitted code belongs in the one namespace whose policy denies it the cluster.
That reasoning is right and the change was wrong: there is no code-exec Service
in hanzo-sandboxes, so it renamed a 502 into a DNS failure. A peer caught it and
said so plainly — target ns is EMPTY, do not ship.

Moving the NAME before moving the SERVICE is the same mistake as a policy whose
selector matches no pod, and this file would have been the fourth this week.
The Service moves first; then the address follows it.

Neither namespace makes this path work today — the Service in hanzo has had no
endpoints for 33 days. The fix is not a better address, it is /v1/sandboxes,
where a caller gets a pod rather than a proxy hop to a workload nobody deployed.
2026-08-06 05:26:52 -07:00
antje 208bc6c1a1 billing: a mounted ledger read still needs the host to deliver to it
sha-1fa6f964cb4d mounted GET /v1/billing/{transactions,credit-balance,
accounts} and shipped them — the route strings are provably in that
image's commerce binary — and all three still answered 404 in production.

An address has two halves. mount.go states what commerce will answer;
manifest.Apps states what the host may hand it, and the commerce row is
an explicit leaf allowlist, not a /v1/billing subtree ("Nobody claims the
bare /v1/billing REMAINDER"). A leaf absent there is never delivered: it
falls to the "/v1" remainder on ai's row and answers ai's bare 404. From
outside that is byte-identical to a route that was never mounted, which
is why the first fix verified correct at every layer anyone thought to
check — source, image bytes, deployed digest, running version — and
changed nothing a caller saw.

credit-balance is stated separately from credits on purpose: they are
sibling prefixes, so matching one does not match the other, and next to
an existing "credits" entry the missing one reads as already covered.
accounts covers its /:id/members child.

The test asserts the ROUTER's half specifically, because nothing else
can. apps/commerce's route tests cannot see this table, and a byte-level
check on the built image cannot either — the string was in the binary the
entire time it was returning 404.
2026-08-06 05:26:12 -07:00
zeekay 0c192319d2 floor: the box pool's row leaves with the product, on the merged counts
Re-measured after b3ded8a5 rather than carried across: the shrink is the same
26 operations and the same 4 paths, but the totals are the merged ones.
2026-08-06 05:26:10 -07:00
zeekay 117f4fed8b Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	openapi/floor.json
2026-08-06 05:25:48 -07:00
zeekay 93a61b817e the sandbox product is named for what it serves, and the box pool's row is gone
The merge collapsed two manifest rows into one and left a name that matched
nothing. The app was called `sandbox`, it served `/v1/sandboxes`, and its
operations were tagged `sandboxes` because the tag comes from the path — one
thing under three spellings, and the Makefile named a fourth.

It is `sandboxes` now, which is what its path says and what every sibling
collection already does: bots, agents, tasks, books. The package stays
apps/sandbox and pkgOf carries that, which is the case pkgOf exists for.

The floor's `sandbox: 26` was the BOX POOL — a warm pool of pods, a daemon
inside each one, a shared pool-wide key, and a bind handshake so a box could
tell which box it was. All of it is deleted, and the row goes with it rather
than being pinned at 0: the product does not exist, and a floor entry for a
product that does not exist is a claim that something is missing.

That deletion is right and it is not mine to mourn. runtime.go says why in its
own words — the boundary is the pod's runtimeClassName, one field, and
"everything the predecessor built to approximate that boundary in Go is deleted
rather than kept for defence in depth, because a second half-boundary is a
second thing to keep true." Every problem the pool had, I spent tonight fixing:
the key that opened every box, the row whose address the CNI had reassigned, the
identity a warm pod could not learn. None of them exist without the daemon.
There is no key, and a pod is addressed by NAME through the apiserver, which
cannot go stale.

Four operations published an operationId and nothing else. These routes bind
through zip.Ctx rather than being typed ops, so zipdoc has no comment to lift
and openapi.Describe is the seam — the other three already used it. They say
what they do now, including the two facts a caller cannot guess: a 404 rather
than a 403 for another org's id, because a 403 confirms the id exists; and that
the volume SURVIVES a delete unless purge=1, which is the one part that cannot
be undone.

1758 paths, and the weave is green.
2026-08-06 05:25:14 -07:00
antje b3ded8a57f the language servers move out of the fleet, and lsp moves under code
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/lsp ran gopls, tsserver and the rest INSIDE a fleet pod: server.go,
workspace.go and langs.go checked repositories out, fetched their dependencies
and executed a third-party toolchain over untrusted bytes in the same process
that holds a principal, a ledger and a KMS-injected environment. That is the
wrong shape however carefully it is written, and hanzoai/lsp exists to be the
right one — a jailed daemon with gVisor, no egress but a module proxy, and no
git credential of any kind.

So this side becomes a PROXY over that daemon, and keeps the three things the
daemon must never hold:

  TENANT      the org is the validated principal's, never a body field, and it
              is the daemon's isolation key.
  REPOSITORY  the revision and the tree come from git's own object plane, for
              the caller's own org. The daemon cannot fetch a repository — a
              credential that could reach every repository is exactly what must
              not sit next to an unjailed compiler — so a cold revision is
              /ask → 409 {"need":"tree"} → /root → ask again, ONCE.
  LEDGER      the gate runs at the prepare price before any work, the debit
              after the answer. "prepare" and "query" are the two Models, and
              spend.go's meteredApps entry still holds.

The surface moves from /v1/lsp to /v1/code/lsp, and from one door with a
`method` field to five typed ops — hover, locate, symbols, diagnostics,
complete. code and lsp are two reads of ONE repository, not two products: code
is the static index, lsp the live server that follows a symbol out of the
repository and into a dependency. One home means one place to look for it, in
the document and in the MCP tool list alike, and an agent picks a tool by its
name rather than by a union behind one. Nested static prefixes resolve by
specificity, so /v1/code/lsp beats /v1/code and both beat ai's bare /v1 — the
same relation storage's /v1/s3/buckets already has to provisioning's /v1/s3.

git gains ONE op, git_rev: the commit a ref names. It is separate from git_files
because the two questions cost differently — resolving is a ref lookup and runs
on every position query; reading is a walk of the whole tree and runs only when
the daemon says it holds no root. Folded together, a hover would drag a monorepo
across a socket. One resolve (coreRev) now backs both.

plane/agents was generated before agents_run_on_behalf existed; running the
generator carries it in.

The published surface GREW: 1762 → 1766 paths, 2480 → 2484 operations. The
floor's "lsp" product is gone because its five operations are tagged "code" now,
which is the move, and code rises 7 → 12.
2026-08-06 05:19:23 -07:00
zeekay 6ae8b84563 sandbox: one word, one noun, one package
The box vocabulary is gone. It named the same thing three ways — a box in the
Go, a machine in the types, a sandbox in the API — so a policy could select
hanzo.ai/box-class while the code set hanzo.ai/sandbox-class and both looked
right in isolation. That is not a style preference: it is how the containment
shipped selecting nothing.

  apps/sandboxes -> apps/sandbox        package sandbox, not sandboxes
  Machine        -> Sandbox             the type is the noun the API serves
  machines.go    -> sandbox.go
  MACHINE_*      -> SANDBOX_*
  /v1/sandboxes stays plural because a collection is plural. The PACKAGE is
  singular because it is one thing.

DELETED, not deprecated: cmd/boxd (~2000 lines), the old apps/sandbox pool and
proxy, apps/sandbox/wire. The daemon existed to serve a filesystem and a shell
from inside the pod over HTTP; the Kubernetes exec subresource already does
that, so boxd was a second implementation of a thing the cluster ships. Deleting
it also deletes what it forced: a shared service key living in an environment
the submitted code could read out of /proc, a bind protocol so a recycled pod IP
could not serve another tenant's checkout, and an HTTP server that had to be
built into every sandbox image.

wire went with it, except for one string. It held the types boxd and the proxy
both marshalled, which is a real reason for a package — but the last survivor is
the /v1/exec path shared by apps/exec and apps/functions, and a package that
exists to hold one constant is not a package. It moves to exec.Path, in the app
that serves it. The reason it must have exactly one home is unchanged and worth
keeping: those two consumers disagreed about it in production, one asking for
/v1/exec and the other building upstream+"/exec", and because the executor did
not exist neither was ever wrong out loud.

Routes registered. Create, List, Get and Delete were exported functions no
request could reach — a caller could exec in a sandbox it had no way to create.
The comment said they were "shared with the compute surface", which was true
while this served /v1/machines (visor's, where a second registration is a
conflict) and false the moment it moved. Handlers present, routes absent,
nothing saying so: the same shape as the policy that selected no pod and the
installer that installed nothing.
2026-08-06 05:17:00 -07:00
hanzo-dev 113bed315a cicd: the release lane queues, and this time a green gate is the proof
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
Re-lands 2f9ffc2d, which 0d7011e2 reverted for want of evidence. The evidence
now exists, and it is a run whose gate had already passed.

Run 895 (41a0e5a3, event push, branch main):

  gate         success    09:07:35Z -> 09:46:45Z
  cicd         success    09:07:35Z -> 09:46:45Z
  containment  success    09:07:33Z -> 09:09:10Z
  image        cancelled  never started -> 10:25:26Z
  rollout      cancelled  never started -> 10:25:26Z
  reach        cancelled  never started -> 10:25:26Z
  fanout       cancelled  never started -> 10:25:26Z
  receipt      cancelled  never started -> 10:25:26Z

The run's own conclusion is `cancelled`. On a push, `${{ github.event_name ==
'pull_request' }}` is supposed to be false and nothing should have cancelled
anything. The forge does not evaluate it to a boolean; a non-empty string is
truthy, so every push cancels the run before it.

The revert argued these runs "were superseded while still QUEUED, not killed
mid-gate, so cancel-in-progress was never the thing acting on them". That holds
for the runs it measured — 889 through 892 never received a runner — and cannot
hold for 895: a run that never started cannot contain three jobs that completed
successfully. Both failure modes are real. Only this one is a line in this file,
and it is the one that throws away work the gate already finished.

Runs 891-909, every one a push to main: 16 cancelled, 3 failure, 0 success. The
single run that earned a release lost it 39 minutes after going green, which is
the whole of "the gate is green and nothing ships" — and why a cutover that has
been on main all day is still 404 in production.

A literal false is read correctly and restores what the comment above already
described. It does not queue without bound: one run holds the group and the
rest collapse into a single pending run, so pushes during a release are
coalesced, not accumulated. The cost is that a stale PR run is no longer
cancelled by its own next push, because the setting can no longer ask what event
it is — cheap next to a release lane that cannot finish.

The other half of the diagnosis under the revert stands and is not addressed
here: ten runners of hanzo-build-linux-amd64 serve five orgs, and a run that
waits longer for a runner than the interval between pushes still starts late.
That is fleet sizing, and it belongs in universe, not in this file.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:12:24 -07:00
zeekay 8374e8d214 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	apps/bots/typed_wire_test.go
#	apps/dns/typed_wire_test.go
#	apps/exec/typed_wire_test.go
#	apps/o11y/typed_wire_test.go
#	manifest/order_test.go
2026-08-06 05:11:56 -07:00
hanzo-dev 9e7a93c65c a metered act keeps its server name across the peer crossing too
MeterUsage documents a debit as exactly-once on the act's ref, and a surface that
already holds the act's server-assigned name sets it precisely so the work and its
charge are one thing under one name — apps/company mints a formation ref and hands
that same value to the debit.

The promise held only while the ledger was co-resident. In the shipped split
topology the debit leaves through meterPeer, which built its plane.Usage from
Project and Service alone: the ref never boarded. The receiver mints a fresh name
when an arrival carries none (apps/finance RecordUsage), so nothing could dedup and
a re-driven formation debit charged the customer twice for one company.

So the name crosses with the act, and the act is named ABOVE the topology branch.
Sealing below it was what let the two paths key the same act differently; sealing
above means neither can. Seal is idempotent, so a caller that already holds the act's
own name keeps it, and the co-resident path — where the metering client seals what it
is given — is unchanged by having been sealed one step earlier.

Two calls are still two acts: an unnamed usage is sealed with a fresh server-minted
name, so identical inferences bill twice and the fix cannot fold distinct work into
one charge.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:09:47 -07:00
hanzo-dev 3f4cb0f6c5 a colliding legacy usage row is skipped, not fatal, at store open
The same act can hold BOTH usage keys at once — one entry under the empty program
and one under the wallet — whenever its ref was nameable by the client across the
change that scoped a usage ref to its wallet. An org that renewed the same domain
once before and once after writes `domain:renew:foo.com` into both program
namespaces.

Re-keying the legacy row onto the wallet then collides on
UNIQUE(kind, program, ref), and a plain UPDATE makes that abort the statement: the
migration fails, so Open fails, so storeFor fails, so the prepaid gate has no ledger
to read and fails closed — correctly — on EVERY paid request that org makes, for
good, on a store no retry can open. The repair runs at first open after a deploy, so
it was armed for every org not yet opened.

OR IGNORE skips the colliding row instead. That is the honest outcome rather than a
concession: the wallet row IS that act's entry, so the probe that asks whether the
act is already paid for finds it, and the legacy row's empty program can never fund a
second debit. It is also curative — a store that already aborted opens on the next
attempt, which is what un-bricks an org that has already hit this.

The repair also moves BELOW the DDL re-apply. On a legacy amount_cents file the
migration rebuilds treasury_postings without its indexes, and the repair reads each
candidate entry's postings twice as correlated subqueries — so running it first
scanned the whole postings table once per usage row, on the one open where the file
is largest and a customer is waiting on it.

And the kind is now the opener's to name. This is a generic journal; which of its
kinds are scoped by wallet rather than by the book is a fact only the app that writes
them holds, so finance passes its own KindUsage and the house book — whose accrual,
seed and payout kinds are all book-scoped — passes none. A literal here could be
renamed in finance and silently become a no-op that bills one act twice; the finance
tests now fail if the kind stops arriving.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:09:47 -07:00
hanzo-dev 61667ffcca build: zstd layers and a cache that stops paying to compress twice
Measured in-cluster on cloud's exact shape, same node pool, same buildkit,
fresh pod, run in both orders:

                     gzip / mode=max        zstd / mode=min
  exporting layers   194.6 245.5 256.1 s    39.2 58.1 53.3 s
  cache export       212.1 s                2.0 s
  image size         1,646,830,883 B        1,626,409,300 B

Derived on the real build: -219s cache export, -136s layer export = ~354s off
17m09s. Both scale WITH the image, so a smaller image compounds them.

WHY mode=max was wrong. The build stage holds the same 4.2 GB the final image
does, so mode=max gzips it a SECOND time and pushes a second 1.6 GB blob. What
it bought: two independent builds imported the cache and got exactly the same 8
CACHED steps — apk add / adduser / symlink / WORKDIR in each stage. Every
expensive step missed both times, and must: COPY . . sits in front of them, and
the compiles write into --mount=type=cache, which is worker-local and never
travels in an exported cache. The four build-stage records mode=min gives up
cost 4.6s cold. It was paying 221s per build to save 4.6s on the next one.

WHY 175s for one export. The plugin layer is 1,627,346,308 of 1,646,830,883
bytes — 98.8% of the image — and 4.15 GB at 175s is 23.7 MB/s: single-core
gzip, with seven of eight CPUs idle.

Three flags are load-bearing, not decoration:
  - oci-mediatypes=true is REQUIRED; Docker schema2 has no media type that can
    name a zstd layer.
  - force-compression=true is REQUIRED. Without it buildkit publishes an OCI
    manifest whose layers are still tar+gzip, reusing blobs it already has —
    silent, and in the "still works, just slow" direction.
  - the cache's compression must MATCH the image's, or mode=min re-compresses
    what the image export just wrote and hands the saving back.

AND THE ONE THAT WOULD HAVE BITTEN. imagePullable in pin.go negotiates the
manifest by Accept, and its list omitted application/vnd.oci.image.manifest.v1
+json. Measured against ghcr with a real zstd image, with a bogus-tag control:

  zstd image, old header  -> 404      zstd image, new header  -> 200
  gzip image, old header  -> 200      nonexistent tag, both   -> 404

ghcr honours Accept strictly, so shipping zstd without this makes EVERY release
pin fail, insisting the registry does not have an image that is sitting there.
That list was coupled to the builder's compression setting without saying so;
the coupling is now written down.

SEQUENCING: this pin.go fix must be DEPLOYED before the build flags flip.
imagePullable runs in the deployed cloud binary and gates both v* and sha- pins.

NO LAYER SPLITTING. Every plugin embeds the per-build version string
(-X ...Version=${VERSION}, confirmed in 5/5 shipped binaries), so all 119 change
bytes on every commit. There is no stable subset to split off, and splitting
would add manifest entries while saving zero export work. Revisit only if the
version stamp moves out of the per-plugin binaries.

NOT VERIFIED: no full real cloud build with these flags yet — the numbers are a
same-shape synthetic whose gzip baseline reproduced production within 11%. zstd
is proven INSIDE this cluster (ghcr stores it; containerd 1.7.28 pulled and RAN
it on a node that had never seen the bytes). Anything pulling from outside — an
old Docker, an external mirror — is untested.
2026-08-06 05:09:10 -07:00
zeekay 9fd46ed039 the ledgers that named TRACE and OPTIONS, and a gate that read another commit
Four apps kept their own copy of the projection's method set and asserted it.
Dropping TRACE and OPTIONS from the document therefore surfaced as eight
failures that looked unrelated to it — bots and dns and o11y naming routes
"which this subsystem no longer serves", exec insisting on 56 operations where
40 are published.

Nothing about the WIRE changed. All() still binds every method and the executor
still answers them; exec's own subtest proves exactly that and is why it stayed.
What changed is what we OFFER, and the ledgers were describing the offer from a
second copy of the truth.

exec reads openapi.Methods() now, the same fix websearch took an hour ago and
for the same reason: one set, one place, both halves move together. The others
had literal entries, which are simply gone.

The counts move with it and say why in the prose beside them, so the next reader
gets the reason rather than a number that used to be right:

  bots  10 -> 8    six product methods on the relay, plus two typed ops
  exec  56 -> 40   8 paths x 5 published methods

SEPARATELY, and not caused by any of that: TestJWKSHasOneDerivation walks the
tree from ".", and git puts WORKTREES inside the working tree. It failed naming
.claude/worktrees/agent-…/apps/base/pool.go — a file from another commit of this
same repo, which this commit is not responsible for and cannot fix. It skips
.claude and .worktrees now. The gate could never fire in CI, which checks out
clean, so it only ever cost whoever had a worktree open — which, here, is
everyone running agents.

Verified against d2c44bab rather than asserted: the metering timeouts and the
frozen-app-order failure are pre-existing, and three of them could not even RUN
at that baseline because the duplicate Describe panicked their package at init.
Fixing that panic did not break them. It made them visible.
2026-08-06 05:08:22 -07:00
hanzo-dev 75719103f8 a metered act keeps its server name across the peer crossing too
CI/CD / containment (push) Successful in 2m16s
Hanzo CI/CD / cicd (push) Failing after 28m35s
CI/CD / gate (push) Failing after 28m35s
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
MeterUsage documents a debit as exactly-once on the act's ref, and a surface that
already holds the act's server-assigned name sets it precisely so the work and its
charge are one thing under one name — apps/company mints a formation ref and hands
that same value to the debit.

The promise held only while the ledger was co-resident. In the shipped split
topology the debit leaves through meterPeer, which built its plane.Usage from
Project and Service alone: the ref never boarded. The receiver mints a fresh name
when an arrival carries none (apps/finance RecordUsage), so nothing could dedup and
a re-driven formation debit charged the customer twice for one company.

So the name crosses with the act, and the act is named ABOVE the topology branch.
Sealing below it was what let the two paths key the same act differently; sealing
above means neither can. Seal is idempotent, so a caller that already holds the act's
own name keeps it, and the co-resident path — where the metering client seals what it
is given — is unchanged by having been sealed one step earlier.

Two calls are still two acts: an unnamed usage is sealed with a fresh server-minted
name, so identical inferences bill twice and the fix cannot fold distinct work into
one charge.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:02:03 -07:00
hanzo-dev d16d05d557 a colliding legacy usage row is skipped, not fatal, at store open
The same act can hold BOTH usage keys at once — one entry under the empty program
and one under the wallet — whenever its ref was nameable by the client across the
change that scoped a usage ref to its wallet. An org that renewed the same domain
once before and once after writes `domain:renew:foo.com` into both program
namespaces.

Re-keying the legacy row onto the wallet then collides on
UNIQUE(kind, program, ref), and a plain UPDATE makes that abort the statement: the
migration fails, so Open fails, so storeFor fails, so the prepaid gate has no ledger
to read and fails closed — correctly — on EVERY paid request that org makes, for
good, on a store no retry can open. The repair runs at first open after a deploy, so
it was armed for every org not yet opened.

OR IGNORE skips the colliding row instead. That is the honest outcome rather than a
concession: the wallet row IS that act's entry, so the probe that asks whether the
act is already paid for finds it, and the legacy row's empty program can never fund a
second debit. It is also curative — a store that already aborted opens on the next
attempt, which is what un-bricks an org that has already hit this.

The repair also moves BELOW the DDL re-apply. On a legacy amount_cents file the
migration rebuilds treasury_postings without its indexes, and the repair reads each
candidate entry's postings twice as correlated subqueries — so running it first
scanned the whole postings table once per usage row, on the one open where the file
is largest and a customer is waiting on it.

And the kind is now the opener's to name. This is a generic journal; which of its
kinds are scoped by wallet rather than by the book is a fact only the app that writes
them holds, so finance passes its own KindUsage and the house book — whose accrual,
seed and payout kinds are all book-scoped — passes none. A literal here could be
renamed in finance and silently become a no-op that bills one act twice; the finance
tests now fail if the kind stops arriving.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:02:03 -07:00
hanzo-devandzeekay 5176fd58df build: a coresident app gets no binary
/zen was linked, copied into the image and pulled on every deploy to be executed
never. cmd/cloud's mount() returns at `if a.Coresident` BEFORE it can resolve a
path or spawn a child; zen's behaviour ships inside /ai, which links apps/zen and
mounts the Claim ahead of ai's catch-all.

The build list is still derived from manifest/apps.go: `names` (119) still guards
the plugin/<app> bijection, `spawned` (118) earns a binary. Flip
Coresident:false and the binary returns next build.

Checked rather than assumed: the other a.Plugin() call site (locate()) reads only
.Addr; with /zen absent resolve() falls to pluginIn() and fetch() returns nil,nil
because CLOUD_PLUGINS is unset — no stat error, no network. Nothing in helm,
compose, smoke or the workflows names a /zen binary.

MEASURED: 4225 -> 4060 MB uncompressed (-164.7 MB), 1.65 -> ~1.60 GB compressed.

While measuring, two things worth recording because they contradict the obvious
guesses:

  - Sqlite/CGO is not the cost, it is a 3.29 MB SAVING per binary. CGO=1 with
    libsqlite3 links the small mattn shim against system libsqlcipher; CGO=0
    links pure-Go modernc. Dropping the tags for "non-storage" plugins would ADD
    388 MB. The Dockerfile's uniform-tags decision is right.
  - 100% of the delta is the duplicated library. plugin/dns carries 741 lines of
    app source and is 32 BYTES larger than an empty app. 119 x 20.23 MB =
    2,407 MB of 4,095 (58.8%) is the same request tier byte-for-byte, 119 times.

There is no Mount-style registry indirection to break here — manifest is 344
packages of stdlib+zip importing zero apps, and every plugin main imports only
cloud + its own app. The fat deps are honest (geth<-treasury, esbuild<-
connectorruntime, k8s<-ai/controllers). A perfect diet of the shared floor is
bounded at <=519 MB (12.7%), because the host is a real serving binary at
15.86 MB.

The big prize is a multi-call binary: 4,095 MB -> 336 MB (-92%), one file plus
118 symlinks, every app still its own process. Built and linked in 12.3s; NOT
shipped because argv[0] dispatch cannot be verified end-to-end without a
container, it needs gen-app-cmds to derive a fused main from 119 hand-authored
mains, and it reverses a documented decision in manifest/release.go. That is a
designed change with a test plan, not a slip-in.

Also rejected with numbers: -trimpath (-10 MB fleet, 0.25%, cold-busts the build
cache), reflect method-pruning (three independent triggers; closing two buys
+128 B), embeds (<=63 MB, 1.5%).
2026-08-06 04:54:57 -07:00
hanzo-devandzeekay 857c38747f build: stop regenerating what git already has
`go generate -run zipdoc ./...` was 355.9s of a 17-minute image build — 35% —
spent regenerating 99 zipdoc_gen.go files that are already committed. The image
now trusts them.

This is strictly stronger, not a trade. The build-time pass would happily build
a CORRECT image from a STALE commit and leave main wrong, silently. That is not
hypothetical: plane/plane.go documents RunOnBehalfIn.Model and HEAD's
apps/agents/zipdoc_gen.go carries not one word of it. Main was stale the whole
time that RUN was "doing its job" every build. Freshness is a property of the
COMMIT, so it is now enforced where commits are.

Two gates existed and neither could fire:
  - make test has carried a zipdoc -check loop for a long time, but CI never
    runs make test — hanzo.yml's test: block runs raw go steps. It has never
    executed in CI, not once.
  - app-contract → surface-check regenerates these files as a SIDE EFFECT
    (mk/plugin.mk: describe: build, build: generate) and then scopes its
    porcelain check to openapi.yaml / openapi/floor.json / plugin/. A stale lift
    was silently repaired in the runner's tree and never reported.

So the new zipdoc-current step runs BEFORE app-contract. After it the tree is
already regenerated and the check asserts nothing.

The two inline -check loops in test and test-fast collapse into one zipdoc-check
target all three callers use — the same discipline app-contract already follows,
and exactly the drift that let the Makefile's own check end up policing nothing.
The old remediation rendered `././...` for d=.; the new one names a command that
works from a bare checkout.

The gate regenerates and diffs rather than asking -check for a second opinion: a
gate must never be able to disagree with the tool it polices. It uses
git status --porcelain, not git diff, so a NEW package's untracked zipdoc_gen.go
cannot slip through.

Also commits the live staleness in apps/agents/zipdoc_gen.go.

Measured: 40.4s warm on an 8-core box, 355.9s cold on the BuildKit runner.
go build ./... clean; TestSpecCarriesProse passes and is byte-unmodified — it
now proves the prose in the file that actually SHIPS is live.
2026-08-06 04:54:57 -07:00
hanzo-dev 471b7cad40 the route ledgers and the frozen order owe the same two changes
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
Same shape as the bots entry: a ledger describing a surface that moved, and a
freeze that a new app walked past. No behaviour changes.

ceff43ac drew the line that the document publishes what was DECLARED rather
than whatever the router happened to bind under All(), which retired the
OPTIONS and TRACE entries every wildcard used to carry. Three ledgers still
named them, so each described operations its own document no longer has:

  - apps/dns: five relay operations at the greedy wildcard, not seven.
  - apps/o11y: the /v1/sentry catch-all, same two.
  - apps/exec: derived rather than listed, so the fix is one line —
    servedMethods is the five methods zip has a typed registrar for, and the
    cross falls from 8x7=56 to 8x5=40. methodReason went with them; it existed
    only to explain OPTIONS and TRACE.

    Worth stating because the two facts disagree: OPTIONS and TRACE are still
    SERVED. All() registers every method and the executor answers them, which
    the proxy subtest asserts and which still passes. They are no longer
    PUBLISHED. The ledger counts the document, so they leave it.

manifest: apps/sandbox joined manifest.Apps between exec and websearch without
joining the frozen sequence, so the freeze guarded 120 of 121 apps. Frozen at
the position it already mounts at, which is what the test compares.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:53:12 -07:00
antje 1fa6f964cb billing: the customer's own ledger had handlers but no address
Transactions, Credits, Team and Settings on billing.hanzo.ai were empty
because GET /v1/billing/{transactions,credit-balance,accounts} and
accounts/:id/members answered 404. They read as a stale deploy and were
not one: commerce declares all four on its api.Route() `user` group
(api/billing/handlers.go), but the co-resident embed registers on the
HOST's router and never compiles that table, so a commerce route reaches
production only if Mount names it. The handlers shipped in the pinned
module all along — v1.50.11 has every one of the four symbols — while no
binary in the fleet served them, which is why grepping the library found
them wired and the tabs stayed blank.

Chain is GetTier's, and each link earns its place on a money read:
IAMTokenRequired resolves the org but FALLS THROUGH when there is no
validated principal, so PinBillingSubject is both the gate and the IDOR
control — it fail-closes that fall-through as 401 (a browser
re-authenticates on 401 and only reports 403) and overwrites every
subject key {user,userId,customerId} with the caller's own account.Payer
subject. That is load-bearing, not decoration: ListTransactions filters
on ?user and GetCreditBalance on ?userId, both unpinned client values, so
an unmounted-but-naive mount would have returned every subject's rows in
the org namespace. Because the pin SETS the key rather than validating
it, the subject is exactly the one account.Payer debits — a read cannot
disagree with the wallet it describes. TokenRequired follows the pin so
the trusted S2S reader still resolves an org; IAMTokenRequired admits
only IAM principals and would leave GetOrganization nil, which panics.

The test asserts 401-not-404. Both are "no data" to a browser, but 404
means the gate never ran and 401 means it ran and refused — only the
second proves the route reached its middleware, and a library-side grep
cannot tell them apart.
2026-08-06 04:51:18 -07:00
zeekay 39ce9a43ec sandboxes: a pod that runs somebody's code, proven against a real cluster
The isolation boundary is the pod's RUNTIME — one field, SANDBOX_RUNTIME_CLASS,
holding gvisor or kata-fc or kata-clh or nothing. That is the whole of the
runtime decision: no fork in the code, no second implementation, and a swap is a
deployment change rather than a release. Empty means the node's default runtime,
which is honest rather than a hole — runsc has to be installed on the nodes
first, and installing it restarts containerd under 204 running pods, so that is
maintenance and not something a release does on its way past.

No daemon inside the pod. Commands go through the Kubernetes exec subresource,
so cloud talks to the apiserver rather than to a pod IP. That deletes three
problems the predecessor had to solve: a shared service key sitting in an
environment the submitted code could read out of /proc, a bind protocol to stop
a recycled pod IP serving another tenant's checkout, and an in-pod HTTP server
that had to be built into every image.

PROVEN LIVE, not asserted — go test ./apps/sandboxes -run TestLive against
hanzo-k8s, 27.7s, image node:22:

    RUNS CODE:        SANDBOX-RUNS-CODE v22.23.2
    EDIT PERSISTS:    export const answer = 42; // edited by the agent
    EDITED CODE RUNS: ANSWER=42
    FAILURE IS DATA:  exit=3 stderr=to-stderr

The file is written through one exec and read back through a SEPARATE one, so
what is proven is a filesystem and not a buffer. The last line is the one an
agent depends on most: a command that exits 3 is a successful call carrying a
failed program, because a caller that cannot tell "your tests failed" from "the
sandbox is broken" will retry the wrong one forever.

The image is an env var, and that is why this could be proven before our own
image exists. A stock node:22 exercises the identical create/exec/edit path.

Named sandboxes, not machines: /v1/machines already exists and is visor's —
whole GPU and VPS instances, sized, quoted and billed. Two products cannot share
a noun. The namespace and pod labels follow the same word, which matters more
than it looks: universe's containment policy selects hanzo.ai/sandbox-class in
hanzo-sandboxes, and a policy naming a label the code does not set is the
enforce-nothing failure this fleet has shipped three times this week.
2026-08-06 04:47:16 -07:00
hanzo-dev 906e8f1c1e lsp and sandbox owe the app contract, and the bot ledger owes ceff43ac
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
Four gates went red because two new apps arrived without the entries every app
owes, and because a change to what the document publishes was not carried into
the ledger that counts it. None is a behaviour change; each is the declaration
the gate exists to demand.

  - spend.go: plugin/lsp declares Price: cloud.Metered, so lsp joins
    meteredApps. Without the entry the surface is free the moment SpendGate
    enforcement is switched on, which is precisely what
    TestMeteredSurfacesRequireStanding refuses to let drift. apps/lsp/meter.go
    already owns the debit — a cold checkout+index is billed, a warm query is
    not.

  - product_gate_test.go: apps/lsp and apps/sandbox mount no product and held
    no pin. Neither has an upstream repo to mount, so both are `unextracted`,
    the bucket that says the functionality lives only here and owes its
    extraction at migration.

  - typed_request_gate_test.go: apps/lsp/lsp.go takes cloud.Request for the
    money gate in front of a query, which needs strictly more of the validated
    principal than the org — the payer and the validated project sub-scope,
    neither of which may become an In field without letting a caller name who
    it bills. One call site, guarded by onHTTP so it fails closed off the HTTP
    path.

  - apps/bots/typed_wire_test.go: OPTIONS and TRACE on the relay wildcard left
    plugin/bots/openapi.json in ceff43ac, which drew the line that the document
    publishes what was DECLARED rather than whatever the router happened to
    bind under All(). The ledger still named them, so it described a surface
    that no longer exists and the partition summed to 10 against a served 8.
    Five relay entries now, not seven.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:47:05 -07:00
hanzo-dev 3e843a56d0 deploy: restore the engine mount a deps commit deleted
apps/deploy/deploy.go:350 calls registerEngineRoutes, and the file that defined
it went away in 77f187f7 — a commit whose message is a commerce dependency bump
about crypto deposit addresses and which says nothing about apps/deploy. It took
engine_mount.go (172 lines) with it and renamed engine.go to source_git.go,
which reads like a dirty worktree committed alongside the bump rather than a
decision: the same file and the same call site are both still present on the
GitHub lineage the author works from.

The tree has not compiled since. `go build ./apps/deploy/...` fails on an
undefined symbol, so go-unit reports "[build failed]" for apps/deploy and
plugin/deploy, the gate cannot pass, and no run can reach the image car — which
is why main has published nothing while commits kept landing on top of it.

Restored verbatim from 77f187f7^. The engine stays gated by
DEPLOY_ENGINE_ENABLED (default off), so this brings back the wiring and no
behaviour.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:47:05 -07:00
hanzo-dev f2279d749a the ai module cloud serves charges a casibase chat answer once
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
github.com/hanzoai/ai v1.832.30 -> v1.832.31, which removes the second debit
on the casibase chat answer path. There, recordCasibaseChatUsage and
AddTransactionForMessage both reached the usage recorder cloud installs, so
one completion charged the customer twice.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:38:28 -07:00
hanzo-dev 0e57f39c4b cloud: drop the two references to the retired GPU charge path
GPU is metered like any other resource through the machine launch, whose
balance gate and per-hour meter both live upstream in Visor. Two references to
the bespoke prepay charge outlived its deletion.

apps/billing/balance.go named gpu_charge.go as a second consumer comparing this
balance against a GPU's price. The point the paragraph makes — that `available`
is spent against rather than displayed — rests on its remaining example, the ai
balance gate that reads the field over the S2S HTTP path, so the clause goes and
the sentence keeps its subject. Comment-only: the FloorMinor rounding it
documents, and every caller that compares rather than debits, are untouched.

.hanzo/workflows/cicd.yml still spelled the drift incident with the two concrete
addresses that the same deletion generalized in cmd/reach/main.go and
openapi/fleet.go. It now describes that incident the way its siblings do — one
build serving a renamed route under its new name while still publishing the old
one — so the release train's own preamble stops naming routes the fleet no
longer serves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:38:14 -07:00
hanzo-dev c283c05729 cloud: drop the two references to the retired GPU charge path
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
GPU is metered like any other resource through the machine launch, whose
balance gate and per-hour meter both live upstream in Visor. Two references to
the bespoke prepay charge outlived its deletion.

apps/billing/balance.go named gpu_charge.go as a second consumer comparing this
balance against a GPU's price. The point the paragraph makes — that `available`
is spent against rather than displayed — rests on its remaining example, the ai
balance gate that reads the field over the S2S HTTP path, so the clause goes and
the sentence keeps its subject. Comment-only: the FloorMinor rounding it
documents, and every caller that compares rather than debits, are untouched.

.hanzo/workflows/cicd.yml still spelled the drift incident with the two concrete
addresses that the same deletion generalized in cmd/reach/main.go and
openapi/fleet.go. It now describes that incident the way its siblings do — one
build serving a renamed route under its new name while still publishing the old
one — so the release train's own preamble stops naming routes the fleet no
longer serves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:33:53 -07:00
hanzo-dev 6db7fa9837 fix(analytics): pull each event durable concurrently
CI/CD / containment (push) Successful in 3m4s
Hanzo CI/CD / cicd (push) Failing after 29m12s
CI/CD / gate (push) Failing after 29m15s
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 sink commits one row per message, and insertSettings asks the store to
batch it (async_insert) while wait_for_async_insert holds the call open until
the block is durable. With a single puller those two deadlock: the one
in-flight row IS the whole batch, so every insert pays a full materialized-view
cascade to land a single fact, and the next message cannot start until it
finishes. Throughput collapses to 1/insert-latency — measured at ~24 rows/min
against a ~2.5s cascade — no matter how much is queued behind it.

The warehouse was ~3.6 hours behind live traffic and losing ground, so every
dashboard, heat map and rollup read empty: they filter on `time`, and `time`
had not advanced since the backlog formed. Nothing was lost — the door commits
to JetStream before it answers, and the stream holds 72h — but the window is
finite.

A JetStream pull consumer load-balances across every client bound to it, so
binding the durable N times is concurrency without a second cursor. Each
message is still delivered once and still acked only after ITS OWN row lands;
redelivery, MaxDeliver and the abandoned-fact advisory are untouched. That
gives async_insert real blocks to coalesce and lets the store overlap the
cascades, so the ceiling becomes the store's rather than the round-trip's.

Also drops insights.raw_sessions_v3_mv in the warehouse (done out-of-band):
854ms and 9.2MiB of every insert spent projecting a table with no readers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:26:14 -07:00
zeekayandhanzo-dev 77f187f765 deps: commerce v1.50.16 — stop issuing crypto deposit addresses
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
Nothing credits a crypto deposit. The intent never leaves Pending, no watcher
observes the deposit address, and GenerateAddress discards the wallet_id, so
money sent to a minted address is received and not credited — and not readily
recoverable either. The rail now refuses before any keygen.

Card, bank transfer and wire are unaffected.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:19:30 -07:00
zeekay 396248e7fe boxd serves on zip, and a box learns which box it is
Two things, one seam.

boxd assembled its own request tier out of net/http: a ServeMux, an
http.Server, a hand-rolled guard wrapping a Handler, and three helpers
(writeJSON, writeErr, and a bind that re-applied a body ceiling per call) that
zip has had all along as c.JSON, zip.Errorf and c.Bind. It is a standalone
binary but it is not a different kind of program, and there is one web framework
here. It is zip now: one middleware seam, one error shape, one place a route is
declared. The METHOD moved onto the route where it belongs — three handlers
opened with the same six-line method switch precisely because a mux matches a
path and leaves the verb to whoever wrote the body, which is how PathFsDelete
came to share a handler with the fs root.

BodyLimit is stated rather than defaulted: zip's default is 4 MiB and a box
takes source, so leaving it implicit would have cut the ceiling 8x while every
test that writes a small file went on passing.

Two things the conversion had to get right rather than translate:

  - A streamed body is written from inside c.SendStreamWriter, not to a captured
    ResponseWriter. agentRun's terminal-frame contract is unchanged — every
    return still ends the stream where it always did.
  - A file handed to c.SendStream is read AFTER the handler returns, so it must
    not be closed on the way out. It was, and the LibreChat artifact test caught
    it as "file already closed" on a body nobody had written yet. Ownership
    transfers to the stream; the one path that never reaches the stream closes
    it itself.

zip has no multipart accessor, so the upload path parses the body zip already
read with the stdlib rather than reaching through c.Fiber() and putting the
framework we are hiding back into a handler. That gap is worth closing in zip —
this is the second consumer that needs it.

SECOND: the box now learns its identity, which is what makes X-Box-Id mean
anything. The header was being sent and compared against BOX_ID — an env var,
read at startup, set nowhere and unsettable in principle: boxes come from a WARM
POOL, the pods are already running when a tenant appears, and a claim is a label
patch on a live pod. There is no moment at which env could carry a per-box id,
so the guard compared against "" and skipped, forever.

Cloud binds the box at claim instead, over POST /v1/box/bind, before the address
is handed to anyone. A failed bind fails the claim and releases the pod rather
than returning an address that would 409 at its own owner. Binding twice to the
same id is fine (a retry must be able to finish); binding to a different one is
refused, because release() deletes a pod rather than recycling it and there is
no legitimate second tenant.

An UNBOUND box now refuses a named call too — that is the recycled-address case
exactly: a pod that died without release() leaves a row pointing at an IP the
CNI has reassigned, and the replacement is warm and bound to nobody, so serving
it would hand a stranger's request to a fresh checkout.

The two tenancy tests count box-bound requests, and the claim now legitimately
makes one. They assert a DELTA rather than an absolute, and the baseline is
itself asserted to be exactly 1 — so the setup cannot quietly absorb a leak.
2026-08-06 04:09:02 -07:00
hanzo-dev fe6a33c65c fleet: one tool per subsystem, and the agent shares the same door
Two halves of the same seam, entangled in the same files.

THE DOOR THE AGENT USES. An agent's tools now come from the fleet's own MCP
door, published on the plane socket every child already dials
(mcp.Serve(door, manifest.MCPPath) in serveWake). Not a second gather: refuse()
is applied inside gather(), where the routing table is written, so any second
projection would be a second policy site and the first time someone forgot it
the agent would see CreateServiceAccountKey while Slack did not. An agent is now
just another MCP client of the one door and cannot see a wider surface than an
external client -- there is no wider surface.

ONE TOOL PER SUBSYSTEM. tools/list published 1,189 flat tools in 977 KB --
roughly 244k tokens to merely enumerate. Clients truncate; Slack keeps 128. So
1,061 operations were unreachable no matter how well ordered. rank() fixed the
order and could not fix a hard cap.

Now: hanzo_<app> with inputSchema {op: enum[...names...], input: object}, plus
hanzo_describe returning one operation's own descriptor bytes. Names in the
enum, schemas on demand -- the schemas were the 977 KB. The grouping key is
gather()'s existing owner map, so there is no second lookup and no second
source. The envelope is a DECODING, not a route: call() unwraps {op,input} into
the (name, message) a direct call carries and takes the same hop with the same
headers, so a flat tools/call by op name still works byte-for-byte and Slack's
128 cached names keep functioning.

MEASURED against the fleet's own corpus (plugin/*/openapi.json, replayed through
real zip children over real sockets), baseline a real curl of api.hanzo.ai:

  BEFORE  1189 tools   977636 bytes   822 B/op
  AFTER    116 tools   106847 bytes    47 B/op
  10.2x fewer tools, 9.1x fewer bytes, for 1.9x MORE operations (17x per op)

Like-for-like the baseline's own 1,189 ops would cost ~56 KB, ~14k tokens
instead of ~244k.

hanzo_describe is published FIRST, not last: it is what makes every other tool
usable, and a client that truncates and drops it holds 116 enums it cannot read.

HEADROOM: 116 of 128, against a 119-app manifest. This fits today and will not
fit forever. When it stops, group by productStems bucket (17) -- do not add a
second projection. Recorded in LLM.md.

SECURITY. TestARefusedOpIsInvisibleUncallableAndUndescribable: the child
genuinely serves CreateServiceAccountKey (asserted against MCPTools(), so
refusing it proves something), and the name appears nowhere in the tools/list
bytes, hanzo_console{op:...} returns -32602, and hanzo_describe{op:...} returns
-32602. Both messages are checked to stay non-oracular. All three paths read the
one set gather() produces. fleet/surface.go has ZERO diff -- refuse() is still
the only gate, unchanged.

describe re-asks gather() on every request rather than caching: a remembered
descriptor could describe an op the rule has since refused.
2026-08-06 04:05:34 -07:00
hanzo-dev f1ec14c198 fleet: a subsystem with no typed op still answers its own door
30 of 117 subsystems answered 308 to the door's tools/list and were reported as
outages. They were not down. Three links, each verified:

1. zip skips installing the MCP door when an app has no typed ops
   (zip mcp.go:99). The deciding term is len(a.Registry())==0 on the SERVED app.
   kms/billing/tasks/platform/index look like they have ops but register them on
   cloud.Plane() -- a DIFFERENT *zip.App -- so their served app has none.
   Census: all 30 dark apps have 0 typed ops on the served app; all 87 working
   ones have >=1.
2. webui.Mount runs in every plugin process, not just the host (serve.go:413),
   and registers the console catch-all.
3. So POST /mcp fell through to webui/mcp.go's signpost, which 308s to
   manifest.MCPPath -- an address that is a 404 inside a child.

The signpost's own comment stated the assumption that made it safe: "a plugin
that serves its own door at FrameworkMCPPath matches a real route and never
reaches here." Link 1 made that false.

Following the redirect would mask it -- the hop lands on 404 in the child. It is
the front door's topology leaking into a process where it is untrue.

Fix is one line in cloud.App(), the constructor every Hanzo program reaches:
MCP.Source is now always non-nil (the declared Plugin.Door, else an empty
per-caller half), so hasCaller() holds and zip installs its own door. Still
exactly one MCP implementation, and zip returns the pre-rendered bytes verbatim
when the per-caller half is empty, so the memcpy stays.

MEASURED, and this is the honest part: 29 of 30 recover their door (kafka needs
a live broker to mount) and they contribute ZERO new tools. Every one has 0
typed ops -- which is why they were dark. This turns 30 phantom outages into 30
honest empty doors and makes hanzo.ai/unavailable mean what it says. It does not
conjure tools that were never registered.

The tool famine is a SEPARATE defect: MCP tools come only from typed ops, and
/v1/chat/completions and /v1/embeddings are raw routes. apps/ai declares exactly
one typed op, which is exactly the "one tool matching chat" the live door shows.

fleet/childdoor_test.go reproduces the 308 against a real apps/exec child and
asserts bytes, not status. Its sibling proves a genuinely-down app is still
reported, so this cannot be "fixed" by making every child look alive.
2026-08-06 04:05:07 -07:00
hanzo-dev 0dfe88d35c slack: state the tenant where it can actually be read
Every @hanzo turn failed in production with "authorize: no org on the call",
and the previous fix did not work because it stated the tenant in a place zip
deliberately ignores.

commerce takes the org from the CALLER's identity and never from an argument
(balance_rpc.go:36), so no caller can name the books it charges. The org has to
ride the caller. But zip reads a STATED caller only where there is NO request
behind the context (caller.go:352-356) — otherwise CallerOf reads the request's
own headers. That rule exists so that stating an identity can never override an
authenticated one.

The prior attempt called cloud.For inside the agents op, which is reached OVER
THE PLANE — a real request. The statement was silently discarded and the gate
still refused. It read exactly like the working background callers elsewhere in
the tree; the difference is invisible without the precedence rule.

So the org is now stated by the DISPATCHER, on a detached context, before the
hop: bridgeRunContext(org) = WithTimeout(cloud.For(context.Background(), org)).
Caller.headers renders it onto the wire (caller.go:302) and it rides onward for
free. This matches the existing convention in this package —
TestTheDetachedImportStatesItsTenant covers the git-import path the same way.

Detaching was independently required: the turn runs in bridgeSpawn's goroutine
after the webhook has already answered Slack 200, so on the request context the
model call was being cancelled the instant we replied.

bridgeReply loses its ctx parameter. An unused context argument here is an
invitation to pass the webhook's, which is precisely the bug; removing it makes
that unavailable rather than merely discouraged. The no-op in the agents op is
replaced by a comment stating why it must NOT be done there.

Also: the install URL asked Slack for 9 of the 13 bot scopes the app manifest
declares. Slack grants exactly what the consent URL requests, so anyone
installing through it got a token with no `commands` — /hanzo would fail at
first use, long after the install looked successful. Adds channels:read,
commands, team:read.

go build ./... clean. apps/integrations passes whole. apps/agents shows the
same 7 pre-existing metering-plane failures as baseline, no new ones.
2026-08-06 03:57:57 -07:00
hanzo-dev 19010dbbed agents: a plane-dispatched run states the tenant it bills
@hanzo got past "agent not found" and then failed with

    bridge: agent run  err="authorize: no org on the call"

A run BILLS. The balance gate is a plane call to commerce, and commerce takes
the org from the CALLER's identity, never from an argument -- deliberately, so
no caller can name the books it charges (apps/commerce/balance_rpc.go:36, and
meter_rpc.go says it outright: "The org is the CALLER'S and can never be named
in the input"). A turn dispatched over the plane has no inbound request to carry
that identity, so the gate refused and every Slack message died after the agent
had already resolved.

Fixed with cloud.For, which is the function that exists for exactly this: it
states the tenant a BACKGROUND call acts for -- one with no request to forward
-- and it CANNOT launder an identity, because zip prefers a gateway assertion
over it whenever a request exists (plane/ask.go:236-242).

The org is trustworthy for the same reason every other Slack path trusts it:
the bridge resolved it from the Slack-verified team_id through the install-to-org
map, never from a payload field.

Tests: a plane run acts for a named tenant, and an empty org states nothing
rather than becoming a blank tenant that would bill an account named "".
2026-08-06 03:25:46 -07:00
hanzo-dev fb35574098 build: build the plugins in parallel
Measured on a 23-minute image build, the four steps that cost it:

  #27  422s  the plugin loop
  #24  396s  go generate -run zipdoc ./...
  #41  244s  exporting cache to registry (mode=max)
  #39  210s  exporting + pushing layers

#27 was ~60 `go build` invocations run one at a time by a shell `for` loop, on
an 8-core runner. Each is a separate process, so Go's own intra-package
parallelism does nothing for the set: seven of eight cores idled for seven
minutes. They are independent binaries with no ordering between them, which is
the definition of embarrassingly parallel.

Now `xargs -P $(nproc)`. The existence check keeps its own pass so a missing
plugin/<name> still fails with the message that names the fix, before any
compile starts and while the output is still readable.

A failing build exits 255, which is xargs' stop-everything code — the whole
step fails fast rather than compiling 59 more plugins to report one error at
the end. It also prints WHICH plugin failed, because parallel output is
interleaved and the go error alone no longer says.

GO_LDFLAGS survives the subshell: it is ENV (Dockerfile:259), so it is in the
environment xargs' sh inherits, not a shell variable that would silently vanish
and leave every binary stamped "unknown". The existing strings|grep check on
the artifact still proves that on the bytes rather than on the flag string.
2026-08-06 03:22:20 -07:00
zeekay 88d3bee3d7 integrations described slack/install twice, so thirteen apps could not build
CI/CD / containment (push) Successful in 2m24s
Hanzo CI/CD / cicd (push) Failing after 51m20s
CI/CD / gate (push) Failing after 51m20s
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/integrations carried two openapi.Describe calls for GET
/v1/integrations/slack/install, written by different hands into different init()
funcs. Describe panics on a duplicate — correctly, because two descriptions of
one operation means one of them renders and nobody can tell which — and that
panic fires at init, so it took down every app that links integrations: ads,
automations, campaign, catalogsync, channels, cloudflare, company, content,
destinations, git, guide, integrations and sync all failed to describe.

The earlier one survives. It was already the superset: it has the attribution
constraint (Slack refuses a slack.com URL in that field, so the click has to
route through an address of ours to be counted) AND the tenant point (public, no
principal, binds no org, because minting an org for an anonymous click is the one
thing that would break isolation). The later one had a single fact the first did
not — 503 where the app is unconfigured, rather than a consent URL with an empty
client_id that Slack renders as its own dead end — so that sentence moved across
before the duplicate went.

The floor drops for the merge's own deletion too: /v1/billing/gpu/charge and
/v1/billing/gpu/eligibility are gone because GPU is metered like any other
resource now, and the bespoke prepay path with it. Checked rather than assumed —
a -1 that is not a multiple of two is not a TRACE/OPTIONS removal, and an
unexplained shrink is exactly what the ratchet is there to make someone look at.

1762 paths, 2480 operations, 185 products. Every one carries an operationId and a
summary; 51 still want a long description and 49 of those are hanzoai/ai, whose
prose belongs on its controllers, in that repo.
2026-08-06 03:21:23 -07:00
zeekay 1deb2d39e3 Merge remote-tracking branch 'forge/main' into feat/sandbox-executor
# Conflicts:
#	openapi.yaml
#	openapi/floor.json
#	plugin/meet/openapi.json
#	plugin/tasks/openapi.json
#	plugin/tracker/openapi.json
2026-08-06 03:10:07 -07:00
zeekay ceff43ac30 the document publishes what was declared, not what the router happens to bind
Two ways the same confusion showed up, and one build that could not run.

TRACE and OPTIONS were published — 33 each — because All() binds every method
at a path and the describe loops read that route table as a product surface.
CONNECT and HEAD were already excluded on exactly that principle. TRACE echoes
the request back; it is the Cross-Site Tracing verb and should not be reachable
on a public API at all, so advertising it in the contract is worse than merely
routing it, because the contract is what the SDKs, the CLI and the MCP tool list
are built from. OPTIONS is CORS preflight: a browser sends it, a person never
does, and `hanzo meet options` is not a command anyone wants. Between them, 66
operations — and the generated CLI already carried none of them, so this is the
document agreeing with the projection that was right first.

websearch held its own copy of that method list. It said seven, including the
two, and the day the document stopped publishing them the copy went on
describing operations that no longer existed — which openapi.Methods() exists to
prevent and which the describe gate caught by name. It reads the projection's own
set now.

The floor drops by exactly 66, in this commit, next to the reason.

Separately: every app Makefile claims "Generated by plugin/gen-app-cmds" and none
of them were. The generator wrote plugin/<app>/main.go and left a human to
remember the second file, so `sandbox` arrived with a main and no Makefile, so
`make describe` had no rule for it, so it published no subset, so `make openapi`
failed on an app that was otherwise finished. The generator writes both now, from
the same manifest row, taking the package from each app's own composition root
rather than a lookup table. It reproduced all 117 existing Makefiles with zero
APPS values changed, which is the evidence the rule is the one already in use.
External modules fall out structurally — no package directory here, no Makefile —
so mk/fleet.mk's EXTERNAL list is not copied.
2026-08-06 03:06:19 -07:00
hanzo-dev 9c4c861384 slack: a real App Home with a model selector, and the turn honours it
The Home tab rendered Slack's own "this is still a work in progress"
placeholder -- what Slack shows for any app that enables home_tab_enabled and
never publishes a view. The choice was never Home vs no Home; it was our page
vs Slack's apology.

The page now carries what a person opening it actually needs:
  Model      enso (auto) / enso-flash / enso-ultra, THEIR choice pre-selected
  Mode       chat only, or chat + code
  Connected  who the turns run as, and the org they bill
  Started    DM, @mention, /hanzo, and a coding run

Both controls write the SAME userLink the chat path reads -- no second settings
store, no third source of truth for which model answers. The turn then carries
that model per-request (plane.RunOnBehalfIn.Model), because it is a preference
of the PERSON asking, not a property of the agent: two people in one workspace
can prefer different models of the same assistant.

Interactivity arrives at the SAME signed URL as events, form-encoded rather
than JSON, so it is sniffed and handled before routing -- that is where the two
encodings actually diverge, and no header separates them.

Three refusals, all deliberate:
  - an UNLINKED user is shown how to connect and given NO controls; a model
    chosen by an identity we cannot resolve is a control that does nothing
  - a value not on our own menu is DROPPED, not stored: the options came from
    us, so anything else is a stale client or a forged payload choosing what
    this org pays for
  - the per-turn model overrides only the BUILT-IN agent; a person's Slack
    preference must not silently re-point an agent their org configured

Isolation is unchanged: the org comes only from the install-to-org map for the
Slack-verified team_id. The payload names which setting and which user, never
the tenant.

11 tests green. apps/integrations and plane suites pass; go build ./... clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:53:47 -07:00
antje d2c44bab81 lsp: live code intelligence over a repo and its resolved deps
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
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-06 02:45:09 -07:00
antje 2224d8491c commerce: a duplicated Describe block panicked the package at init
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/commerce would not run a single test. openapi.Describe PANICS on a
duplicate (openapi/register.go) and this package registers from an init(), so
eight re-described billing routes took the whole package down before any test
started:

    wire, crypto/options, crypto/deposit, crypto/deposit/:id,
    methods (GET and POST), methods/:id, portal/methods

Each already had a description at lines ~222-315; a later block at 444-552
described them again. Removing that block leaves 91 unique Describes and no
duplicates. The unique webhooks/:provider entry that followed it is untouched.

WHY THIS MATTERED MORE THAN A PANIC. Tests that fail to RUN are worse than tests
that fail, and this hid several: with the package dead at init, the billing
subject-pin guard added minutes earlier could never go red — a guard whose whole
job is to stay red until a route pins its subject. Removing the duplicates makes
it execute (14 subject-taking routes inspected).

It also uncovered six pre-existing failures the panic had been masking. All six
are the same environmental class, not defects: 42 occurrences of "no RAM-backed
scratch for the pure-Go SQLCipher codec (need tmpfs at /dev/shm or
HANZO_SQLITE_RAMFS_DIR)". macOS has neither, so the encrypted per-org store
refuses to open rather than decrypt to persistent storage. They run in CI.

The surviving descriptions are the earlier ones. The removed set was in places
more detailed — if that wording is wanted, replace the earlier text rather than
adding a second registration.
2026-08-06 02:44:48 -07:00
antje e957c431bc admin: the backfill floors and the money board rounds — Minor() refused both
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
Two call sites had picked the wrong one of the three Minor variants, and both
failed silently in a way that looked like something else.

BACKFILL (blocked the finance cutover). apps/admin/finance/backfill.go read a
commerce balance with Minor(), which REFUSES anything finer than a cent. The
ledger keeps eighteen decimals and per-token charges are routinely finer than a
cent, so every org that has spent anything carries a sub-cent tail — and the
backfill returned "read commerce balance: amount … is finer than its minor unit"
and migrated ZERO. Fail-safe, and completely stuck. It now FLOORS, which is the
direction a migration must take: the amount moved must never exceed the amount
held, or the migration mints the difference. The dust stays behind and settles.

MONEY BOARD (display). apps/admin/moneyboard.go read the treasury reserve the
same way, so the same tail rendered as SrcOf("treasury", err) — "could not reach
the treasury" — for a treasury that was reachable and correct. It now ROUNDS.
Nothing is spent from that figure, and refusing to show it is worse than a half
cent.

The rule the two sites got wrong is now pinned in plane/money_test.go against the
real production balance, which distinguishes all three:

    149913.078983985999994361  ->  Minor() ERR   Floor 14991307   Round 14991308

    Minor()       a DEBIT      exact or refuse — rounding money you TAKE is theft
                               one way and a gift the other
    FloorMinor()  a COMPARISON never overstate — admitting spend a balance cannot
                  or MIGRATION cover leaves a negative balance nobody authorised
    RoundMinor()  a DISPLAY    nearest — nothing is spent from it

RoundMinor also gains the coverage it never had: nearest in both signs, and it
still errors on overflow and on a malformed amount rather than wrapping or
rounding to zero.

Found by an adversarial review of the money path (its M1/M2), which asked the
question I had not: where should I have floored and did not.
2026-08-06 02:39:32 -07:00
hanzo-dev c8a44c7014 agents: the chat brain is enso, not the degraded fallback tier
The built-in default agent I added minutes ago took cloud.FallbackModel, which
is "best". That constant's own doc says what it is for:

  "keeps a bot's reply landing when the flash tier is saturated; the
   interactive chat path never uses it"

A Slack turn IS the interactive chat path, so I had wired the DEGRADED tier as
the default brain — I reached for the nearest available model constant instead
of asking what the chat model should be.

It is now enso, Hanzo's own auto-routing SKU that selects per query in the
gateway's catalog, with BRIDGE_AGENT_MODEL to override per deployment. Studio
names the same default for the same reason (STUDIO_CHAT_MODEL or "enso").
Forwarded verbatim and never validated here: the catalog resolves it, and a
check in cloud could only disagree with the thing that decides.

Tests pin both halves — the default is enso and is never "best", and the
override wins.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:39:26 -07:00
hanzo-dev d0cc005862 Merge remote-tracking branch 'forge/main' into forge-sync
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-06 02:31:59 -07:00
hanzo-dev 46f4f8222a sync github main onto the forge — the lineage the release pipeline cuts from
The forge already carried the front-door body-limit fix (internal/edge). These
seven were only on GitHub, so the next pipeline release would have shipped
without them:

  84e26665 commerce: the 128 silent operations say what they do
  702ecbf7 preflight admits the four headers the console actually sends
  bd4115d3 o11y: the product scope is the fleet, sub-cent debit is not an outage
  8f1f50e0 CORS: allowed origins derive from verified site hosts
  3bfdeee0 slack: the agent turn crosses the plugin boundary over ZAP/UDS
  c78fbe72 a console the customer owns can call the API it was forked from

Resolutions:

- apps/billing/usage_coresident.go — FloorMinor, not RoundMinor. Both fix the
  same 502, but balance.go:126 already floors a row from the same ledger, and
  two roundings on one ledger is how the next discrepancy starts.
- apps/framework — RoleAssignment/roleRef. Without the rename the weave refuses
  the whole document: iam publishes a Role ENTITY and framework published a
  (user, role) GRANT under the same name, and a generated SDK binds whichever it
  read last. revokeRole still named the pre-rename type; it takes *roleRef now,
  which is the URL-path form it actually addresses.
- manifest/apps.go — UNION, not a side. github adds /v1/event.js (the hosted
  analytics tag, previously falling to ai's bare /v1 remainder, which a browser
  reads as a broken script tag); the forge has /v1/replay, which github lacks.
  Taking either side alone drops a live route. The superset check I wrote for
  the last merge is what caught it — it asserted, and it fired.
- Generated artifacts (openapi.yaml, plugin/*/openapi.json, */zipdoc_gen.go) are
  taken from github, not regenerated here: `make describe` on this tree still
  fails for 14 apps, and github's are the set that was verified green at 1735
  paths. A half-regenerated document is worse than a whole borrowed one.

go build ./... exits 0. TestTheServedDocumentIsTheArtifact and the door
body-limit tests both pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:31:58 -07:00
antje 56d4ee779f commerce: /v1/billing/tier pins the subject — it was leaking every customer's balance
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
I introduced this tonight. To make the S2S tier reader resolve an org I mounted
the route RequestContext -> IAMTokenRequired -> TokenRequired and stopped there.
TokenRequired AUTHENTICATES and does not PIN, which is the exact hazard the
comment on /v1/billing/methods already spells out: any authenticated browser can
then name another subject.

GetTier reads ?user= verbatim and answers with that subject's wallet —
prepaidAvailable, creditsRemaining, effectiveAvailable. Every one of the other
subject-taking billing routes carries accountclient.PinBillingSubject(); this one
did not.

It is CROSS-CUSTOMER, not merely cross-subject. Every self-serve signup lands in
the SAME org (account.SignupOrg = "hanzo") with a per-person subject, so the org
namespace was closed while the subject was open. Measured live against the running
pod before the fix — one caller, four wallets:

    ?user=hanzo/z      prepaidAvailable 10966
    ?user=hanzo        prepaidAvailable  6375
    ?user=hanzo/admin  prepaidAvailable 10000
    ?user=hanzo/dev    prepaidAvailable     0

The pin does not undo the fix it sits on top of: PinBillingSubject admits a
verified service-token caller that names its own org and leaves its query
UNTOUCHED (apps/account/billing_coresident.go), which is exactly what ai's rate
limiter and apps/metering send. TokenRequired stays after it so that caller still
resolves an org.

The guard is a CLASS guard because a one-route fix would not have found this one.
It walks the AST of the mount table and fails any /v1/billing route that takes a
caller-named subject without a pin.

Two things the guard had to survive to be worth committing:

- The first version used a regexp and SILENTLY SKIPPED the route it was written
  for. The non-greedy chain match ended at the first `\n\t)` and the scan resumed
  past several registrations, so it reported "14 routes checked", passed, and
  would have passed with the leak present. It now asserts it can SEE
  /v1/billing/tier, so a walk that stops reaching the table fails loudly instead
  of reading as green.
- The alerts group is allowlisted only after checking it: billingSubject derives
  from the RESOLVED ORG (org.Name, spend_alerts.go:62) and every read is
  org.Namespaced, so there is no caller-named subject to pin. An unverified entry
  in that list turns this guard into a rubber stamp for the leak it exists to catch.

Verified both ways: removing the pin fails the guard naming mount.go:603.

Found by an adversarial review of the money path, which ranked it the one thing
worth fixing before launch.
2026-08-06 02:31:30 -07:00
github-zeekayandhanzo-dev 9d48b131de metering: a usage ref is server-minted on every surface, and pre-cutover rows carry their wallet
Closes the last two surfaces where a client could name the usage idempotency ref
(the ai answer surface and the domain register/renew/transfer refs) so Usage.Seal()
mints them, and backfills the program column on pre-existing finance.usage rows so a
ref straddling the deploy debits once. R7 asserts no plane op carries metering.Usage;
R8 routes the split-deploy debit over the plane, not the tag-stripped HTTP wire.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:30:16 -07:00
hanzo-dev 7c49095dca a /v1/event gen_ai span is an LLM observation, so it lands on event.span
The span-signal slice of the analytics fan-out gets the sibling the error slice
already has. AddSpanSink mirrors AddErrorSink and filters on routeOf -- the
plane's ONE routing rule -- so the lens can never carry a fact the plane filed as
something else: an event with a span BODY that routes as an act stays an act.

apps/o11y writes the row, and it writes a ROW rather than calling a module
because that is what the read side is. llmobs.Module has no ingest surface; its
observations/traces/sessions/users are querier reads of event.span filtered by
`gen_ai.system EXISTS AND gen_ai.hanzo.org_id = <caller's org>`
(impllmobs.genAIFilter). So the only way a span becomes an observation is to BE
such a row, and the rows go through insertSpans, which owes event.span and the
event.trace partial the trace list resolves against -- a span written without its
partial is a span every trace read answers empty over.

Only gen_ai spans project. The marker is the reader's own, so the lens admits
exactly the spans the LLM views return; an ordinary span would be write
amplification no read can answer with, and a second copy of a span the ZAP door
already owns. An attribute that renders empty is dropped rather than stored
blank, because a key stored empty satisfies EXISTS while naming no provider.

The tenant is the SLUG, stamped last and unconditionally. gen_ai.hanzo.org_id is
the only org discriminator the span views have, and the handler binds it from the
validated X-Org-Id -- so the o11y org UUID the Sentry lens derives would be a row
every LLM view returns zero of. A wire-supplied org is overwritten, never read.

The span slice carries the scrubbed property copy the fact row stores, which is
where this seam differs from the destinations slice: that consumer FORWARDS and
must hash raw match keys, this one STORES, and scrubText's contract is that a
token in a property is redacted before storage. A projection must not store more
than the plane stores.

CLOUD_LLM_LENS turns it off; default on, the CLOUD_SENTRY_LENS posture. It
installs from mountPlaneIngest rather than mountRuntime, which is the same rule
against a different dependency -- a lens installs where the thing it writes
through becomes available, and mountPlaneIngest sets the plane sink after
mountRuntime has already run. ShutdownO11y detaches it first, beside the error
lens, so no in-flight ingest dispatches into a closing connection.

Mutation: widen the filter to admit any event carrying a span body and
TestFanOutSpansIsThePlanesRoute goes red with

  want the 2 span-routed events, got 3: [... {Name:click Kind:track SpanID:x}]

-- the act the plane filed as an act, arriving on the span projection under its
own name and kind. Restored, green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:29:46 -07:00
zeekayandhanzo-dev 211d021c66 deps: commerce v1.50.14 — the crypto rail stops offering Solana
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 buyer who picked SOL reached the confirm screen and met 'the custody service
is not accepting new addresses'. It could never have worked: a Solana address is
an Ed25519 key and the fleet runs only the secp256k1 ceremony, so mpcd never
emits sol_address. The asset picker is rendered from the processor's currency
list, so listing it is what put the button there.

Every other chain mints — ethereum, bitcoin, polygon and lux all verified
returning addresses through our own MPC.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:25:57 -07:00
hanzo-dev 5435fe3a05 slack: @hanzo answers out of the box, and the agent can call tools
THREE things, all measured against production tonight.

1. AGENT NOT FOUND. With the plugin-boundary fix deployed the turn finally
   reached agents, which answered `agents: agent not found`. The bridges ask
   for the conventional ref ("hanzo") and Store.Resolve is a plain row lookup
   with NO seeding anywhere — so an org that connected Slack and did nothing
   else had no agent, and @hanzo could never work out of the box in ANY
   workspace. The conventional ref now resolves to a BUILT-IN default: not
   persisted (a row would fork the definition per org and strand already-seeded
   orgs on a later change), and a row the org DOES create still wins because
   Resolve is tried first. An unknown ref stays a miss — silently substituting
   the chat agent would make a typo in `code: repo` run the wrong thing and
   look like it worked.

2. TOOL CALLING. executeRun did one completion and returned the text; Agent
   .Tools was stored, updated and displayed but never read by a run, so the
   agent could not reach anything. It now runs a bounded tool loop and takes
   the actor, so every tool call is attributable to the org and user that
   caused it.

3. TOOL SURFACE. fleet/mcp.go + fleet/surface.go curate and gate what a client
   may see: the live server projected 1,323 internal ops with zero annotations,
   and Slack saved the first 128 ALPHABETICALLY — a window containing zero
   product tools and 36 credential/auth ops including CreateServiceAccountKey.

Cross-agent collisions found and fixed while integrating: tools.go's truncate
collided with an existing test helper (renamed truncateToolResult, which says
what it bounds), and executeRun gained an actor parameter that two test callers
had not been updated for.

go build ./... clean. fleet and plane suites green; apps/agents' one failure,
TestTargetOpsProjectEverywhere, is the known pre-existing op-id drift.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:25:55 -07:00
zeekayandhanzo-dev 3918c4ab81 sandbox: the tenancy tests run again, against the code that exists
413 lines of harness and tenancy tests referenced stateWarm, classDesktop and
pool.nodeTaint from an implementation that was replaced, so the package compiled
and its whole suite did not — zero coverage on the one component whose job is
keeping one tenant out of another's box.

The substitutions are both outside the implementation: client-go's fake dynamic
client for the pool, and an http.RoundTripper swapped into the package-level
boxClient for the box. forward() is therefore exercised byte for byte — headers,
query handling and all — against the production route table rather than a
paraphrase of it. Route tables are shared vars, so a route that moves breaks the
tests instead of leaving them testing a path nobody serves.

Six tests are skipped rather than deleted, each naming the mechanism it needs.
That is the point of the port: a skip that names a missing mechanism is a
finding, and deleting it is how the mechanism stays missing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:25:13 -07:00
zeekayandhanzo-dev 8da312c255 exec: the shared pool lives where the policy is
apps/exec pointed at code-exec.hanzo.svc while apps/sandbox schedules boxes into
hanzo-boxes, so the two halves of one executor sat in different namespaces under
different containment — and the half in `hanzo` was the half sitting beside the
datastores. They run the same binary on the same submitted code; only the
lifetime differs, which is not a reason for two blast radii.

Also corrects two comments that described the containment as absent. It was:
the policy selected `app: code-exec` in namespace `hanzo` and matched no pod.
universe now declares one policy over hanzo.ai/box-class in hanzo-boxes, so
saying so is accurate rather than aspirational — which is the property those
comments were written to protect in the first place.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:21:31 -07:00
zeekayandhanzo-dev b4992c630e sandbox: the executor exists, is mounted, and ships in the image
Three things were true at once and each hid the next. cmd/boxd was written and
untracked, so no release could contain it. Cloud's Dockerfile built /cloud and
/smoke and nothing else, so committing boxd would still not have put it in an
image. And apps/sandbox had a Mount with no plugin/sandbox beside it, so the
package compiled, linked into nothing, and answered 404 in production while the
code to answer sat in the tree.

That is why `COPY --from=ghcr.io/hanzoai/cloud:<pin> /boxd` in hanzo/bot's
Dockerfile.box was red by construction — grep -c boxd Dockerfile was 0 — and why
every consumer already pointing at the executor (hanzo.app's ProjectFs,
apps/exec's upstream, apps/functions' invoke) had nothing behind it. The
33-day-old code-exec Service with <none> endpoints is the same fact seen from
the cluster.

boxd builds HERE rather than in hanzo/bot because its types ARE
apps/sandbox/wire's types: the scheduler and the daemon agree because they
compile against one declaration, not because two repos were kept in sync by
hand. It is copied into the FINAL layer, not just the build stage — a binary
that exists only in the builder is not in the published image, and the box image
reads the published one.

boxd is also the half that runs untrusted code, so it refuses to start in the
configuration where that code can steal the pool key: submitted work running as
boxd's own uid can read CODE_EXEC_API_KEY out of /proc/<pid>/environ, and that
key is shared across the pool, so the credential a box hands its own workload is
the credential that opens every other tenant's box. Reproduced end to end. Two
cheaper defenses do not work and pgroup_unix.go records why measurements, not
reasoning: scrubbing the child's env misses that the child reads boxd's, and
os.Unsetenv misses that the kernel serves /proc/environ from the exec-time stack
block rather than the live environment.

.gitignore takes boxd in both spellings. It is the one binary people build from
inside its own directory, since it runs standalone on a laptop, and that file
already carries what committing binaries cost this repo once.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:13:45 -07:00
github-zeekayandhanzo-dev ac09a86d3a metering: a usage ref is server-minted on every surface, and pre-cutover rows carry their wallet
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
Closes the last two surfaces where a client could name the usage idempotency ref
(the ai answer surface and the domain register/renew/transfer refs) so Usage.Seal()
mints them, and backfills the program column on pre-existing finance.usage rows so a
ref straddling the deploy debits once. R7 asserts no plane op carries metering.Usage;
R8 routes the split-deploy debit over the plane, not the tag-stripped HTTP wire.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:13:36 -07:00
zeekayandhanzo-dev 84e2666543 commerce: the 128 silent operations say what they do
`make describe` refuses to regenerate openapi.yaml while any operation is bare,
so the artifact could not be rebuilt AT ALL and had drifted: on clean main the
host served 1698 paths against the golden's 1696 and
cmd/cloud.TestTheServedDocumentIsTheArtifact was red.

128 of the bare operations were commerce's, and commerce registers its routes
from an embedded module, so there is no doc comment here for zipdoc to lift —
openapi.Describe beside the route is the seam. Nine are written out: the wire
top-up rail, the crypto custody rail, the saved-card family, and the tenant's
payment-rail toggle. The other 119 are seventeen merchant kinds behind ONE
generic REST scaffold, so the mechanics are written once and composed with the
kind. Seventeen hand-copied paragraphs describing one generator is the drift
DescribeRest already exists to prevent one level down.

Every sentence is written from the handlers, and what earns space is what a
caller gets wrong:

  - PUT is a true REPLACEMENT — the body is decoded onto a FRESH entity, so a
    field the body omits is written back as its zero value;
  - POST /<kind>/{id} with NO override is a PARTIAL UPDATE, never a create;
  - a wallet read renders the account's ENCRYPTED key blob and its salt, so
    whoever may read one can attack it offline down to the owner's passphrase —
    which is the reason the kind is admin-gated;
  - a webhook's delivery consults neither `enabled` nor `live`, so enabled=false
    does not stop delivery; deleting the row is what does;
  - a discount is enabled by DEFAULT, so a bare create makes a live discount;
  - the per-kind permission table covers 5 of the 17 kinds. On the other 12 the
    scaffold logs that it is skipping the check and ALLOWS, so the route gate is
    the whole authorization story. Each kind now says which it is.

THREE defects were hiding BEHIND that refusal, because describe-apps stops at
the first app that fails and commerce sorts early. Each is repaired, not
recorded:

  - integrations: GET /v1/integrations/slack/install was bare. The handler
    already carries the prose; it is a raw route, so zipdoc cannot lift it.
  - the weave refused two schema names that meant two things. `Role` was iam's
    role ENTITY and framework's (user, role) GRANT — the grant is now
    RoleAssignment, converted at the handler boundary with the engine type
    untouched. `Application` was iam's OAuth client and crm's startup-program
    submission — the latter is now ProgramApplication.
  - manifest: /v1/event.js, the hosted analytics tag, is served by analytics and
    was routed to ai's bare "/v1". A prefix owns SEGMENTS, so "/v1/event" never
    covered it, and a browser reads that 404 as a broken script tag rather than
    as a routing mistake. Claimed on the analytics row.

All three predate this change and are provable on clean main: regenerating the
stale plugin/iam/openapi.json alone makes the weave fail the same way.

The regenerated zipdoc_gen.go files are the same class of staleness — prose that
was in the Go source and had never reached the artifact.

make describe exits 0. openapi.yaml carries 1735 paths, up from 1696, served
byte-identical to the golden. go build ./... is clean. The pre-existing red
tests (apps/commerce TestBalanceCents/TestInProcessClient, apps/framework's
three, apps/projects TestForkCreatesProjectFromTemplate) fail identically before
and after.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:11:32 -07:00
hanzo-dev 41a0e5a3b5 dataset: settled waits for the slot to clear, not just for the status
CI/CD / containment (push) Successful in 1m37s
Hanzo CI/CD / cicd (push) Successful in 39m10s
CI/CD / gate (push) Successful in 39m10s
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
TestADisposalIsIdempotent failed the release gate on a 409 — "a materialisation
of this dataset is running" — for a materialisation that had already finished.

The two facts land at different instants. plane.run publishes the version with
p.record and gives the tenant's scan slot back from a DEFERRED p.release, so
between the status going terminal and the slot clearing describe answers
status: ready, running: true. settled returned on the status alone, inside that
window, and dispose refuses a held slot — so the next call 409'd on a job that
was over.

The CI log says exactly this: the FAIL is printed BEFORE the 'dataset
materialised' line for the same dataset, and that line is logged AFTER
p.record — so the version was published, the test read it, disposed, and was
refused by a slot whose owner had not yet returned.

It is microseconds wide, which is why it passes on an idle machine and failed on
a loaded runner. running is already on the wire for this (typed.go view), so
settled now waits on the state the refusal is made from instead of a proxy for
it. No production behaviour changes: a client polling describe was always told
running: true, and always had to wait for it.

30 consecutive runs green under CPU contention.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:07:31 -07:00
hanzo-dev 292697bdb7 Merge main: the trunk moved while the event branches landed
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:31:10 -07:00
hanzo-dev aab3d069f5 Merge event-error-sentry-lens: /v1/event errors project onto the Sentry plane
Every fact the plane files under the error signal fans out, detached and
fail-soft, to an error sink apps/o11y installs when its embedded runtime is
up: analytics.ErrorEvent -> the Sentry wire -> o11y's normalize+fingerprint
-> Modules.Sentry.Ingest, landing in o11y_sentry_events + the o11y_issues
lifecycle under the org's canonical project (get-or-create, cached; the org
UUID is the read side's own UUIDv5 formula, pinned by TestDeriveOrgUUID).
So an error a product beacons to /v1/event surfaces on sentry.hanzo.ai
beside the errors a Sentry SDK posts to /v1/event/{project}/envelope.

Re-expressed on the trunk where it moved past the branch:

  - AddErrorSink returns a remover, the same plural-sink discipline as
    AddSink; o11y detaches it first in ShutdownO11y.
  - The filter is routeOf — the plane's ONE routing rule — so the lens can
    never carry a fact the plane filed as something else; the branch's
    bespoke isErrorEvent detector goes away.
  - The envelope's first-class release/environment/service/site/trace
    qualifiers win over the legacy property spellings; service_name tags
    the wire's service when it named one, else the emitting surface.
  - The branch's pk_ mint-door flow test is superseded: IAM mints pk- at
    POST /v1/keys and capture_keyorg_test covers the resolver seam.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:31:03 -07:00
hanzo-dev 0d7011e2b3 Revert "cicd: the release lane queues, because the expression that said so was not read"
CI/CD / containment (push) Successful in 2m51s
Hanzo CI/CD / cicd (push) Failing after 29m16s
CI/CD / gate (push) Failing after 29m17s
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 reverts 2f9ffc2d. The diagnosis under it was wrong and the change bought
nothing, so it should not sit in the release lane implying otherwise.

What the evidence actually shows: runs 889, 890, 891 and 892 all report
started_at = 1970-01-01 — they never received a runner at all. They were
superseded while still QUEUED, not killed mid-gate, so cancel-in-progress was
never the thing acting on them and a literal false could not have saved them.
Superseding a queued run with a newer commit is moreover the RIGHT behaviour for
a release lane; the newest commit is the one worth shipping.

The real constraint is capacity against push rate: the ten runners of
hanzo-build-linux-amd64 serve five orgs and were 10/10 busy throughout, while
main took a push every few minutes. A run that waits longer for a runner than
the interval between pushes can never start. That is a fleet-sizing and
scheduling question, not a line in this file.

Reverted rather than reworded because the setting is only honest as the
expression that states the intent; a comment describing a mechanism I could not
reproduce is a worse landmine than the bug it claimed to fix.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:29:16 -07:00
hanzo-dev 702ecbf7a6 preflight admits the four headers the console actually sends
X-Actor-Id, X-Act-As-Project, X-Act-As-Org and X-CSRF-Token are stamped on every
signed-in console call (client.ts baseHeaders + applyCsrfToInit) and none of them
were in corsAllowHeaders. A header absent from that list fails PREFLIGHT: the
browser reports an opaque "TypeError: Failed to fetch" and the request never
reaches a server log, so there is nothing to find on our side. Measured from a
signed-in console.hanzo.ai page: adding any ONE of the four blocked the call,
the identical request without it returned 200.

Naming a header only lets the browser SEND it. Each stays exactly as trustworthy
as before — SanitizeIdentity still strips and re-mints client-supplied identity,
so an intent is validated, never believed.

Taken as a one-hunk cherry-pick rather than by merging PR #385: that branch is
443 files and 49,500 insertions off an ancient base, and merging it would drop
~17 commerce prefixes from manifest/apps.go and revert usage_coresident.go's
FloorMinor back to RoundMinor.

NOTE: this constant's own doc says it mirrors hanzoai/gateway routes.go
corsPreflightMiddleware so the browser contract is byte-identical through either
path. The gateway needs the same four or the mirror is broken.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:27:29 -07:00
zeekay 326f432cca the visor probe was measuring a static file server
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
o11y probed visor.hanzo.svc:19000/health. visor serves its health as a typed op
at /v1/health, and everything outside /v1/ falls through to a static passthrough
that answers 200 with index.html. So the probe was green whatever the service was
doing -- it measured the file server, not visor.

This is the same bug found twice tonight in two repos. visor's OWN liveness and
readiness probes requested /api/health, also not a route, also served the SPA at
200, so a pod with a dead database stayed in the Service. One bug twice is a
pattern, and a pattern earns a gate rather than a second one-line fix.

The gate is narrow on purpose. It cannot know which path a given service serves
-- that is the service's business -- so it refuses only what is provably wrong:
a probe URL with no path, and a bare /health on a host known to answer under
/v1/. Anything it cannot prove wrong it permits, because a gate that guesses is a
gate someone disables.

Mutation: revert the probe to /health and it goes red with

  visor: probes "/health" on visor.hanzo.svc, which serves under /v1/. Outside
  /v1/ the request falls to the static passthrough and answers 200 with HTML, so
  this probe is green whatever the service is doing.

Restored, green. The first run of that mutation reported "clean=1" and I nearly
read it as a pass -- the package was not compiling, because the test named the
target slice `probes` and it is `fleetTargets`. A package that fails to BUILD
reports zero test names, which is exactly why go vet answers "does this build"
and a test-name diff does not.
2026-08-06 01:27:02 -07:00
hanzo-dev 2f9ffc2da0 cicd: the release lane queues, because the expression that said so was not read
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
concurrency.cancel-in-progress was ${{ github.event_name == 'pull_request' }},
which on a push is supposed to be false. The forge does not evaluate it to a
boolean — it cancelled every push run regardless, so the setting written to keep
releases from being killed midway was itself killing them.

Measured on main this morning: runs 883-890 all CANCELLED, each by the next push
arriving seconds later, and 27 consecutive runs produced no release. The gate was
not merely red; even once green it could not have finished, because a repo pushed
more often than its gate takes to run never reaches the end of one. That is the
whole reason a feature merged yesterday is still 404 in production.

A literal false is read correctly and restores the behaviour the comment already
described: ONE release at a time, queued, never cancelled. The image is pushed
early and the tag and pin come last, so a killed run leaves an orphan image — the
ten between v1.801.335 and v1.801.350 are what that looks like.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:25:03 -07:00
hanzo-dev 6c48821537 Merge event-plane: the owner gate and the every-signal proof land on the evolved plane
The five branch commits join the trunk. Their event plane — the fact, the
stream, the writer, the /v1/event door — already lives here in evolved form
(one event.fact table discriminated by signal; the door table in event.go;
/v1/insights/e retired), so content conflicts resolve to the trunk
throughout, and the branch's two proofs land re-expressed against it:

  - manifest/shadow_test.go: a prefix belongs to exactly ONE app. The router
    merges identical patterns silently (mutation-checked: a duplicated
    prefix passes TestAppsMountWithoutConflict), so the table is where the
    invariant must hold. The branch's two recorded collisions are both
    fixed on this side (storage's deeper /v1/s3 leaves; zen's Gates), so
    the gate lands with no exception list.
  - apps/analytics/signal_live_test.go: one batch, four signals, four rows —
    act/error/log/span each land exactly one row under its own signal, with
    the columns their reads sort and group by, sharing one trace.

The branch's fleet-wide zipdoc/openapi regeneration, its o11y receiver
ports, and its telemetry rungs are superseded by the trunk's plane sink,
typed ops and generated surfaces, and resolve to the trunk.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:24:50 -07:00
zandGitHub bd4115d323 Merge pull request #387 from hanzo-inc/fix/o11y-fleet-telemetry
o11y: the product scope is the fleet, and a sub-cent debit is not an outage
2026-08-06 01:23:56 -07:00
zandGitHub 8f1f50e066 Merge pull request #386 from hanzo-inc/cors-verified-site-hosts
CORS: allowed origins derive from verified site hosts, not a static list
2026-08-06 01:23:50 -07:00
hanzo-devandzeekay f9a5cea2f7 smoking is a fact this pipeline records, not one it infers from the artifact
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 image job skipped the smoke on `resumed`, and `resumed` was read from the
registry. So a run that PUSHED and then FAILED the smoke came back, found its own
bytes under its own version, declared itself resumed, and skipped the build AND
the proof — handing rollout an image nothing had ever booted, with every job
green. One flag was answering two questions and only one of them had an authority.

An artifact existing proves it was built. It never proves it was tested.

`built` stays the registry's answer and decides the build alone. `smoked` is a
separate fact with a separate authority: refs/smoked/sha256-<digest> at the
commit, created by the same compare-and-swap the version claim already uses — one
mechanism for recording a fact, used twice. Kept in git rather than beside the
image because the registry is the thing being attested and ns hanzo-build alone
had five ways to write it; an attestation stored inside what it attests can be
written by anyone who can write that.

Keyed by DIGEST, never by version. A receipt naming a version proves a version
was smoked and says nothing about which bytes that name resolves to now.

The smoke boots repo@digest instead of repo:tag for the same reason, and rollout
now refuses to ship when the digest pin.sh wrote is not the digest this run
smoked — a values file whose tag moved and whose digest did not deploys the old
image and reports success.

Measured on the real conditions, before vs after, at the state that caused it
(bytes exist, never smoked): before SKIPPED the smoke, after RUNS it. The two
agree on every other state.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:22:43 -07:00
hanzo-dev 6d69166d6c generated: carry the projections the lineage merge left behind
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 merge that brought the github lineage onto the forge one took the SOURCE of
several ops — agents' session count, affiliates' royaltyFailures among them —
without the artifacts derived from it, so the drift gate read the tree as a
surface that had changed and a document that had not.

Nothing here is hand-written: zipdoc_gen.go for seven apps, affiliates' subset
and the weave, all regenerated from the source already on main. A generated file
committed stale is the same defect as one never generated, and it fails the gate
in the same place for both.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:21:51 -07:00
hanzo-dev 9d119cae91 gate: the typed-request allowlist named apps/commerce/risk.go twice
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
Two lineages each justified the same exemption, and the merge kept both entries.
A duplicate key in a map literal is a COMPILE error, so the root package's tests
did not run at all — go-unit reported "[build failed]" for the one package whose
job is to hold this gate, which is the failure mode the gate exists to prevent.

Kept the entry that states the mechanism (screen.op / seen, what comes off the
decoded In versus the request, and that a call with no request resolves no payer
and is screened as that state rather than exempted from it), and folded in the
one fact only the other carried: the request is parked by the app-wide Bridge,
which is why the same resolver works on the /mcp leg and the browser's alike.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:16:24 -07:00
hanzo-dev 4d60d5fa02 manifest: the saved-card top-up gets an address the fleet routes to commerce
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 / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
/v1/billing/topup was mounted on commerce and named nowhere in this table, so the
fleet published the path and handed it to ai's bare "/v1" remainder — the route
of last resort. The prepaid balance gate on that row would have made topping up
require the balance the top-up exists to create: the exact trap the /v1/cart
paragraph above describes, this time on the door that funds the account.

It was invisible until the operation was described, because router_test.go's
oracle compares PUBLISHED paths against what the router hands each app, and an
undocumented route publishes nothing to compare. The document and the table are
each other's check; a route missing from one hides a defect in the other.

The stem /v1/billing/topup replaces the leaf /v1/billing/topup/token rather than
joining it: commerce serves both top-up doors, a prefix owns its whole subtree,
and the subtree has no other claimant.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:13:11 -07:00
hanzo-dev 3bfdeee0b1 slack: the agent turn crosses the plugin boundary over ZAP/UDS
@hanzo answered every message with "the agent hit an error handling that".
Cause, from the log: `cloud: app is not deployed here: agents`.

bridgeReply called agents.RunOnBehalf DIRECTLY, and that function gates on the
agents package's `mounted` global. A PLUGIN IS A PROCESS: a package global is
per-process, so `mounted` is nil on the bridge's side unless agents happens to
be in the same binary. The in-process seam was the ONLY door, which made
co-residency an undeclared requirement — and integrations and agents are
separate plugins in every real deployment, so every chat bridge failed
identically, for Slack, Discord, Teams and Telegram alike.

The turn now travels as a typed plane op over ZAP/UDS:

  plane.AgentsRunOnBehalf  + RunOnBehalfIn/RunOnBehalfOut   (plane/plane.go)
  POST /agents/run-on-behalf                            (apps/agents/onbehalf_rpc.go)
  plane.Ask from the bridge                             (apps/integrations/bridge.go)

Both doors run the SAME runOnBehalf, so org isolation, linked-subject
attribution and billing are identical whichever way the call arrived —
sessions_rpc.go's "two doors, one teardown" shape, applied to the run.

The bridge no longer imports apps/agents at all: reaching into another plugin's
package WAS the coupling, and the dropped import is the proof it is gone. The
plane door lives in its own file so onbehalf.go keeps its promise to know
nothing of zip.Ctx or the wire. Status travels explicitly rather than being
inferred from a non-empty Output — "ran and had nothing to say" and "failed"
are different answers and a bridge must not post the second as the first. An
empty subject is refused, never defaulted to the org: a turn that lost its
caller must not bill the tenant for an unattributable act.

ALSO: a DM is answered INLINE. Threading every DM reply buried a one-line
answer behind a "1 reply" click; a channel still threads because the reply
shares the room, and a DM the user deliberately threaded is honoured.

Tests: DM inline, explicit DM thread honoured, channel mention still threads;
the existing route test now STATES the inline intent rather than the old
behaviour. apps/integrations and plane suites green. apps/agents'
TestTargetOpsProjectEverywhere fails identically on unmodified main (op-id
naming drift) — verified by stashing tracked AND untracked.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:10:22 -07:00
hanzo-dev c53e8b48ed Merge remote-tracking branch 'forge/main' into forge-merge
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-06 01:09:55 -07:00
hanzo-dev d9be5e356a commerce, integrations: two live routes join the published surface
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/billing/topup and /v1/integrations/slack/install were both registered on the
router and described nowhere, so the drift gate stopped: an operation that says
nothing about itself publishes an operationId and no sentence, which is an SDK
method that cannot explain itself and an MCP tool a model cannot pick. Neither
route was reachable from any generated client.

Both are raw by nature, so both state themselves with openapi.Describe rather
than a lifted doc comment: topup is charged and redirected, install answers a
302. The prose is written from the handlers — the gate each one gets, the tenant
scope, and the one rule a caller would otherwise get wrong: for topup that
paymentMethodId is NOT covered by the billing-subject pin and a card owned by
another subject answers 404 rather than 403, because 403 would be an ownership
oracle over other people's cards; for install that it is anonymous ON PURPOSE
and the org is resolved at the provider callback like every other install.

Regenerated: openapi.yaml 1751 -> 1753 paths, 2509 -> 2511 operations, and the
per-app subsets beside them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:08:52 -07:00
hanzo-dev b990531677 Merge remote-tracking branch 'forge/main' into forge-merge
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:07:02 -07:00
hanzo-dev e6ce93416a merge github main into the forge lineage — the buildable one
The forge is what builds cloud (the build job clones git.hanzo.ai/hanzoai/cloud;
hanzo-inc/cloud has no build workflow at all, only Dependabot). So work landing
on GitHub main was shipping nowhere. 133 forge commits vs 52 GitHub commits off
merge-base 1720eb20, 15 conflicts.

Resolutions, each decided rather than defaulted:

- Dockerfile: ARG CONSOLE_IMAGE deleted. Deleting it IS the change — the console
  is a published site now, not an embedded image.
- apps/billing/gpu_charge.go: delete kept. The forge removed the bespoke prepay
  charge path on purpose ("GPU is metered like any resource"); GitHub modified a
  file whose reason for existing was gone.
- go.mod: higher version wins both ways (commerce v1.50.12, plans v1.4.14).
  Forward, never backward.
- manifest/apps.go: forge kept, after PROVING it a superset — GitHub contributed
  no prefix the forge lacks, the forge adds /v1 and /v1/cart.
- apps/commerce/mount.go: GitHub's comment, because it CORRECTS the forge's. The
  forge said byte-identical patterns "merge silently, first wins"; measured
  against the pinned zip they do not — the composer refuses the program and names
  every conflicting pair. Plus the forge's POST /v1/billing/topup route, which
  GitHub lacks.
- apps/affiliates: the forge's typed accrualsOut, given the field GitHub's
  untyped map had and the typed shape did not. royaltyErrs was already counted
  and then dropped on the floor — the same silence the typed leg exists to end.
- slack_events.go: the forge's fuller comment plus GitHub's app_home_opened arm.
- plane/plane.go: both sides had independently added an identical
  ProjectsOwnership constant. Kept the one grouped with ProjectsResolveKey.
- go.sum regenerated by tidy, never hand-merged.

Two defects the merge exposed and this fixes: duplicate imports in commerce
mount.go, and affiliates calling s.Log.Warn where its two siblings in the same
loop call o.s.Log.Warn.

Carries the front-door body-limit fix (internal/edge): cmd/cloud built its app
with no BodyLimit, so the door enforced fasthttp's 4 MiB while the pod's
GATEWAY_BODY_LIMIT read 100 MiB. Measured on api.hanzo.ai: 4,194,304 bytes
passed, 4,194,305 answered 400.

go build ./... exits 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:06:42 -07:00
hanzo-dev f618983c15 commerce v1.50.13 — the release that drops the same GPU charge 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 bespoke prepay endpoints existed on both sides of this seam: cloud served
/v1/billing/gpu/{charge,eligibility}, and commerce served the handlers behind
them. Cloud's went in the previous commit; v1.50.13 is the commerce release that
deletes ChargeGPU and GPUChargeEligibility, so the module this binary links no
longer carries the path at all.

Nothing here referenced those two handlers — cloud names commerce's billing
handlers one by one and never called RegisterHandlers, which is why the route
was already gone from this binary. The bump is what makes that true of the
module graph as well, rather than leaving a deleted door still compiled in.

The ledger rule is untouched: billing/bucket still classifies a gpu-tagged
withdrawal as prepaid and never a grant, for every writer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:03:23 -07:00
zeekayandhanzo-dev 70449ed2cd sandbox: make it compile, ZAP-native, with one implementation instead of two
Two agents built apps/sandbox concurrently in the same working tree and each
overwrote the other's files. What survived was halves: proxy.go from one,
client.go from the other, both declaring boxPath with different signatures, and
the package did not build at all.

Resolved to ONE implementation. sandbox.go wires forward() from proxy.go and
nothing referenced client.go's httpBox transport, so client.go was the orphan
and is deleted rather than kept as a second way to reach a box.

proxy.go is now zip-native. It had been written against Fiber's context —
c.QueryString, c.BodyStream, c.Get, c.Set, c.Response — none of which exist on
zip.Ctx. zip offers two escape hatches that would have made it compile,
AdaptNetHTTP and c.Fiber(), and both are declined on purpose: either one makes a
second way to write a handler in a codebase whose whole premise is that there is
one. The real surface does the job — c.Query, c.Body, c.Header, c.SetHeader,
c.Status, c.SendStream.

The query forwarding is the part that changed shape rather than syntax. Copying a
raw query string blindly would make this an opaque tunnel that cannot tell a real
parameter from a smuggled one. wire.QueryParams now names the five the box
surface actually reads (path, depth, q, regex, limit), declared once where both
ends can see it, and the proxy forwards exactly those.

Response streaming is preserved and matters: SendStream rather than a buffered
read, because a `pnpm install` streams for minutes and the caller should watch it
arrive.

Also s.State.stores.Get -> .For; cloud.OrgStore has no Get.

NOT DONE, and it should not look done: harness_test.go and tenancy_test.go were
written against the OTHER implementation and reference stateWarm, classDesktop
and pool.nodeTaint, none of which exist here. The package BUILDS but `go test`
does not compile. Those two files need porting to this implementation — they are
kept rather than deleted because tenancy isolation is exactly the property worth
testing, and deleting the test would be the worst way to make the suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:02:46 -07:00
hanzo-dev 7bdde57fa9 o11y: the product scope is the fleet, and a sub-cent debit is not an outage
Three console cards were dark for three unrelated reasons. Two of them are here.

METRICS — /v1/billing/usage answered 502 "billing upstream unreachable" with
nothing upstream involved. usage_coresident.go asked plane.Money.Minor() for
cents, and Minor() REFUSES an amount finer than a cent rather than round behind
the caller. A per-token AI charge is routinely finer than a cent, so one such row
failed the whole page; usage() tests err before coResident, so the refusal
surfaced as a dead upstream. This is the 2026-08-03 balance bug exactly —
balance.go and apps/ai were converted to FloorMinor then and this row was missed.
Measured on the live fleet, same org, same ledger, same process:
  GET /v1/billing/balance -> 200   (FloorMinor)
  GET /v1/billing/usage   -> 502   (Minor)
Only the call differed. The row now also carries the EXACT amount through, so the
envelope's `decimal` is populated on this branch too — fixing the 502 without it
would have traded a visible error for a silently understated bill.

FLEET — knownServices, a hand-kept list of 26 k8s workloads, was the only door
into resolveService. manifest.Apps lists 119 routed apps. The overlap was TWELVE.
The other 107 answered honest-empty on status, product metrics and scoped logs —
not because their telemetry was missing (TracingMiddleware has been stamping
http.route on every one of their request spans into event.span all along) but
because this package had never heard of them. The set is now DERIVED from the
manifest the host already builds its router from, so a new plugin gets
metrics/logs/status the moment it has a row, which it must have to be routable at
all. 117 of 119 now scope. The two that do not are the two that cannot:

  zen is Coresident — it routes no prefix of its own.
  ai claims the API ROOT — it routes everything nobody else claimed.

Neither is a product boundary, for mirror-image reasons.

Routes come from the manifest rather than "/v1/"+name because for 48 apps that
convention names a subtree nobody serves (plan serves /v1/plans, storage serves
/v1/s3/buckets, account serves /v1/orgs and five more). Scoping RED to a path with
no spans returns zero, which reads as a healthy idle service — an answer, and
wrong.

Longest prefix wins, as the router already decided it. manifest.OwnerOf warns that
"anything deciding policy from a bare HasPrefix scan will attribute those paths to
the wrong app", and a RED query decides policy: admin owns /v1/admin and seven
apps live under it. Eleven apps need this; for the other hundred the exclusion set
is empty and costs nothing. Measured against six hours of live spans: a bounded
app with seven exclusions is 4.3s against a ~5s baseline, i.e. free — while
expressing ai's fallback as "/v1 minus 251 nested prefixes" costs 15.5s to compute
a number that would have counted KMS's 161,705 requests as inference. That is the
second reason the fallback has no route scope.

Addresses still come from probes.go: an app the fleet does not probe has no URL
and is not probed, which stays the honest outcome.

No new routes and no new Owns* flag. A health-shaped auto-registration loop would
have declared /v1/<name>/{metrics,logs,status} on top of 15 addresses 13 apps
already serve for unrelated business reasons, and zip refuses a twice-declared
address by crash-looping the binary.

Tests fail when they find nothing: the fleet gates guard their iteration source
with a magnitude floor, assert both directions, pin the two exemptions at exactly
one each, and refuse the vacuous run. Verified by reverting each fix and watching
them fail — the sub-cent test with the exact production error, the fleet gate with
106 unresolved apps, the attribution gate with ai swallowing /v1.

Also drops a comment citing query.go as the pin to follow. That file is gone, and
its removal is what broke the console's Logs card.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:54:36 -07:00
zeekayandhanzo-dev 8e0f8b29dc vm's agent binding is typed, so the client stops guessing its wire
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
Visor typed its agent-binding routes (hanzoai/visor 575fc60), which drops the
casibase envelope with them: the answer IS the value, the status IS the outcome.
Nothing here could have kept working — an AgentBinding carries its own `status`
field, so the envelope reader takes "Pending" for an envelope status, decides
the upstream failed, and answers 502. A read of an unbound machine and every
unbind would have gone the same way, on a 204 with no body to decode.

So the client says which wire it is reading, per call site:

	cl.call  the casibase {status,msg,data} envelope at HTTP 200 (23 sites)
	cl.op    a typed op: the value, 204 for void, 404 for a miss (9 sites)

Not a fallback and neither sniffs — which wire an upstream op speaks is a
property of that op, and the two cannot be told apart by looking. They share
`do`, so there is still one request builder, one identity rule, one status map.
`call` shrinks to zero as visor finishes and goes with the last noun.

The addresses converge rather than translate. This package already published
/v1/machines/agents and /v1/machines/:id/agent to its own callers while spelling
vm's side three other ways; vm answers on ours now, so the translation is gone
instead of moved. The owning org travels once, as ?owner — vm derives the
binding's org from the same resolved principal, so the body field repeating it
is gone too.

An upstream 404 is a FACT about the machine (it runs no bot), not a fault:
notFound() recognises it so getAgent answers in its own words and messageBot
keeps its 400 — "no bound agent to message" tells a caller what to do, where a
relayed 404 says only that some lookup missed.

The fake vm in bots_http_test.go now speaks both wires, written to match what
visor's own controllers/agent_wire_test.go asserts against the real handlers —
a fake agreeing only with this client would prove the two agree with each other
and nothing about the service.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:52:57 -07:00
hanzo-dev 83818f7d17 GPU is metered like any resource; the bespoke prepay charge path is gone
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/billing/gpu/charge and /v1/billing/gpu/eligibility were a second,
product-specific way to move an org's money. They duplicated the general
metered path and nothing called them: no first-party caller exists for either
address, for gpuCharge/gpuEligibility, or for the commerce handlers behind them.

A machine is launched through /v1/machines, which fronts the compute provider's
resell endpoint. That path already owns both halves this one hardcoded: it
authorizes the first launch hour against the org's balance BEFORE provisioning
and refuses when the funds are not there, then records the usage keyed on the
machine id the provider minted, and an hourly sweep meters it from there. One
meter bills every resource, so a GPU needs no door of its own.

Deleted with it: proxyGPUCharge and the commerceProxy.post it was the only
caller of, pinSubjectBody, and the manifest prefixes, retired-name mappings and
published operations that named either address. The floor drops by the two
operations that left, so the reduction is reviewed next to its reason.

The ledger rule the endpoint was built to protect is untouched and lives where
it always did: a gpu-tagged withdrawal draws prepaid, never a credit grant,
because billing/bucket classifies it that way for every writer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:46:24 -07:00
hanzo-dev 44ae535256 integrations: declare slack/install in the raw surface, so the projection gate can see it
CI/CD / containment (push) Successful in 2m16s
Hanzo CI/CD / cicd (push) Failing after 40m28s
CI/CD / gate (push) Failing after 40m28s
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/integrations/slack/install landed as a route but not as a DECLARATION,
and TestSurfaceIsRegistered compares the live router against the union of
typedOps and rawRoutes: 42 live, 41 declared. The gate did exactly its job —
a route nobody recorded a decision about is the thing it exists to catch.

It belongs in rawRoutes rather than typedOps for the reason already written at
the top of that map: a 302 is not a JSON body, so it cannot be a typed op. It
sits with the other redirect legs it is a sibling of.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:42:26 -07:00
hanzo-devandzeekay af9e808558 a published image with no tag is a red release, not a quiet one
CI/CD / containment (push) Successful in 44s
Hanzo CI/CD / cicd (push) Failing after 49s
CI/CD / gate (push) Failing after 50s
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
hanzo.yml states the invariant as "main push -> build -> smoke -> tag -> pin ->
prove live", and every job in cicd.yml upholds its own link. Nothing upheld the
chain itself: each job can only see the release it is running, so an image
published by a lane that never enters the workflow is invisible to all of them.

That is how 82 versions came to be published with no tag in any repo. Seven of
them are recent — 478, 479, 480, 481, 482, 483, 484 — and 480 served
api.hanzo.ai while reporting {"revision":"unknown"} on its own health endpoint.
485 was published untagged while this was being written, which is the point: the
hole is open now, not in the log.

The lane is not a broken gate, it is a bypassed one. 484 and 485 came from
Jobs applied straight to ns hanzo-build (build-cloud-slackinstall,
build-cloud-slackfix) whose buildctl line reads

  --opt=context=https://github.com/hanzo-inc/cloud.git#refs/heads/main
  --opt=build-arg:VERSION=v1.801.485
  --output=type=image,name=ghcr.io/hanzoai/cloud:v1.801.485,push=true

No build-arg:REVISION, so the Dockerfile's `unknown` default wins and the binary
can never name its commit. No version claim, so the number is taken rather than
owned. No tag, because nothing in that command mints one. A context of
refs/heads/main is not even a fixed input — it resolves at fetch time, so the
image is unreproducible as well as untraceable.

So the check is lane-agnostic on purpose. It does not ask who built an image or
whether some workflow succeeded; it asks the registry what is published and the
remotes what is tagged, and any published version that is neither tagged nor
recorded is red. A gate phrased in terms of a lane can only catch that lane, and
the lane that caused this was the one nobody thought to instrument.

It runs at the end of receipt rather than in rollout: a pre-existing orphan must
not be able to block a deploy, because a gate that wedges production on someone
else's old mess is switched off within a day. It is on the main path rather than
a cron for the same reason — a scheduled reconciliation can stop running and
look identical to a clean fleet.

Tags are read from BOTH git.hanzo.ai and github.com/hanzoai/cloud and the union
counts. cloud is canonical on the forge, but cicd.yml claims its version through
the GitHub refs API, so receipts genuinely live in two namespaces; asking only
one would paint every release red from the other. Two of the recorded entries
(319, 340) look tagged from a checkout and are not — the tag exists in one local
clone and was never pushed. Reading `git tag` instead of the remotes is how they
stayed invisible, and it is why the record is derived from the remotes.

None of the 82 were tagged retroactively. Fifteen carry a real commit in
org.opencontainers.image.revision and several name a commit on main, so they
could have been. Here a v* tag is a RECEIPT, minted only after build and smoke
pass; minting one now would assert that a pipeline proved something it never
ran, trading a gap everyone can see for a false claim nobody can detect later.
The image -> commit link those fifteen do have is recorded in column two, which
costs nothing and claims nothing. The other 67 report revision=unknown and no
mapping is guessed for them.

The tests are the mutation: same registry listing every time, and only the tag
list moves. Untagged is red with the refusal quoted verbatim, tagged is green,
recorded is green, and an unrecorded orphan beside a recorded one is still red.
An unreadable source exits 2 rather than 0 or 1, because "could not look" and
"nothing found" demand opposite handling and collapsing them is how a verifier
comes to pass by accident.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:40:13 -07:00
zeekayandhanzo-dev 11b1bd057e read visor's typed nodes op as its own shape, and say so when it is not
CI/CD / containment (push) Successful in 31s
Hanzo CI/CD / cicd (push) Failing after 46s
CI/CD / gate (push) Failing after 46s
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
Upstream /v1/k8s/nodes is now a typed zip op, so it answers {"nodes":[...]} with
no {status,msg,data} around it. Both consumers move together: listK8sNodes and
managedMachines' third source.

client.call is split at the seam that actually exists. do issues the request —
URL, forwarded identity, credential, body limit, transport errors, one path for
everything. call reads an ENVELOPE (visor's untyped controller routes). op reads
a typed op's Out. Two readings because visor really serves two shapes right now;
call and envelope shrink as routes are typed and go with the last one.

The two readings must not be merged, and that is the trap this closes: decoding
an envelope into visorNodes does not fail — the keys are simply unknown — so
Nodes stays nil and an operator running eight clusters is told, with a 200, that
they have no worker nodes. The op always writes the key, so nil means "this Visor
does not serve this op": listK8sNodes answers 502 and the fleet fold drops that
one source and logs why, leaving the registry and live-droplet sources intact.

TestK8sNodesRefusesTheOldEnvelope and TestMachinesDropDOKSNodesOnSkew pin both
halves against a fake that speaks the pre-typed wire. Without the check the first
serves `200 {"nodes":[]}`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:32:21 -07:00
hanzo-dev ba605fd057 the front door was enforcing 4 MiB while the config said 100
cmd/cloud built its app as zip.New(zip.Config{AppName: "cloud", MCP: ...}) with
no ReadBufferSize and no BodyLimit. It is the FRONT DOOR -- every public request
reaches it before the program behind it -- so it terminated HTTP on fasthttp's
defaults: 4 KiB headers, 4 MiB bodies. cloud.App() set both correctly for the
program, and none of it could be reached past the door.

Measured on api.hanzo.ai: GATEWAY_BODY_LIMIT=104857600 in the running pod's
environment, and 4,194,304 bytes passed while 4,194,305 answered 400. That is
4 MiB exactly. In-cluster, straight to cloud.hanzo.svc:8000, the same 400 --
so ingress and gateway were never involved.

The consequence nobody saw: the 16 MiB default had ALSO never taken effect. Its
docstring says it exists so a 1M-token prompt (~4.3 MB of JSON) can reach the
1M-context models; at 4 MiB those models were unreachable, and fasthttp's wire
error is the opaque 400 "Error when parsing request", which reads like a
malformed payload rather than a size cap. Someone wrote that comment believing
it was fixed.

app.go already says an app is obtained one way and a program never builds a
zip.App itself. The door does, deliberately -- it links zip, manifest and webui
and nothing else, and importing the cloud root to reach a number would re-fuse
the monolith it replaced. So the numbers move to internal/edge, a leaf both can
import. One home per number; a second home is how this broke.

TestTheDoorAcceptsABodyLargerThanTheFrameworkDefault sends a real 4 MiB+1 KiB
body through a real door and fails on 400. It reads the wire, not the config,
because reading the config back is what the old code would also have passed:
the value was computed correctly and never reached the transport. Mutation-
tested -- hardcoding the framework defaults back turns both tests red.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:31:41 -07:00
hanzo-dev c4a874230f identity: the cross-org project guard was off everywhere it was installed
CI/CD / containment (push) Successful in 32s
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / gate (push) Failing after 1m8s
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
projectIsForeign answered false for a foreign project-id on every surface that
installed it, so the guard that keeps one org from asserting another's project
was a no-op fleet-wide. The registry it consults lives in the `projects` plugin
and is never mounted in the process where the edge middleware runs, so the
lookup could not succeed anywhere it mattered — measured false against a project
id belonging to another org.

The question "does this project belong to this org" cannot be answered inside
the identity boundary alone, so it becomes a plane op and is asked across the
boundary: plane.ProjectsOwnership, with the same fail-closed posture the rest of
the edge has. A caller that asserts a project it does not own is refused rather
than admitted on a lookup that silently returned false.

Cherry-picked onto the ship branch with its plane op, which is the 33 lines the
guard needs to reach the registry. Both were on the mirror main and not on the
integration main this image builds from, so production carries the hole today.
It ships with the tracker/meet cutover rather than behind it: a live cross-tenant
isolation gap outranks a UI host migration, and one image closes both.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:29:06 -07:00
hanzo-dev 2f89d36760 pricing: an admin asks about the catalog, not about the embedded bundle
CI/CD / containment (push) Successful in 32s
Hanzo CI/CD / cicd (push) Failing after 48s
CI/CD / gate (push) Failing after 49s
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
Disabling a model made it invisible to the one caller who must still see it.

The read path delegates existence to the embedded @hanzo/pricing bundle, which
is a stale snapshot of first-party prices. The authority for what exists and
whether it is enabled is the OVERLAY, and commerce backs that. Those two
disagree in exactly one case — a model an admin has disabled — because the
bundle then stops serving it, and its 404 returned before the gate below could
decide. VisibleCatalog already keeps disabled entries for admins
(`if !isAdmin && !visible { continue }`); it simply never ran.

So a 404 from the bundle no longer ends the question: when the overlay knows the
id and the caller is an admin, the admin gets the overlay's answer, annotated
with the same _overlay state the list surface returns.

Measured before changing anything, because the shape invited two wrong guesses.
The op prints, for the admin request: bundle status=404 isAdmin=true. The
identity bridge was fine and the gate was fine — only their ORDER against the
bundle was wrong.

Not a regression this change introduced: apps/pricing was green at fe9bacb0 and
red after, and nothing in this cutover touches it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:26:04 -07:00
zeekayandhanzo-dev 810f51561b boxd: make the LibreChat file contract match the client that reads it
Three defects, each of which fails silently rather than loudly, and all three
found by another agent reading hanzo.chat's client source instead of our own
docs. Verified against ~/work/hanzo/chat before changing anything.

1. POST /v1/upload omitted message:"success". crud.js:108 is
   `if (result.message !== 'success') throw` — so EVERY upload failed, with
   "Error uploading file: undefined" and nothing on our side to say why.

2. Upload elements were {name,id}. crud.js:112 builds the identifier as
   `${result.session_id}/${result.files[0].fileId}`, and the client's own JSDoc
   types the element as {fileId, filename}. Our shape produced
   "<sid>/undefined": the upload looked fine and the file was unreachable.

3. GET /v1/files/{sid} returned {session_id, files}. Two readers assume a bare
   array — ProgrammaticToolCalling guards with Array.isArray and returns [],
   and process.js:294 calls response.data.find(...) directly. An object does not
   error there; it makes every session read as permanently empty, which is much
   harder to notice than a crash.

The three endpoints need three DIFFERENT shapes, which is why one struct could
not serve them: exec returns files:[{name}] (unchanged — newFiles is correct and
"fixing" it to match upload would break it), upload returns {fileId, filename},
and files/{sid} returns bare [{name:"<sid>/<fileId>", lastModified}] because
process.js matches on that prefix and reads that field.

librechat_contract_test.go pins all four shapes with the consumer line number
that reads each one, so a later tidy-up has to argue with hanzo.chat rather than
with a naming preference. Keep it. Credit for the findings goes to the agent
that read the client; only the file was lost to a concurrent-write collision.

cmd/boxd builds and its suite passes, including the four new conformance cases.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:22:13 -07:00
hanzo-dev b380f95a5a slack: @hanzo can answer, and the Home tab stops apologising
TWO defects, both measured in production.

1. EVERY Slack DM answered "Sorry — I couldn't reach your Hanzo account just
   now." getUserLink already had the right branch:

       if errors.Is(err, kms.ErrSecretNotFound) { return userLink{}, false, nil }

   but the live error was the STRING `kms kms_get: kms.get: store: secret not
   found`. A plugin is a PROCESS: an error crossing that wire is re-created from
   its text, so the sentinel does not survive and errors.Is is false on the far
   side even though the store said exactly that. An unlinked user therefore took
   the BROKEN branch instead of "not linked yet", and the link prompt that
   teaches them how to connect was unreachable — the feature could never be used.

   Repaired in kmsGet, the ONE door onto the store, so the sentinel is whole for
   every caller above it. Text matching is kept to the store's own phrase: a
   broken store must never be laundered into a silent "unlinked".

2. The App Home tab rendered Slack's own "this is still a work in progress"
   placeholder, which it shows for any app that enables home_tab_enabled and
   never publishes a view. The choice was never Home vs no Home — it was our page
   vs Slack's apology. app_home_opened now publishes a view answering what this
   can do and what to type. A failed publish is logged and swallowed: a cosmetic
   surface must not fail an event Slack would retry into an identical render.

The install route is DECLARED in ops_projection_test's rawRoutes rather than
bumping a count: that test asserts the surface is exactly what someone decided
on, and it caught the addition, which is it working.

Full apps/integrations suite green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:14:39 -07:00
zandhanzo-dev cddb063cac docs: the sandbox — spec for the executor four consumers already point at
Every control plane for agentic execution exists; the thing they control does
not. This settles where it lives and what it looks like on the wire.

Four consumers, not three: apps/exec (proxy with no upstream),
apps/functions/invoke.go (same unset env), apps/coding (bot-gateway
/v1/coding-tasks, fail-closed on a docker daemon no pod has), and hanzo.app's
ProjectFs. hanzo.chat's execute_code resolves end to end onto zero endpoints
today.

Decisions: control plane is apps/sandbox in cloud (three of four consumers are
already in this binary; the standalone twin of that same pattern was deleted
last week for being a duplicate). Image is three layered tags in hanzoai/bot,
where the recipe already exists digest-pinned and non-root and has never been
built because bot has no CI at all. boxd is Go in cmd/boxd so its wire types
are one declaration shared with apps/sandbox and drift is not expressible.

Records a live defect proven with real output: apps/exec preserves the request
path (/v1/exec) while apps/functions appends /exec, so no value of
CODE_EXEC_UPSTREAM satisfies both consumers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:00:56 -07:00
hanzo-dev 33dc53f320 pricing: the last two catalog ids the tests still named are read too
Hanzo CI/CD / cicd (push) Failing after 46s
CI/CD / gate (push) Failing after 46s
CI/CD / containment (push) Successful in 55s
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
Retiring zen4 cost eight red gate runs on one line, because a test that
writes a catalog id down is making a claim about the snapshot and snapshots
retire models. The single-segment id started being read from the mounted
catalog; the SLASHED one did not, and the same literal was spelled a second
time in the enablement flow. The bundle already carries opus-4.7, 4.8 and 5,
so 4.6 ages out the way zen4 did — between two survivors, invisibly.

Both ids are now derived by SHAPE through one reader. A provider-qualified id
is what the greedy wildcard exists for and a single-segment one is what the
ordinary route serves; which id has that shape is the catalog's business.
The read is the PUBLIC view, which also makes the assertions mean more than
they did: the model is baseline-visible before it is disabled, so the
customer's 404 is the visibility gate refusing, not the lookup missing. Those
two answers reach the wire identically, which is why the failure pointed at
the admin gate instead of at the snapshot.

The precondition the route-shape tests rest on — the catalog serves both
shapes — is now stated by a test of its own instead of surfacing as a 404 far
from its cause.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:55:51 -07:00
antje 7f37c196e2 pricing: the admin test reads its model id from the catalog it mounted
Hanzo CI/CD / cicd (push) Failing after 54s
CI/CD / gate (push) Failing after 54s
CI/CD / containment (push) Successful in 55s
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
Eight gate runs red on one line: the admin HTTP test hardcoded zen4 (and
zen5), and pricing v1.4.10's regenerated snapshot retired the zen4
generation — so the single-model section 404'd an id that no longer exists
and every push since the catalog bump failed the gate. A literal model id
in a test is a claim about the snapshot, and snapshots retire models; the
single-segment id is now READ from /v1/pricing/models on the mounted
subsystem, so the sections prove the ROUTE SHAPES (greedy wildcard vs
single segment, the visibility gate, the over-deep 400) against whatever
the catalog actually carries.
2026-08-05 23:45:23 -07:00
hanzo-dev c78fbe721f a console the customer owns can call the API it was forked from
The allowed browser origins were a static list of domains we own, so the
shipped feature — fork hanzoai/console, deploy it on your own domain — could
not work: no list of our domains can name a customer's domain.

An origin is now admitted from either of two sources. DECLARED is the platform
allowlist an operator writes. PROVEN is a host whose site_hosts row is
verified, which is the DNS-01 proof that subsystem already takes: the claimant
publishes a 128-bit token as TXT at _hanzo-challenge.<host>, fqdn.Verify is
fail-closed, and ResolveHost's status='verified' filter is the existing
hostname-hijack boundary. CORS asks that boundary rather than growing a second
one, so a pending claim on a name grants nothing here either.

The Origin header is chosen by the caller, so the cheap total rules run first
and the store read runs last: exactly scheme://host reconstructed and compared,
https only, no port, and fqdn.Valid — which is also what keeps bare project
slugs out, since site_hosts holds every project's slug as a row that is always
verified and a bare label is not an FQDN. Answers are cached in BOTH
directions and the cache is bounded, because the attacker picks the key.

Two defects fixed on the way. Vary: Origin was assigned, not appended, so it
fought middleware_markdown's Vary: Accept and the loser's protection silently
vanished; and it was set only when the origin was allowed, though the answer
that carries no ACAO depends on Origin just as much. It is now appended on
every origin-dependent answer, including the denial.

And there was a second CORS authority. hanzoai/ai is mounted in-process and
inserts a filter ahead of every /v1 route that 403s any origin outside 21 apex
domains compiled into the module. The preflight short-circuits here and never
reaches it, so a customer console would have passed the preflight and failed
the call — allowed preflight, denied request. Its positive half is already
dead in production (setCorsHeaders returns early on X-Forwarded-Host, which
the ingress always sets), so only the refusal is live; clearing Origin on the
hop into it, for origins this edge already admitted, takes its own origin==""
no-op path. Denied origins are passed through untouched and still 403.
WebSocket upgrades keep their Origin: dev_bridge's CheckOrigin admits an empty
one for CLI clients, so clearing it there would fail open, and a socket's
origin check is a different mechanism from CORS.

The per-IP cap moves ahead of CORS. Resolving an unknown origin is a plane hop
in production, and a caller rotating a fresh hostname per request would defeat
the answer cache and turn each inbound request into an internal one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:44:59 -07:00
hanzo-dev 18021f78f1 slack: native agent surface + the Direct install URL
Three parts, all required before Slack will OFFER @hanzo as an agent:
  1. assistant:write bot scope                          (slack.go)
  2. assistant_thread_started / _context_changed events (slack_events.go)
  3. the Agents & AI Apps toggle in the app config      (not code)
Without all three the app answers @mentions and DMs fine but never appears in
Slack's "Add Agents" picker, which is why only Claude was listed.

Both events merely ack, which the default arm already did, so the wire
behaviour is unchanged. They are named explicitly because a reader asking
'does this app support agents' must find the answer here; a silent default
cannot say yes.

GET /v1/integrations/slack/install -> 302 to Slack's consent URL. Slack REFUSES
a slack.com URL in the Direct install URL field and requires one of OURS that
302s to slack.com -- because the field is an ATTRIBUTION hook: routing the
Marketplace click through our address is what lets an install be counted, and
forcing the 302 stops the counter becoming a detour that never reaches consent.

A redirector and nothing more. Same destination every time, built by the SAME
slackAuthorize the console's Connect button uses. It carries no state and binds
no org: the org resolves where it always did, at the generic
/v1/integrations/:provider/callback. Minting a principal for an anonymous click
is what the isolation bar forbids.

Applied here because the BuildKit lane builds github.com/hanzo-inc/cloud#main,
which had NONE of these -- it is 49 commits ahead of the forge while the forge
is 124 ahead of it. Consolidating those is separate work; this is the change.

go build clean; go test -run Slack ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:34:33 -07:00
hanzo-dev 03f868685b commerce's generated resources are routed to commerce, not to ai
Hanzo CI/CD / cicd (push) Failing after 50s
CI/CD / containment (push) Successful in 51s
CI/CD / gate (push) Failing after 51s
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 fleet publishes /v1/commerce/{collection,product,wallet,webhook,...} — 17
resource families commerce genuinely serves — and routed every one of them to
`ai`, which owns the bare "/v1" prefix. commerce's manifest row enumerates 30
specific prefixes and none of them reached these, so a prefix deeper than the
sibling that currently wins was simply missing.

They were DARK: published in the document, therefore offered by every generated
SDK, the MCP tool list and the spec-derived CLI, and reaching a different app on
the wire. That is the exact defect the router oracle exists to catch, and it
could not see them until they had prose — an app that cannot project its own
document produces no paths to check, so this gap sat behind the describe failure
rather than behind a passing test.

Naming the 17 prefixes is the fix the oracle asks for, and it is the narrower one:
a bare "/v1/commerce" would capture the whole subtree including addresses other
apps may later claim, while a prefix per family owns exactly what commerce serves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:32:25 -07:00
hanzo-dev 1df9106886 slack: add the Direct install URL route
Hanzo CI/CD / cicd (push) Failing after 57s
CI/CD / gate (push) Failing after 57s
CI/CD / containment (push) Successful in 1m34s
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/integrations/slack/install -> 302 to Slack's consent URL.

Slack REFUSES a slack.com URL in the Direct install URL field and requires one
of OURS that answers 302 to slack.com. That is not a hoop: the field is an
ATTRIBUTION hook. Routing the Marketplace / Add-to-Slack click through our own
address is what lets an install be COUNTED, and forcing the 302 is what stops
the counter becoming a detour that never reaches consent. Same destination
every time.

A redirector and nothing more. The consent URL is built by the SAME
slackAuthorize the console's Connect button uses, so there is one source of
truth for what we ask a workspace to grant however the click arrived.

It carries NO state and binds NO org, which is correct rather than a gap: the
org is resolved where it always was — the generic /v1/integrations/:provider/
callback, from the signed state a console connect minted or OrgForExternalID
for a workspace already connected. An install beginning here finishes there
under the same rules as every other install, and that callback already returns
the browser to the console on a labeled failure rather than a JSON dead end.
Minting a principal here would be inventing an org for an anonymous click,
which the isolation bar forbids.

Registered as a LITERAL before the /:provider wildcards (fiber resolves by
registration order) and Terminal like its siblings — the person clicking
Install in Slack's directory has no Hanzo session, so it must not 403.

Unconfigured answers an honest 503 rather than a consent URL with an empty
client_id, which Slack renders as its own error page with no way back.

Tests assert the contract Slack validates: 302 to slack.com/oauth/v2/authorize
carrying client_id, assistant:write and our callback as redirect_uri.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:31:21 -07:00
hanzo-dev 3d3a2e2286 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	Dockerfile

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:10:49 -07:00
antje 6000a2765c cicd: read the secret where the broker actually puts it
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) Failing after 48s
CI/CD / gate (push) Failing after 49s
CI/CD / containment (push) Successful in 1m8s
CI/CD / image (push) Skipped
Same fix as billing's lane: the KMS read returns a flat {env, name,
value}, both pin steps indexed .secret.value, so the self-pin reported
UNIVERSE_PIN_TOKEN missing even once it existed. Tolerant read; the
automation identity (forge user 'deploy', write on hanzo/universe, token
sealed at deploy/UNIVERSE_PIN_TOKEN env prod) is live.
2026-08-05 22:39:05 -07:00
antje 06f1752e18 commerce v1.50.12 — the saved cards become chargeable, and the top-up door that charges them gets a route
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 account had four vaulted Square cards and $149k of balance, and no
customer-facing door could charge any card on file: subscribe/card demanded
a fresh nonce per attempt (vaulting one more identical row each time), and
POST /v1/billing/topup — the saved-card top-up commerce already ships, the
same chargeAndCredit core the auto-recharge cron uses — had no co-resident
route, so it fell to the account bridge and died in self-dispatch.

commerce v1.50.12 (this bump) makes subscribe/card accept paymentMethodId,
stamps brand/last4/expiry + fingerprint on vaulted rows, dedupes a re-saved
card into its existing row, heals legacy rows on list, and adds the
ownership guard Topup's body field always needed. The mount here routes
POST /v1/billing/topup with topup/token's exact chain — risk screen
included, both credit the spendable wallet.
2026-08-05 22:23:13 -07:00
zeekay b56d41ede2 a co-resident call has no wire, so stop asserting things about its bytes
Hanzo CI/CD / cicd (push) Failing after 23m23s
CI/CD / gate (push) Failing after 23m23s
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 / containment (push) Successful in 1m6s
TestPlaneWireCarriesNoFieldNames observed the bytes crossing the plane's socket
and proved they were ZAP rather than JSON -- field names absent, values present.
That was right, and zip v1.27.0 made it unobservable: Ask now asks zip.Serving
whether the peer is this process and hands the call to the op's own invoke seam,
so there are no bytes. The test read "no body was observed" and went red on a
change that was correct.

The encoding assertion did not need rescuing, it needed deleting: internal/zapenc
already pins it at the encoder, in TestNothingIsJSON and TestBytesAreAZAPMessage.
Cloud was keeping a second copy of a fact zip owns, at a level that could only
observe it by accident of the transport.

What is true HERE, and was not true before v1.27.0, is that the wire is gone. So
the middleware is still the instrument and an EMPTY observation is now the
result: if a body ever appears, cloud has started serialising a value, handing it
to the kernel, reading it back and parsing a fresh copy, to reach a function
pointer that was in memory the whole time.

Mutation: disable the zip.Serving short-circuit in plane/ask.go and the same call
puts 39 bytes on the socket --
  "ZAP\x00\x01...\x00acmeusd"
-- which is both the failure and a demonstration that the frame is still ZAP
carrying values with no field names when a wire is genuinely involved.
2026-08-05 22:07:39 -07:00
zeekayandhanzo-dev a8750097be the project read's mutation proves a wire only where there is one
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 / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
zip v1.27 gave plane.Ask a co-residence short-circuit: zip.Serving(app) finds a
peer this process is already serving and zip.Here runs the op through the same
opByName and invoke the socket plane uses, with nothing encoded. That is right,
and it made this file's mutation test assert the wrong thing — it unlinked a
socket the call had stopped using and called the success a failure.

The two halves are now pinned separately, because they are two facts:

  co-resident  zip.Serving finds iam, the call runs in-process, and unlinking
               the socket changes nothing because nothing was going to touch it
  separate     the socket is the whole dependency: unlink it with the peer alive
               and the call fails, rebind and it succeeds

"Separate" is arranged the way zip.Serving actually decides it — the lookup keys
on SocketPath(name), so a caller resolving iam.sock in a different runtime
directory misses the local registration and dials for real. Same arrangement
planewire_probe_test.go uses, and the frames it captures are what says this path
is a wire at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:04:57 -07:00
zeekayandhanzo-dev ec602f5655 iam names the three plane reads it owns, not one
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:04:57 -07:00
zeekayandhanzo-dev 1ed34d4ed6 admission asks iam who the caller is, instead of replaying the caller's credential
The waitlist gate resolved approval with an HTTP GET to /v1/iam/get-account, and
because HTTP gave it no way to say WHO was asking, it said so by forwarding the
caller's own Cookie and Authorization header — cloud taking a user's raw
credential and presenting it to another service so that service would answer
about that user. It worked. It is the wrong shape twice: the credential is
handled where it need not be, and the answer is only as good as a bearer the
gateway had already validated once.

plane.IAMApproval carries the validated principal instead. iam reads the subject
with cloud.Who, resolves it with GetUserBySubject, and answers the recorded
approvalStatus — raw, judged by nobody. Whether "pending" gates a person stays
in admission next to the gate, so the identity store and the gate can never
disagree about who is on a waitlist.

EnforceConfig.IAMBase goes with it, and it was already dead: nothing in the tree
set it, so the resolver it built had an empty base and the lookup it installed
returned ok=false forever — a gate that had been failing open by configuration
accident rather than by decision. A peer is addressed by name, so there is no
base to supply and no way for one to be absent.

The availability rule is unchanged and now pinned against a real socket rather
than a JSON table: iam SAID pending → gated; iam SAID nothing → approved; iam
could not be ASKED → approved, and that verdict is NOT cached, so a recovered
iam re-gates on the very next request rather than a ttl later.

TestApprovals_NoCredentialCrossesTheWire proves the point on the bytes, not from
source. A recording relay sits at the socket the caller dials; the caller's
secret appears nowhere in the frames and the gateway-asserted subject does. The
structural half is that a plane handler has no header accessor at all, so a
forwarded credential would have nothing to read it.

TestApprovalStatusFromAccount is deleted rather than ported: it parsed IAM's
{status,data,properties} envelope in both its top-level and data-wrapped shapes,
and that envelope had no reader left.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:04:57 -07:00
zeekayandhanzo-dev 82b7db43d2 platform reads projects over the plane, and stops minting a credential to do it
The canonical project read was an HTTP GET to /v1/iam/projects. IAM is a peer —
the pod boots ~25 single-app processes and iam is one of them — so the only
thing the hop needed was a way to say which tenant was asking, and HTTP had
none. Platform built one: a per-org "<org>-platform-kms" IAM application, minted
on first need, sealed into KMS, cached for a minute, and admitted by IAM's
authorize as read-only/own-org-only.

The plane carries the tenant. cloud.For puts the org on the call, the callee
reads it with cloud.Who, and it is not a field a caller can set — so the whole
apparatus was standing in for something the transport now does. A minted secret,
a KMS seal, a cred cache and a grant, all deleted, and the narrowest grant is
the one that was never issued.

The workaround went with it. IAM frames its single-project read as a POST, which
the machine grant rightly refused, so Get was derived from List and the reaper
logged "projects: get hanzo/index: status 403" every cycle — a client tripping
the wall it came through. One op, one verb, nothing to route around.

iam declares the op because iam owns the store (apps/iam/projects_rpc.go), next
to the roster read that is there for the same reason. The projection is five
fields; the identity record stays home.

Proven on the wire, not by reading it: planewire_probe_test.go puts a recording
relay at the socket the caller dials and asserts the capture is ZAP —

  ZAP magic, Content-Type: application/zap, addressed by name at
  /.well-known/zip/op/iam_projects, X-Org-Id riding the call, and the reply
  reading  acmewebWebthe site2026-08-01T00:00:00Z

five values with ZERO field names, which is what zapenc's positional layout
means and what a JSON fallback could not look like. Plus the mutation: unlink
the socket with the peer alive and the call fails, rebind and it succeeds.

Co-residence is unchanged and still wins — no IAM_URL means this binary IS the
IAM and iamProjects reads the store directly. A Go call beats any transport.

ServePlane freezes the plane app, so every test here drops it on cleanup;
without that a later Mount panics on a frozen registry, which is what the two
new files sorting before pushbuild_e2e_test.go surfaced.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:04:57 -07:00
zeekayandhanzo-dev 0fcc5483b3 the scratchpad that measured the seam is not part of the seam
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
zz_probe_seam_test.go was a throwaway: it printed what zip.Op and cloud.Request
look like at each of the four doors so the design could be built on measurements
rather than on a reading of the framework. It answered its question -- op.Path is
the same value four times, the request is an envelope on three of them and absent
on the fourth -- and toll_test.go asserts that answer properly, in
TestOperationIsTheSameValueAtEveryDoor, where a regression fails a build instead
of printing a line into a log nobody reads.

It was swept into the tree by an add-all and never meant to land. Deleted rather
than kept "in case", because a test that asserts nothing is a test that goes stale
without anybody noticing it has.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:03:57 -07:00
zeekayandhanzo-dev 90ff06b6f1 the edge gate says it took the request, instead of the toll guessing that it did
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / image (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
Toll stands down for a request BillingGate has already answered, so an operation
reached over REST is charged once rather than twice. The first version worked out
whether that had happened: if the request's own path names a declared surface then
the edge must have answered for it. That is true, and its truth rests on a line in
a composition root two files away.

Unmount BillingGate and the inference still says yes. The toll still stands down,
nothing charges anything, and both gates are present in the source the whole time.
An invariant spread across two lines nobody reads together is an invariant nobody
is keeping -- which is the same shape as the bug this seam exists to close, where
the gate was real, the coverage was real, and the hole was real, all at once.

So the claim is made rather than deduced. BillingGate parks it at the point it
commits to the charge -- after every decision NOT to charge, because a request it
waved through is one the op seam must still weigh, and over MCP and the plane the
path it read was an envelope and not the operation at all. Toll reads the claim.
Unmount the edge gate and the claim stops being made, which is exactly what makes
the op seam take over.

TestTollTakesOverWhenTheEdgeIsGone is that property, and it is the test the
inference version would have passed. Composed without BillingGate, REST must still
be charged exactly once -- by the toll -- and refused at zero.

Four mutations, four distinct failures:

  gate removed        MCP + ZAP "balance settled at 25c, want 0c", CLI serves free
  stand-down removed  REST "books hold 1 deposits and 2 debits, want 1 and 1"
  price the REQUEST   MCP + ZAP unbilled again -- the original bug, reproduced
  claim never made    REST double-charges

flow and auto said in prose that cloud sets no zip authorizer and that no
transport but HTTP reaches a money gate anywhere in the fleet. Both were true when
written and neither is now, and an MCP tools/call was never the payer-less case
they grouped it with -- it arrives as POST /mcp and carries a request like any
other. Corrected where it is written, not left for the next reader to disbelieve.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:01:41 -07:00
hanzo-dev 3a91970954 slack: declare the Agents surface so @hanzo can be added as an agent
CI/CD / fanout (push) Skipped
CI/CD / containment (push) Successful in 1m38s
Hanzo CI/CD / cicd (push) Failing after 25m12s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / gate (push) Failing after 25m13s
CI/CD / image (push) Skipped
Slack's "Add Agents" picker lists only apps that declare the Agents & AI Apps
surface. Ours declared none of it, so the workspace was offered Claude and
nothing else — not a bug in the bot, which answers @mentions and DMs fine, but
the app was never OFFERED as an agent.

The declaration is three parts and ALL are required:
  1. the assistant:write bot scope            <- added here (slack.go)
  2. assistant_thread_started +
     assistant_thread_context_changed events  <- added here (slack_events.go)
  3. the Agents & AI Apps toggle in the app
     config at api.slack.com                  <- NOT code; owner action

Both events merely ack, which the default arm already did — so the wire
behaviour is unchanged. They are named explicitly because a reader asking
"does this app support agents" must be able to find the answer, and a silent
default cannot say yes; it also gives setSuggestedPrompts an obvious home later.

No reply is needed to open the thread: the user's first message arrives as a
normal message event with channel_type=="im", which the existing arm already
routes to the agent, so conversation works as soon as the app is listed.

Zero runtime risk on existing installs: Slack never grants new scopes to an
existing token, so this changes only the consent URL for a re-install.

Based on forge/main (70a67035) — github.com/hanzoai/cloud main is 4504 commits
behind and does not carry apps/integrations at all.

go build ./apps/integrations/ ok; go test -run Slack ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 21:54:04 -07:00
zeekay 70a6703590 LLM.md: how far the transport deletion got, and what is holding the rest
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 next agent needs two facts this file did not hold. First, that most of the
paths apps/commerce/transport carries are not registered in this binary at all —
commerce's api.Route() bundle is behind //go:build cloud — so the rollup and the
transaction list dispatch to a 404 and the balance re-enters cloud's own customer
handler and 401s, while a split deploy leaves the base URL empty and every reader
answers a silent zero. Not a degraded path: one that has never worked in either
shape.

Second, the line between what converted and what did not, and WHY. books, usage
and payout (with referrals, affiliates and authors) ask the ledger by name, because
the ledger holds their answer. metering's tier and cap, admin's subscriptions and
costs, storefront's store reads and billing's remaining proxies do not, because
their answer lives in a hanzoai/commerce handler whose logic is in unexported
helpers — so those want the payments.go pattern, a value-taking core exported from
the module, and NOT a second implementation in cloud. Writing a spend verdict twice
is how a cap and a rate limit come to disagree about which requests they bind.

Until those land, maxDepth, the goroutine-id parsing and CLOUD_COMMERCE_HTTP_URL
stay. The file says so, so nobody reads a half-finished conversion as a finished
one.
2026-08-05 21:51:21 -07:00
zeekay 72328b2b68 the toll's ledger read takes the selector, and go vet is what says so
CI/CD / image (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
I changed ListEntries to take the `test` selector every other read on that ledger
already took, and toll_test.go — which landed while I was rebasing — calls it.
The root package stopped COMPILING, and I read the suite by comparing failing
test NAMES against a baseline, which reported no regression: a package that fails
to build reports zero test names, so the comparison could not see it. The name
diff said clean while the package was not compiling at all.

go vet ./... compiles every test file and is what actually answers "does the tree
build". It is the check that belonged in front of the name comparison, and it is
how this was found.
2026-08-05 21:48:44 -07:00
zeekay 1f7d52a21e the usage fake takes the selector too, or the capability silently vanishes
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
coResidentUsage resolves ListUsage by INTERFACE ASSERTION and treats a miss as
"this ledger cannot list usage" — a fall-through, not an error. So a fake left on
the old signature does not fail to compile; it stops satisfying the interface,
the capability disappears, and the customer's usage view answers from nowhere.
Three tests said ok=false and one said the envelope was not JSON, which is what
that looks like from the outside.

Worth stating because the shape recurs: an optional capability resolved by
assertion turns a signature change into a silent behaviour change. The tests
caught it here. Nothing in the type system would have.
2026-08-05 21:42:24 -07:00
zeekay 2ca0e9b19d the usage page reads the ledger, and stops summing a word nobody writes
Two service-token GETs — /v1/billing/usage/rollup and /v1/billing/transactions —
sent through the commerce transport, which co-resident dispatches back into this
binary's own router BY PATH. Neither route is registered here (commerce's
api.Route() bundle is behind //go:build cloud), so both were 404s. Split into
per-app binaries the base URL was empty, the reader called itself "not
configured", and the whole spend block degraded to honest zeros. A customer's
usage page showed a blank month while their wallet was being debited all along.

Under that, isSpend matched "withdraw" — a word the ledger has never written; it
writes finance.usage. So even a row that arrived would not have counted, and the
category breakdown and the series would have summed to zero on live data. The
kind crosses as the ledger's own spelling and is parsed once by the one
recognizer, which makes that disagreement a compile error.

rollupWire is gone and rollup replaces it, with two fields where there were four.
There is ONE balance because the ledger has one number: what it calls available
IS the settled balance, and two names for one value is how a reader comes to
subtract one from the other. There is no overage because overage needs a plan
ALLOWANCE and none exists here — this is a prepaid wallet, there is nothing to
exceed, and the field it replaces was read out of a JSON body that never came.

The scope tests moved with the read. They proved isolation by asserting an
X-Org-Id header and a ?user parameter the reader itself set — which proves the
reader agrees with itself. The org rides the CALL now, so they assert what the
LEDGER acted for, and a forged ?user=victim&org=other is not a thing the request
can express.
2026-08-05 21:39:14 -07:00
zeekayandhanzo-dev 62bf4d964c three programs that were meant to pay people, asking a question nobody answered
referrals, affiliates and authors all qualify and accrue on ONE figure: what has
this org spent. They read it through payout.Client, which was an http.Client
aimed at GET /v1/billing/usage/rollup and sent through the commerce transport.

Co-resident, that transport dispatches back into this binary's own router BY
PATH, and /v1/billing/usage/rollup is registered nowhere here — commerce's
api.Route() bundle is behind //go:build cloud and is never compiled in. The read
was a 404 wearing an upstream failure's clothes. Split into per-app binaries it
failed differently and worse: the base URL is empty in every process but
commerce's, so Configured() was false and SpendCents returned a silent ZERO. A
program that pays a commission on spend, told the spend was zero, pays nothing
and reports nothing wrong. Referrals qualified nobody. Affiliates accrued
nothing. Authors were paid nothing.

It asks the ledger by name now. The client holds no address and no credential,
because reaching a peer takes neither — Configured() and ErrUnconfigured named
things that do not exist for a peer, and both are gone. What replaces them is
ErrNoLedger, which is the ROUTER'S word and the only absence a program may act
on; every other failure is an outage and is returned as one. The user argument is gone from
the read: the figure was always the org's, and the rollup's user parameter was
the org's own name being sent back to it.

The tests were green through all of it, because each stood up an httptest.Server
for the wire under test and proved the mock answers — which nobody doubted. They
drive a real per-org encrypted ledger and a real plane socket now, and the
no-mint proof got stronger for the move: the money-IN ops are registered live
beside the read, each failing the test if invoked. A caller cannot reach those by
half-matching a path. It has to name the op.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 21:39:14 -07:00
zeekay d23d314ec8 a commerce read serving a commerce read, with the wire unlinked
The depth cap it replaces was never a fix. maxDepth = 8, counted in a map keyed
on a goroutine id parsed out of the header of runtime.Stack, existed because a
co-resident caller entered the ROUTER: the transport dispatched an http.Request
into the whole shared fiber app, every edge middleware ran again, and a
middleware that itself reads commerce read again on a cache it had not yet
filled. A counter measures that recursion; it does not remove it.

A caller that enters the OP cannot recurse into a middleware, because it never
runs one. So the test does not count how deep the nesting goes. It unlinks the
socket the plane serves on, proves nothing can dial it, and shows the nested
read still answering — which no call carried by a transport could do.

The mutation is the other half: point the runtime dir at a directory this
process serves nothing in, so zip.Serving stops answering for commerce, and the
same call FAILS. Point it back and it answers again. Without that, the first
test is a coincidence with good manners.

Measured under strace, whole binary, connect() traced: 6 op handlers ran and
FOUR connect() syscalls occurred, all AF_UNIX to commerce.sock and all four the
test harness's own — three readiness probes waiting for the listener, one after
the unlink to prove the wire was really gone. None of the six calls opened
anything.
2026-08-05 21:39:14 -07:00
zeekay ab3e92caa0 books reads the ledger, and stops matching words the ledger never wrote
Two defects, one cause. books ingested commerce's money through an S2S
GET /v1/billing/transactions sent over the commerce transport, and that
transport dispatches co-resident requests back into this binary's own router by
PATH. /v1/billing/transactions is registered nowhere here — commerce's api.Route()
bundle is behind //go:build cloud and is never compiled in — so the read was a
404 wearing an upstream failure's clothes. Split into per-app binaries it was
worse: the base URL is empty in every process but commerce's, the reader called
itself 'not configured', and the general ledger ingested nothing at all.

Underneath that, the classifier matched 'deposit' and 'withdraw' — spellings the
ledger has never written; it writes finance.deposit and finance.usage. So even a
row that arrived would have classified as nothing and posted nothing. The tests
passed because the fake source spoke the same invented vocabulary. The kind now
crosses as the ledger's own and is parsed once with finance.ParseKind, which
makes that disagreement a compile error.

A refund is not a third kind. The ledger writes two, and a return of unspent
funds is a NEGATIVE deposit — the reversal rule already books it, against the
clearing account the cash arrived through. The refund arm and its Bank leg are
deleted with the word that reached them.

FinanceTxns takes an input now: which books, and how many rows. Sandbox money
and real money live in physically separate files, so the selector has to travel
— a reader that posts test rows into real revenue restates the company's income
and nothing downstream can tell. ListUsage and ListEntries take the same
selector every other read on that ledger already took; they arrived later and
had forgotten it, so a sandbox caller was silently answered from real money.
2026-08-05 21:39:14 -07:00
zeekay 0b6b0664f1 the ledger's own total, from the process that holds it
Six subsystems needed one number — what has this org consumed — and none of
them could get it. Each asked commerce for GET /v1/billing/usage/rollup over
the commerce transport, which co-resident dispatches back into this binary's
router BY PATH. That route is registered nowhere here: commerce's api.Route()
bundle is behind //go:build cloud and never compiled in. So every one of those
reads was a 404 wearing an upstream failure's clothes. Referrals qualified
nobody, affiliates accrued nothing, authors were paid nothing, the usage page
showed a blank month, and the admin fleet read broke — while the figure sat in
a SQLite file in whichever process mounts commerce.

finance_spend answers it there, from the ledger's OWN windowed sum: the same
source the rolling spend cap reads, so a program that qualifies an org on its
spend and the gate that stops that org spending cannot disagree about the
amount. One balance field, because the ledger has one number.

plane.Ask now runs an op that is registered in THIS process directly, through
zip.Serving + zip.Here (zip v1.26.1). A fused binary registers every app's
internal ops on one plane app, so commerce asking commerce for a balance was a
function call wearing an address; dialing our own socket for it encoded a value
we already held and parsed it back into a copy. The decision is made once, in
the one dispatcher — a caller names the op and never learns where it ran.
2026-08-05 21:39:14 -07:00
zeekay fd14cb10a5 make check, make describe, make openapi -- words a person types
Hanzo CI/CD / cicd (push) Failing after 22m50s
CI/CD / gate (push) Failing after 22m50s
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 / containment (push) Successful in 1m29s
The three fleet targets were surface-check, describe-apps and openapi-weave:
compound words, and worse, words from the implementation's vocabulary rather
than from what you get. "weave" and "subsets" describe how the thing is built.
"openapi" and "describe" describe the thing.

  surface-check  -> check      regenerate FROM SOURCE, fail on any diff
  describe-apps  -> describe   every app describes itself, and runs zipdoc first
  openapi-weave  -> openapi    compose every app's document into openapi.yaml

The root Makefile's own `describe` target is gone: it chained the generate, the
loop and the compose, which made two definitions of one word. mk/fleet.mk's
describe now absorbs the `go generate -run zipdoc ./...` step, so there is one.
`openapi` deliberately does NOT depend on describe -- it reads the committed
documents and stays a three-second command; `check` is the twelve-minute one
because regenerating is the whole of what makes it a gate.

55 references across 25 files, including hanzo.yml and .hanzo/workflows/cicd.yml,
so the gate CI runs is the gate a person runs by hand.

Also here: zip v1.27.0, which is where the toll's op.invoke seam and the
co-resident Here call come from. The working tree carried
`replace github.com/zap-proto/zip => /home/z/work/zap/zip` while that was
unpublished -- a path that exists on exactly one machine, and go.mod is not the
place to record which one.
2026-08-05 21:33:01 -07:00
zeekay 31d92d6b20 the money gate moves to the operation, because the transport stopped being HTTP
Money was gated by HTTP middleware, and HTTP middleware reads the path the
TRANSPORT carried. For a plain REST call that path IS the operation, so the gate
looked right and tested green. It is not the operation anywhere else. One typed
op registered once is seen by the edge as four different things depending on the
door it arrived through, and PriceOf("/mcp") is Undeclared while
Billable("/.well-known/...") is false -- so an op reached over MCP or over the
call plane was free. Not by anyone's decision: the gate was asking about the
wrong value. Free never errors, so nothing said so.

That was survivable while everything spoke HTTP. It stops being survivable the
moment internal hops move to ZAP over UDS, because each converted call site is a
hop that leaves the only surface where the gate exists. The leak would have grown
with the migration rather than with traffic.

zip funnels every projection of a typed handler -- REST, MCP tools/call, the
by-name call plane, CLI LocalInvoke -- through one dispatcher, op.invoke, and
offers one hook inside it. Its argument is a zip.Op: {Method, Path, OperationID},
the operation as a value, identical on all four paths. So DefaultPrice and
Billable are asked there, about op.Method and op.Path, and answer the same thing
however the call arrived. The edge gates stay, because an untyped handler has no
op to ask about, and the toll stands down when the request's own path already
names a declared surface -- one operation, one answer.

Proven on four doors with both controls: an unpriced op moves nothing, and a read
of a priced surface moves nothing, each re-funded first so it cannot pass by
being broke. Neutering Toll fails MCP and ZAP at "balance settled at 25c, want
0c"; restoring it passes.

toll_wire_test.go closes the gap that proof leaves. The four-door test builds its
own app and installs the hook itself, so deleting both lines from serve.go leaves
every subtest passing -- measured, not supposed. Two claims, one gate: the toll
charges correctly, and the server actually mounts it. The second is now pinned,
and goes red naming which of the two lines went missing. A gate nothing mounts is
worth what no gate is worth, and worse, because the passing suite reads as
assurance.
2026-08-05 21:24:23 -07:00
antje 054448bdcc pricing v1.4.10 + console sha-c709b1f — the price list stops quoting models that do not exist
Hanzo CI/CD / cicd (push) Failing after 22m22s
CI/CD / gate (push) Failing after 22m23s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / containment (push) Successful in 1m21s
2026-08-05 20:23:56 -07:00
antje 80723cc000 pricing v1.4.10 + console sha-c709b1f — the price list stops quoting models that do not exist
pricing: the embedded catalog was a snapshot from 2026-03-14. /v1/pricing/models
was therefore selling 10 zen4 rows and 26 zen3 rows — both generations retired —
while the five zen5 rows that DO exist carried no price at all. 38 phantom rows
gone; the real SKUs now carry the numbers the gateway serves, derived from zen's
own cost x margin rather than hand-kept.

That regeneration only became correct today: the zen SERVICE was pinned at v1.4.1
while the library was at v1.4.11, so `npm run sync` reads ZEN_URL and would have
copied the same DigitalOcean-era prices straight back in. A sync is only as true
as the service it reads.

console: the playground's six starters suggested `zen-omni` or `zen-coder`, and
the gateway serves neither. Nothing threw — applying a starter falls back to the
selected model when the suggestion is absent — so the card advertised a model
that could never be the one that ran.
2026-08-05 20:22:17 -07:00
antje c410d20f34 ai: the web-search seam is testable, and tested
CI/CD / containment (push) Successful in 1m44s
Hanzo CI/CD / cicd (push) Failing after 24m46s
CI/CD / gate (push) Failing after 24m47s
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
I installed this seam and wrote nothing that would notice its removal. ai's
builtin registry DECLARES web_search unconditionally and holds no backend; this
host supplies one. Delete that line and nothing fails to compile, no route 404s,
no test goes red — the tool just answers "not available in this deployment" to
every agent, forever. That is the same shape as every other wired-but-never-
consulted defect in this tree, and I had just re-created it.

installWebSearch takes the searcher as a PARAMETER rather than calling
websearch.Search directly, so the adapter — the truncation and the field mapping
— runs without a network round trip. Production passes the real one; the tests
pass their own.

What the tests hold:

- the seam is CLOSED after install (the deletion case)
- Content maps to Snippet. websearch.Result names the body Content and the tool
  contract names it Snippet; lose that rename and every agent gets empty
  snippets, which a model reads as "the web had nothing to say about this"
  rather than as a bug
- the limit is honoured, and 0/negative mean UNSET rather than "truncate to
  nothing"
- an empty upstream is an empty SLICE, not an error — the distinction the whole
  tool layer rests on: an error means the capability is unavailable and the
  model should say so, an empty list is a genuine finding

TestMountInstallsTheWebSearchSeam is a source assertion and says so. Mount needs
a real zip.App and cloud.Deps (it runs ai's Bootstrap), so no test in this
package constructs one; it pins the call site against deletion while the
behavioural tests pin the adapter. Naming the limit beats implying coverage that
is not there.

Three negative controls run, each failing only its own test: dropping the call
from Mount, blanking the Snippet mapping, and ignoring the limit.
2026-08-05 19:35:35 -07:00
antje 98b33ebb6c console: pin the sha tag — 8.5.62 was re-published from another commit 2026-08-05 19:33:25 -07:00
antje 9c8c82941a console: pin the sha tag — 8.5.62 was re-published from another commit
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
2026-08-05 19:33:25 -07:00
hanzo-dev a3b4e79d0e merge: the console is a published site, not an embedded image
webui/console.go's //go:embed all:dist and webui/dist/ are gone, as is the
CONSOLE_IMAGE Dockerfile stage and the Makefile webui target. cmd/cloud loads
the ACTIVE release of the hanzo-console site into RAM at boot and swaps it on an
atomic.Pointer, resolving it through sites.CurrentResolver() — the same path cd
uses, so there is one answer to 'which console is live'.

Publishing drops from an image build plus a Recreate rollout (a measured 2m15s
api.hanzo.ai outage to ship a CSS fix) to a sub-second release activate that
restarts nothing. Rollback likewise: two independent levers now, one for the
binary and one for the console.

Dockerfile conflict resolved toward this change — ARG CONSOLE_IMAGE is deleted
because deleting it is the point. Kept main's still-true warning that no host
routes to the standalone console Deployment, since rolling it still reaches
nobody.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:24:25 -07:00
antje 734fc21eca console 8.5.62 — the agent quickstart, the live tool plane, one door to the builder
CI/CD / receipt (push) Skipped
CI/CD / containment (push) Successful in 1m51s
Hanzo CI/CD / cicd (push) Failing after 28m39s
CI/CD / gate (push) Failing after 28m40s
CI/CD / image (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
2026-08-05 19:05:26 -07:00
antje a649d23fb6 console 8.5.62 — the agent quickstart, the live tool plane, one door to the builder 2026-08-05 19:05:25 -07:00
antje e60b16a8ec console 8.5.61 — the agent quickstart, the live tool plane, and a rail that expands in place
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
2026-08-05 19:00:37 -07:00
antje 07f74a9a2a console 8.5.61 — the agent quickstart, the live tool plane, and a rail that expands in place 2026-08-05 18:59:01 -07:00
zeekayandhanzo-dev f341c34f28 gate: a transport that never carries a byte must not come back
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 stub this removes was invisible for as long as it existed. It compiled. It
had test coverage -- two tests asserted the stub's error and passed. It logged
"deps.IAM -> ZAP RPC" at boot. Every angle said the wire was up except a running
fleet, and nothing in the repo looked from that angle.

So the gate is lexical and blunt, because the defect announces itself in the
words it uses: "not yet wired", "zapc-gen", "TODO" anywhere in clients/ is a
transport somebody is trusting and nobody has built. doc.go is exempt -- it
explains the deleted shape on purpose, and history is how the next reader learns
why the shape is what it is.

The second gate closes the visible half. A peer is addressed by NAME
(plane.Ask -> zip.SocketPath), so there is no endpoint for a deployment to
supply; the eight CLOUD_<X>_ZAP_ADDR knobs were what let a deployment believe it
had chosen a transport. It matches the SHAPE rather than a list of subsystem
names, because a list has to be kept in step and the ninth entry added is the one
that slips through. Note the digits in the suffix match: O11Y is exactly why a
[A-Z_]+ pattern would have missed one, and did, when I first swept for these.

Both were mutation-tested rather than merely observed to pass. Reintroducing the
stub in clients/relapse.go and a CLOUD_IAM_ZAP_ADDR read in config.go fails both
with the reason attached; removing them passes. A gate that has only ever been
green is a gate nobody has checked.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:45:47 -07:00
hanzo-dev 6fff25f874 commerce: a top-up that can never be credited is refused before the card is charged
The mint has refused a payment whose two organisations are two organisations since
the split that fixed the receipt read. It refused from the wrong side of the money.

screen.record runs the ledger boundary AFTER next(c) — after commerce's card core has
taken the card — so the one caller the rule exists for, a SuperAdmin acting inside a
customer's org, got the worst of both halves: the charge cleared on the CUSTOMER's
merchant account, the credit had no address to land at, and the door answered 500 over
real money that was now permanently uncreditable. Worse, chargeable again: the retry
the answer invited replays into the same refusal, and a fresh idempotency key takes a
fresh card authorisation, every fifteen minutes for as long as somebody keeps trying.

Both names are already resolved by seen(), off a request that has moved nothing, so
the question is asked in screen.decide — one place, both doors, every projection of
the typed op — before the handler runs. It answers 409 rather than the screen's own
403 because it is not a risk verdict (nothing was scored, the scorer is not asked) and
an operator reading the door's refusals should never have to guess which of the two it
was, and the sentence names POST /v1/admin/grants: a platform operator meaning to fund
a customer is not doing anything wrong, they are at the wrong door. The predicate is
one method, payment.diverged, because the boundary keeps its own reading of the same
rule as a backstop — the pre-charge refusal is a property of the two doors the screen
is composed onto, not of the ledger, and a mint reached any other way still refuses.
Callers that pay for themselves are untouched: principal answers one string for both
names for an ordinary member, a service token and a SuperAdmin at home.

MigrateOrg keeps the exactly-once it documents over an input that MOVES. Its ref is
fixed per org and its amount is whatever commerce held when somebody ran it, so the
honest second run — a retried operator call over a balance the customer has spent from
since — collided with the deposit's conflict rule and came back an error. That rule is
right for a SETTLEMENT, where a ref is one payment and a differing amount is a
different payment wearing a taken key; a backfill ref names an ORG's one cutover and
nothing else may write it. So a conflict on its own ref is read back as "already
carried": the original entry id, no money moved, the same answer on every subsequent
run. depositByRef is untouched.

And a refusal now says WHETHER A RETRY CAN CLEAR IT. "The customer's own retry is the
recovery path" was true of a lost write and false of every refusal that is a fact about
the settlement itself — a charge that settled in another currency or for nothing, a
settlement the door could not identify, a reference already posted for another payment,
the two names that cannot be one name. Telling a customer to retry one of those is
worse than telling them nothing: the same key replays into the same refusal and a fresh
key charges the card again. Each branch answers through uncredited or stranded; the
RECONCILE line carries a terminal field to alert on and names what does settle it (the
admin grant credits the balance, the processor refunds the charge), and the customer is
told not to retry.

finance's deposit conflict is exported as ErrRefTaken, the twin of the usage plane's
ErrRefReused and for its reason: it is the one deposit failure no retry can clear, so a
caller that renders it as a transient fault sends a customer round a loop that cannot
end. The credit door reads it to classify its refusal; the backfill reads its own ref
back through it.

Note a response-shape change already in flight from the deposit-ref work: the ledger's
idempotency lookup returns the whole entry, so POST /v1/admin/treasury/seed's
seedData.Entry (apps/treasury/treasury.go) now carries `postings` on the REPLAY branch
too, where the created branch always had them. Additive, and the two branches agree now.

Tests are mutation-proven. The pre-charge refusal is asserted on a COUNTED charge
handler, because a status code cannot tell a refusal from a refusal that happened after
the money moved: leave the rule only at the boundary and the masquerade row runs the
handler once and answers 500; invert it and both org == ledger rows lose their 200 and
their money. The receipt-read discrimination moves to a direct settle call, which is
where that read still exists now no door reaches it. A structural check pins every
fin.Deposit call site in the package with the reason it may mint, so the next one is
added deliberately.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:42:49 -07:00
hanzo-dev 9171758046 commerce: a top-up that can never be credited is refused before the card is charged
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / fanout (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
The mint has refused a payment whose two organisations are two organisations since
the split that fixed the receipt read. It refused from the wrong side of the money.

screen.record runs the ledger boundary AFTER next(c) — after commerce's card core has
taken the card — so the one caller the rule exists for, a SuperAdmin acting inside a
customer's org, got the worst of both halves: the charge cleared on the CUSTOMER's
merchant account, the credit had no address to land at, and the door answered 500 over
real money that was now permanently uncreditable. Worse, chargeable again: the retry
the answer invited replays into the same refusal, and a fresh idempotency key takes a
fresh card authorisation, every fifteen minutes for as long as somebody keeps trying.

Both names are already resolved by seen(), off a request that has moved nothing, so
the question is asked in screen.decide — one place, both doors, every projection of
the typed op — before the handler runs. It answers 409 rather than the screen's own
403 because it is not a risk verdict (nothing was scored, the scorer is not asked) and
an operator reading the door's refusals should never have to guess which of the two it
was, and the sentence names POST /v1/admin/grants: a platform operator meaning to fund
a customer is not doing anything wrong, they are at the wrong door. The predicate is
one method, payment.diverged, because the boundary keeps its own reading of the same
rule as a backstop — the pre-charge refusal is a property of the two doors the screen
is composed onto, not of the ledger, and a mint reached any other way still refuses.
Callers that pay for themselves are untouched: principal answers one string for both
names for an ordinary member, a service token and a SuperAdmin at home.

MigrateOrg keeps the exactly-once it documents over an input that MOVES. Its ref is
fixed per org and its amount is whatever commerce held when somebody ran it, so the
honest second run — a retried operator call over a balance the customer has spent from
since — collided with the deposit's conflict rule and came back an error. That rule is
right for a SETTLEMENT, where a ref is one payment and a differing amount is a
different payment wearing a taken key; a backfill ref names an ORG's one cutover and
nothing else may write it. So a conflict on its own ref is read back as "already
carried": the original entry id, no money moved, the same answer on every subsequent
run. depositByRef is untouched.

And a refusal now says WHETHER A RETRY CAN CLEAR IT. "The customer's own retry is the
recovery path" was true of a lost write and false of every refusal that is a fact about
the settlement itself — a charge that settled in another currency or for nothing, a
settlement the door could not identify, a reference already posted for another payment,
the two names that cannot be one name. Telling a customer to retry one of those is
worse than telling them nothing: the same key replays into the same refusal and a fresh
key charges the card again. Each branch answers through uncredited or stranded; the
RECONCILE line carries a terminal field to alert on and names what does settle it (the
admin grant credits the balance, the processor refunds the charge), and the customer is
told not to retry.

finance's deposit conflict is exported as ErrRefTaken, the twin of the usage plane's
ErrRefReused and for its reason: it is the one deposit failure no retry can clear, so a
caller that renders it as a transient fault sends a customer round a loop that cannot
end. The credit door reads it to classify its refusal; the backfill reads its own ref
back through it.

Note a response-shape change already in flight from the deposit-ref work: the ledger's
idempotency lookup returns the whole entry, so POST /v1/admin/treasury/seed's
seedData.Entry (apps/treasury/treasury.go) now carries `postings` on the REPLAY branch
too, where the created branch always had them. Additive, and the two branches agree now.

Tests are mutation-proven. The pre-charge refusal is asserted on a COUNTED charge
handler, because a status code cannot tell a refusal from a refusal that happened after
the money moved: leave the rule only at the boundary and the masquerade row runs the
handler once and answers 500; invert it and both org == ledger rows lose their 200 and
their money. The receipt-read discrimination moves to a direct settle call, which is
where that read still exists now no door reaches it. A structural check pins every
fin.Deposit call site in the package with the reason it may mint, so the next one is
added deliberately.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:38:22 -07:00
zeekayandhanzo-dev 4d5062e2a5 a transport that cannot carry a byte is not a transport
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
clients/rpc.go declared ZAP RPC for nine subsystems -- iam, base, commerce,
ai, o11y, vfs, mq, payments, vault -- and every one of its thirty-odd methods
returned the same thing:

    cloud: ZAP RPC client for iam@iam.hanzo.svc:9653 not yet wired
    (zapc-gen pending) -- VerifyJWT

It was live wiring, not dead code. BuildDeps selected it whenever
CLOUD_<X>_ZAP_ADDR was set, and logged "deps.IAM -> ZAP RPC" when it did. So
the single signal an operator had said the wire was up, while nothing crossed
it. Setting CLOUD_VFS_ZAP_ADDR replaced a working S3-backed blob client with
one that failed every Put and Get. A missing client is diagnosed in seconds;
one that reports itself configured and then fails elsewhere is diagnosed in an
incident.

pickKMSClient already made this argument and already acted on it -- it dropped
CLOUD_KMS_ZAP_ADDR for the plane, with a comment saying a stub that looks
configured is worse than none. The argument was never specific to KMS. This
applies it to the other eight.

The peer plane is the transport, and it is the only one: plane.Ask over the
peer's own socket, addressed by NAME, with plane/gen emitting a typed client
per app. It takes no endpoint, which is why removing eight config fields and
eight env knobs removes nothing real -- no manifest in universe sets any of
them, so no deployment loses a path it had.

What each dependency does now is what it did: co-resident when mounted here,
and the disabled stub -- which says "enable the subsystem" -- when not. Payments
and vault stay non-nil because commerce nil-checks them, but they stop claiming
a cardholder-data transport that never carried a byte; nothing in the tree
invokes CreateIntent, ConfirmIntent, GetIntentStatus or Charge at all.

The two tests that covered this asserted the fake. One pinned that a configured
address yields a client which is NOT disabled -- true, and true only because it
failed in a transport-specific way instead. Both now assert the honest
property: a subsystem that is not here reports itself DISABLED, and no address
can say otherwise.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:35:07 -07:00
antje e1f2c5bb08 commerce: a duplicate declaration is a boot panic, not a silent merge
The comment above this mount said byte-identical patterns "merge silently,
first wins". Measured, they do not. zip refuses the whole program and names
every conflicting pair with file:line and the group path it came through:

    zip: this program does not compose, so it has no projection:
    zip: GET /v1/commerce/collection: declared by "/collection" at
      rest/rest.go:133 (via root -> /v1/commerce -> /collection) and by
      "/collection" at rest/rest.go:133 (...)

The correction matters because the false version makes an addition look free.
Commerce's api.Route already registers order, customergroup, promotion, region,
taxregion, pricelist, claim, role and the rest; the admin needs them at
/v1/commerce, so someone will reach for this leaf. Adding a kind here while
api.Route still registers it does not merge — it takes the STANDALONE down at
boot, in a different binary from the one that changed. One address, one
declaration.

WHERE the refusal lands differs by zip and both are before traffic: this tree
resolves v1.25.1, which accepts the second Route() call and refuses at
COMPOSITION; commerce standalone pins v1.24.2, which refuses inside Route().
That difference is not academic — the first version of this test only
registered, read "accepted", and would have shipped the claim that the rule was
gone. It now asks for the composition, and it is proven by construction: remove
the second Route() call and it fails.

Also drops the stale "two equal-specificity params with different names" framing
from the sibling guard's message, which described a narrower collision than the
one that actually fires.
2026-08-05 18:24:27 -07:00
hanzo-dev 326c0f57c6 the console is a published site, not a binary
console.hanzo.ai is this binary and stays this binary — same host, same
origin, /v1 still answered here, session cookie still first-party. What
moves is the SOURCE OF THE BYTES.

The console was //go:embed'd from webui/dist, overwritten at image build
from a pinned console-embed. That welded the frontend's lifecycle to the
backend's: a CSS fix cost a ~22-minute cloud build plus a Recreate
single-replica rollout — a measured 2m15s outage of api.hanzo.ai to ship
a stylesheet. It also needed a whole discipline to stay honest (never
:latest — v1.801.215 built 12 minutes early and silently shipped the
previous console, green — and never re-point a cut tag), a discipline
that existed only because the console's identity lived in a Dockerfile.

It is a site release now, through the site-release subsystem already in
this repo: the ACTIVE release of the hanzo-console site, read from the
same S3 the site edge reads, resolved through the same registry
cd.hanzo.ai resolves through (sites.CurrentResolver — one resolution
path, not a second). Publish is under a second, rollback faster, and
neither builds nor restarts anything.

  webui         keeps the whole handler — per-Host white-label <title>,
                static-export route shells, precompressed negotiation,
                cache policy, the API-namespace 404 — and takes the FS as
                an argument. Still a leaf. The cached index.html is gone:
                against a live release a cached shell names the previous
                build's chunk hashes, so it is read per request now, via
                the one HTML-shell path the route shells already used.
  webui/release loads the release whole into RAM (68 files / 9.3 MB) and
                swaps it atomically when the active pointer moves. The
                steady state is one resolver call per poll; bytes are
                re-read only when the release actually changed.

Required at the front door, reported in a child. cmd/cloud starts
projects (idempotent; bounded, so it refuses rather than hangs) and
fails with the reason if the release will not load. cloud.Listen cannot:
a child would have to ask projects, only the host can start projects,
and the broker every child needs boots first — so a child logs the
reason and answers 503 naming it. Never a blank shell.

Also: _next/static and assets/ are immutable by PATH at the site edge.
The basename rule needs a separator before the hash run, which Next does
not emit, so 5 of 44 _next/static objects were served max-age=3600 and
re-fetched hourly forever. The prefix stays narrow — _next/ also holds
_next/data and a build-id dir, and this path serves arbitrary tenant
sites, where a year of immutability on a service worker outlives every
deploy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:12:31 -07:00
hanzo-dev e2311f80b6 commerce's generated families state their own gate, because the seventeen do not share one
Hanzo CI/CD / cicd (push) Failing after 26m10s
CI/CD / gate (push) Failing after 26m25s
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 / containment (push) Successful in 2m27s
The 119 derived sentences were true about tenancy and silent about everything
else, and the seventeen families they cover are not uniform on the axis that
matters to a caller. Read off mount.go and commerce's api/resources:

  - transfer, wallet and webhook are ADMIN-gated (TokenRequired(permission.Admin)).
    A plain member's token is refused 403 there, on reads as much as writes.
  - collection, discount, product, variant, saleschannel and stocklocation sit
    behind paywall.Require: no active subscription, trial or redeemed invite is
    402 subscription_required, and an unreadable billing store fails closed 503.
  - collection, product, return, subscriber and variant additionally carry a
    DefaultPermissions table that CheckPermissions enforces per method (403).
    The other twelve have no table, and the miss logs and ALLOWS — so claiming a
    scope check on those would be prose the server does not honour.

One sentence repeated seventeen times said none of it, which is worse than the
bare operationId it replaced: it told a reader that a plain token may write a
wallet and that the only refusal on a product is 404. A description nothing
downstream can check is believed. So the table now carries the gate — the only
thing that actually varies — and the sentence is generated from it.

Three corrections to the shape prose while reading the handlers:

POST on the item address is the METHOD-OVERRIDE door, not "an address that
accepts every method". With no override it runs r.Patch; `_method` (form or
query) or X-HTTP-Method-Override set to PUT, PATCH or DELETE runs THAT one, so a
POST here can DELETE the record. An invalid override is ignored, not 405'd —
IsValidMethodOverride only admits those three and the method stays POST.

LIST returns the Pagination envelope {page, display, count, models, facets}, not
a bare array, and it fails closed to an EMPTY PAGE with 200 when no org
namespace resolves — so empty `models` does not mean "no records exist".

PUT decodes onto an empty record carrying only the existing key, which is why
omitted fields are reset; PATCH decodes over the loaded record. Both 404 on an
unknown id. DELETE writes a copy aside under an internal deleted key first, so
the row stops answering but is not erased.

Also: a family that outgrows the table comes OUT of it rather than gaining a
Describe as well — Describe panics on a duplicate key, so doing both aborts the
binary at init. The old comment said the explicit one "wins", which would have
sent the next reader into that panic.

Regenerated: openapi.yaml and plugin/commerce/openapi.json. 119 operations, none
bare; regenerating from source reproduces these bytes exactly.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:04:20 -07:00
antje 68c462f461 zen 1.4.11 — the text plane leaves DigitalOcean for openrouter
Every chat and vision SKU (zen5-mini, zen5-flash, zen5, zen5-coder, zen5-pro,
zen-vl, zen-guard) now routes through openrouter. DigitalOcean cannot be billed
right now, and a provider we cannot pay is not a route.

It is cheaper on every rung, because openrouter publishes each upstream's own
rate: zen5 quotes 2.28/7.26 per MTok where it quoted 4.176/13.2. Margin is
untouched at 3x — the saving reaches the caller.

OPENROUTER_API_KEY must be in the environment for this image. zen's resolver
reads env first and KMS only as a fallback, so a missing key is not a degraded
route, it is a 401 on every chat. The universe values carry it in the same
commit that pins this image.

Four SKUs stay on DigitalOcean and nothing can move them: openrouter is a
chat-completions aggregator with no /embeddings, /images/generations,
/videos/generations or /rerank, so zen-embedding, zen-image, zen-video and
zen-rerank have no rung to move to.
2026-08-05 17:47:52 -07:00
hanzo-dev b775b94f68 finance: a metered act is named by the server, and its name is unique to the wallet it debits
A usage debit was idempotent on the caller's X-Request-Id. That header is the
CLIENT's: zip's RequestID middleware keeps an incoming one verbatim and the
gateway CORS-allows it from a browser, so the payer chose the key the ledger
deduped on. Pin one value and every call after the first replayed into the
first one's entry — the work ran, the wallet did not move, and the spend cap,
which sums that ledger, never rose. It reached finance from every metered
surface: the edge BillingGate, the net/http meter, ~25 ResourceMeter callers,
and zen, whose own field comment called the header "idempotency".

The key also carried no subject. (kind, program, ref) held program empty for
usage, so ONE namespace per org held every subject's refs and the first subject
to take a ref owned it for everyone: two people in one org sharing a ref meant
the second one's call debited nobody.

Both halves are the same mistake — the key named neither who paid nor which act
— and both are fixed where the key is formed rather than at the 27 places that
supply attribution:

  - The scope is the WALLET. usageProgram is walletAcct, so the account a ref is
    unique within IS the account the posting debits; the key and the money can
    no longer name two wallets.
  - The name is the SERVER's. metering.Usage.Ref carries it, Usage.Seal mints
    it, and Record seals what it is given — one seam, so every caller is fixed
    without touching one of them. Seal is idempotent, so a caller holding a
    server-assigned act id (a message row's id, an x402 settlement id, a domain
    registration ref) sets Ref instead and its own retry stays exactly-once.
  - RequestID stays, as what it always was: correlation, on the log line and the
    attribution wire, never the money.
  - A ref names ONE charge. A hit whose amount differs is ErrRefReused, not the
    first entry — the rule the deposit already states, so the GPU charge, the
    one surface whose key a caller may name, answers 409 instead of handing back
    work nobody paid for.

Exactly-once is preserved and proven both ways: three attempts at one sealed act
debit once; two acts identical in every field debit twice.

Mutation-proven: key the ledger on the header again and 20 pinned calls bill 1¢
instead of 20¢; drop the subject from the key and the second subject's wallet
never moves; make the mint non-stable and one act charges three times; drop the
amount check and a $5.00 charge under a $0.30 name is answered "ok".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:44:36 -07:00
hanzo-dev 8f1688163f finance: a metered act is named by the server, and its name is unique to the wallet it debits
CI/CD / containment (push) Successful in 2m31s
Hanzo CI/CD / cicd (push) Failing after 15m56s
CI/CD / gate (push) Failing after 16m11s
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 usage debit was idempotent on the caller's X-Request-Id. That header is the
CLIENT's: zip's RequestID middleware keeps an incoming one verbatim and the
gateway CORS-allows it from a browser, so the payer chose the key the ledger
deduped on. Pin one value and every call after the first replayed into the
first one's entry — the work ran, the wallet did not move, and the spend cap,
which sums that ledger, never rose. It reached finance from every metered
surface: the edge BillingGate, the net/http meter, ~25 ResourceMeter callers,
and zen, whose own field comment called the header "idempotency".

The key also carried no subject. (kind, program, ref) held program empty for
usage, so ONE namespace per org held every subject's refs and the first subject
to take a ref owned it for everyone: two people in one org sharing a ref meant
the second one's call debited nobody.

Both halves are the same mistake — the key named neither who paid nor which act
— and both are fixed where the key is formed rather than at the 27 places that
supply attribution:

  - The scope is the WALLET. usageProgram is walletAcct, so the account a ref is
    unique within IS the account the posting debits; the key and the money can
    no longer name two wallets.
  - The name is the SERVER's. metering.Usage.Ref carries it, Usage.Seal mints
    it, and Record seals what it is given — one seam, so every caller is fixed
    without touching one of them. Seal is idempotent, so a caller holding a
    server-assigned act id (a message row's id, an x402 settlement id, a domain
    registration ref) sets Ref instead and its own retry stays exactly-once.
  - RequestID stays, as what it always was: correlation, on the log line and the
    attribution wire, never the money.
  - A ref names ONE charge. A hit whose amount differs is ErrRefReused, not the
    first entry — the rule the deposit already states, so the GPU charge, the
    one surface whose key a caller may name, answers 409 instead of handing back
    work nobody paid for.

Exactly-once is preserved and proven both ways: three attempts at one sealed act
debit once; two acts identical in every field debit twice.

Mutation-proven: key the ledger on the header again and 20 pinned calls bill 1¢
instead of 20¢; drop the subject from the key and the second subject's wallet
never moves; make the mint non-stable and one act charges three times; drop the
amount check and a $5.00 charge under a $0.30 name is answered "ok".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:42:18 -07:00
antje c3a7ea534f commerce: route the merchant leaves, and check routing where it is decided
Mounting the bundle was not enough and the last release proved it. The light
host routes by manifest.Apps: commerce's row names each leaf it serves, `ai`
holds the bare "/v1", and longest-prefix wins. A leaf nobody names goes to ai's
catch-all, so commerce sat there serving routes the host never handed it.
Production said so precisely — /v1/commerce/tenant (named) answered 200 from
commerce in the same second /v1/commerce/product (unnamed) answered 404 from ai.

So name them, which is the rule this file already states: nobody claims the bare
/v1/commerce remainder, every leaf is named deeper. Seventeen leaves, read off
the live router rather than guessed. Each is gated in Mount — token, admin token
and paywall — so this changes who is ROUTED, not who is allowed.

The guard is the real repair. Its first version asserted the bundle BINDS
/v1/commerce/product; it did, and production still 404'd, because binding says
nothing about the host's routing table. It now asserts every bound leaf is named
on commerce's manifest row, and it is proven by reverting the row: 17 failures,
one per leaf, then green.

That closes a gap the existing oracle cannot see. manifest/router_test.go
compares routing against plugin/commerce/openapi.json — a GENERATED file — so a
bundle whose spec has not been regenerated is invisible to it and it passes
vacuously, which is exactly what it did here. Reading the live router needs no
regeneration step, so an upstream kind fails the moment it is linked.

Separately and NOT fixed here: `describe` for commerce is already red without
this change. It refuses 128 operations that "say nothing about themselves", 9 of
which are pre-existing (/v1/billing/methods, /v1/billing/wire,
/v1/billing/crypto/*, /_/commerce/providers/{name}) and untouched by this commit.
So plugin/commerce/openapi.json is stale at 73 paths and cannot be regenerated
until those operations carry doc comments. Routing does not depend on it; the
published spec and the generated SDKs do.
2026-08-05 17:21:36 -07:00
antje 200e490bb8 carry the launch console — console.hanzo.ai is this binary
console-embed 8.5.58 is the launch build: the sidebar down to chat, the builder,
models, keys, usage, billing and settings with everything else behind the beta
flag; a playground that offers only what the gateway routes and holds frontier
prices behind a plan; the org's own logo in the mark row; search across the whole
catalog. This pin named 8.5.48, so none of it was reachable.

The routing fact is the one worth writing down: hanzo-domains.yaml sends
console.hanzo.ai to `service: cloud`, and nothing routes to `service: console`
at all. The SPA a user loads is the one //go:embed bakes in here, so a console
release ships only when this line moves. console:v8.5.59 was built, pinned and
rolled to Ready today and served no one.
2026-08-05 17:12:53 -07:00
hanzo-dev 537d70b578 commerce describes its generated resources, and one Artifact stops meaning two things
CI/CD / containment (push) Successful in 1m58s
Hanzo CI/CD / cicd (push) Failing after 22m32s
CI/CD / gate (push) Failing after 29m15s
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 THINGS STOOD BETWEEN THE FLEET DOCUMENT AND SOURCE, both revealed only once
licensing stopped failing first — surface-check stops at the first app that cannot
project, so everything behind it was invisible rather than passing.

commerce publishes 17 generated CRUD families over the same store, 7 operations
each: 119 sentences differing in one word. They are DERIVED now, for the reason
openapi.DescribeSPA derives the two SPA addresses — prose written 119 times drifts
119 ways, and the next family the module adds would arrive undescribed and stop the
gate again. Adding one is a line in `resources`. The vocabulary stays plain on
purpose: these are generic store resources, so each sentence states the shape (what
the address is, what the method does, what the id means) and stops. A resource that
earns its own prose keeps an explicit Describe above — Describe panics on a
duplicate, so the hand-written sentence and the loop cannot both claim an address.

The weave then refused for a different reason, and it was right to: schema
"Artifact" meant a RELEASE artifact in licensing (download_url, cosign_signature)
and a RESEARCH artifact in research (git_sha, run_id, retention_class). Schema names
are global in the woven document, so every generated SDK would have bound whichever
it read last — one name, two shapes, silently. research's is the one this repo owns,
so it becomes ResearchArtifact; licensing's stays, being the external module's.

Regenerated: openapi.yaml, the affected subsets and the floor.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:12:37 -07:00
hanzo-dev 108db1c56e commerce: a top-up mints where the charge and the credit are one org, and a deposit ref is one payment
Two money bugs on the deposit path, one introduced and one waiting.

A card top-up names TWO organisations and the split that fixed the receipt read
left the MINT choosing between them. The charge clears on the EFFECTIVE org's
merchant account and commerce writes its receipt there; the credit is addressed
to principal.WalletOf. For every caller but a masquerading SuperAdmin those are
one string. For that one they are two, and the deposit took the second: a
customer's card funded a platform admin's spendable balance and the door
answered 200. Neither address is right — crediting the ledger moves a customer's
money into the admin's books, crediting the charged org has a masqueraded session
top up the org it is only inspecting — so the mint refuses a payment whose two
names are not one name, with the door's own 500 and a RECONCILE line naming both.
A SuperAdmin funding a customer uses the admin grant, which is a credit that
states whose it is. The refusal sits AFTER the receipt read, so the masquerade is
still the one caller that can tell payment.org from payment.ledger and the read
this replaces cannot quietly go back to the payer's namespace. Callers that pay
for themselves are untouched: org == ledger, so the guard cannot fire.

A deposit's idempotency key is (kind, program, ref) and carries neither subject
nor amount, so two DIFFERENT payments naming one ref in one org's books collide.
Both the in-transaction replay branch and the recovery read answered the second
of them with the FIRST one's entry id: alice's $1 posted, bob's $500 was told
SUCCESS, bob's wallet stayed at zero and nothing said a payment had been dropped.
A ref hit is a REPLAY only when it is the same money to the same wallet; anything
else is refused, because crediting anyway breaks the exactly-once the ref exists
to give and answering with the other payment's entry is the swallow. Nothing
reaches it today — the settlement ref is a Square payment id — but the webhook,
the backfill and the credit RPC all pick their own. Both callers now read the ref
through one function, so they cannot answer the question two ways.

The subject lives in the entry's legs, so the ledger's idempotency lookup returns
the whole entry the way its journal listing does rather than a header the caller
must complete; one postings read serves both, over a connection or inside a
transaction.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:10:57 -07:00
hanzo-dev dacf3cf507 commerce: a top-up mints where the charge and the credit are one org, and a deposit ref is one payment
CI/CD / containment (push) Successful in 2m1s
Hanzo CI/CD / cicd (push) Failing after 7m46s
CI/CD / gate (push) Failing after 7m54s
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 money bugs on the deposit path, one introduced and one waiting.

A card top-up names TWO organisations and the split that fixed the receipt read
left the MINT choosing between them. The charge clears on the EFFECTIVE org's
merchant account and commerce writes its receipt there; the credit is addressed
to principal.WalletOf. For every caller but a masquerading SuperAdmin those are
one string. For that one they are two, and the deposit took the second: a
customer's card funded a platform admin's spendable balance and the door
answered 200. Neither address is right — crediting the ledger moves a customer's
money into the admin's books, crediting the charged org has a masqueraded session
top up the org it is only inspecting — so the mint refuses a payment whose two
names are not one name, with the door's own 500 and a RECONCILE line naming both.
A SuperAdmin funding a customer uses the admin grant, which is a credit that
states whose it is. The refusal sits AFTER the receipt read, so the masquerade is
still the one caller that can tell payment.org from payment.ledger and the read
this replaces cannot quietly go back to the payer's namespace. Callers that pay
for themselves are untouched: org == ledger, so the guard cannot fire.

A deposit's idempotency key is (kind, program, ref) and carries neither subject
nor amount, so two DIFFERENT payments naming one ref in one org's books collide.
Both the in-transaction replay branch and the recovery read answered the second
of them with the FIRST one's entry id: alice's $1 posted, bob's $500 was told
SUCCESS, bob's wallet stayed at zero and nothing said a payment had been dropped.
A ref hit is a REPLAY only when it is the same money to the same wallet; anything
else is refused, because crediting anyway breaks the exactly-once the ref exists
to give and answering with the other payment's entry is the swallow. Nothing
reaches it today — the settlement ref is a Square payment id — but the webhook,
the backfill and the credit RPC all pick their own. Both callers now read the ref
through one function, so they cannot answer the question two ways.

The subject lives in the entry's legs, so the ledger's idempotency lookup returns
the whole entry the way its journal listing does rather than a header the caller
must complete; one postings read serves both, over a connection or inside a
transaction.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:09:32 -07:00
antje 0e6de791bb carry the launch console — console.hanzo.ai is this binary
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
console-embed 8.5.58 is the launch build: the sidebar down to chat, the builder,
models, keys, usage, billing and settings with everything else behind the beta
flag; a playground that offers only what the gateway routes and holds frontier
prices behind a plan; the org's own logo in the mark row; search across the whole
catalog. This pin named 8.5.55, so none of it was reachable.

The routing fact is the one worth writing down: hanzo-domains.yaml sends
console.hanzo.ai to `service: cloud`, and nothing routes to `service: console`
at all. The SPA a user loads is the one //go:embed bakes in here, so a console
release ships only when this line moves. console:v8.5.59 was built, pinned and
rolled to Ready today and served no one.
2026-08-05 17:08:09 -07:00
hanzo-dev d575536d8a licensing types its own ops, so cloud stops describing a route it retired
CI/CD / containment (push) Successful in 2m37s
Hanzo CI/CD / cicd (push) Failing after 13m25s
CI/CD / gate (push) Failing after 13m25s
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/licensing used to register ONE untyped wildcard — app.All("/v1/licensing/*")
over its own net/http mux — and an untyped route cannot carry prose, so its seven
method descriptions were declared here, in cloud, keyed to that pattern.

At v0.1.10 it types them instead: app.Group("/v1/licensing") with zip.Post(g,
"/issue", ...) and siblings. A typed op carries its sentence in the handler's own
doc comment and zipdoc lifts it, so the seven declarations in this file now name no
operation at all. That is worse than missing prose: they render nowhere while
reading, in the source, exactly as though they had landed.

Deleted with the route they described. The gate that caught it is the same one that
catches the opposite case — an app that cannot project its own document — which is
why it is worth having pointed at both directions.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:52:37 -07:00
hanzo-dev a10583c5b9 commerce: guard the merchant mount at /v1/commerce
Nothing else in this package calls commerceresources.Route, so the two ways it
can fail were both invisible. Registration into a shared router panics on two
equal-specificity params with different names — a BOOT panic that passes every
build check. And the routes simply going missing is what 404'd every admin data
view in production.

Reads the route TABLE rather than driving a request: a request exercises
commerce's datastore and tenant resolution, and a panic in there reads as "the
route is missing" when the route is fine.

Trailing slashes are trimmed because the bundle binds /v1/commerce/product/ and
every client calls /v1/commerce/product. Those are one route only because
fiber's StrictRouting defaults false and zip never sets it, so that is pinned
too — defensively: zip.Config exposes no such field, so unlike the absence
assertion (proven to fire on a bogus kind) it cannot be falsified today.
2026-08-05 16:50:28 -07:00
hanzo-dev b833f8b341 commerce: mount the merchant resources at /v1/commerce
api.hanzo.ai/v1/commerce is THE commerce endpoint, and commerce is a PLUGIN of
this binary — there is no commerce backend pod. So the merchant surface is
reachable through this binary or it is not reachable at all. It was not: every
admin data view 404'd in production while sign-in, catalog and billing answered
200. Honest, and total.

Mounts commerce's api/resources LEAF, not its api.Route. Route also binds an
index route, a permissive CORS policy and a wildcard OPTIONS onto whatever
router it is handed — right for a process that owns its tree, wrong in this
shared one, where byte-identical patterns merge silently and two
equal-specificity params with different names panic at registration. Importing
that package would also drag checkout, subscriptions and thirdparty/netlify,
which this binary deliberately does not carry.

commerce v1.50.7 -> v1.50.11 for the leaf.

Package tests unchanged: 40 failures before and 40 after, the identical set —
all pre-existing ledger/payments/settlement cases on this line.
2026-08-05 16:43:04 -07:00
hanzo-dev d71928235a the projections' own pins learn what v1.26 and the gallery already changed
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
Five checks were measuring something other than what ships.

captable pinned two MCP tool names in their pre-v1.26 spelling. zip v1.26.0
settled ONE rule — an id derives from the absolute path the occurrence answers at
— so the tools are patch_v1_captable_stakeholders_by_id and
post_v1_captable_rounds_by_id_close. bab535c2 taught the bots and agents
projections that rule and missed this file. The segments still name the same two
ops unambiguously and what the test proves is unchanged: the op is addressable
through its In alone, over a transport with no URL.

pricing declared /v1/pricing/datastore and /v1/pricing/services and proved
neither, so the byte-identity golden had quietly stopped covering two sections.
They are covered now. The scanner also skips fiber's "/" route, which is
middleware territory — every app.Use rides it as a handler chain, by design — and
never was a section address.

projects pinned the template repository at hanzo-templates/synapse. It is not
there: 4203c7f2 found 45 of 66 gallery entries pointing at repositories that do
not exist, verified every source against the GitHub API, and repointed the static
sites to hanzo-apps/template-<slug>, which is that org's convention. Only 21
templates (expo-*, flutter*, swiftui*, desktop-*) ever moved to the
hanzo-templates layout this line was written against — the catalog is mixed by
design, and the variant case beside it still pins hanzo-templates/prism-react.
The assertion's intent is untouched: a fork seeds the REPOSITORY, not the gallery
page that is an HTML 404, and the provider is github.

git's ZAP round trip installed its identity shim through app.Fiber().Use before
Mount. At zip v1.26 Fiber() materialises a DRAFT router and every later
registration invalidates it, so the next call builds a new *fiber.App and the shim
was written onto a throwaway. The handlers saw no X-Org-Id, principal.Org failed,
and createRepo answered a 403 that zapface rewrites to a bare UNAUTHORIZED naming
none of it. It is a zip component now, installed by the same compose the server
runs (f6c9605b, same class).

And apps/commerce/risk.go joins allowedRequestUses with its reason. The fraud
screen in front of the typed mint op reads the payer, the door actually reached
and the jurisdiction signals a credit decision is made on — an identity gate
reading strictly more than the org, which is one of the sanctioned reasons. The
amount comes off the decoded In and the settlement off the returned receipt, both
deliberately not read from the wire, so the request is consulted for exactly the
facts no projection can carry on a type.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:39:51 -07:00
hanzo-dev 65c401b656 content: generate is a typed op, and the money wire it stayed raw for is unchanged
POST /v1/content/generate published an operationId and nothing else — no request
schema, no response schema, no MCP tool input, no CLI flags, an untyped `any` in
every generated SDK — for the ONE agentic call in the content loop. It stayed a
raw handler on a stated belief: that a studio-render billing denial answers the
platform's nested {"error":{"code","message"}} at 402/503, and a typed op can
answer only its Out schema or zip's flat {status,code,error}.

That belief is out of date. cloud.Denied carries the money wire's own status and
body as an ERROR — the one refusal channel a typed op has — and cloud.DenyEnvelope
writes those bytes back verbatim; apps/projects, apps/dataset and apps/risk
already pair them. So the op is typed and the 402 body is byte-for-byte what it
was, which TestGenerate402IsTheMoneyWireBody asserts by shape: exactly one key,
`error`, nested {code,message}, code insufficient_balance. Off the HTTP path
(MCP, CLI) deniedErr.Unwrap keeps the status and the sentence.

Two details the conversion turns on. 402 is DECLARED so a generated client knows
the refusal is a shape it can read; it is never the status this op RETURNS, since
statusFor takes Statuses[0] when the Out states none, so a success is still 201
and the 402 reaches the wire from the envelope. And every GenerateInput field is
`url:"-"`, because zip's binder fills an In field from the QUERY as well as the
body: without it the route silently starts accepting ?doctype= and ?source_media=,
inputs the raw handler's c.Bind never took — and a source_media reachable from a
query string is an SSRF surface reachable from a link. A wire widening no status
code shows, pinned by TestGenerateStillReadsTheBodyAndOnlyTheBody.

The prose moves from the openapi.Describe init onto the handler, which is where
zipdoc lifts it from for a typed op, so the description is declared once rather
than beside the wire fact that no longer holds. The regenerated document is the
point of the change: /v1/content/generate now carries a GenerateInput requestBody
and GenerateResult on 201.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:39:51 -07:00
hanzo-dev 8efed16ce6 zip middleware is scoped to the router, so money and identity gates reach their own leaves
Three surfaces installed a gate on a group and then registered the routes it was
meant to cover somewhere else, which zip v1.26 does not forgive: a Use is scoped
to the group INSTANCE it was installed on and its children, and is never matched
by prefix across the app. Measured, not reasoned: a probe registering the same
prefix twice shows the second group's routes with none of the first's middleware,
and a route on the App with none of any group's.

books rebuilt `app.Group("/v1/books")` in scan.go and bank_api.go, so six reads —
bank/transactions, inbox, vendors, rules, transactions, bank/unreconciled — and
the vendor/rule upserts answered per-org money WITHOUT Cache-Control: no-store,
while /gl and /questions next door carried it. Each file now installs noStore on
the group it builds. It keeps building that group from a literal prefix because
cmd/zipdoc files a doc comment by the prefix it can READ in the file: passing the
group down as a parameter drops every leaf below out of the document and the MCP
tool list. The five RAW handlers stay on the App with their whole paths for the
same generator reason in reverse — a raw handler on a readable prefix is one
zipdoc describes FOR it, and the only doc comment at that call site is
cloud.Handle's "binds a Service-scoped handler to a route", which would ship as
what /v1/books/scan does.

company and validators declared their collection root on the App, outside the
group carrying the cap and the identity gate. An oversized body at /v1/company
answered 400 from the decode instead of 413, and an anonymous POST to
/v1/validators with a malformed body answered 400 where the surface has always
answered 403 — the refusal has to precede the decode, and it was not running at
all. Neither root can hang off its own group with an empty leaf, which joins to
"/v1/company/" and moves the published address. So the gate rides the ROUTER:
With() wraps the leaf it registers and installs nothing at "/v1". That distinction
is load-bearing — a Use there gates every other subsystem's /v1 routes, and
cloud's own ownership gate refuses it in as many words. .Group("/v1") is then only
an address, and one zipdoc can read, so both ops keep their prose. Same shape
apps/sync already uses.

Pinned by TestReadsAnswerTheirFrozenEnvelope, TestVendorAndRuleUpsertsEchoThe-
NormalizedRow, TestBodyCapCoversJSONNotTheDeck and TestIdentityRefusalStill-
PrecedesTheBody.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:39:51 -07:00
hanzo-devandantje e173ea0305 commerce serves its merchant surface at /v1/commerce, through this binary
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
Commerce is a plugin of cloud, so its merchant surface is reachable through this
binary or it is not reachable at all. It was not. There is no commerce backend
pod; commerce-api.hanzo.ai routes here; and this embed carried no resource
bundle — so every admin data view 404'd in production while sign-in, catalog and
billing all answered 200. Honest, and total: /v1/product AND /v1/commerce/product
both 404'd, so no client-side prefix change could have reached anything.

Products, variants, collections, discounts, sales channels, stock locations,
subscribers and webhooks now answer at api.hanzo.ai/v1/commerce/<kind> — the
endpoint every Hanzo surface is told to use.

It mounts the resources LEAF (commerce v1.50.11, api/resources), not api.Route.
Route also binds an index route, a permissive CORS policy and a wildcard OPTIONS
onto whatever router it is handed: right for a process that owns its tree, wrong
in this shared one, where byte-identical patterns merge silently and two
equal-specificity params with different names panic at registration. Importing
that package would also drag checkout, subscriptions and thirdparty/netlify —
weight this binary deliberately does not carry, and the real reason the bundle
was skipped rather than a decision anyone made.

The leaf's dependency graph contains netlify zero times; api's still contains it
twice. That boundary is the whole mechanism.

productEvents is nil here: the storefront publish loop belongs to the
standalone's event bus, and the CRUD does not depend on it.

Package tests unchanged: 23 failures before this commit and 23 after, the same
set — all pre-existing ledger/payments/settlement cases on forge/main.
2026-08-05 16:20:29 -07:00
hanzo-dev e3f3913ff6 commerce: a top-up's receipt is read where the charge was written, and a refused credit still teaches
A card top-up names TWO organisations and the settlement held one value for both.
commerce writes the receipt under the EFFECTIVE org — the org the door is acting in,
resolved by iammiddleware for the browser route and by payingOrg for the typed op —
while the balance it funds is principal.WalletOf's, which for a platform SuperAdmin is
its own books. Those are the same string for every caller except a SuperAdmin
masquerading into a customer's org, and for that one the receipt read looked in the
admin's books for a row commerce had written in the customer's: not found, so the door
refused a charge that had already cleared. Permanently — the retry replays the same
receipt into the same absent namespace, and a fresh idempotency key charges the card
again. The payment now carries both names as principal names them, `org` for the data
namespace and `ledger` for the billing key: the receipt is read from org, the deposit
lands on (ledger, subject), and the screen and the accrual key on ledger as before.

A settled charge the credit refuses is still taught to the risk model. Teaching is
telemetry about a payment that HAPPENED, so gating it on the credit dropped real
settlements — a charge in a currency this ledger does not hold, a receipt it could not
read — out of the payer's velocity, and a caller who could provoke the refusal could
pay all day and accrue nothing. Both doors now run one value (screen.record) that holds
the order: the money first, because only it can refuse the door, then the model, always.

A deposit that fails on money ALREADY in the books answers with the money. finance
dedups a replay inside its insert transaction; it cannot cover a transaction that never
reached its own read — the write lost to a concurrent poster of the same Ref, or the
request context that died between the charge and the post. Both leave the ref credited
and the caller holding an error for it, which at a credit door is a 500 on a card that
cleared. A failed deposit now asks the only question it has left, on a context detached
from the caller's because the caller's is what may have just died.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit de1e5c4341)
2026-08-05 16:19:06 -07:00
hanzo-dev de1e5c4341 commerce: a top-up's receipt is read where the charge was written, and a refused credit still teaches
CI/CD / containment (push) Failing after 57s
Hanzo CI/CD / cicd (push) Failing after 28m42s
CI/CD / gate (push) Failing after 28m50s
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 card top-up names TWO organisations and the settlement held one value for both.
commerce writes the receipt under the EFFECTIVE org — the org the door is acting in,
resolved by iammiddleware for the browser route and by payingOrg for the typed op —
while the balance it funds is principal.WalletOf's, which for a platform SuperAdmin is
its own books. Those are the same string for every caller except a SuperAdmin
masquerading into a customer's org, and for that one the receipt read looked in the
admin's books for a row commerce had written in the customer's: not found, so the door
refused a charge that had already cleared. Permanently — the retry replays the same
receipt into the same absent namespace, and a fresh idempotency key charges the card
again. The payment now carries both names as principal names them, `org` for the data
namespace and `ledger` for the billing key: the receipt is read from org, the deposit
lands on (ledger, subject), and the screen and the accrual key on ledger as before.

A settled charge the credit refuses is still taught to the risk model. Teaching is
telemetry about a payment that HAPPENED, so gating it on the credit dropped real
settlements — a charge in a currency this ledger does not hold, a receipt it could not
read — out of the payer's velocity, and a caller who could provoke the refusal could
pay all day and accrue nothing. Both doors now run one value (screen.record) that holds
the order: the money first, because only it can refuse the door, then the model, always.

A deposit that fails on money ALREADY in the books answers with the money. finance
dedups a replay inside its insert transaction; it cannot cover a transaction that never
reached its own read — the write lost to a concurrent poster of the same Ref, or the
request context that died between the charge and the post. Both leave the ref credited
and the caller holding an error for it, which at a credit door is a 500 on a card that
cleared. A failed deposit now asks the only question it has left, on a context detached
from the caller's because the caller's is what may have just died.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:16:55 -07:00
hanzo-dev 03ee897d77 the console gate claims only products a plan can grant
Hanzo CI/CD / cicd (push) Failing after 2m18s
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
studio, bot and platform leave appProducts. No catalog version licenses them, and
nothing enforces them either — RequireProduct has zero production callers and the
three sites that would call it say so outright ("DEFERRED — DO NOT ENABLE YET ...
flip on once the catalog licenses it"). Listing them bought no strictness: it made
CheckEntitlement answer Active:false for every org on every tier, enterprise
included, so the projection reported all three LOCKED to every customer behind a
purchase that does not exist. When they launch they come back ATOMICALLY — this
list and the granting plans in one change, never half-wired.

A NOT-GATED PRODUCT IS NOT A LOCKED ONE, and that distinction is why the keys stay.
@hanzogui/shell maps over these names unconditionally, so dropping them from the
projection would break the console rather than correct it. shellApps now says what
must be REPORTED and appProducts says what is licence-CHECKED — the two stopped
being one list the moment cloud stopped claiming products nothing grants — and an
ungated app answers true, which is the truth, instead of a lock nobody can lift.

engine stays in the catalog and out of the console, deliberately. It is licensed by
a DIFFERENT authority: apps/plan/licence.go stamps it into a signed licence
("licensing.product:"+id) that a customer's own engine verifies offline. No console
surface reads it and none should, so the paid-for-nothing test is scoped to the
console's own vocabulary and names the two authorities where a reader will meet
them. engine-rocm resolved itself: the catalog dropped it at v1.4.14.

The test helper derives from shellApps instead of spelling the surface twice — the
count lived in a literal list AND a length check, two places that never mentioned
each other.

FOLLOW-UP, filed not fixed: @hanzogui/shell still advertises studio/bot/world/
platform to the browser as tier "pro", client-side, against a different endpoint.
That third vocabulary is the only place the product intent is written down, and
reconciling the console with the server is its own piece of work.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:11:20 -07:00
hanzo-dev 015c6956fa world is not a separately billable product, so cloud stops gating on it
The console paywall asked the licence authority about "world", and no catalog
version has ever licensed that id. World access was conveyed by a tier-level
`bundles` field pointing at separate world-free/pro/team/enterprise PLANS;
@hanzo/plans carried that at v1.4.4 and deleted both the field and those plans at
v1.4.11, with nothing put in its place.

Asking about a product nothing grants is not a near miss — CheckEntitlement
resolves cleanly and answers Active:false for every org on every tier, enterprise
included, so GET /v1/entitlements reported world LOCKED to every customer
unconditionally. A lock no purchase can lift is worse than no claim at all, which
is what made this a contract failure rather than a missing feature.

apps/world already resolves its limits to the free floor when the plan does not
resolve (entitlement.go ResolveWorldLimits). That is now the whole answer rather
than a degraded one.

TestEntitlements_WorldTiers asserted world-pro/free/enterprise resolve through
the Go/goja path and cannot pass against a catalog that no longer sells them. The
seam it covered is worth keeping, so it becomes TestEntitlements_RetiredTierIsAnError:
a live tier still resolves and carries entitlements, and a retired id ERRORS
rather than answering an empty map — the distinction that matters, because an
empty map reads as "this tier grants nothing" and would silently strip a paying
subscriber of everything they bought.

Not touched: studio, bot and platform are asked about and granted by nothing
either, but unlike world they have never had a mechanism at all — that is a
product decision about whether they are sold, not a stale reference to remove.
engine-rocm resolved itself upstream: the catalog dropped it at v1.4.14.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:11:20 -07:00
z 41668ddefd cicd: one workflow_dispatch, so the on: block decodes
CI/CD / containment (push) Successful in 1m44s
Hanzo CI/CD / cicd (push) Failing after 3m8s
CI/CD / gate (push) Failing after 3m21s
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
`on:` declared workflow_dispatch twice - bare at the top, and again under the
comment explaining the resume entry point. YAML forbids a duplicate mapping
key, and the forge reads this trigger by DECODING the block: act/model
WorkflowDispatchConfig() decodes `on:` into map[string]yaml.Node and returns
nil the moment that fails. DispatchActionWorkflow reports nil as
`has no workflow_dispatch event trigger`, so every dispatch of this workflow
answered HTTP 422 - including the RESUME entry point the comment describes and
the call a sync lane makes after a fast-forward, which is the one door left
when a machine push has eaten the event.

Pushes still triggered runs throughout; only the dispatch door was shut.

Keep the documented occurrence, drop the bare one. The trigger set is
unchanged: push(main, tags v*), pull_request, workflow_dispatch.
2026-08-05 23:03:44 +00:00
zeekayandhanzo-dev d702218872 an accelerator is a build of engine, not a product; and one word hid an app
Hanzo CI/CD / cicd (push) Failing after 50s
CI/CD / gate (push) Failing after 50s
CI/CD / containment (push) Successful in 1m1s
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
engine-rocm was a SKU on the Enterprise plan, and nothing else. rocm is a cargo
feature in hanzo-cli/Cargo.toml beside cuda, metal, mkl and accelerate -- peers,
all built from one source tree -- so an AMD owner on Max was refused a binary an
NVIDIA owner on Max could download. That prices the plan by the customer's
hardware brand. Removed in plans v1.4.14, where the test that asserted the SKU
existed now asserts no plan sells an accelerator at all; licensing v0.1.10 says
the same thing where the distinction lives, on Entitlement.ProductID and
Release.Product. Nothing enforced it: no release carries Product "engine-rocm".

.gitignore line 62 was the bare word `tools`. A gitignore pattern with no slash
matches at ANY depth, so it covered apps/tools -- 25 tracked files, the /v1/tools
MCP catalogue -- and plugin/tools. Adding a NEW file to either was refused, and a
refusal to add is how work disappears with no diff to notice it. It sat directly
above a block that anchors every other root binary (/gateway /account /authz
/smoke /gen-app-cmds) and explains why. It is now /tools, in that block, proven
both ways: a new file under apps/tools is visible, a root ./tools binary is not.

Also here: iam v1.34.21 (13 more typed ops, 98 untyped -> 85), commerce's cart,
and the document regenerated from all of it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:33:30 -07:00
zeekayandhanzo-dev 395003c376 the cloud charges for the first time
Three defects, each of which alone made the meter charge nobody.

The edge gate billed reads. DefaultPrice priced by path and ignored method, so
declaring a surface at 25c charged 25c for GET /v1/<surface>/list. That is why
0 of 119 surfaces had ever declared a positive price: 95 chose Free, 24 chose
Metered, and DefaultPrice returned 0 for every path in production. Consumes(method)
in price.go is now the one rule, read by both DefaultPrice and spend.go's
Billable, so the charge question and the standing question cannot drift.

Two surfaces promised a meter and kept none. auto and flow declared Metered --
which makes the platform require standing and makes the edge charge nothing, on
the promise that a downstream meter owns it -- and had no meter. Every durable
run and every workflow run was free, invisibly. Both now gate before the work
and debit after. meteredWithoutAMeter is empty: 21 of 24 hold their own meter,
3 meter through the AI wrapper, 0 charge nothing.

The third was the launch blocker. The in-handler meter addressed money by bare
org; the customer's balance page, the edge gate and the paywall all address it
by principal.WalletOf. In a tenant org those coincide. In the shared signup org,
where a self-serve stranger lands, they do not -- so a stranger's top-up went to
hanzo/stranger while 21 products checked and debited hanzo, the platform's own
pool. Proven by mutation: revert the address and a customer who has just paid
gets 402 insufficient_balance. Fourth recurrence of a class apps/principal/wallet.go
already documents three instances of.

prepaid_e2e_test.go runs it over a real socket: real RSA key, real JWKS the
validator fetches, real RS256 bearer, real listener, real net/http client, real
double-entry ledger. Top up 100c, four 25c calls to 75/50/25/0, fifth refused
and the handler does not run; one balanced ledger entry per movement; an
unpriced op and a GET on the priced surface both move nothing (re-funded first,
so they cannot pass by being broke); another org's valid token cannot spend this
org's balance. Run against both addressing modes, because the address is half of
what is being proved.

Chargeable: 24 of 119 surfaces, and all 24 now actually charge. On the correct
payer address: 3 (auto, flow, tools). Migration is atomic per surface -- a
half-migrated surface gates one wallet and debits another, worse than
consistently wrong -- so the remaining 21 move one at a time: principal.Ledger(c)
-> principal.Payer(c) at both the Gate and the Meter in that surface, together.
booksOf makes it inert; a caller still passing a bare org gets the identical
value byte for byte.

Not closed: the money gates are HTTP-only. zip has a per-op authorizer at
op.invoke covering REST, MCP tools/call, CLI LocalInvoke and the ZAP call plane,
and cloud sets none, so a priced op reached over MCP or the plane bypasses
billing entirely.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:33:06 -07:00
zooqueenandhanzo-dev a1782c4091 a usage row is a figure someone reads, and it rounds explicitly
CI/CD / containment (push) Successful in 1m42s
Hanzo CI/CD / cicd (push) Failing after 26m21s
CI/CD / gate (push) Failing after 26m21s
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 usage page 502'd on an honest ledger: per-token debits are
routinely finer than a cent (0.00589 USD is a live row), plane
Minor()'s exactness guard refuses those BY DESIGN — a rounded debit is
a ledger that drifts — and the co-resident usage read was making a
display go through a debit's gate. Sustained 'billing upstream
unreachable' on GET /v1/billing/usage, ~8/min, org hanzo, right now.

RoundMinor is the explicit display rounding that guard demands, made
once beside FloorMinor so every summary makes the same one:
half-away-from-zero to the currency's minor unit, for figures someone
reads — never a balance a gate spends against (FloorMinor: down is the
only safe direction) and never a debit (exact or refused, unchanged).
The test pins the 502's own row and that Minor() still refuses it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:26:35 -07:00
zooqueenandhanzo-dev 095acf3da1 cicd: a rerun is still 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
The first fully green gate pass this pipeline ever produced skipped its
image: the run was an API rerun, act_runner serves github.ref as null
on those, and the image guard read null != refs/heads/main as 'not
main'. The guard now accepts the rerun's null-ref shape (the workflow
only triggers from main pushes, tags and dispatch, and PRs are still
refused by event_name), and workflow_dispatch exists so a green head
can be re-driven without an empty commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:15:32 -07:00
hanzo-dev 4ca19ce1f4 surface-check frees each app binary once it has projected its document
CI/CD / containment (push) Successful in 1m44s
Hanzo CI/CD / cicd (push) Failing after 29m39s
CI/CD / gate (push) Failing after 29m40s
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 fleet sweep builds one binary per app — 120 of them, ~37 MB each — and kept
every one for the length of the run. Nothing reads them after `describe` has
written that app's subset, so 4.3 GB sat in bin/ purely because no line removed
it.

That is not tidiness. The gate runs on a shared runner whose docker-storage is
an EmptyDir capped at 38Gi, and the cap is enforced by EVICTION: the pod is
killed mid-job, the log simply stops, and the run reports failure with no error
in it. Measured twice today (git-runner-6 at 20:44:35, git-runner-5 at 21:15:31,
both "Usage of EmptyDir volume docker-storage exceeds the limit 38Gi"), each
landing inside app-contract.

Freeing as it goes takes bin/ from 4.3 GB to 74 MB across a full sweep, verified
green on the same tree: 1710 paths, regenerated from source and unchanged.

It does NOT make the gate fit on its own — the Go build cache from linking 120
binaries dominates, and a clean runner still climbs ~20 GB during this target.
This removes the part that was pure waste; the rest is a capacity question about
the runner, not about the sweep.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:42:36 -07:00
hanzo-dev 11e05786ea commerce: a settled card top-up credits the ledger the spend gate reads
A customer's card was charged and the balance that decides whether an inference
request is served never moved. commerce's card core credits its OWN transaction
store; every spend gate reads apps/finance — build.go's balanceReader,
apps/metering's fetchAvailable, GET /v1/billing/balance are all fin.Balance — and
the two are different files on disk. The only door that reaches finance,
POST /v1/billing/credit, is not mounted. So a paid top-up funded nothing.

Both credit doors already compose one value onto their handler, which is where all
four projections of the typed op and the browser's raw route converge on "the
charge cleared". That is where the deposit is issued now, before the risk record
and, unlike it, synchronously: a credit that cannot be posted refuses the door with
a 500 and a RECONCILE line rather than answering success to a customer whose money
went nowhere. The customer's own retry is then the recovery — the money core
replays the receipt and the deposit completes the half that was missing.

The address is the payer's wallet, (payerOrg, principal.Subject), which is
byte-for-byte what principal.WalletOf hands the spend gate and apps/billing hands
the balance route. That also closes the split payments.go recorded: commerce's
typed door credits the org POOL, while for a member of the shared signup org — or
a credential carrying a signed person: billing_account claim — the wallet the gate
reads is the person's, so an agent's payment funded a balance nobody could spend.

The key is the settlement's own reference (firstRef: the processor's, else the
receipt), the same key the risk record is filed under, so a replay, a webhook and
both doors credit one payment once. The amount, the currency and the books come
off the RECEIPT, never off the request: commerce replays the first receipt for a
repeated idempotency key whatever amount the repeat carried, so sizing a credit
from the request would let a settled $5 charge be replayed as $5,000. A sandbox
charge credits the sandbox books, because the receipt states which bucket cleared
and finance keeps them in separate files.

The deposit goes through finance's own Deposit rather than commerce's injected
creditledger adapter for one reason: CreditInput has no test field, so that seam
cannot say which books to write.

Tests: a settled top-up at either door funds the wallet the gate reads, the
customer's /v1/billing/balance reports it, and a metered debit spends it; three
posts of one settlement credit once; one payment answered at both doors credits
once; a $42 settlement replayed under a $5,000 request credits $42; a sandbox
charge leaves the live books at zero; and every way the credit can fail refuses
the door instead of answering 200. Seven mutations — dropping the key, the credit
at either door, the test flag, keying on the receipt, sizing from the request, and
swallowing the refusal — are each caught by a named test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:34:48 -07:00
hanzo-dev 484e905346 commerce: a settled card top-up credits the ledger the spend gate reads
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 customer's card was charged and the balance that decides whether an inference
request is served never moved. commerce's card core credits its OWN transaction
store; every spend gate reads apps/finance — build.go's balanceReader,
apps/metering's fetchAvailable, GET /v1/billing/balance are all fin.Balance — and
the two are different files on disk. The only door that reaches finance,
POST /v1/billing/credit, is not mounted. So a paid top-up funded nothing.

Both credit doors already compose one value onto their handler, which is where all
four projections of the typed op and the browser's raw route converge on "the
charge cleared". That is where the deposit is issued now, before the risk record
and, unlike it, synchronously: a credit that cannot be posted refuses the door with
a 500 and a RECONCILE line rather than answering success to a customer whose money
went nowhere. The customer's own retry is then the recovery — the money core
replays the receipt and the deposit completes the half that was missing.

The address is the payer's wallet, (payerOrg, principal.Subject), which is
byte-for-byte what principal.WalletOf hands the spend gate and apps/billing hands
the balance route. That also closes the split payments.go recorded: commerce's
typed door credits the org POOL, while for a member of the shared signup org — or
a credential carrying a signed person: billing_account claim — the wallet the gate
reads is the person's, so an agent's payment funded a balance nobody could spend.

The key is the settlement's own reference (firstRef: the processor's, else the
receipt), the same key the risk record is filed under, so a replay, a webhook and
both doors credit one payment once. The amount, the currency and the books come
off the RECEIPT, never off the request: commerce replays the first receipt for a
repeated idempotency key whatever amount the repeat carried, so sizing a credit
from the request would let a settled $5 charge be replayed as $5,000. A sandbox
charge credits the sandbox books, because the receipt states which bucket cleared
and finance keeps them in separate files.

The deposit goes through finance's own Deposit rather than commerce's injected
creditledger adapter for one reason: CreditInput has no test field, so that seam
cannot say which books to write.

Tests: a settled top-up at either door funds the wallet the gate reads, the
customer's /v1/billing/balance reports it, and a metered debit spends it; three
posts of one settlement credit once; one payment answered at both doors credits
once; a $42 settlement replayed under a $5,000 request credits $42; a sandbox
charge leaves the live books at zero; and every way the credit can fail refuses
the door instead of answering 200. Seven mutations — dropping the key, the credit
at either door, the test flag, keying on the receipt, sizing from the request, and
swallowing the refusal — are each caught by a named test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:32:13 -07:00
hanzo-dev da5c989790 entitlements: name the third product list, the one the server never held
CI/CD / containment (push) Successful in 1m50s
Hanzo CI/CD / cicd (push) Failing after 31m50s
CI/CD / gate (push) Failing after 31m51s
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 gate compares cloud's appProducts against @hanzo/plans licensing.product_ids
because those are the two lists the server can reach. They are not the two lists
that decide what a customer sees.

@hanzogui/shell's APP_ENTITLEMENTS (hanzo-registry.ts) maps studio, bot, world and
platform to a minimum tier of "pro" and enforces it CLIENT-SIDE, against
GET /v1/billing/subscriptions — a different endpoint from the projection this
package serves. Nothing shipped reads GET /v1/entitlements at all: the shell hook
the projection's own doc comment names as its consumer reads the billing endpoint
instead, and Console reads the ENABLEMENT store. So the projection is a correct
answer to a question no client asks, and the only written-down statement that these
apps are paid lives in the client, where it is advice rather than enforcement.

That map also names the mechanism the catalog really used. Its world entry reads
"bundled via world-pro on pro/plus/max, world-team on team" — the tier-level
`bundles` field, which v1.4.4 carried for exactly those tiers and v1.4.11 deleted
along with the world-*/social-* ladders, with nothing put in its place. cloud never
read `bundles`. So World was granted by a key cloud does not consult, and is now
granted by no key at all.

Three vocabularies, then, not two, and the honest resolution collapses all three.
Recorded in the header and in the failing message so whoever picks it up starts
from the list that already has an answer instead of rediscovering it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:30:38 -07:00
hanzo-devandzeekay 5fe7767c9c entitlements: the paywall's products and the catalog's products are one list
Hanzo CI/CD / cicd (push) Failing after 52s
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
Nothing compared the two lists that decide whether a paying org may open a
console app, so they drifted until they shared one element out of eight.

  ASKED   apps/entitlements/require.go appProducts
          = studio, bot, world, platform, team
  GRANTED @hanzo/plans entitlements["licensing.product_ids"], union over
          EVERY tier of v1.4.4, v1.4.11 and v1.4.12
          = engine, engine-rocm, team

studio, bot, world and platform are licensed by no plan in any version, so
CheckEntitlement resolves cleanly and answers Active:false for every org on
every tier, enterprise included. That is not open-by-default: the licence leg
is dead, cloud.Stand falls to the wallet alone (LicenceNone + a readable zero
balance = Unpaid), and GET /v1/entitlements reports the app LOCKED to every
customer unconditionally, because the projection reads no kill switch. The
enforcement path is dark today only because paywall_enforced defaults off and
bots/world/platform still carry their RequireProduct markers commented out.

The other direction is the evidence for why. licensing.* is one coherent
block describing a signed ENGINE licence — app_ids, product_ids, seats,
engine_features — and apps/plan/licence.go consumes it exactly that way. When
the catalog gated a console product it used a different mechanism entirely:
World had its own tier ladder (category "world") and its own world.* keys,
which apps/world/entitlement.go reads, and 4633fe56 measured the same thing
from the other end ("the world-*/social-* lines carried no licensing block
even while on sale"). team overlaps only because team is genuinely both a
console app and a seat SKU. A spelling coincidence is not a shared vocabulary.

So this states the invariant instead of the answer, because which side gives
is a pricing decision and not the gate's to make. The gate is a COMPARISON,
not a pin: pinning today's five strings would have passed every day of the
drift, since both lists were internally consistent the whole time and wrong
only about each other. It re-derives both sides on every run and names both,
in both directions, and it FAILS today — truthfully, the way apps/plan's
TestEntitlements_WorldTiers does.

The probe carries a positive control. Reading product_ids at the top level
instead of nested under entitlements returns an empty set for every plan,
which is a clean and confident WRONG answer that would make the whole gate
pass vacuously; a probe that finds no grants anywhere now says its own query
is broken rather than reporting the catalog as empty.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:19:14 -07:00
zooqueenandhanzo-dev 8060b8f36e console-embed -> 8.5.55: the response answers under the tabs, the org's logo is the mark
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
8.5.55 (console 12d061f57) moves the playground Response panel to a
full-width row directly under the surface tabs on every screen size,
lets an org upload its logo in Settings (compact data URL, one save
path), renders that logo in the context switcher's org slot, and drops
the expanded rail's separate brand row — the switcher carries the
identity, so the H above it said the same thing twice.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:14:56 -07:00
hanzo-dev 4633fe565f commerce: a licence answers from the row, not the price list
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
CheckEntitlement asked @hanzo/plans which products a subscriber's tier licenses.
The catalog lists what is ON SALE TODAY, so a tier retired at 1.4.5 resolves to
404 -> found=false -> continue -> Active:false. That is a definitive "not
entitled", indistinguishable from a real refusal, for someone commerce is still
charging. On the team product it reaches cloud.Refuse(ReasonUnpaid): a paying
subscriber gets a 402 for a licence they hold.

This is the licensing half of the cut Paid already made for the paywall. Ask what
you bought, not what is for sale. The tier's authority row survives retirement —
that is what Status is for — and commerce v1.50.9 persists the licensing block on
it and backfills the rows archived before the field existed, so the row can now
answer.

So the resolver reads the row. Features are derived from the block it carries;
only the token spelling lives in Go, and a spelling is not a policy — the
plan->product decision is the row's.

Scope, measured rather than assumed: of the thirteen retired slugs only plus and
team-max ever licensed anything, both ["team"]. developer, custom and the
world-*/social-* lines carried no licensing block even while on sale, so nothing
regressed for them and nothing is invented for them here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:05:11 -07:00
hanzo-dev 887f43991b gate: allowlist commerce/risk's payer resolver — car 0 was red, blocking every release
969d755c added a cloud.Request call site in apps/commerce/risk.go without an
allowlist entry, so TestRequestEscapeHatchIsPinned failed on that commit ALONE
(proven on a clean tree). That test is car 0 of the release train and every
later car `needs:` it, so main has been unshippable since it landed.

The escape hatch is justified and the reason was already written at the call
site; this records it where the gate reads it. The credit screen serves BOTH the
browser's REST call and an agent's tools/call, and reads the PAYER, the billing
ADDRESS and the JURISDICTION off the request it is being served over — parked by
the app-wide Bridge, which runs for /mcp and the op path alike.

None of the three can be an In field, for exactly the reason apps/dataset is
already allowed here: a caller that could name its own payer would charge
another org. And on the MCP leg no HTTP response exists yet when this returns,
so a response reader would watch an agent's payment settle and learn nothing.
The screen fails closed when the request is absent.

The gate now passes. Nothing else changed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:01:24 -07:00
8cf3dc73e3 describe: every app is attempted, and the failures are named
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
Under `set -e` this loop stopped at the first app that failed, and every app
after it never ran. An app that never ran reports nothing, and nothing is
indistinguishable from passing — so the gate was silently checking a prefix of
the fleet and reporting success.

On 2026-08-05 an unbalanced brace in apps/commerce/describe.go stopped the loop
there. Roughly 45 apps behind it never regenerated. Because `meet` was where the
run appeared to end, three separate people diagnosed a defect in `meet` — and
one really was there, so the misattribution survived scrutiny. Two independent
defects braided by one truncation, and the truncation is what made them hard to
separate.

Now the loop runs every app, collects the failures, names them, and exits
non-zero. A gate that hides what it did not check is worse than one that checks
nothing, because it is believed.

Proven by mutation: with a parse error injected into apps/eval, 117 apps still
ran, the summary named `eval`, and make exited non-zero. Removed, 117 ran and it
exited 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:58:12 -07:00
hanzo-dev 62909aa886 Merge origin/main: the commerce credit screen lands beside the seam work
No overlap — 969d755c is apps/commerce and its generated openapi
artifacts; this branch is the plane seams, the JWKS derivation and the
two silent zeros. Clean merge, no conflicts.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:53:51 -07:00
zeekayandhanzo-dev b78f41c4b9 one rule names an operation, and cloud stops keeping a second copy of it
Hanzo CI/CD / cicd (push) Failing after 54s
CI/CD / gate (push) Failing after 54s
CI/CD / containment (push) Successful in 1m2s
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.26.0 derives an operation id from the ABSOLUTE path with a parameter
written by_<name>, which is the rule cloud's openapi package already used. So
operationID and sanitize here were a SECOND implementation of one rule, kept in
step by nothing but attention. They are deleted; From calls zip.ID.

The proof that they were a true duplicate rather than merely a close one is that
`make describe` regenerates openapi.yaml BYTE-IDENTICAL across this change —
same sha256, 1710 paths, no diff in any of the 119 subsets. A refactor that
claims to remove a copy should be able to show the copy said nothing of its own,
and this one can.

The renames those two rules used to disagree about landed already in 0d602fc9;
nothing about the published document moves here.

The param-vs-literal test goes with the code it tested. It was a unit test of
this package's copy of the rule, and the rule now lives in zip, which grew its
own tests for it in zip a774624 — the collisions the encoding has to survive: a
parameter against a literal of the same name, the hyphen that must not fold into
the separator, the four spellings of one parameter that must reach one name, and
the '_' aliasing it deliberately CANNOT resolve.

Literals pinned in tests move because the RULE says so, not to go green:
  apps/dataroom     10 dotted (v1.dataroom.get_datarooms_id -> get_v1_dataroom_datarooms_by_id)
  apps/ingress       4 (put_v1_ingress_routes_id -> put_v1_ingress_routes_by_id)
  apps/automations   1 (get_v1_automations_runs_id -> get_v1_automations_runs_by_id)
  apps/notify        1 (v1.notify.post_send -> post_v1_notify_send)
  apps/guide         a comment that still described the dotted scheme as current

Suite: 183 ok, 0 build failures. Eleven tests go green — dataroom's MCP tool
list and its agent round trip, automations' and notify's by-name calls,
ingress's tool prose — and no test fails that did not already fail on a pristine
checkout of this base.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:53:03 -07:00
hanzo-dev 17a314be54 agents: absence has one spelling
mountedStore answers ErrNoPeer, and three sibling functions answered the
same fact — agents is not in this process — with a bare string a caller
cannot test for. RunOnBehalf, namespaceFor and eachStore now say it the
one way, so "not here" is routable wherever it is raised rather than only
where it was noticed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:50:08 -07:00
hanzo-dev cf0215cb71 agents, authors: a revoke that stopped nothing, and royalties that accrued zero
Two seams returned a zero with no error, and both are seams whose zero is
read as an answer.

agents.StopSessions answered (0, nil) when the package was not in the
process. agents ships as its own binary and the revoke lives in link, so
that was every deployment: a credential revoke replied 200 with
{"sessionsStopped":0} having torn down nothing, while the sessions kept
running under the revoked account. It now errors with ErrNoPeer, and link
takes the plane leg — two doors onto the ONE StopSessions, so the actor
scoping that bounds a revoke to its own user's sessions holds across the
boundary. The org is the caller's plane identity and never an argument;
the actor is built on the answering side from the org the plane proved.

authors.AccrueForOrg had no error channel at all. Same split, same
consequence, on the money path: the royalty leg of every affiliate sweep
latched nothing and reported a completed accrual of zero. It returns
(int, error) now, absence is ErrNoPeer, and a lookup failure is returned
rather than logged-and-zeroed — a sweep that could not read the authors is
not a sweep that found none. The sweep reports royaltyFailures beside the
count.

An empty match stays (0, nil) in both: fail-closed is a real answer, and
turning it into a fault would break a revoke that legitimately matches
nothing.

projects.LiveSites, the third of the three, was already fixed on main —
Ready() plus the plane leg in apps/catalog serving(), with corpus()
returning the error so a failed read cannot prune the catalog.

plane/gen was stale: the ops the seam work added were declared but their
typed clients had never been regenerated, so plane/git, plane/platform
and plane/projects were missing them and plane/sync and plane/tracker did
not exist. Regenerated.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:47:22 -07:00
hanzo-dev 24de3c8deb surface: project /v1/replay into the document it was added without
Hanzo CI/CD / cicd (push) Failing after 54s
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 session-replay write door landed as routes and typed ops but the document
was not re-projected, so /v1/replay existed in the binary and in no client: not
in openapi.yaml, not in analytics' subset, and therefore in none of the eight
generated SDKs, the MCP tool list or the spec-derived CLI.

Regenerated from source, which is the same fix surface-check names. The floor
ratchet follows the routes that now exist.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:43:08 -07:00
hanzo-dev 0d602fc9dc surface: regenerate the document for zip v1.26.0's operation names
zip v1.26.0 gave an operation ONE naming rule, and the bump landed without
re-projecting the document it renames. 758 operationIds across openapi.yaml and
55 plugin subsets still carried the old spellings — post_collaborator_rpc_documentId
and v1.admin.affiliates.post_id_approve where the rule now says
post_collaborator_rpc_by_documentid and post_v1_admin_affiliates_by_id_approve.

Nothing about the surface moved: same paths, same operations, same prose. An
operationId is the METHOD NAME in eight generated SDKs, the tool name in the MCP
list and the command in the spec-derived CLI, so leaving it stale means every
client offers a name the document no longer agrees with.

This is what surface-check exists to refuse, and it is why the gate has been red
on main: `make describe`, then commit what it wrote.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:40:25 -07:00
hanzo-dev 989044e51d surface: the embedded SPAs describe themselves, from one declaration
The release gate is car 0 of the train, and it stops at any app that cannot
project its own document. /meet and /tracker are bound with All(), so each
publishes every method the generator knows — fourteen operations apiece with no
handler to lift a sentence from, because a static bundle has no typed op. meet
died there and tracker would have next.

tasks had already solved it, in forty lines this file would have needed three
copies of. So the declaration is asked for rather than restated:
openapi.DescribeSPA(prefix, name) derives both addresses, the working methods
and the leftovers from the one fact that differs, and tasks now calls it too.

The copy tasks carried had ALREADY drifted — it promised a missing asset
"answers 200 with HTML rather than 404" after spa.Handler had begun answering
404 under assets/, so the published document described behaviour the binary no
longer had. The shared sentences state spa.Handler's actual policy, including
the assets/ exception and the 503 an unsynced bundle answers with.

Regenerated: openapi.yaml, plugin/{meet,tracker,tasks}/openapi.json, and the
floor ratchet (1700→1705 paths, 2354→2383 operations).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:40:25 -07:00
hanzo-dev 72fa86e1d6 merge: /v1/replay — the session-replay write door, on one credential
Hanzo CI/CD / cicd (push) Failing after 1m54s
CI/CD / gate (push) Failing after 1m55s
CI/CD / containment (push) Successful in 2m7s
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 door session recordings never had, plus the removal of the second token that
crept in with it. Cloud authenticates once against the IAM-issued publishable
pk- and puts the resolved ORG on the message as a routing fact; the ingester
files by that. No hi_ project token on the wire, and no org=token env map.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:36:22 -07:00
hanzo-dev 4187ded0b0 analytics: the replay door carries the org, not a second credential
The snapshot message carried a `hi_` Insights project token, read from a
hand-maintained CLOUD_REPLAY_TOKENS env map of org=token. That was a second
token system — minted by Insights, not by IAM — made load-bearing on a path that
had ALREADY authenticated. IAM issues credentials here; nothing else does.

So the token is gone, and nothing replaces it. The door authenticates once,
against the IAM-issued publishable pk-, exactly as every other write on this
surface does. What the message now carries is the ORG that credential resolved
to — a routing fact, in one copy, in the header the consumer reads. The ingester
resolves it to the project the org owns and files the recording there.

Tenancy is not weakened, it is narrowed to one source: the org is whatever
eventTenant produced server-side, never a body field and never anything a caller
can set. The old fail-closed refusal of an unconfigured org goes with the map it
guarded — there is no longer a mapping that can be missing, so an authenticated
caller cannot be told its own org is "not configured for replay". Admission is
authentication and nothing else.

The envelope's `token` field goes too. The consumer never read it; it existed
because the fork's schema has one, and a tenant value that nothing reads is a
value that can only disagree with the one that does.

Also drops maxReplayEvents. Two caps bounded one thing — how much a single
request may carry — and the byte cap is the one derived from a real limit (the
1 MiB bus payload, with room for the double encoding). A 2000-event cap is an
arbitrary second answer, and a recorder that hit it got a 400 about a batch
whose only problem was its size.

Kept, and load-bearing: the 403 for a reduced principal (a screen recording has
no safe projection), the derived 413 (an honest bound a recorder can chunk
against), the synchronous produce (a receipt means durable), and the
manifest/apps.go prefix row (a path omitted there falls to commerce's bare /v1
catch-all and 405s silently).

The wire contract test is the contract — the consumer is another process in
another language — and it now asserts the org header, the absence of any token
header, and that no `hi_` reaches the message at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:34:42 -07:00
hanzo-dev 63b84e3dfe analytics: /v1/replay, the write door session recordings never had
Session recordings listed and would not play, because nothing captured DOM and
nothing produced the snapshot stream. The rest of the pipeline was already there
and already working: an ingester consumes
`session_recording_snapshot_item_events`, writes a snappy block to object
storage, and lands the row carrying `block_urls` that the player reads back.
Only the door was missing.

So this writes no blob and invents no format. It renders an accepted batch as
the ONE message that consumer already parses — the partition key, the seven
headers, and the double-encoded envelope — and produces it. `snapshotRecord` is
pure, so the whole wire contract is asserted byte for byte with no broker and no
clock: the consumer is in another language and another repo, where nothing in
this build could catch a drifted header name.

The public wire stays ours — sessionId, windowId, distinctId, events — and the
$-prefixed vocabulary is translated in the one place that has to speak it. The
token travels in a header because that is the copy the consumer reads; it is
written from the same argument as the envelope's, so the two cannot disagree.

Admission is the existing one: eventTenant, the same publishable pk- and the
same refusals as every other write, and the tenant comes from the credential
rather than the body. An org with no configured team token fails closed instead
of answering 200 to a batch it would discard.

Verified end to end against the live pipeline: this door's own rendered bytes,
carrying a real 19-event browser recording, produced a block
(?range=bytes=0-1911) whose min_first_timestamp equals its first block
timestamp, and the recording plays in the insights UI with its inputs masked.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:34:42 -07:00
hanzo-dev 8be8d411d3 deps: the JWKS endpoint had three derivations and two ignored the pin
CLOUD_JWKS_URL exists because the public issuer host is fronted by
Cloudflare, which 403s a server-side loopback — iamurl.go's own comment
root-caused that on 2026-07-04. Production pins the in-cluster address
against CLOUD_IAM_ISSUER=https://hanzo.id for exactly that reason.

jwksURLFor honoured the pin and was unexported, so the two planes that
could not reach it built the URL themselves: durable.go's gated ZAP
listener and apps/base's per-app pool each concatenated the suffix onto
the ISSUER. Both fetched signing keys from the host that refuses them
while the edge validator used the working one — one fleet verifying one
set of keys at the front door and a different set behind it.

It is exported as JWKSURLFor now and all three callers go through it.
TestJWKSHasOneDerivation pins the behaviour, and because the defect was
not calling it wrongly but not calling it at all, it also walks the
source: one file may spell the path as a URL, and naming durable.go is
what the check does when the inline rebuild comes back.

Also lands the rest of the deps-policy delta over 06c672b: the Deps.Brand
doc named "osage" and "any customer brand", neither of which the registry
has (brand.For folds an unknown id to hanzo silently); apps/platform's
`var apexOf = brand.Apex` alias is deleted with its call sites reading
brand.Apex directly, and apps/sites' unrelated apexOf — the published-site
ZONE, a different question with the same name one package away — is
siteZone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:34:16 -07:00
zeekayandhanzo-dev ca3448e458 commerce: the paywall asks what you bought, not what is on sale
Hanzo CI/CD / cicd (push) Failing after 50s
CI/CD / gate (push) Failing after 51s
CI/CD / containment (push) Successful in 59s
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
ActivePaidPlan classified a subscriber by looking their tier up in the
@hanzo/plans catalog. The catalog lists what is ON SALE TODAY; a
subscription records what was bought. plans v1.4.10 retired the
plus/team-max/custom ladder, so every org still paying for one of them
stopped resolving as paid — LicenceNone at the spend gate, a 402 to a
customer commerce was still charging.

Commerce goes out of its way to prevent exactly this: a retired plan is
ARCHIVED, never deleted, "so invoices and renewals that already reference
it still resolve — retiring a tier stops new sales; it never strands a
subscriber". The subscription carries that row itself, frozen at subscribe
time (StartSubscription: sub.Plan = *p), category and price included.

So classify on the row in hand and delete the second lookup. plan.Paid
becomes a pure predicate over a tier's money facts — an account category
that costs money — with no catalog read, no sync.Once and no error leg to
fail open through, because there is no longer a catalog that can be
unreadable. One authority (what the customer bought) instead of two that
drift apart the moment the price list changes.

Category still does the product-line work it always did: world/social/dns
are not cloud accounts and never clear the cloud paywall, retired or not.

Also re-points three plan tests that pinned the pre-v1.4.10 catalog
(the ladder canary now reads go $9 / dev $19 / pro $49 / max $99 /
team $25 per-seat, and the vocab floor no longer assumes the entitlement
namespace set only grows).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:33:41 -07:00
zeekayandhanzo-dev b3b81d4b4e commerce: route the cart — the first step of a sale, as four typed ops
Hanzo CI/CD / cicd (push) Failing after 55s
CI/CD / gate (push) Failing after 55s
CI/CD / containment (push) Successful in 1m2s
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-05 13:31:26 -07:00
hanzo-dev 969d755ce6 commerce: the agent's payment tool runs the credit screen the browser's URL does
The screen was ROUTER middleware. zip records a typed op ONCE and four projections read
that one record — the REST route, the MCP tool, the by-name call plane and the CLI — and
all four dispatch to the op's HANDLER (registeredOp.invoke). Router middleware is composed
around the fiber handler the REST route is served through and around nothing else. So
POST /v1/payments was screened and `takePayment`, which is published in tools/list, was
not: an agent calling the tool reached commerce's one card money move with no decision in
front of it, and the settlement it produced taught the model nothing. The control was
present on the browser's door and absent on the agent's, and every HTTP test stayed green
because HTTP is the one plane it worked on.

The screen is composed onto the HANDLER of both doors now — screen.op for the typed op,
screen.route for the raw top-up — so it sits inside the value every projection invokes.
Only the composition point moved. The decision, the payer rule and the settlement key are
the landed ones, resolved once per payment into one `payment` value that the screen judges
and the record teaches, so the two halves cannot name different subjects.

Composing on the handler also stops the screen reading the wire, which is what lets it
work off the REST path at all. The amount is the DECODED input's, because over MCP the
request body is a JSON-RPC envelope with the payment inside `arguments` — a body reader
states no value on exactly the door an agent calls, leaving the sharpest axis a credit
door has blind there while it reads fine on the browser's. The settlement is the RETURNED
receipt, because over MCP the op's answer is wrapped in a tools/call result and no HTTP
response exists when the handler returns. The payer, the address and the jurisdiction
still come off the request, which the app-wide Bridge parks for /mcp and for the op plane
exactly as it does for a REST route, so all three resolve on every plane with a connection
behind it; a call with no request at all resolves no payer, is screened as that state, and
is refused by the handler's own gate rather than exempted from the screen.

The structural check reads WHERE THE VALUE WENT rather than which spellings appear. Every
credit door's handler expression must be built from an identifier that holds the screen,
and nothing anywhere may compose the screen as router middleware. The check it replaces
was satisfied by the exact registration that carried this bug.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched; the screen
reaching two more planes widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:30:13 -07:00
zeekayandhanzo-dev 56ffd25b69 commerce: the fleet served the last three steps of a sale and not the first
/v1/store/{storeid}/authorize, /capture and /charge have been served by this
binary for as long as it has existed. The cart they operate on had no address at
all: hanzoai/commerce implements the whole noun — models/cart, api/cart, the
route table — and cloud never mounted it, so the documented flow began at step
two. The docs taught /v1/cart anyway, and it resolved to ai's bare /v1
remainder, whose prepaid balance gate would have made filling a basket require
the balance the basket exists to create.

Four typed ops close it, on the payments.go pattern rather than a passthrough:
openCart, getCart, setCartItem, discardCart. The RULES are not restated — the
module's own cart.Cart.SetItem still resolves a product or a variant into a line,
updates a quantity in place and drops the line at zero. What is added is the part
that was actually missing: an address, a declared input, a declared answer, and
prose, so the operation reaches the OpenAPI document, the MCP tool list, the SDKs
and the CLI instead of being a route and nothing else.

One way to change a line. There is no DELETE beside the set: quantity zero
removes, because that is what SetItem does and a second spelling of one act is a
second set of edge cases. Quantity is the RESULT and not a delta, so a retry is
safe and a double-submit cannot double an order.

Identity is not an input. The org comes from the validated principal cloud.Bridge
parks on the context, never from a field, so a cart is created, found and amended
only inside the caller's own namespaced store. Another tenant's id answers 404 on
both the read and the write — never 403, which would confirm it exists.

Exercised against a running binary behind a real JWKS issuer, not a fake: open
201, read 200, set refused 400 on each of its four bad shapes, discard 200 and
idempotent, unknown id 404, anonymous 403, and a second org refused acme's cart
id on both verbs.

The wider admin surface — products, orders, customers, promotions, inventory,
tax, regions, fulfillment — is implemented in the module and still unrouted here.
This adds the cart and claims nothing else.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:29:22 -07:00
hanzo-dev 02f2ef9cb1 commerce: the agent's payment tool runs the credit screen the browser's URL does
CI/CD / containment (push) Successful in 37s
Hanzo CI/CD / cicd (push) Failing after 58s
CI/CD / gate (push) Failing after 58s
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 screen was ROUTER middleware. zip records a typed op ONCE and four projections read
that one record — the REST route, the MCP tool, the by-name call plane and the CLI — and
all four dispatch to the op's HANDLER (registeredOp.invoke). Router middleware is composed
around the fiber handler the REST route is served through and around nothing else. So
POST /v1/payments was screened and `takePayment`, which is published in tools/list, was
not: an agent calling the tool reached commerce's one card money move with no decision in
front of it, and the settlement it produced taught the model nothing. The control was
present on the browser's door and absent on the agent's, and every HTTP test stayed green
because HTTP is the one plane it worked on.

The screen is composed onto the HANDLER of both doors now — screen.op for the typed op,
screen.route for the raw top-up — so it sits inside the value every projection invokes.
Only the composition point moved. The decision, the payer rule and the settlement key are
the landed ones, resolved once per payment into one `payment` value that the screen judges
and the record teaches, so the two halves cannot name different subjects.

Composing on the handler also stops the screen reading the wire, which is what lets it
work off the REST path at all. The amount is the DECODED input's, because over MCP the
request body is a JSON-RPC envelope with the payment inside `arguments` — a body reader
states no value on exactly the door an agent calls, leaving the sharpest axis a credit
door has blind there while it reads fine on the browser's. The settlement is the RETURNED
receipt, because over MCP the op's answer is wrapped in a tools/call result and no HTTP
response exists when the handler returns. The payer, the address and the jurisdiction
still come off the request, which the app-wide Bridge parks for /mcp and for the op plane
exactly as it does for a REST route, so all three resolve on every plane with a connection
behind it; a call with no request at all resolves no payer, is screened as that state, and
is refused by the handler's own gate rather than exempted from the screen.

The structural check reads WHERE THE VALUE WENT rather than which spellings appear. Every
credit door's handler expression must be built from an identifier that holds the screen,
and nothing anywhere may compose the screen as router middleware. The check it replaces
was satisfied by the exact registration that carried this bug.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched; the screen
reaching two more planes widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:26:13 -07:00
hanzo-dev d787f93f72 Merge: the seams that answered nil now answer over the plane
Seven remote seams returned a zero value when their owning app was not in
the process: OnGitPush answered nil, so every push built nothing and
reported success; OnServiceRelease answered nil, so every release patched
no CR; projectIsForeign answered false, so the cross-org project guard was
off across the fleet. Absence now travels as ErrNoPeer over the existing
Ask plane rather than as a zero that reads like an answer.

Four genuinely-local seams stay local, each with its reason written down.
TestAllListed parses the package for Register* and fails the build when a
new one is added without declaring itself remote or local, so the class
cannot come back.

plane/plane.go conflicted because both sides appended types after Site and
each left its last struct open around a shared closing brace. The two sets
are disjoint — LiveSite* is the site directory, the branch's are the git,
sync, tracker, platform and projects call shapes — so both are kept and
LiveSite is closed on its own.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:24:50 -07:00
zooqueenandhanzo-dev bab535c2cb zip v1.26.0: one rule for an operation's name, and the tests learn it
Hanzo CI/CD / cicd (push) Failing after 50s
CI/CD / gate (push) Failing after 50s
CI/CD / containment (push) Successful in 1m5s
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 gates' first full execution found the tail their absence had been
hiding: projection tests pinning path-derived operationIds while zip
spelled a Group-declared op dotted (v1.agents.post_targets). v1.26.0
settles it the published way — an id derives from the absolute path the
occurrence answers at — and the agents projections go green untouched.

Three harness truths follow the same stricter build: the bots fleet
harness mounted the relay twice (Mount already owns it) and zip now
refuses the duplicate it used to hand-detect, so the guard test pins
zip's own refusal; the route scanner skips fiber's '/' middleware
chains (stacked by design) and the derived HEAD rows; and the measured
partition reads 10 served — the document now carries every method of
the relay's one All() registration, each named in the ledger.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:18:16 -07:00
zeekayandhanzo-dev 292e2fa8cd make: one door — fleet.mk is included, not summoned by full path
CI/CD / containment (push) Successful in 59s
Hanzo CI/CD / cicd (push) Failing after 57s
CI/CD / gate (push) Failing after 58s
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
mk/fleet.mk defines openapi-weave, describe-apps and surface-check. The root
Makefile had no include line, so all three were reachable only as
`make -f mk/fleet.mk <target>` — a path nobody would guess, mentioned nowhere,
and absent from `make help`. surface-check is the DRIFT GATE; it sat behind a
door with no handle. fleet.mk's own header has always said "included from the
repo root (`include mk/fleet.mk`)". It just never was.

Including it needed one real fix first. ROOT was:

    ROOT := $(abspath $(dir $(firstword $(MAKEFILE_LIST)))..)

firstword is whichever makefile make STARTED with. Run as `make -f mk/fleet.mk`
that is fleet.mk and the root resolves correctly — but INCLUDED from the root
Makefile it is `Makefile`, so ROOT became the repo's PARENT and the `include
$(ROOT)/mk/go.mk` beneath it pointed outside the tree. lastword is this file in
both cases, which is what makes the header's claim actually true.

And pinning .DEFAULT_GOAL := help, because the include inserts three targets
ahead of `help` and make takes the first target it parses — without it, typing
`make` would silently have run the openapi weave. I introduced that regression
with the include and caught it by running bare `make`; positional defaults are
exactly the kind of thing that should be stated, not inferred.

Verified both doors and the neighbours: `make -f mk/fleet.mk -n openapi-weave`
resolves, `make -n openapi-weave` resolves, bare `make` prints help, and
`describe` and `compose` still parse. All three targets now appear in help.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:16:30 -07:00
hanzo-dev 134e03d829 meet: the native call client, and one SPA handler for every app
CI/CD / containment (push) Successful in 58s
Hanzo CI/CD / cicd (push) Failing after 1m1s
CI/CD / gate (push) Failing after 1m1s
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
Reconciles blue/meet-ui into main beside blue/tracker-ui, and settles the two
places the branches disagreed.

/v1/meet/health is main's TYPED op, not the branch's untyped route: zipdoc
lifts its prose from the op, so the branch's openapi.Describe for it would have
been a second copy of the same sentences drifting against the first. The
session route keeps its Describe — it is still untyped, and prose declared
beside the route table is the only way an untyped op reaches the document.

spa is now the ONE SPA handler, and it is tracker's rules rather than the
weaker set three apps shared:

  - a miss under assets/ is 404, not the shell. That subtree is content-
    addressed, so a name that is not there is a stale shell asking for a purged
    chunk, never a client-side route; answering HTML hands a <script> tag a
    document and reports "Unexpected token '<'" for what is a cache miss. It
    stays 503 while the bundle has no shell at all, so an unsynced deploy is
    still loud on every path rather than a plausible 404.
  - a directory is not a page. http.FileServer lists one, so serving whatever
    stat succeeds on published the whole asset manifest at /<app>/assets/.
  - `//go:embed dist`, not `all:dist`, in all four ui packages. dist/.sync-stamp
    names the source repo, branch and commit of each bundle; `all:` embedded it
    and the handler serves anything it can stat, so /meet/.sync-stamp,
    /tasks/.sync-stamp and /research/.sync-stamp published that. The stamp stays
    in git and leaves the binary.

Proven by booting both plugins, not only by tests: /tracker/ and /meet/ answer
the shell no-cache, a deep link falls back to it, a hashed asset carries the
immutable hint, a purged chunk and a directory are 404, .sync-stamp is not
served, /v1/tracker/health is 200 and /v1/meet/session is 401 with no session.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:08:48 -07:00
hanzo-dev aa4be57a37 tracker: the native board, embedded and routed
Reconciles blue/tracker-ui into main: apps/tracker's typed surface, the
embedded SPA at /tracker, and internal/manifesttest — the gate that reads an
app's LIVE router declaration rather than its published document, which is the
only thing that can catch an untyped static prefix.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:02:33 -07:00
zooqueenandhanzo-dev fe9bacb03f console-embed 8.5.50 -> 8.5.53: the picker offers what routes
CI/CD / containment (push) Successful in 1m7s
Hanzo CI/CD / cicd (push) Failing after 26m3s
CI/CD / gate (push) Failing after 26m3s
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
8.5.53 (console 0f6cec331) resolves each catalog row's routing id
against the live set — the playground submitted the bundle's
anthropic/claude-haiku-4.5 while the gateway routes claude-haiku-4.5,
so picking Haiku 4.5 errored 'model not available'. It also stops
offering models live under no spelling, and gates frontier-priced
models behind a standing subscription (the gateway's 402 stays the
enforcement point; the picker stops promising what it would refuse).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 11:27:21 -07:00
zeekayandhanzo-dev 79105531ed apps: the other eleven test harnesses owe the composer's bridge too
Hanzo CI/CD / cicd (push) Failing after 1m2s
CI/CD / gate (push) Failing after 1m2s
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
Same fact as the guide commit, the rest of the estate. 9396df70 moved the
principal enrichment into cloud.Identify and updated 66 packages' harnesses; the
ones here were missed, so each built a bare zip.App, mounted a subsystem on it,
and then watched every typed op refuse a caller production serves — 82 tests
across automations, cms, company, entitlements, erp, framework, help, marketing,
marketplace, prefs and templates.

erp and cms read as a doctype bug and were not one: the module install 403s
first, so the doctype never exists and every later create honestly answers 404.
One cause, two sentences.

Two comments were describing the world before 9396df70 and now describe this
one: marketplace said the subsystem installs the bridge app-wide, and
automations' MCP harness said the plain harness does not install one. The
ordering claim both make is unchanged and still exactly right.

templates' TestTheBridgeIsInstalledAheadOfTheLeaves was pinning the OLD
arrangement out loud — "no app-wide bridge, exactly as this package's tests have
always mounted it". Both its teeth are untouched: a validated caller must get
201, an anonymous one must get 403. Only who installs the enrichment changed,
from the subsystem to the composer, which is what 9396df70 decided.

No gate was loosened anywhere. It cannot be: principal.OrgOf refuses when the
user claim is empty, so the bridge parks nothing for an anonymous request and
every anonymous-refusal test still refuses.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 11:13:16 -07:00
zeekayandhanzo-dev 2ea0113f1a guide: a test app owes the composer's bridge, or every typed op refuses its own caller
9396df70 moved the principal enrichment out of all 67 subsystems and into the
one composer, cloud.Identify — correctly: a subsystem asserting identity for
itself repeats a claim it cannot check, and two of those copies sat on nodes
owning no routes, which is the outage it was fixing. It updated 66 packages'
test harnesses to install what the composer installs. guide's four were missed.

A harness that builds a bare zip.App and mounts guide on it therefore ran with
no cloud.Bridge, so principal.OrgFrom and cloud.Request read nothing and every
typed op answered 403 to a caller production serves — the exact failure
Identify's own comment predicts for the boundary installed alone. 27 tests.

The bridge cannot admit anyone: principal.OrgOf refuses when the user claim is
empty, so an anonymous request parks nothing and still refuses.
TestBlueprintAdminRequiresSuperAdmin passes unchanged — anon, a validated
non-admin and an org admin are all still 403, and only SuperAdmin reads the
blueprint. Nothing was loosened; the harness stopped lying about how the
program is built.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 11:13:16 -07:00
zeekayandhanzo-dev ff5d9c2231 ci: cloud runs its tests
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / gate (push) Failing after 1m7s
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
Pinned to @v1.0.17, which declares the reusable's `tests` input as
`type: boolean`. The forge substitutes a workflow_call input's DECLARED-TYPE
ZERO VALUE, so `inputs.tests` was always false and the test step was SKIPPED on
every single run. Measured on this repo: 20 skipped, 0 executed.

The most critical service in the fleet has been shipping with its suite never
having run. Not a flaky suite, not a slow one — never executed. The `tests:`
expression right below the pin has been decorative the whole time.

@v1 declares the input `type: string` with `default: 'true'` and guards on
`inputs.tests != 'false'`, so that expression finally means what it reads as.

VERIFIED BEFORE MOVING, because this pin was deliberate: the comment above it
records that v1.0.17 was taken to fix a `set -u` abort. v1 (8e43277) CONTAINS
v1.0.17 (81b3c18) — checked with merge-base against the REMOTE tags, since a
local clone here holds a stale annotated v1 pointing elsewhere. So this gains the
tests without losing the fix the pin was for.

Expect this to go red before it goes green. That is the point.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 11:07:26 -07:00
hanzo-dev 9095fd9c1d commerce v1.50.8: the mpc webhook verifies its signature
Hanzo CI/CD / cicd (push) Failing after 52s
CI/CD / gate (push) Failing after 53s
CI/CD / containment (push) Successful in 1m37s
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.50.7 accepted any non-empty signature on the mpc webhook and accepted
everything when no key was set, so a forged unauthenticated POST to
/v1/billing/webhooks/mpc minted credit with a caller-chosen tenant,
beneficiary and amount. v1.50.8 verifies the digest as hex HMAC-SHA256 over
the raw body with hmac.Equal and refuses every state it cannot verify.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 11:03:46 -07:00
zeekayandhanzo-dev e65c899794 the zipdoc gate reads this module, not a copy of it parked under .claude
CI/CD / containment (push) Successful in 1m28s
Hanzo CI/CD / cicd (push) Failing after 1m44s
CI/CD / gate (push) Failing after 1m45s
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 test` finds the packages to check by grepping the tree for the
//go:generate directive. An agent worktree at .claude/worktrees/<id>/ is a whole
second checkout of this repository, so the walk found 203 packages where there
are 104 — every one of them twice — and went red on the COPY's apps/o11y while
nothing in this module had changed.

--exclude-dir='.?*' is the same rule the Go source-walking gates state
(typed_request_gate_test.go, orgns_test.go, iamurl_test.go, cmd/cloud/mount_test.go):
a dot-directory is not this module's source. The glob is .?* rather than .* so it
cannot match '.' or '..' and the search root survives.

Control-tested: with drift planted in apps/agents/zipdoc_gen.go the gate says
'stale' and exits 1; restored, all 104 packages pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 10:51:58 -07:00
zeekayandhanzo-dev 96914b6c32 the namespace gate reads this module, not a copy of it parked under .claude
CI/CD / containment (push) Successful in 1m44s
Hanzo CI/CD / cicd (push) Failing after 2m4s
CI/CD / gate (push) Failing after 2m5s
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
TestOnlyOrgnsBuildsANamespace walks the tree from the module root and skipped
.git, node_modules, webui and vendor by name. An agent worktree lives at
.claude/worktrees/<id>/ — a whole second checkout of this repository — and the
walk read it, found the SAME two doors (orgns.go and apps/finance/finance.go) at
paths that exist for nobody else, and failed. Nothing in the module had changed.

Every other source-walking gate here already states the rule and says why:
typed_request_gate_test.go ('a leftover working copy'), iamurl_test.go,
cmd/cloud/mount_test.go. One rule, one spelling — a dot-directory is not this
module's source. This was the only gate that had not learned it.

Control-tested both directions: with namespace.Parse planted in a real package
the gate names the file and fails; with it removed, and with two agent worktrees
present, it passes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 10:43:31 -07:00
hanzo-dev 5a2de795b9 commerce: the agent's payment door is screened by the credit gate the browser's is
commerce holds ONE card money move and this binary opens two addresses onto it.
POST /v1/billing/topup/token was screened; POST /v1/payments ran the same
billing.TakePayment — the same bounds, the same idempotency guard, the same
mintauth-authorized deposit — with nothing in front of it. So the risk gate was
not a bound on the mint, it was a bound on one entrance, and the other entrance
is the one published as an MCP tool for an agent to call.

Both doors now hold the SAME screen, resolved once at the composition root and
handed to each registration. The gate becomes a zip.Middleware because a typed op
has no handler chain to sit in — zip composes middleware around it at
registration — so one gate reaches both forms; screenChain hands the raw route
its chain as `next`. The decision, the payer rule and the settlement key are the
landed ones, not copies.

Because both doors resolve the payer through payerOrg + principal.Subject and key
their settlement on the gateway's own payment id, a burst split across the two
accrues on ONE subject and one payment reached through both converges to one
observation. settlementOf learns the typed door's spelling of the ledger receipt
(`id`, where the browser answers `transactionId`) so a settlement whose processor
states no reference is still keyable there, and the settlement check reads the
whole 2xx band because the typed op declares 201.

The read op is not screened: it mints nothing, and a scorer that is present and
mute must not be able to withhold a customer's own receipt.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched;
widening the gate widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 10:09:00 -07:00
antjeandhanzo-dev 2a69a6141b commerce: the risk tests bind a socket inside the address cap
TestRiskGate_ANotDeployedScorerDoesNotCloseTheCreditDoor and
TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage failed on every
Mac and passed in CI.

A unix socket address is capped near a hundred bytes — 104 on darwin — and
t.TempDir embeds the calling test's own name. The second of those names is 61
characters, so the sockaddr went over the cap and every dial answered
`connect: invalid argument`.

That is the part worth writing down: the failure looked exactly like the thing
under test. Both tests assert that an UNDEPLOYED peer is absent rather than an
outage, and an over-long address produces a dial error indistinguishable from a
peer that is not listening. A test whose environment failure mimics its own
subject cannot be read at a glance, and this one was read as a real regression.

shortRuntimeDir is the same fix, for the same reason, as servePeerLedger in
ledger_peer_test.go — an anonymous short-named dir keeps the address inside the
cap on every platform. Named and commented so the next test that needs a working
socket uses it instead of rediscovering the cap.

unusableRuntimeDir is untouched: it builds a deliberately over-long address to
assert the refusal path, which is the one case that WANTS to exceed the cap.

apps/commerce goes from 5 local failures to 3. The remaining three are
TestPeerLedger_*, which need a RAM-backed scratch (`/dev/shm` or
HANZO_SQLITE_RAMFS_DIR) for the pure-Go SQLCipher codec; macOS provides neither,
so they refuse to run rather than decrypt to persistent storage. Environmental,
not a defect, and not addressed here.

Verified both ways: reverting shortRuntimeDir fails exactly those two tests
again.
2026-08-05 10:03:18 -07:00
hanzo-dev 512693979b commerce: the agent's payment door is screened by the credit gate the browser's is
Hanzo CI/CD / cicd (push) Failing after 2m0s
CI/CD / gate (push) Failing after 2m1s
CI/CD / containment (push) Successful in 2m21s
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
commerce holds ONE card money move and this binary opens two addresses onto it.
POST /v1/billing/topup/token was screened; POST /v1/payments ran the same
billing.TakePayment — the same bounds, the same idempotency guard, the same
mintauth-authorized deposit — with nothing in front of it. So the risk gate was
not a bound on the mint, it was a bound on one entrance, and the other entrance
is the one published as an MCP tool for an agent to call.

Both doors now hold the SAME screen, resolved once at the composition root and
handed to each registration. The gate becomes a zip.Middleware because a typed op
has no handler chain to sit in — zip composes middleware around it at
registration — so one gate reaches both forms; screenChain hands the raw route
its chain as `next`. The decision, the payer rule and the settlement key are the
landed ones, not copies.

Because both doors resolve the payer through payerOrg + principal.Subject and key
their settlement on the gateway's own payment id, a burst split across the two
accrues on ONE subject and one payment reached through both converges to one
observation. settlementOf learns the typed door's spelling of the ledger receipt
(`id`, where the browser answers `transactionId`) so a settlement whose processor
states no reference is still keyable there, and the settlement check reads the
whole 2xx band because the typed op declares 201.

The read op is not screened: it mints nothing, and a scorer that is present and
mute must not be able to withhold a customer's own receipt.

Shadow is unchanged. No organisation is armed and defaultRegime is untouched;
widening the gate widens the record, not the enforcement.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 10:01:14 -07:00
antje 659a4ad4a4 commerce: the risk tests bind a socket inside the address cap
CI/CD / image (push) Failing after 28m23s
CI/CD / gate (push) Successful in 12s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / containment (push) Successful in 1m1s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
TestRiskGate_ANotDeployedScorerDoesNotCloseTheCreditDoor and
TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage failed on every
Mac and passed in CI.

A unix socket address is capped near a hundred bytes — 104 on darwin — and
t.TempDir embeds the calling test's own name. The second of those names is 61
characters, so the sockaddr went over the cap and every dial answered
`connect: invalid argument`.

That is the part worth writing down: the failure looked exactly like the thing
under test. Both tests assert that an UNDEPLOYED peer is absent rather than an
outage, and an over-long address produces a dial error indistinguishable from a
peer that is not listening. A test whose environment failure mimics its own
subject cannot be read at a glance, and this one was read as a real regression.

shortRuntimeDir is the same fix, for the same reason, as servePeerLedger in
ledger_peer_test.go — an anonymous short-named dir keeps the address inside the
cap on every platform. Named and commented so the next test that needs a working
socket uses it instead of rediscovering the cap.

unusableRuntimeDir is untouched: it builds a deliberately over-long address to
assert the refusal path, which is the one case that WANTS to exceed the cap.

apps/commerce goes from 5 local failures to 3. The remaining three are
TestPeerLedger_*, which need a RAM-backed scratch (`/dev/shm` or
HANZO_SQLITE_RAMFS_DIR) for the pure-Go SQLCipher codec; macOS provides neither,
so they refuse to run rather than decrypt to persistent storage. Environmental,
not a defect, and not addressed here.

Verified both ways: reverting shortRuntimeDir fails exactly those two tests
again.
2026-08-05 09:37:06 -07:00
hanzo-dev 4f50e93aa0 meet: re-embed, and pin that the auth module ships exactly once
The bundle carried @hanzogui/admin twice, so AuthGate's one-code-one-redemption
guard existed twice and the OIDC code was redeemed once per copy — the second
failure destroying the session the first established. Deduped in the SPA's vite
config; TestTheAuthModuleIsBundledOnce asserts it here, against the bytes that
ship, because the defect is invisible in the source and lives only in how the
bundler resolved the graph.

Cold, against a strict single-use stub: /meet/login, one authorize, exactly one
authorization_code exchange, token persisted, lands in the lobby, reload adds
none.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:26 -07:00
hanzo-dev c1850f6428 risk: a decision names the issuer whose organisation it was reached for
The org column on a risk_decided row is the bare slug, and an org name
is unique within an issuer rather than across issuers.

One warehouse serves every brand's deployments — there is no brand in
the datastore address, the database name, or anything else that reaches
it; two brands are two deployments of one artifact differing in a flag.
So two brands' identically named organisations are one value in that
column, in this table and in every other table on that plane.

The column stays what the plane's column is: filing risk's rows under a
qualified key would put them in a partition none of the organisation's
own lenses read, which is the whole point of stating decisions there.
What this app can do is refuse to add to the collision. The subject
column already cannot collide, because the digest covers the qualified
tenant, and the brand now travels as a server-minted attribute — so a
row states which issuer's organisation it describes and a read can bind
it. Qualifying rather than assuming costs one bounded attribute and is
correct whether or not a second brand ever shares the warehouse.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev a772201867 risk: an action the vocabulary does not rank cannot be a finding
severest and fuse both document that an action outside cloud's action
vocabulary ranks below allow and can never become the answer, and both
relied on the same predicate to hold it.

Asked as "not empty and not allow", that predicate answered YES for such
a string. A determination carrying one was therefore treated as a
finding, could lead the composition — it is only ever ranked against
other findings — and would then stand, contradicting the two comments
that promise it cannot.

It is the severity that answers now, because the vocabulary is a ranking
and the question is about rank: has this rule reached something stricter
than proceeding. Nothing in this package mints an unrecognised action
today; the guarantee is worth having from the predicate rather than from
the fact that nobody has broken it yet.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev 8f58c273c1 risk: a rollup that names no product folds this app's own decisions back in
"A score records nothing" is the invariant that keeps this plane from
measuring itself, and it held for exactly one hop.

Every decision is also stated onto the shared event plane — one
risk_decided row per decide, under product 'risk', with the decided
subject as its distinct id. The person rollup folded every signal='act'
fact with no product filter at all, so those rows came back as ACTIVITY:
a subject screened often enough became unusual for having been screened,
and the model learned from its own output one hop later.

The rollup that folds a plane now names the product it will not fold,
spelled from the same constant the emitter files rows under so the two
cannot drift to different words for one product. Only the person rollup
needs it — the session rollup requires a session id this app never
states, and the fault rollup reads a table it never writes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev 309735ff01 risk: an event the burst window cannot hold has not just happened
The rings' own claim is that they only move forward, and the guard that
made it true was measured against the wrong window.

velocity folds an event older than a window's span to that window's
leading edge — the right call for a compliance aggregate that must not
drop a record, and a detector evasion in reverse for a live rule, where
it means the event is counted as having just happened. placeable is that
guard, and it refused an event the WIDEST span the rings keep could not
hold. Thirty days is the one span nothing reaches: an event ninety
minutes old cleared it comfortably, and was then folded to now in the
one-hour ring — which is the ring the pace count and the pace accrual
are both read from. The burst window could be filled with last month's
history.

Placement is now measured against the narrowest window the rings keep,
which is the window the rules read. It is taken from the windows the
store is actually built with rather than written down as an hour, so a
deployment that changed them moves this with them instead of leaving a
rule reading a window nobody keeps, and a test holds the published value
against a real ring set's own answer.

What it costs is stated rather than hidden. velocity writes all four
windows in one call, so an event more than an hour behind the edge no
longer enters the wider rings either and the model loses a late arrival.
It still learns from the event itself. A wider aggregate missing one
late event is a slightly stale baseline; the narrowest one gaining it is
a rule freezing a payment for something that happened last month.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev 29afeacdad risk: a velocity window anchored to its last event never expires
The reading a pace bound acts on was a statement about whenever the
rings were last taught something, not about now.

A velocity window sums the buckets in [lead-N, lead], and `lead` is the
newest event the rings received. So a subject that did sixty funded
things inside one hour last Tuesday read sixty events in "the last hour"
for as long as the residency lived. Nothing cleared it: the op that
reads the window records nothing, so the anchor never advanced and the
window never slid off. One busy hour, and every later payment by that
organisation was frozen by a burst that had finished a week earlier.

The reading is now believed only while the aggregates have been taught
something inside the span they claim to describe. Past that the two
aggregate halves fall silent for that axis and the event's own stated
facts are the whole rule — the same reading a subject with no history
gets, and the honest one, since a window with nothing in it is what
"has done nothing lately" means.

The edge is the tenant's and not the key's, and that is said out loud
rather than hidden: the engine's observation carries no per-key leading
edge, so the finest recency available here is "has this organisation
been taught anything inside the span". It clears the case that matters —
a self-serve organisation is its own payer, so its edge is its payer's
edge — and leaves one open, where a busy organisation's traffic keeps
the edge fresh for a quiet subject.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev e509f6a0d5 risk: the velocity halves read a history nothing was writing
The credit door's pace and fan-out rules were structurally dead — not
mis-tuned, but unable to reach a correct answer for any input.

The door stated an amount, a currency, a country and an address, and no
aggregation axis at all. onFan reads a device and a counterparty, so it
could not fire for any top-up ever sent; onPace read one axis where it
reads three. A rule half nothing states an axis for is indistinguishable
from a rule half that found nothing, and the second reads as clean.

And nothing taught the model anything. A decide records nothing by
design, the published learn door is an organisation calling itself, and
at a self-serve credit door the organisation IS the payer — so the two
halves read an empty history for exactly the subject they exist for.
Five payments of eleven thousand dollars looked like five first
payments, every one under every stated bound by construction.

So a settlement now teaches, over a plane op of its own:

  The server states it. The value is what settled, the moment is a
  server clock, the subject is the one the credit landed on, and nothing
  is stated unless the handler answered that money moved. A request that
  fails, is declined or is refused upstream teaches nothing.

  It is keyed on the settlement. Settlement is at-least-once, so the key
  is the processor's own reference for the charge — the one identifier
  the synchronous door and a replayed webhook share — falling back to
  the ledger receipt where the processor states none. Velocity that
  double-counted a retry would freeze a customer for paying once.

  It cannot be pre-empted. Dedupe means the first writer of an id wins,
  so the settlement id lands in a namespace the public learn door now
  refuses outright. The same guard covers folded buckets, which could
  already be claimed.

  It cannot fail the payment. The money has moved by then, so the call
  is detached, bounded, dropped at a ceiling and panic-guarded.

The door also states the address our own edge resolved as its
counterparty, which is what makes the fan-out reachable there. It states
no device, and that is declared at boot rather than left to read as a
rule that found nothing: no fingerprint reaches this binary from a
top-up, and a user-agent stated as one would summon a person for every
customer. Nothing is invented.

And the payer is now a subject kind of its own. An account's learned
history is its metered inference spend, so judging a top-up as an
account scored a payment against a distribution of money spent OUT —
and the windowed value bounds, which are a payments appetite, accrued on
that same key. A customer with a large inference bill was examined for
it, and no restatement of the number could fix it: one appetite over two
populations is two appetites.

Finally, a negative value is refused at the one door every bound is
applied at. The aggregates accrue a sum and the bounds are read off it,
so a negative amount does not describe a small event — it subtracts from
the finding, and one taught event cancels an hour of real payments. A
recorded row the door would now refuse is skipped and said rather than
abandoning the whole replay, which would blind a tenant for as long as
the row is retained.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:12:15 -07:00
hanzo-dev 8060116636 risk: a decision names the issuer whose organisation it was reached for
CI/CD / image (push) Successful in 18m4s
CI/CD / gate (push) Successful in 11s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / containment (push) Successful in 56s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
The org column on a risk_decided row is the bare slug, and an org name
is unique within an issuer rather than across issuers.

One warehouse serves every brand's deployments — there is no brand in
the datastore address, the database name, or anything else that reaches
it; two brands are two deployments of one artifact differing in a flag.
So two brands' identically named organisations are one value in that
column, in this table and in every other table on that plane.

The column stays what the plane's column is: filing risk's rows under a
qualified key would put them in a partition none of the organisation's
own lenses read, which is the whole point of stating decisions there.
What this app can do is refuse to add to the collision. The subject
column already cannot collide, because the digest covers the qualified
tenant, and the brand now travels as a server-minted attribute — so a
row states which issuer's organisation it describes and a read can bind
it. Qualifying rather than assuming costs one bounded attribute and is
correct whether or not a second brand ever shares the warehouse.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:09:35 -07:00
hanzo-dev 5db95ffa08 risk: an action the vocabulary does not rank cannot be a finding
severest and fuse both document that an action outside cloud's action
vocabulary ranks below allow and can never become the answer, and both
relied on the same predicate to hold it.

Asked as "not empty and not allow", that predicate answered YES for such
a string. A determination carrying one was therefore treated as a
finding, could lead the composition — it is only ever ranked against
other findings — and would then stand, contradicting the two comments
that promise it cannot.

It is the severity that answers now, because the vocabulary is a ranking
and the question is about rank: has this rule reached something stricter
than proceeding. Nothing in this package mints an unrecognised action
today; the guarantee is worth having from the predicate rather than from
the fact that nobody has broken it yet.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:09:22 -07:00
hanzo-dev 2f1e7803a7 risk: a rollup that names no product folds this app's own decisions back in
"A score records nothing" is the invariant that keeps this plane from
measuring itself, and it held for exactly one hop.

Every decision is also stated onto the shared event plane — one
risk_decided row per decide, under product 'risk', with the decided
subject as its distinct id. The person rollup folded every signal='act'
fact with no product filter at all, so those rows came back as ACTIVITY:
a subject screened often enough became unusual for having been screened,
and the model learned from its own output one hop later.

The rollup that folds a plane now names the product it will not fold,
spelled from the same constant the emitter files rows under so the two
cannot drift to different words for one product. Only the person rollup
needs it — the session rollup requires a session id this app never
states, and the fault rollup reads a table it never writes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:09:08 -07:00
hanzo-dev 09c77104be risk: an event the burst window cannot hold has not just happened
The rings' own claim is that they only move forward, and the guard that
made it true was measured against the wrong window.

velocity folds an event older than a window's span to that window's
leading edge — the right call for a compliance aggregate that must not
drop a record, and a detector evasion in reverse for a live rule, where
it means the event is counted as having just happened. placeable is that
guard, and it refused an event the WIDEST span the rings keep could not
hold. Thirty days is the one span nothing reaches: an event ninety
minutes old cleared it comfortably, and was then folded to now in the
one-hour ring — which is the ring the pace count and the pace accrual
are both read from. The burst window could be filled with last month's
history.

Placement is now measured against the narrowest window the rings keep,
which is the window the rules read. It is taken from the windows the
store is actually built with rather than written down as an hour, so a
deployment that changed them moves this with them instead of leaving a
rule reading a window nobody keeps, and a test holds the published value
against a real ring set's own answer.

What it costs is stated rather than hidden. velocity writes all four
windows in one call, so an event more than an hour behind the edge no
longer enters the wider rings either and the model loses a late arrival.
It still learns from the event itself. A wider aggregate missing one
late event is a slightly stale baseline; the narrowest one gaining it is
a rule freezing a payment for something that happened last month.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:08:54 -07:00
hanzo-dev bdcc62a676 risk: a velocity window anchored to its last event never expires
The reading a pace bound acts on was a statement about whenever the
rings were last taught something, not about now.

A velocity window sums the buckets in [lead-N, lead], and `lead` is the
newest event the rings received. So a subject that did sixty funded
things inside one hour last Tuesday read sixty events in "the last hour"
for as long as the residency lived. Nothing cleared it: the op that
reads the window records nothing, so the anchor never advanced and the
window never slid off. One busy hour, and every later payment by that
organisation was frozen by a burst that had finished a week earlier.

The reading is now believed only while the aggregates have been taught
something inside the span they claim to describe. Past that the two
aggregate halves fall silent for that axis and the event's own stated
facts are the whole rule — the same reading a subject with no history
gets, and the honest one, since a window with nothing in it is what
"has done nothing lately" means.

The edge is the tenant's and not the key's, and that is said out loud
rather than hidden: the engine's observation carries no per-key leading
edge, so the finest recency available here is "has this organisation
been taught anything inside the span". It clears the case that matters —
a self-serve organisation is its own payer, so its edge is its payer's
edge — and leaves one open, where a busy organisation's traffic keeps
the edge fresh for a quiet subject.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:08:40 -07:00
hanzo-dev a85c2248bb risk: the velocity halves read a history nothing was writing
The credit door's pace and fan-out rules were structurally dead — not
mis-tuned, but unable to reach a correct answer for any input.

The door stated an amount, a currency, a country and an address, and no
aggregation axis at all. onFan reads a device and a counterparty, so it
could not fire for any top-up ever sent; onPace read one axis where it
reads three. A rule half nothing states an axis for is indistinguishable
from a rule half that found nothing, and the second reads as clean.

And nothing taught the model anything. A decide records nothing by
design, the published learn door is an organisation calling itself, and
at a self-serve credit door the organisation IS the payer — so the two
halves read an empty history for exactly the subject they exist for.
Five payments of eleven thousand dollars looked like five first
payments, every one under every stated bound by construction.

So a settlement now teaches, over a plane op of its own:

  The server states it. The value is what settled, the moment is a
  server clock, the subject is the one the credit landed on, and nothing
  is stated unless the handler answered that money moved. A request that
  fails, is declined or is refused upstream teaches nothing.

  It is keyed on the settlement. Settlement is at-least-once, so the key
  is the processor's own reference for the charge — the one identifier
  the synchronous door and a replayed webhook share — falling back to
  the ledger receipt where the processor states none. Velocity that
  double-counted a retry would freeze a customer for paying once.

  It cannot be pre-empted. Dedupe means the first writer of an id wins,
  so the settlement id lands in a namespace the public learn door now
  refuses outright. The same guard covers folded buckets, which could
  already be claimed.

  It cannot fail the payment. The money has moved by then, so the call
  is detached, bounded, dropped at a ceiling and panic-guarded.

The door also states the address our own edge resolved as its
counterparty, which is what makes the fan-out reachable there. It states
no device, and that is declared at boot rather than left to read as a
rule that found nothing: no fingerprint reaches this binary from a
top-up, and a user-agent stated as one would summon a person for every
customer. Nothing is invented.

And the payer is now a subject kind of its own. An account's learned
history is its metered inference spend, so judging a top-up as an
account scored a payment against a distribution of money spent OUT —
and the windowed value bounds, which are a payments appetite, accrued on
that same key. A customer with a large inference bill was examined for
it, and no restatement of the number could fix it: one appetite over two
populations is two appetites.

Finally, a negative value is refused at the one door every bound is
applied at. The aggregates accrue a sum and the bounds are read off it,
so a negative amount does not describe a small event — it subtracts from
the finding, and one taught event cancels an hour of real payments. A
recorded row the door would now refuse is skipped and said rather than
abandoning the whole replay, which would blind a tenant for as long as
the row is retained.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:08:27 -07:00
hanzo-dev 1543a4b295 tracker: embed the bundle whose sign-in redeems its code exactly once
Re-syncs after the shared CallbackHandler's single-use guard learned to hold
across two instances of its own module.

The tracker did not have the defect. Measured against a stub that enforces
single use — proven by exchanging one code twice, 200 then 400 — a cold run
redeems EXACTLY ONCE, keeps the token, and lands in the board authenticated,
with the stub's own ledger agreeing: 1 exchanged, 0 rejected. A reload spends
nothing further.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 09:06:55 -07:00
hanzo-dev deb8a0cac8 meet: re-embed — the cold visitor now meets the sign-in door
Measured cold against the real binary and a STRICT IAM stub that 404s any
doubled or missing prefix: a zero-cookie visit to /meet/ is redirected by
AuthGate's render guard to /meet/login rather than mounting the lobby, and the
PKCE round trip resolves to exactly /v1/iam/oauth/authorize — the prefix once,
never twice.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:57:04 -07:00
antje 7ad7ec9ca7 projections: the fleet document follows the typed conversions, under fleet-unique names
Hanzo CI/CD / cicd (push) Successful in 13s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 36s
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 weave refuses one schema name with two shapes, and regenerating the
subsets for the converted surfaces surfaced exactly that: billing's series
bucket collided with admin's usagePoint; affiliates' directory tally, share
link row, payout family, sweep report and analytics summary collided with
referrals, link, authors, treasury and platform; and link's typed inputs
collided with agents, usage and visor. Each colliding type takes a name the
fleet does not already speak — sample, totals, tally, codeView, remittance,
settlement, accruals, enrollReq, readingReq, readingView, ingestReq,
ingestResp, boardResp — and no byte moves: a Go type's name never appears
in its JSON.

The subsets regenerate from the live routers (the commerce dedupe, the
declared Cache-Control response headers, notify's raw template object, the
renamed schemas) and openapi.yaml is rewoven from them — the document the
SDK repos pull.
2026-08-05 08:55:31 -07:00
antje 4402ecabac o11y: the probe cycle proves itself in the registry's own encoding
The native availability road existed one step short of proof: the reporter's
gauge was read back through Gather, a test's convenience, not what the
framework ships. The exporter serializes the registry's text encoding, so the
evidence that a probe cycle lands the series where the exporter will find it
has to read that encoding — a real prober Start, both verdicts, and
hanzo_service_up present in EncodeText output under the same service label the
meter pipeline publishes. The meter path stays: two producers, one truth,
until every rule row is re-pointed with proof.

The four raw registrations that remain now state their reason where they are
made: the alert replay serves text/plain for an operator's tail, the receiver
speaks Alertmanager's webhook protocol and must accept a body that will not
parse, the sessions route relays the runtime's envelope byte-for-byte, and
the sentry wildcard carries Sentry's DSN-keyed frames.
2026-08-05 08:53:27 -07:00
antje 048295e8fb doors: the host probes become typed ops; every raw door names its reason
/healthz and /readyz were the last plain-JSON raw routes across cmd/cloud,
apps/git and apps/integrations. They now register as typed ops, so the answer
the kubelet GETs is the same answer a native ZAP typed Call reads, and both
probes carry their shape in the registry every projection — OpenAPI, MCP, the
CLI, the SDKs — is built from. The bytes did not move: probeOut declares
absent ahead of status because the raw handlers marshalled a map and
encoding/json writes map keys sorted, and TestProbeAnswersAreByteStable pins
every body and status the old handlers answered, including the draining and
unfit 503s.

Every other raw registration in the three packages was audited and stays raw
for a reason now stated at its registration: the Slack, GitHub, Discord,
Teams and Telegram doors speak their platform's own webhook protocol over the
raw request; the link legs and the generic OAuth callback drive a browser
with a 302 or an HTML page; smart-HTTP speaks git's own pack protocol; the
git UI serves server-rendered HTML; and the five ZAP procedures answer the
bridge's envelope. git.go's webhook comment also catches up with webhook.go:
that door is a tombstone answering 410, not an HMAC verifier, and the comment
no longer describes a verification the handler stopped doing.
2026-08-05 08:53:27 -07:00
antje fa292797e4 the tail of the surface goes typed: notify's sends, link whole, both health probes
Every convertible raw route in notify, link, meet and analytics is now a typed
op — the one registration REST, OpenAPI, the MCP tool list, the CLI and the
by-name call plane all project from. An untyped route is invisible to four of
the five, which is why notify's send routes could never be reached as a typed
zip.Call by IAM's OTP sender.

notify: the three send routes type without moving a byte. The two-shape refusal
(one recipient answers the bare {message_id,status} outcome, several answer the
{items:[...]} envelope) is carried by the Out's own MarshalJSON, so every JSON
projection writes the exact bytes the raw handler always wrote while the call
plane reads one declared shape. The sync selector becomes a declared In field,
which is what makes it expressible in a by-name call's arguments at all. The
template values ride a raw JSON object rather than a map, because the call
plane computes an input's whole layout before reading any payload and refuses
a map field outright — a map anywhere on the In failed every by-name call,
including the OTP send that carries no template values at all. A socket test
drives the real call plane with template_vars populated, so the projection this
conversion exists for is proven, not presumed.

link: all eleven routes convert; the openapi.Describe ledger dies and its prose
moves onto the ops, where zipdoc lifts it. The (org, subject) gate stays the
one caller() both planes key.

meet and analytics: the two health probes' refusal — 200 and 503 carrying the
SAME body — went stale when zip learned to declare a non-2xx with a typed body.
Both now declare WithStatus(200, 503) and the report's own StatusCode picks,
so the degraded answer keeps its body and the refusal ledgers shrink.

The survivors each carry their reason in place: visor's polymorphic launches,
verbatim catalog passthroughs and the streaming bot verb; books' raw-bytes
uploads and the two 501 link-flow stubs; base's and tasks' verbatim relays of
another program's bytes; meet's text/plain token; analytics' ingest doors, tag
asset and Sentry relay; projects' archive deploys.

Two link tests that mount typed agents onto a bare app now install the
composer's Bridge — they were failing 403 at base for want of it.
2026-08-05 08:53:27 -07:00
antje 068fcc3d6d billing + affiliates: a route that owns its shape is a typed op
The affiliate program's whole surface (17 routes) and billing's finance
projection plus the per-account breakdown (7 routes) become typed ops, so
the document, the MCP tool, the CLI command, the SDK method and the ZAP
call all project from each single registration. The contract does not
move: same paths, same field names, same statuses — apply and attribute
state their 200-vs-201 on the answer — bare arrays stay bare, the admin
envelope stays {status,msg,data}, and every write that refused a bodyless
request with c.Bind's 400 still refuses one, because zip's tolerant
decode would otherwise turn that refusal into a write of zero values
(requireBody replays it).

Refusal ORDER is part of the contract too, and zip decodes a body before
a handler runs, so the admin family rides a gated group: a non-admin
sending a body that will not parse is answered the 403 first, exactly as
the raw handlers ordered their refusals, and approve keeps its OPTIONAL
body through a tolerant decode rather than growing a 400 it never
answered. Body-only fields opt out of URL binding (url:"-"), so a money
parameter can never ride a query string into an access log and the query
can never override the body.

Per-tenant money answers declare Cache-Control: no-store on the contract
itself — zip.WithResponseHeader plus each Out's ResponseHeaders — so the
directive reaches the document, the SDKs and the tool schema instead of
riding a context slot no projection can see.

Four /v1/billing routes stay raw because they serve bytes: each serves
commerce's own body and status — a 402 decline keeps its reason —
enriched or envelope-built on the co-resident leg, a shape they
deliberately do not own. Each names that reason at its registration;
their prose stays on openapi.Describe, while the converted ops carry
theirs as doc comments zipdoc lifts. The saved-card trio is commerce's
co-resident surface now, so the package holds eleven routes: seven
typed, four raw.

The dual-shape reads keep BOTH shapes exactly through optional fields: an
enrolled zero — a rate of 0, no commission yet — still renders, and a
not-enrolled caller never grows a field. typed_compat tests pin those key
sets, the bodyless refusals, the bare-array kinds and the cache
discipline; typed_request_gate_test pins the two new request resolvers
(billing's payer/caller, affiliates' sudo/actor/requireBody).
2026-08-05 08:53:27 -07:00
antje 773b8b5e31 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.

The file also takes the name of what it proves — the peer path — because its
old name reached for a word this repo does not use for transport.
2026-08-05 08:53:26 -07:00
antje e5ff2a86a1 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 — those are sanctioned reasons. The rest are not
raw by sanction at all: each is a JSON-shaped module route that is not typed
YET, because the typing is module work. The module-handler note above Mount
says so once, and every citing registration — the store/catalog/plan bundle
mounts included — is a conversion hanzoai/commerce still owes through the
payments.go exported-core pattern, never a route that cannot be typed.

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 08:53:26 -07:00
hanzo-dev 0a2ca8b676 tracker: embed the order-independent seam, and stop the sync eating its own stamp
Re-syncs after the shared seam learned to remember a refusal that arrives before
anyone is listening for it.

The tracker did not have that bug — verified cold, on a zero-cookie zero-storage
browser: it made NO /v1/tracker request at all before bouncing to sign-in,
because AuthGate returns null and navigates when there is no token, so its
children never mount to fetch. The fix is to the seam rather than to this app,
and it matters here anyway: nothing but the gate's render guard was stopping it.

The README's sync command now excludes .sync-stamp. The source dist has no copy,
so a plain --delete removed it, and no test can catch that: what embed_test.go
asserts is exactly that the stamp is absent from the binary. It went missing
twice — the warning belongs in the command, not in the file the command deletes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:51:52 -07:00
hanzo-dev 6eb932ca3a meet: re-embed the client — it signs in on this host now
The bundle carrying AuthGate + the /meet/callback route. meet.hanzo.ai is its
own host and cloud's session cookie is host-only, so a first-time visitor had no
way to obtain a credential; the client now authenticates its own visitors
through the shared door rather than a login of its own.

The IAM base is ${origin}/v1/iam under the workspace's @hanzo/iam 0.9.4, pinned
by a test on the COMPOSED authorize URL because the wrong polarity does not
404 — cloud answers it 200 text/html from the SPA catch-all and sign-in stalls
with nothing to see.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:41:30 -07:00
hanzo-dev 684538b6b4 tracker: restore the provenance stamp rsync --delete removes
The sync command in the README deletes it — the source dist has no copy — and
embed_test.go cannot notice, because what that test asserts is precisely that
the stamp is NOT in the binary. So the file now says so at the top of itself,
where whoever runs the sync will read it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:36:05 -07:00
hanzo-dev 066b40c6ad tracker: embed the bundle whose sign-in works, and pin the horizon absolutely
Re-syncs the SPA after the shared gate learned to recover from an expired
session, to refuse an off-origin return-to, and to compose the IAM URL the
installed SDK actually wants. Verified against this binary with a STRICT IAM
stub that 404s any path but the five real endpoints — so a doubled or missing
/v1/iam prefix cannot pass unnoticed the way it would in production, where an
unknown path answers 200 text/html from the SPA catch-all.

The bundle also stops fetching webfonts from a third party: a signed-in admin
surface should not announce each of its visitors to Google, and a strict CSP
would block the request anyway. It wears the forge's own stack instead, which is
what the views were copied from.

And the schedule-horizon test now asserts ABSOLUTE instants (the years 2300 and
9999) rather than maxScheduleAt + 1. A case written in terms of the constant
moves with it, so loosening the horizon — the one regression that test exists to
catch — would have kept it green. The constant itself is pinned to
2200-01-01T00:00:00Z separately, so changing it is a deliberate edit rather than
a silent widening.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:35:45 -07:00
antjeandhanzo-dev 2ea6f6ca84 platform: 32 raw routes become typed ops, and the prose moves onto the handlers
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m0s
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 projects every TYPED op — zip.Get[In, Out] and friends — into REST, OpenAPI,
MCP tools, the CLI and eight generated SDKs from ONE registry entry. A raw
app.Get(path, func(*zip.Ctx) error) serves the same bytes and reaches none of
them: it publishes an operationId and nothing else, so the SDK method cannot
explain itself and the CLI command has no help text. Every route on /v1/platform
was raw. All 32 are now ops, and NONE stays raw — not one of them streams,
serves bytes, redirects a browser, or speaks a third party's protocol, so not one
of them had a reason to.

The wire is unchanged, and that is the whole claim: same paths, same JSON field
names, same status codes, same refusal sentences. converted_test.go is the proof
rather than the assertion — it walks every converted route asserting status and
the exact set of JSON keys a client's decoder binds, and the SAME FILE passes
against the pre-conversion tree. The expectations were read off the old
c.JSON(status, …) calls, so it is a regression test and not a mirror.

WHAT THE CONTRACT GAINED. The subset went from 9 published schemas to 43: every
route's real input and output is now in the document instead of an unnamed body
a generator had to guess at. Three statuses the routes have always sent became
sayable — 201 on create, 202 on deploy/preview/promote/rollback/run/runner, 204
on the two deletes — where zip previously had no vocabulary for them and the
document said 200 about a route that has never sent one. Two routes answer with
their own status because the ANSWER states it: /v1/platform/health is 200 or 503
carrying the real reason as a typed body, and add-domain is 201 for a claim it
created or 200 for an idempotent re-add. Both declare their set, so a generated
client expects what the service sends.

THE PROSE MOVED, IT DID NOT CHANGE. ~340 lines of openapi.Register/Describe —
a SECOND source that had to be edited in lockstep with the router and, being
second, could not be — are gone. The same sentences are now doc comments on the
handlers they describe, lifted by zipdoc into zipdoc_gen.go, which is the only
road prose travels to the document, the tool list, the SDKs and the CLI.

FOUR NAMES YIELDED, because the fleet's schema namespace is FLAT and
single-valued: one name, one shape, wherever two apps meet, or every generated
SDK binds whichever it read last. buildView and pipelineView are published by
apps/agents and apps/world, healthView by apps/compliance, and Drift by
apps/plugins with an entirely different shape. Platform's were not published, so
platform's yield — the rule apps/templates followed when its Template became a
StarterKit — each to a word its own doc comment already used: the console
projections are rows on a board, and Drift is the verdict it says it is. The
JSON field is still `drift`; only the Go type is renamed. fqdn.Record collides
with apps/projects and was left alone: it is the same type, so the shapes are
identical and the weave accepts it.

THE GATE MOVED, IT DID NOT CHANGE. cloud.Guard wraps a zip.Handler, and a typed
op has none — it receives a context and its decoded In. So the fleet board's
cloud.Admin/cloud.Super gate is now the first line inside each op (board.admit),
applying the same Scope.Admits over the same AuthorityOf. gate.go gains ONE
exported method for it: Scope.Refusal, the 403 Guard itself now returns, so the
two forms of the one gate cannot answer differently and the refusal sentence
stays derived from the scope rather than written at a call site. It is a METHOD
and not a function taking a Scope because Refuse already names the 402 every
spend gate renders — two refusals that mean different things do not share a name.

An In field is never a tenant key: the org comes from the validated principal, so
cross-tenant identifiers remain structurally not inputs. The three cloud.Request
call sites are declared in allowedRequestUses with their reason — this plane
SPENDS the caller's identity (the run fee, the audit actor) rather than only
reading it, and /v1/runner authorizes on a shared credential no org can express.

No middleware is installed here. cloud.Bridge belongs to whoever composes the
app, and the tests compose their own.

Tests: the full apps/platform suite is green on Linux, where the SQLCipher codec
finds the RAM-backed scratch it fails closed without; the same suite cannot run
on darwin without a tmpfs, before this change or after.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:20:47 -07:00
hanzo-dev 6e8b072da6 meet: re-embed the client — keyed room route, shared-layer session read
The bundle behind RED F2 (a different room is a different component) and B2 (the
session read goes through @hanzogui/admin's useFetch, so meet inherits the shared
401 door instead of growing a login), plus the disconnect rule that keeps the URL
and the screen naming the same room.

Verified against this binary and a real LiveKit: from a LIVE standup, navigating
to retro lands on retro's join screen and mints nothing; both grants name the room
their URL names.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:10:15 -07:00
hanzo-dev a944a0156a tracker: embed the bundle that can sign a visitor in
Re-syncs the SPA now that it authenticates its own visitors through AuthGate
(@hanzogui/admin + @hanzo/iam PKCE) rather than relying on a cookie some other
host set — which cloud never sends here, session cookies being host-only.

The bundle carries no credential and no tenancy of its own: it obtains a token
from IAM on this origin, and cloud mints the validated org from that token's
SIGNED membership claim. Verified against this binary: a fresh browser with no
cookie and no storage reaches the board through the OIDC redirect, and a raw
fetch without the bearer is still 403.

Registers one redirect URI: https://tracker.hanzo.ai/tracker/callback — under
the SPA's own base, which is the prefix the manifest routes to this app.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 07:57:17 -07:00
hanzo-dev 5ef9048180 meet: make the authority answerable, so the rules past it are tested
RED F1. The machine-credential exclusion (p.Subject != "") in admits and spaces
was correct and UNTESTED, and mutation proved it: deleting it changed nothing the
suite could see. Every IAM-lane test stopped at an unanswerable ask — there is no
team peer in a unit test — so the machine got as far as the ask and was refused by
the missing peer rather than by the rule. A decision that only exists past a
boundary needs the boundary to be crossable in a test, or it is not tested.

roster is that boundary, named: the process that OWNS the membership rows, with
peer{} the real one (apps/team over the internal plane) and state.rows() defaulting
to it. Nil-safe by construction rather than by discipline — every load() failure
path returns a state carrying only a reason, and a lobby read on one of those must
answer like production instead of panicking. The org still rides the context; it is
not a parameter, so a caller cannot ask about another tenant by naming one.

With an authority that ANSWERS, the rules past it are reachable and now pinned:
a human is seated and a machine is refused WITHOUT THE AUTHORITY BEING CONSULTED
(refused, and refused for the right reason — only the second survives a mutation);
the authority's role decides, and offer and admit agree across every role on the
IAM lane too; no row is a refusal; an unreachable authority is a refusal on both
doors. Three mutants, three kills: neuter p.Subject!="" -> both machine tests fail;
drop the role filter -> the offer/grant test fails; drop the empty-workspace guard
-> the widening test fails.

It also corrected the suite's own story. With an authority that can answer, a room
name with no separator is visibly ASKED about (strings.Cut returns the whole string)
rather than refused up front — only a LEADING underscore is refused by meet itself.
Both are fail-closed, but the old comment described the wrong one, and a test that
flatters the program is not a test.

RED F4. plane.Spaces claimed "Account is empty exactly when there are no
workspaces". The implementation resolves the account BEFORE walking the rows, so it
never satisfied the biconditional. The one-way invariant is the one that matters
and the one now documented and tested: a non-empty Items implies a non-empty
Account, so every workspace offered has an identity to seat under. A doc that
overstates an invariant is worse than none — it is the one a caller writes an `if`
against.

RED F5. Said plainly why session answers outside ready(): same reason the bundle
is served outside it. A bad key file drops teamSecret with everything else, so such
a deploy answers a lobby read on the IAM lane (which never needed teamSecret) while
every mint is 503. Honest rather than contradictory — the workspaces someone
belongs to do not stop being true because this binary cannot sign.

RED F3. spa/ had three dependents and no tests of its own; each app proved the
policy against whichever bundle happened to be committed, and none could build the
tree that breaks it. The trees here are synthetic and hostile: an unsynced bundle
(503, naming the app), a chunk that is not there, seven traversal shapes that must
end inside the bundle or nowhere. Two behaviours are recorded rather than changed —
a missing chunk falls back to the shell (which is why the shell is no-cache), and
http.FileServer canonicalises /index.html to ./ with a 301.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 07:44:44 -07:00
hanzo-dev 2b4b185a0a tracker: route the board, gate the writes, bound the dates
Red's review of the native tracker. Five findings, all in the seams around the
code rather than in it.

ROUTED (blocker). /tracker was in no manifest row, so in the fleet — one process
per app behind a prefix router — the board's own host reached whoever owns "/"
and the visitor got the console's HTML shell with a 200. The prefix is now
claimed beside the API it reads.

Nothing could have caught that: manifest/router_test.go asks the real router
where each app's PUBLISHED paths land, and an embedded SPA is an untyped route
that appears in no openapi.json. So internal/manifesttest asks a different
question of a different source — it mounts the app and reads its LIVE router
declaration, which zip builds complete by construction, untyped routes included.
Every app can adopt it in five lines; tracker does. It fails without the manifest
fix and passes with it.

GATED (must-fix). Tracker writes ran with no anti-CSRF gate while the fleet
reflects *.hanzo.ai with credentials — a wildcard covering hosts that serve
arbitrary user content — so a page there could read and write another org's
boards with the visitor's own ambient session. The estate's ONE gate
(apps/account, minted at GET /v1/csrf) now runs on the group, discriminating by
METHOD: a gate written six times is a gate the seventh write forgets. Reads pass
untouched and a Bearer/gateway caller is unaffected, being un-CSRF-able.

BOUNDED (must-fix). checkSchedule accepted any non-negative int64, so
dueAt=2^63-1 was a legal write — and the timeline sizes its grid from the data,
so that one row made every member of the org who opened the view render an
unbounded number of ticks. A stored value that breaks the reader for everyone who
looks at it is the write's fault, so the horizon is enforced at the write. The
client clamps too, in the SPA; neither is load-bearing alone.

And three things the static handler gave away or got wrong: /tracker/assets/
returned a directory listing (fs.Stat succeeds on a directory and http.FileServer
lists it), /tracker/.sync-stamp published the source repo, branch and commit
(`all:dist` embeds dot-files; plain `dist` does not), and a missing hashed asset
answered the SPA shell — handing a <script> tag an HTML document, which reports
as a corrupt bundle rather than the cache miss it is.

Finally: `?scheduled=yes` bound FALSE and returned the whole board, because zip
parses a bool with ParseBool and leaves the zero value when it fails. Every other
filter on this route refuses its unknown values; so does this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 07:44:27 -07:00
zeekayandhanzo-dev 43018a6bcc weave: one name, one shape — framework names its own grant
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m1s
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 weave refused the fleet because schema "Role" meant two things: IAMs

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 07:44:14 -07:00
zooqueenandhanzo-dev 0298e5fb4d slack: a slash body that names an operation runs it, as the person who typed it
CI/CD / image (push) Failing after 26m43s
CI/CD / gate (push) Successful in 15s
CI/CD / containment (push) Successful in 1m29s
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 14s
The registry the ⌘K bar reads is the registry Slack reads. `/hanzo platform
apps-get web` is now that operation, executed; anything that is not an operation
is prose and reaches the agent brain exactly as before. One branch in
slackSlashTurn, and the list it consults is zip.CommandsFromSpec over the fleet
document — the same projection GET /v1/commands serves, resolved in this process
rather than fetched, because a Slack turn should not depend on the host
answering a public GET about itself. 2,328 commands today.

TWO QUESTIONS, and a body has to answer both.

NAMING. Exact `<service> <operation>` first, whatever the method; failing that
an unambiguous prefix of a GET's name. A mutation is reachable only by its whole
name — structurally, because the fuzzy branch never looks at a non-GET, not by a
check someone can forget. An ambiguous abbreviation names nothing.

INVOKING. "audit log for last week" NAMES `audit log`, then hands it four words
it has nowhere to put; without the second question it became a CLI usage dump
instead of an answer. So zip's own runner parses it first, with an invoker that
refuses — the arity, the flag names and the required flags are checked by the ONE
parser, before any credential is touched and before anything is sent. Failing
either question is prose.

THREE THINGS THE FIRST CUT GOT WRONG, all found by review and all pinned:

  THE ADDRESS. zip percent-encodes a path argument so it cannot become path
  structure, and fasthttp decoded it again: `platform apps-get
  ../../../v1/iam/users` left as GET /v1/iam/users. One operation's ARGUMENT
  addressing another operation, on the caller's own credential — which voids
  every decision made above it, including "a mutation must be named". Fixed on
  both sides: a positional must be one bare segment (no "/", "%", "..", control
  rune, and no query or fragment rune), and zip 1.25.3 stops the transport
  re-reading the address. Measured: the request-level flag ALONE does not hold,
  because HostClient.doNonNilReqResp assigns it from the client's own field.

  THE ORG. The call carried only Authorization, so middleware_identity fell back
  to the token's HOME org — a person linked in org A's workspace ran mutations in
  org B. The workspace org now rides as X-Org-Id, and membership is checked here
  first through authz.Claims.EffectiveOrg (the same published predicate the front
  door decides with), because the front door DISCARDS a selection outside the
  signed set and continues rather than refusing. Right for a browser, wrong here.

  THE REFUSAL. A spent or revoked sealed token said "try again shortly" forever —
  a loop with no exit, for a credential that is never coming back. IAM refusing
  the grant (4xx, or an OAuth error) is now told apart from IAM being
  unreachable, and only one of them is worth waiting for; the other says the link
  expired and carries the URL that fixes it.

AUTH is otherwise as designed: the refresh token slack_link sealed in KMS mints a
short-lived hanzo.id access token — the credential that whole flow exists to
establish, spent here for the first time — and the call goes through the front
door, so the command meets the same authorizer a REST client would. A rotated
refresh token is re-sealed, because IAM's are single-use and keeping the spent
one would work exactly once. Never a service identity.

Everything on this branch is EPHEMERAL, stated once at the branch: a command's
result is org data the caller asked for, and posting it to a whole channel is a
disclosure nobody chose. The prose answer keeps the in_channel delivery it had.

On the way out a payload passes audit.Redact — a chat message has left Hanzo's
custody, and some operations answer with provider config, so `api_key` would be
written into a workspace's history by someone who only asked what was configured.
The fleet's ONE credential-field denylist already knows those names. A REFUSAL
goes through it too: the status and the sentence survive, because a revoked key
and an outage need different actions, but the body they travel in is a payload
like any other. And every value is escaped for mrkdwn INCLUDING the backtick,
which would otherwise close the code block and let the rest render as markup.

The renderer has one rule: a scalar is read at a glance so it is printed, a
container is not so it is named by its size. A fact about the value's shape,
which holds for payloads nobody has seen — the alternative is a list of
interesting field names, wrong for every operation added after it was written.
Over the budget it says so and points at the console. It is NOT a second guard:
root scalars and the records in a list both print, so redaction is the only thing
standing there.

zip.Remote reads the context only before dialling and the https transport
declares no deadline, so the invoker applies the turn's deadline itself, where
the bridge pool slot is held. Without it one unanswered request would pin a slot
for the life of a socket and 32 would wedge the bridge for every org. The send is
left to finish into a buffered channel nobody reads — one goroutine and one
socket until the transport gives up, which is the honest limit of this side.

bridgeIdentity is lifted out of bridgeReply because two things now run on behalf
of a linked user and the link prompt carries a URL: a second copy of it is a
second chance to forget it is ephemeral.

The cost accepted: apps/integrations links plugin, so the binary carries the
4.5 MB of embedded subsets the host already carries. That is what reading the
whole fleet's registry without a network hop costs, paid at link time rather than
per request; the weave itself is lazy and once, and a fleet whose document will
not weave loses commands, not Slack.

Pins: the parse table (exact mutation, fuzzy GET, ambiguous, six shapes of prose
that open with a real command, help, an apostrophe, Slack's escaped entities,
quoted values, a quoted empty service — which is how the internal `/_/` plane is
addressed and the only way to type it); no prefix of a non-GET names it and every
non-GET names itself, over the fixture AND over all 2,328 real commands; ten
argument shapes that would retarget the path are refused and never leave the
process while an ordinary id goes through unchanged; the workspace org reaches
the front door; membership decides across six token shapes; a refusal is reported
in the service's own words and never as an outage; a credential is redacted at
the root and on a row; a payload cannot close the code fence; a cancelled context
returns rather than waiting on the socket; a refused grant is told from an outage
and says so; an unlinked turn produces the link prompt and writes nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 05:39:42 -07:00
hanzo-dev 62e19ca722 zip v1.25.2 — regenerating iam gives its 95 types their names back
CI/CD / image (push) Successful in 19m39s
CI/CD / gate (push) Successful in 1m19s
Hanzo CI/CD / cicd (push) Successful in 1m18s
CI/CD / containment (push) Successful in 1m22s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
`make describe` could not be run to completion and have its output committed,
which is the condition every stale artifact on main has grown behind. Not
because of the apps that refused it — those are fixed — but because the run
itself was not faithful: regenerating plugin/iam under v1.25.1 stripped the
`iam.` qualifier off all 95 of its schemas, and the bare `Role` that fell out
collided head-on with framework's. The weave refuses that, correctly, so the
one command told to repair the document produced a document that could not be
woven.

THE COMMITTED SUBSET WAS THE CORRECT SIDE, which is the opposite of the usual
direction and is the whole reason this is a dependency bump and not a
regenerate. `iam.Role` is the right name; bare `Role` was the defect.

zip qualifies a composed child's types by the child so that two children may
both honestly call a type Role, and it read that name off the ENCLOSING
definition — which for a route declared inside a group is a group, and
App.groupConfig strips a group's name on purpose, a group being a prefix and a
prefix not being an author. hanzoai/iam declares its whole surface in groups.

o11y is the control and it is what made this legible: it composes the same way,
registers on its child directly rather than through a group, and kept all 777 of
its o11y.* names through the very same regeneration that stripped iam's 95. Same
run, same binary, same toolchain, opposite answers — so the cause was in the
composition and never in the machine.

Fixed upstream in v1.25.2 (zap-proto/zip: "a group has no name, so it must not
answer for who wrote the op"), which carries the nearest NAMED ancestor down the
walk. A root's own groups are untouched, so a service that composes nothing
still names its own types exactly as it did.

Regenerated with the bump, and the delta is the proof: across all 119 subsets
the ONLY file that moves is plugin/iam, by 11 descriptions — no path, no
operationId and no schema name anywhere in the fleet. Those descriptions are
iam v1.34.5 → v1.34.20 catching up, and two of them are load-bearing: oauth/logout
now says it ACTUALLY ends the session (it spent a release answering
{"status":"ok"} without revoking anything), and authorize now documents
prompt=none|login|select_account.

  woven 1700 paths / 2083 schemas / 180 tags from 119 apps — byte-identical
  /v1/commands: 2328 commands over 2354 operations

go.sum also drops the entries for three superseded versions that a previous bump
left behind; `go mod tidy` is the only thing that touched it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 05:31:57 -07:00
hanzo-dev 724c3ffdc6 risk: a decision only the deciding process can see is not a control anyone can operate
Everything the risk plane writes down is its own. The observation record is
what a tenant TAUGHT its model; the policy record is what it STATED; both are
per-tenant files on this app's own shelf, reachable only through this app's own
ops. /risk/decide wrote neither — it is pure by contract — so a verdict existed
for the length of one plane call and then existed nowhere. Nothing joined any of
it to the traffic the organisation was already measuring, so the questions an
operator actually has could not be asked at all: did the gate start refusing the
hour the signup flow changed, is the shadow regime alerting on the campaign's
own traffic, which stage carries the refusals.

So each decision is ALSO stated on the plane the organisation already reads —
event.fact, filled by POST /v1/event, read by /v1/insights, grouped by every
product lens. One row per decide, in the same table as the pageview that led to
it, which is what makes "risk decisions beside product analytics" a join rather
than a project.

THE PLANE IS ASKED, NOT LINKED. analytics owns that table and the pod forks one
process per app, so its write core is in another pid — the shape this seam has
now met twice (cloud.SetRiskScorer, cloud.SetObsErrorIngest) and the answer is
the one both landed on: a plane op on the owning app's socket. analytics gains
event_capture, the WRITE side of the door obs_error_post already claims a slice
of, reaching the SAME write core every HTTP door reaches — one admission, one
normalizer, one storage projection. No new transport, no hop through the fleet's
front door, no second gate.

THE TENANT IS THE CALLER'S AT BOTH ENDS. The emitted row is filed for the tenant
the verdict was REACHED under, and the door mints its org from the plane
principal; the contract carries no field that could name one, so a peer can only
ever write into its own organisation's partition. An unidentified caller writes
nothing rather than defaulting to a tenant.

IT CANNOT FAIL THE DECISION. The gate that asks this scorer is the credit door,
so the emit happens after the verdict is computed, on a detached goroutine, with
its own budget, behind a ceiling that DROPS rather than queues. A queue defers
the loss instead of bounding it and makes this process's memory a function of
how fast the fleet is being screened; dropping says the true thing, which is
that a telemetry row is expendable and a decision is not. An absent peer, a
refusing one and a wedged one are the same fact here and none of them is the
verdict's.

WHAT TRAVELS IS BOUNDED, IN BOTH SENSES. The attributes are the stage, the
subject's kind, the action, the cause, the refusal, the model shape, the policy
version, the posture, and — on a scored answer only — the score and the cut it
was held against. Every value is server-minted, from a closed set the op already
refuses outside of, or narrowed before it travels: the country to alpha-2, and
the amount to which stated bound it was past rather than to itself. The subject
is a tenant-and-kind-scoped digest, so decisions about one subject still group
and the shared plane does not become a second copy of what the gate stated.

SHADOW IS EMITTED AND SAYS SO, which is the whole point of a shadow regime: what
the gate WOULD have done, on real traffic, changing nothing. No organisation is
armed and the default posture is untouched.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 04:48:44 -07:00
hanzo-dev 713c4f122e projections: two committed documents no longer match the source they were lifted from
CI/CD / image (push) Failing after 23m26s
CI/CD / gate (push) Successful in 13s
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 13s
Both gates in `make test` are red on main for reasons that predate this branch,
and both are the same failure: a derived artifact was not regenerated with the
source it derives from.

apps/projects/zipdoc_gen.go is missing the prose on POST /sites/live. The op was
added with its doc comment and Go drops comments at compile time, so the lift is
the only way that prose reaches the published document — the op ships described
by nothing.

openapi.yaml is missing the paragraph that says arming the risk model is an admin
act. It is present in plugin/risk/openapi.json, which is committed, so the golden
and the subset it is woven from disagree with each other in the tree. That
document is what the SDK repos pull.

Regenerated, not hand-edited: `go generate -run zipdoc ./apps/projects/...` and a
weave of the already-committed subsets. No source changed and no route moved.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 04:47:11 -07:00
hanzo-dev a4a47cad88 risk: a decision only the deciding process can see is not a control anyone can operate
Everything the risk plane writes down is its own. The observation record is
what a tenant TAUGHT its model; the policy record is what it STATED; both are
per-tenant files on this app's own shelf, reachable only through this app's own
ops. /risk/decide wrote neither — it is pure by contract — so a verdict existed
for the length of one plane call and then existed nowhere. Nothing joined any of
it to the traffic the organisation was already measuring, so the questions an
operator actually has could not be asked at all: did the gate start refusing the
hour the signup flow changed, is the shadow regime alerting on the campaign's
own traffic, which stage carries the refusals.

So each decision is ALSO stated on the plane the organisation already reads —
event.fact, filled by POST /v1/event, read by /v1/insights, grouped by every
product lens. One row per decide, in the same table as the pageview that led to
it, which is what makes "risk decisions beside product analytics" a join rather
than a project.

THE PLANE IS ASKED, NOT LINKED. analytics owns that table and the pod forks one
process per app, so its write core is in another pid — the shape this seam has
now met twice (cloud.SetRiskScorer, cloud.SetObsErrorIngest) and the answer is
the one both landed on: a plane op on the owning app's socket. analytics gains
event_capture, the WRITE side of the door obs_error_post already claims a slice
of, reaching the SAME write core every HTTP door reaches — one admission, one
normalizer, one storage projection. No new transport, no hop through the fleet's
front door, no second gate.

THE TENANT IS THE CALLER'S AT BOTH ENDS. The emitted row is filed for the tenant
the verdict was REACHED under, and the door mints its org from the plane
principal; the contract carries no field that could name one, so a peer can only
ever write into its own organisation's partition. An unidentified caller writes
nothing rather than defaulting to a tenant.

IT CANNOT FAIL THE DECISION. The gate that asks this scorer is the credit door,
so the emit happens after the verdict is computed, on a detached goroutine, with
its own budget, behind a ceiling that DROPS rather than queues. A queue defers
the loss instead of bounding it and makes this process's memory a function of
how fast the fleet is being screened; dropping says the true thing, which is
that a telemetry row is expendable and a decision is not. An absent peer, a
refusing one and a wedged one are the same fact here and none of them is the
verdict's.

WHAT TRAVELS IS BOUNDED, IN BOTH SENSES. The attributes are the stage, the
subject's kind, the action, the cause, the refusal, the model shape, the policy
version, the posture, and — on a scored answer only — the score and the cut it
was held against. Every value is server-minted, from a closed set the op already
refuses outside of, or narrowed before it travels: the country to alpha-2, and
the amount to which stated bound it was past rather than to itself. The subject
is a tenant-and-kind-scoped digest, so decisions about one subject still group
and the shared plane does not become a second copy of what the gate stated.

SHADOW IS EMITTED AND SAYS SO, which is the whole point of a shadow regime: what
the gate WOULD have done, on real traffic, changing nothing. No organisation is
armed and the default posture is untouched.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 04:47:11 -07:00
antje b733fca6e7 ai: say why deep_research is unwired — it is money, not plumbing
CI/CD / image (push) Successful in 17m29s
CI/CD / gate (push) Successful in 11s
Hanzo CI/CD / cicd (push) Successful in 11s
CI/CD / containment (push) Successful in 1m5s
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 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:38:01 -07:00
hanzo-dev dcfa933abc risk: a rule that reads one event cannot see what several events do
The rule beside the model reads the event in front of it: where the payer
acted from, and how much moves. Both are bounds on ONE payment, so the
way past them is arithmetic anyone can do — five payments of eleven
thousand instead of one of fifty-five. Every piece is under the bound by
construction, which is what splitting it is for, so no point-in-time test
can ever see it. The same blindness in the other direction: twenty
accounts sharing one device are each unremarkable taken alone, and the
pattern exists only in what they share.

Two more halves now answer beside the first, and both read the
organisation's OWN aggregates rather than the event. PACE is what one of
the event's identifiers — its subject, its counterparty pair, its device
— had already done inside the aggregates' narrowest window. FAN-OUT is
how many distinct subjects one device or one counterparty already ties
together, counted from that organisation's own record.

The value bounds are not restated. What one payment may not move, an hour
of payments may not move either: the accrued value is judged against the
same examining and freeze thresholds a single event is, so there is one
statement of appetite and two readings of it. Only the count needed a
bound of its own, and the freeze is a conjunction exactly as geography's
is — a busy hour alone is not a determination that anything is wrong, and
a shared identifier never freezes anything at all, because a household,
an office and a farm are the same shape from a count.

The window is TAKEN and never named. A rule that read a misspelled window
name would read zero, which is indistinguishable from a quiet subject, so
the burst window is the narrowest one the aggregates actually keep and a
ring set that keeps none is refused rather than answered with a zero.
Both bounds are stated where the geography rule's are, and the count is
held above what a fold alone puts in that window: at or under it, the
rule would fire on this organisation's own history the moment a residency
rebuilt.

The reading is the tenant's own, on both halves. The rings belong to one
resident and the record query carries the qualified tenant as its leading
predicate, so two brands' identically named organisations — which share
one shelf file — cannot see each other's. A reading that cannot be taken
is an error and never an empty one, because empty means "this subject has
done nothing", which is the answer that would silently allow.

Reading the aggregates is a bounded range scan of the record's own
covering index, measured against a record filled to its ceiling at 6.3 ms
— four per cent of the door's budget, and the reason there is no second
index to widen the record's published per-tenant bound.

Shadow is preserved whole. The findings are computed and recorded, the
outcome is left exactly where the model left it, and the cause says what
would have happened. No organisation is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 04:12:20 -07:00
hanzo-dev fef9142132 risk: a rule that reads one event cannot see what several events do
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 58s
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 rule beside the model reads the event in front of it: where the payer
acted from, and how much moves. Both are bounds on ONE payment, so the
way past them is arithmetic anyone can do — five payments of eleven
thousand instead of one of fifty-five. Every piece is under the bound by
construction, which is what splitting it is for, so no point-in-time test
can ever see it. The same blindness in the other direction: twenty
accounts sharing one device are each unremarkable taken alone, and the
pattern exists only in what they share.

Two more halves now answer beside the first, and both read the
organisation's OWN aggregates rather than the event. PACE is what one of
the event's identifiers — its subject, its counterparty pair, its device
— had already done inside the aggregates' narrowest window. FAN-OUT is
how many distinct subjects one device or one counterparty already ties
together, counted from that organisation's own record.

The value bounds are not restated. What one payment may not move, an hour
of payments may not move either: the accrued value is judged against the
same examining and freeze thresholds a single event is, so there is one
statement of appetite and two readings of it. Only the count needed a
bound of its own, and the freeze is a conjunction exactly as geography's
is — a busy hour alone is not a determination that anything is wrong, and
a shared identifier never freezes anything at all, because a household,
an office and a farm are the same shape from a count.

The window is TAKEN and never named. A rule that read a misspelled window
name would read zero, which is indistinguishable from a quiet subject, so
the burst window is the narrowest one the aggregates actually keep and a
ring set that keeps none is refused rather than answered with a zero.
Both bounds are stated where the geography rule's are, and the count is
held above what a fold alone puts in that window: at or under it, the
rule would fire on this organisation's own history the moment a residency
rebuilt.

The reading is the tenant's own, on both halves. The rings belong to one
resident and the record query carries the qualified tenant as its leading
predicate, so two brands' identically named organisations — which share
one shelf file — cannot see each other's. A reading that cannot be taken
is an error and never an empty one, because empty means "this subject has
done nothing", which is the answer that would silently allow.

Reading the aggregates is a bounded range scan of the record's own
covering index, measured against a record filled to its ceiling at 6.3 ms
— four per cent of the door's budget, and the reason there is no second
index to widen the record's published per-tenant bound.

Shadow is preserved whole. The findings are computed and recorded, the
outcome is left exactly where the model left it, and the cause says what
would have happened. No organisation is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 04:10:52 -07:00
hanzo-dev 9368213918 risk: no composition root links both the scorer and the abuse gate
apps/commerce installs the risk scorer; apps/gateway arms its fleet-wide
abuse gate on cloud.RiskScorerInstalled() and refuses mode=live while that
reads false. As composed, plugin/commerce links commerce alone and
plugin/gateway links gateway alone, so the gateway process sees no scorer
and the gate cannot be armed. That refusal is the fail-safe direction and it
is currently unconditional — which is what makes it fragile: nothing
anywhere fails when it stops being true.

One import into either root would make that predicate answer true in the
process that arms the gate, and the gate would be armed on a co-residency
accident rather than on the question arming asks — whether the risk plane
can answer for the FLEET, which no in-process global settles.

So the invariant is checked structurally, over every `package main` under
plugin/ and cmd/, following first-party imports transitively because linking
is transitive. Anti-vacuity assertions require the scan to actually reach
each app, so a renamed package or a moved root fails the gate instead of
silently passing it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:39:34 -07:00
hanzo-dev f5649816ef risk: a jurisdiction listing with no date decides nothing
The operator's listing won on MEMBERSHIP alone, so one stated without an
`as_of` went in force whole — and reference.Jurisdictions then refused every
country on it, correctly, because "not listed" from a listing of unknown
currency is not a fact. onGeography swallows that refusal by design, so the
ACTION tier became unreachable, the freeze the rule exists for silently
vanished, and what remained — review at or past the freeze value — looks
exactly like a rule that is working. Nothing logged it: the error that
mattered was consumed per country.

A date is now part of what makes an operator listing usable, checked where
the listing is chosen, and an unusable one loses to the dated compiled
default instead of disarming the half of the rule it was stated to arm.
Unset and malformed are unchanged — both already resolved to the empty
listing and fell to that same dated default.

Choosing is split from reading the environment: resolve is a rule over a
stated listing and can be exercised against every shape one can take, where
a value resolved once at first use cannot be. The reason travels out on
listing.Gap rather than being logged from inside the resolution, because a
control that switched itself off has to be visible from OUTSIDE the process
— Mount says it at startup and /v1/risk/health keeps saying it, beside the
listing's date and age.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:39:34 -07:00
hanzo-dev 1a9bfd840b risk: taking a model live is an act for an admin of that organisation
Arming and tuning arrive at one address in one body, and only one of them is
self-service. PUT /v1/risk/policy was gated by the billing check and the
tenant guard, neither of which asks about authority, so any member of an
organisation could state {"live":true} and take its model out of shadow —
the decision that lets the model freeze that organisation's customers'
payments, taken by whoever held a token.

Setting `live` now requires cloud.Admin: an admin OF THAT ORG, with
SuperAdmin as the stated superset rather than the requirement, because the
organisation is arming ITSELF and requiring platform sudo would make every
customer's governance decision ours to take. Stating the appetite and the
sample stays self-service.

The check reads the platform's one predicate set and lives beside ops.gate
and caller, which is where this package is allowed to reach for the raw
request. It runs after the principal resolves and before anything is
written, so an unauthorised caller learns nothing about which appetites the
op accepts — the ordering ops.adopt already makes.

Disarming is deliberately left self-service: returning a model to shadow
cannot freeze a payment.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:39:34 -07:00
hanzo-dev 15e839a6eb risk: the credit door tells an outage from a decision
Two facts reached the same answer at the self-serve credit door, and each
one reached the wrong one.

A scorer socket that cannot be DIALLED read as a scorer that is not there.
plane.Listening separates three facts — no listener, a listener, and a
socket present but unusable — and scorerUp folded the third into the first
by discarding the error. Absence is the one refusal the fail policy exempts,
so under fd exhaustion (EMFILE), a denied run directory (EACCES) or an
over-long one (ENAMETOOLONG) the probe answered "no scorer here" in
microseconds, against a scorer that was healthy, and a privileged top-up
settled unscored. The budget never caught it because nothing was slow.
scorerUp now returns the error and the seam refuses on it, so a genuine
absence still allows and an outage blocks.

A payment a RULE froze read as a scorer that had not answered. Since the
rule and the model were fused, Action and Refusal are independent: an armed
org with a warming model on a frozen payment answers {restrict, "warming"},
and a gate branching on the refusal alone called that a 503 "try again in a
moment" — inviting the retry that settles the payment it had just frozen.
The pair is the discriminator: riskUnavailable is the only producer of a
block that carries a refusal, and the scorer's own vocabulary tops out at
restrict, which TestActions_TheScorerNeverBlocks now holds closed
structurally.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:39:34 -07:00
hanzo-dev 6739b4b497 risk: no composition root links both the scorer and the abuse gate
CI/CD / image (push) Successful in 23m41s
CI/CD / gate (push) Successful in 12s
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / containment (push) Successful in 59s
CI/CD / rollout (push) Failing after 5s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
apps/commerce installs the risk scorer; apps/gateway arms its fleet-wide
abuse gate on cloud.RiskScorerInstalled() and refuses mode=live while that
reads false. As composed, plugin/commerce links commerce alone and
plugin/gateway links gateway alone, so the gateway process sees no scorer
and the gate cannot be armed. That refusal is the fail-safe direction and it
is currently unconditional — which is what makes it fragile: nothing
anywhere fails when it stops being true.

One import into either root would make that predicate answer true in the
process that arms the gate, and the gate would be armed on a co-residency
accident rather than on the question arming asks — whether the risk plane
can answer for the FLEET, which no in-process global settles.

So the invariant is checked structurally, over every `package main` under
plugin/ and cmd/, following first-party imports transitively because linking
is transitive. Anti-vacuity assertions require the scan to actually reach
each app, so a renamed package or a moved root fails the gate instead of
silently passing it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:36:03 -07:00
hanzo-dev 03e99b322b risk: a jurisdiction listing with no date decides nothing
The operator's listing won on MEMBERSHIP alone, so one stated without an
`as_of` went in force whole — and reference.Jurisdictions then refused every
country on it, correctly, because "not listed" from a listing of unknown
currency is not a fact. onGeography swallows that refusal by design, so the
ACTION tier became unreachable, the freeze the rule exists for silently
vanished, and what remained — review at or past the freeze value — looks
exactly like a rule that is working. Nothing logged it: the error that
mattered was consumed per country.

A date is now part of what makes an operator listing usable, checked where
the listing is chosen, and an unusable one loses to the dated compiled
default instead of disarming the half of the rule it was stated to arm.
Unset and malformed are unchanged — both already resolved to the empty
listing and fell to that same dated default.

Choosing is split from reading the environment: resolve is a rule over a
stated listing and can be exercised against every shape one can take, where
a value resolved once at first use cannot be. The reason travels out on
listing.Gap rather than being logged from inside the resolution, because a
control that switched itself off has to be visible from OUTSIDE the process
— Mount says it at startup and /v1/risk/health keeps saying it, beside the
listing's date and age.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:35:47 -07:00
hanzo-dev ee5f8ade2b risk: taking a model live is an act for an admin of that organisation
Arming and tuning arrive at one address in one body, and only one of them is
self-service. PUT /v1/risk/policy was gated by the billing check and the
tenant guard, neither of which asks about authority, so any member of an
organisation could state {"live":true} and take its model out of shadow —
the decision that lets the model freeze that organisation's customers'
payments, taken by whoever held a token.

Setting `live` now requires cloud.Admin: an admin OF THAT ORG, with
SuperAdmin as the stated superset rather than the requirement, because the
organisation is arming ITSELF and requiring platform sudo would make every
customer's governance decision ours to take. Stating the appetite and the
sample stays self-service.

The check reads the platform's one predicate set and lives beside ops.gate
and caller, which is where this package is allowed to reach for the raw
request. It runs after the principal resolves and before anything is
written, so an unauthorised caller learns nothing about which appetites the
op accepts — the ordering ops.adopt already makes.

Disarming is deliberately left self-service: returning a model to shadow
cannot freeze a payment.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:35:16 -07:00
hanzo-dev bae3cff236 risk: the credit door tells an outage from a decision
Two facts reached the same answer at the self-serve credit door, and each
one reached the wrong one.

A scorer socket that cannot be DIALLED read as a scorer that is not there.
plane.Listening separates three facts — no listener, a listener, and a
socket present but unusable — and scorerUp folded the third into the first
by discarding the error. Absence is the one refusal the fail policy exempts,
so under fd exhaustion (EMFILE), a denied run directory (EACCES) or an
over-long one (ENAMETOOLONG) the probe answered "no scorer here" in
microseconds, against a scorer that was healthy, and a privileged top-up
settled unscored. The budget never caught it because nothing was slow.
scorerUp now returns the error and the seam refuses on it, so a genuine
absence still allows and an outage blocks.

A payment a RULE froze read as a scorer that had not answered. Since the
rule and the model were fused, Action and Refusal are independent: an armed
org with a warming model on a frozen payment answers {restrict, "warming"},
and a gate branching on the refusal alone called that a 503 "try again in a
moment" — inviting the retry that settles the payment it had just frozen.
The pair is the discriminator: riskUnavailable is the only producer of a
block that carries a refusal, and the scorer's own vocabulary tops out at
restrict, which TestActions_TheScorerNeverBlocks now holds closed
structurally.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 03:34:57 -07:00
hanzo-dev cded0a8127 risk: a payment is judged by a rule when the model has no opinion
A fresh account's first top-up is the one the model can never have an
opinion about: it has no history, so it is warming, so it declines, so
the credit door allowed it unscored. Ten million dollars from a
jurisdiction no anti-money-laundering supervision reaches is not
anomalous — it is unprecedented, which scores as nothing.

A second judge now answers beside the model, reading two stated facts:
the jurisdiction the payer acted from and the value moving. It does not
read the score, the cut or whether the model has warmed, so a refusing
model cannot soften it; the fused verdict is the severer of the two, and
a model with no opinion contributes an allow.

The jurisdiction listing is the one the screening engine already
maintains, in two tiers because the required response differs: a tier
called for countermeasures may freeze, a tier under monitoring may only
summon a person. A formal designation stays the AML plane's to make.

The gate states the jurisdiction our own edge resolved, under the same
trust rule the client address already uses — a direct caller's header is
the caller's own writing and is refused. The billing jurisdiction is the
one worth judging and no part of it reaches this process; the weaker
signal is documented as weak and replaced at one line when there is one.

Shadow is preserved whole. The finding is computed and recorded, the
outcome is left exactly where the model left it, and the cause says what
would have happened. No organisation is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 02:45:52 -07:00
hanzo-dev 074b11d984 risk: a payment is judged by a rule when the model has no opinion
CI/CD / image (push) Successful in 18m11s
CI/CD / gate (push) Successful in 1m28s
Hanzo CI/CD / cicd (push) Successful in 1m27s
CI/CD / containment (push) Successful in 1m19s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 1s
A fresh account's first top-up is the one the model can never have an
opinion about: it has no history, so it is warming, so it declines, so
the credit door allowed it unscored. Ten million dollars from a
jurisdiction no anti-money-laundering supervision reaches is not
anomalous — it is unprecedented, which scores as nothing.

A second judge now answers beside the model, reading two stated facts:
the jurisdiction the payer acted from and the value moving. It does not
read the score, the cut or whether the model has warmed, so a refusing
model cannot soften it; the fused verdict is the severer of the two, and
a model with no opinion contributes an allow.

The jurisdiction listing is the one the screening engine already
maintains, in two tiers because the required response differs: a tier
called for countermeasures may freeze, a tier under monitoring may only
summon a person. A formal designation stays the AML plane's to make.

The gate states the jurisdiction our own edge resolved, under the same
trust rule the client address already uses — a direct caller's header is
the caller's own writing and is refused. The billing jurisdiction is the
one worth judging and no part of it reaches this process; the weaker
signal is documented as weak and replaced at one line when there is one.

Shadow is preserved whole. The finding is computed and recorded, the
outcome is left exactly where the model left it, and the cause says what
would have happened. No organisation is armed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 02:43:40 -07:00
hanzo-dev 06c672b9e5 deps: Domain was never one value, and three apex derivations disagreed
`Deps` braided capabilities (a KMS client — a thing you USE) with policy (a
domain, a default model — a thing you DECIDE). Different owners, lifetimes and
failure modes: a client is wrong when it cannot connect; a policy is wrong when
it says the wrong thing, silently.

THE HEADLINE: `Domain` answered two questions. 18 readers want THIS PROCESS'S
API host; 5 want THE DEPLOYMENT'S APEX. The build outage was a reader in the
second group reading a field built for the first — selfGitHost = deps.Domain =
"api.hanzo.ai", so the forge at git.hanzo.ai (a SIBLING) was refused and every
native build fell back to GitHub.

dc84b46d fixed ONE derivation. There were THREE, with three algorithms:
  platform.apexOf          publicsuffix
  sites.registrableDomain  last-two-labels
  git.defaultSSHHost       TrimPrefix "api."
They diverge on 3 of 7 realistic hosts. defaultSSHHost("cloud.hanzo.ai") yields
git.cloud.hanzo.ai, which platform's allowlist then REFUSES — the identical
outage, still armed, needing only a different CLOUD_DOMAIN. And sites fed a bare
multi-label suffix (co.uk) into SelfDomains, claiming every domain under it.
All three now call brand.Apex / brand.Sibling — one implementation in the only
leaf all four consumers reach.

TWO MORE LIVE DEFECTS, measured:
  IAMIssuer had TWO answers. With CLOUD_BRAND=lux and nothing pinned,
  Config.IAMIssuer = "https://lux.id" while the package-level IAMIssuer()
  read env raw with no brand fallback and returned "". Empty flows to IAMBase(),
  which the sk- key resolver dials; unresolvable -> nil -> EVERY API-KEY REQUEST
  AUTHENTICATES AS ANONYMOUS, silently, while JWT keeps working.

  Domain was brand-blind where IAMIssuer was not: unpinned lux resolved
  issuer=lux.id alongside domain=api.hanzo.ai.

DELETED, not migrated — no value gained a second home:
  AIFallbackModel  1 reader, set by nobody, ever -> cloud.FallbackModel
  AIDefaultModel   -> cloud.DefaultModel; universe sets enso-flash and the
                   constant IS enso-flash, so behaviour is preserved. This made
                   agents.go's "model is required" branch provably dead.
  Self             ZERO readers, its whole life. selfID(cfg) is real and stays.
  chart iamIssuer  a value in NO brand row, rendered unconditionally, beating
                   the derivation on every chart deploy.
  the "api.hanzo.ai" literal in apps/sites -> brand.APIHost, the same root
  Config uses.

KEPT, each with a written justification in deps.go: Brand (whose deployment
this is, fixed at exec), Domain (this process's own API host — now documented as
NEVER the apex), IAMIssuer (the trust root; verifying identity precedes any
store you would read it from), Version/Env (boot facts).

deps_policy_test.go reflects over Deps and FAILS on any field without a
rationale, names the three evicted fields with why, and proves the env knobs are
inert — so a policy field cannot quietly re-enter through the dependency door.

build ./... 0, vet ./... 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:54:12 -07:00
hanzo-dev bae7523fce git: answer the status and mirror ops, and stop the handlers calling back through cloud
The server halves for git_status and git_mirror, beside the import and inbound
ops already published here. Same rules as the others: the org is the caller's
plane identity, an anonymous caller is refused, an unmounted store answers 503.
A repo git does not know is simply absent from the reply, which is a real answer
and reads as not-imported.

It also closes a loop that was latent in the two existing handlers. planeInbound
and planeImport called cloud.InboundGitSync and cloud.ImportGitRepo, which now
fall through to the plane when the in-process importer is nil — so a handler
routing through them could dial its own socket and answer itself. It cannot
today, because Mount registers the importer before publishing the op, but that is
an ordering nobody is checking and exactly how the loop arrives later. Both now
call githubImporter directly, like the sync and tracker handlers.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:40:45 -07:00
hanzo-dev 68d70cc367 drop the "seam" vocabulary, and shorten the names
Renames only. No behavior changes.

"Seam" named the thing badly. Every one of these is just one app calling
another, or a process calling itself, and saying so plainly is shorter than the
jargon was:

  sync_seam.go             -> sync.go
  tracker_seam.go          -> tracker.go
  seam_absence_test.go     -> absent_test.go
  seamClassification       -> kinds
  crossApp / inProcess     -> remote / local
  absentSeamCalls          -> calls
  seamsInSource            -> registers
  clearSeams               -> unregister
  upsertIssueSeam          -> upsertIssue
  TestCrossAppSeamAbsenceIsAnError -> TestAbsentErrors
  TestAbsenceIsDistinguishable     -> TestAbsentIsNoPeer
  TestEverySeamIsClassified        -> TestAllListed

roundtrip_test.go is new and is the missing half of the argument. absent_test.go
shows a call errors when the app is gone, which on its own a call that always
failed would also satisfy. This stands the app up on a real socket and shows the
same call arrives with the caller's org and comes back with the app's answer —
through the public functions, not through Ask, so the op name, the wire types
and the org forwarding are all under test. Any of them drifting would leave
absent_test.go green and production broken.

The word survives in files this change does not own; it is house vocabulary
there and churning them would collide with work in flight.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:37:08 -07:00
hanzo-dev 3ba3f5e511 meet: an account-less token is no offer either
The last way the lobby and the mint could still disagree. mint refuses a token
carrying no account — the account IS the identity the seat is taken under — so a
lobby that offered a workspace off one would show a room, take the choice, and
then decline it. token.Generate cannot mint that shape, so this is not an
attacker's path; it is the invariant written down where the offer is made rather
than assumed from where it is granted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:29:29 -07:00
hanzo-dev 9335b8e21b meet: the call client is ours too, served from the same binary as the token
The media server (LiveKit, live.hanzo.bot) and the join-token mint were already
native. The CLIENT was the office plugin inside a forked Team front — a whole
product's UI carrying one screen — so a Hanzo call was ours everywhere except
the part people look at. This embeds the native client in meet's own binary and
serves it at /meet, beside the /v1/meet it reads.

ONE ORIGIN is the point, not a convenience: the lobby's read is a credentialled
same-origin GET, so there is no CORS grant to make, no second host to hold a
session on, and no bundle carrying an API address it could be pointed away from.
Same shape apps/tasks already has.

THE LOBBY NEEDED A FACT NOBODY PUBLISHED. A room is bound to its tenant by the
leading segment of its name — apps/meet refuses to mint unless the caller holds a
privileged member row in exactly that workspace — so a client that does not know
its own workspace cannot compose a room name any lane would admit, and a uuid is
not something a person types. Two additions, and no new authority:

  plane.TeamWorkspaces — memberOf asked the other way round, over the same rows
  and the same three refusals, so the two can never disagree about who is a
  member of what. It is a person's OWN memberships, never a workspace's roster.

  GET /v1/meet/session — the identity a seat would be taken under, the media
  address, and the workspaces this caller may open a room in, already narrowed by
  the SAME predicate the mint admits on. The offer and the grant come from one
  rule or they drift; a test holds them together across every role.

It is UNTYPED for a reason that is this app's alone: the gate is principal.Minted,
the boundary's own attestation, and a typed op holds a context rather than a
request. The context-side facts a typed op can read are header-derived, and in a
hand-written plugin main nothing strips those — which is exactly the forgeable
signal admits was fixed to stop selecting on.

An unreachable team is a REFUSAL, not an empty list: "you have no workspaces" is
a lie when the truth is "the authority is down", and it sends someone to ask for
an invite they already have.

LIVEKIT_WS is served rather than compiled in, because it is a deployment fact and
the bundle is a build artifact — baked in, a dev cluster's UI dials production.
Unset is an empty string the client acts on, not a 503: the published office
client supplies its own address, so this degrades the native UI and nothing else.

spa/ is the fifth copy of one SPA handler collapsed into the first. tasks and
research had it byte-identical and meet would have made three; the policy in it
(immutable hashed assets, index fallback for a deep link, 503 for an unsynced
bundle) is not obvious enough to be worth restating, and a policy restated drifts.
Both existing suites pass against it unchanged. webui stays its own handler — it
owns "/" and has to refuse the API namespaces and rewrite the title per brand.

manifest names /meet and the whole /v1/meet subtree. Listing each leaf was a list
that had to be edited every time a route was added, and an unnamed leaf falls to
whichever row holds the bare remainder.

The test helper `session` became `workspaceToken`, which is what it always made.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:27:33 -07:00
hanzo-dev 7ee4842d67 tracker: serve the board — the UI the /v1/tracker surface exists for
Embeds the Hanzo Tracker SPA (hanzoai/admin apps/admin-tracker) and serves it at
/tracker/*, beside the /v1/tracker surface it reads. One binary, one origin, one
deploy — which is what lets tracker.hanzo.ai answer with a native board and
retire the Huly tracker that answers it today.

Same origin is the design, not a convenience. The SPA sends NO tenancy of its
own: the composer's identity check mints the validated org from the IAM session
before either half runs, so the page carries a session cookie and nothing else.
A UI on a second host would have to hold a token and name an org, which is
precisely the client-supplied tenancy this surface refuses.

The routes stay untyped and always will: they answer HTML and hashed assets
under their own content types and cache hints, plus index.html for every path
the client router owns, and a typed op publishes JSON.

embed_test.go pins the two failures a bad sync makes SILENT rather than loud:
a bundle built for the wrong base resolves every chunk to a path nothing serves
(a blank page, not an error), and the API prefix is inlined at build time, so a
bundle built against a dev proxy renders and then talks to a surface this binary
does not answer.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:13:29 -07:00
hanzo-dev 2b039d66be tracker: a work item's schedule is an interval on the one row, not a milestone table
The board reads Status; a timeline reads dates, and the tracker had none. Adds
StartAt/DueAt (unix seconds, 0 = unset) as mutable board state on Issue, plus
one filter — Scheduled — selecting the rows a gantt has somewhere to draw.

No milestone table, because a milestone is not a second kind of thing: it is
this row with a due date and no start, an interval of zero length. A dated epic
is a phase and its children are already reachable by ExtRef. The timeline stays
what contract.go says every work-item surface is — a FILTER over the one table
— so the board and the timeline cannot drift.

The interval is validated at the boundary and refused rather than normalised: a
negative bound, or an end before its beginning, is 400. The PATCH validates the
RESULTING pair, so moving only the due date is still checked against the start
already stored.

Forward-added the way the polymorphic spine was: ADD COLUMN with defaults after
the base DDL, its index after the columns, so a live tracker.db migrates without
a crashloop and every existing row reads as unscheduled.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:45:43 -07:00
hanzo-dev a90253de1a sync, tracker: answer the reconcile and upsert ops on the plane
The server halves for sync_run and tracker_upsert. Same callee pattern as
apps/git/files.go: the org is the CALLER's plane identity, an anonymous caller is
refused rather than defaulted, and an unmounted store answers 503 instead of
inventing a reply.

Each handler calls the LOCAL implementation directly — reconcileEvent and
upsertIssueSeam — never cloud.Sync or cloud.UpsertIssue. Those now fall through to
the plane when the in-process seam is nil, so a handler routing back through them
would let the process serving the op dial its own socket. The doc comments say so,
because the fall-through is what makes that the non-obvious failure mode.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:39:48 -07:00
hanzo-dev b0b3623a35 projects, platform: answer the three ops whose callers were just moved onto the plane
The client halves landed in the previous commits; without these the calls reach a
live app and get "unknown op", which is louder than the silence but still not the
capability. These are the server halves.

  projects_ownership  the identity boundary asks who owns a claimed project
  platform_push       a landed push becomes a build
  platform_release    a proven image is patched onto its Service CR

Each follows the callee pattern already in the tree (apps/git/files.go,
apps/projects/key_rpc.go, apps/platform/rpc.go): the org comes from the CALLER's
plane identity, an anonymous caller is refused rather than defaulted, and an
unmounted store answers 503 instead of inventing a reply.

projects_ownership REFUSES when this process does not hold the project store,
rather than answering "nobody owns it". That distinction is the whole defect in
one line: a process that cannot look does not know, and the boundary reads
"not owned by another org" as permission to forward the claim.

exposePush reads the `mounted` global at call time instead of capturing the
service, matching the push builder registered directly above it — Shutdown sets
that global to nil and a captured pointer would keep building out of a torn-down
store.

plane.Built loses its speculative Matched field. Nothing consumes it and
buildFromPush does not return the count, so producing it would mean widening a
signature with six test call sites to compute a value no caller reads. ZAP fields
append at the end, so it can arrive the day something needs it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:38:14 -07:00
hanzo-dev 8045a16fa0 test: a cross-app read of an unmounted app must ERROR, and must keep having to
Nothing caught this defect class, which is why it kept shipping. Two tests, one
to check the rule and one to keep it checked.

TestCrossAppSeamAbsenceIsAnError calls every cross-app seam with nothing
registered, no socket and no router — the state a process is in when the owning
app is simply not beside it — and fails any that returns nil. Before this change
OnGitPush and OnServiceRelease returned nil there; that is now a failing test
rather than a passing one.

TestAbsenceIsDistinguishable additionally requires the error to wrap ErrNoPeer,
because "this app is not deployed here" and "it is here and the call failed" need
opposite responses, and a fleet that cannot tell them apart has already shipped
that mistake in both directions.

TestEverySeamIsClassified is the part that outlives today. It parses this package
for `func Register*` and fails if one is missing from seamClassification, so a new
seam cannot be added without its author recording in writing whether it crosses a
process boundary. A seam classified crossApp must also appear in absentSeamCalls
or the test fails as well — a classification that nothing exercises is a claim,
not a guarantee. Adding a silent seam now requires deleting a test that says not
to.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:56 -07:00
hanzo-dev a5f1c6d060 identity: the cross-org project guard was off everywhere it was installed
projectIsForeign refused an X-Project-Id registered to ANOTHER org. It consulted
a registry list that `projects` fills at its Mount, and documented the empty case
as safe: "with no registry mounted, nothing is ever foreign, so the guard is a
no-op passthrough (an unmounted registry owns nothing)".

No registry is EVER mounted where this runs. The boundary is edge middleware in
every process; the registry belongs to projects, which runs as its own plugin. So
the list was empty everywhere, the guard concluded "not foreign" for every value,
and the cross-org project impersonation check was off fleet-wide. Measured with
projects absent: projectIsForeign(acme, "someone-elses-project") = false. Its own
tests passed because they exercised only the co-resident case.

Absence of an ANSWER may never read as permission. The registry is now ASKED over
the plane, and the outcomes stay distinct rather than collapsing into one bool:

  mine                  keep    provably the caller's own
  other                 refuse  a cross-org claim
  neither, no error     keep    an unregistered within-org label, still free-form
  ErrNoPeer             keep    no projects app exists anywhere in this fleet, so
                                no identifier is registered and none CAN be
                                foreign — the one case the old passthrough had
                                right, now proven instead of assumed
  any other error       REFUSE  fail closed, as before

ProjectOwnership is the exported total form: co-resident registries answer
directly, otherwise projects is asked. It cannot decline to look — the only way
it yields no answer is by returning an error.

Cached per (org, project) on a detached context with a short TTL, the same shape
as the scope-rule read in middleware_ratelimit.go: this runs on every request
that asserts a project, and a client disconnect must not poison the cache.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:25 -07:00
hanzo-dev 00932e361d build: a push that triggers no build, and a release that patches no CR, must say so
OnGitPush and OnServiceRelease returned a NIL ERROR when nothing was registered.
Not a wrong value — the absence of one. The push lands on git's embedded server
and the builder belongs to platform; the proving build and the Service CR control
plane are likewise different apps. Different apps are different processes, so
"nothing registered" was the state of every caller that ever ran, and every push
and every release reported success having done nothing. Measured, with the owner
absent: both returned nil.

A test asserted this. TestOnServiceReleaseNoop proved "the dispatch seam is a safe
no-op" and passed for as long as it existed; the no-op was never safe. It is
replaced by its opposite, plus a case proving the co-resident leg still wins so a
local releaser never pays a socket round trip to reach itself.

ServiceReleaserRegistered() is DELETED. A bool cannot carry "the app is
elsewhere": it read false both for a fleet with no control plane and for the
ordinary split fleet where platform is the next process over. apps/deploy turned
that false into a 503 that told operators the release plane did not exist while it
was up and reachable — a refusal whose stated cause was not the cause. Presence is
now answered by making the call, which is the only moment it is knowable at all,
since the router may start a lazy app on demand.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:10 -07:00
hanzo-dev cd72bdede0 plane: git status/mirror, sync and tracker cross the boundary they were nil across
Four seams answered "not registered" for a capability running in the process next
door. Every plugin main mounts exactly ONE app and cmd/cloud mounts none, so the
registrant and the caller are never the same process: each seam was nil on
precisely the path that had work to do.

  GitRepoStatuses  the console repo list draws in integrations; the repos are
                   git's, so every repo rendered as never-imported
  EnsureGitMirror  the sync engine declares the target; git owns the repo that
                   pushes it, so configuring a mirror reconciled inbound forever
                   and pushed nothing back
  Sync             webhooks land on integrations and pushes on git; the engine is
                   the sync app, so no trigger could ever reach it
  UpsertIssue      integrations holds the GitHub App; the tracker owns the store,
                   so every mirrored issue and backfill row was refused

Each now takes the leg ImportGitRepo already took: the in-process call when the
owner is co-resident, the plane when it is not. No new mechanism — Ask is the one
mechanism and these are four more callers of it.

The Err*Unavailable sentinels stay for the case they always described honestly: a
registration this process was supposed to have and does not.

A map cannot cross ZAP, so the repo statuses travel as a slice whose rows carry
their own name.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:33:10 -07:00
hanzo-dev 7bee6de9ef event: prove every signal reaches its own table, and restore the door's prefix
The plane had no proof that event.log and event.span were reachable at all — the
reads had been repointed onto them while nothing was shown to fill them.
TestLiveEverySignalLandsInItsOwnTable drives one fact of each signal through the
SAME door and asserts each landed in exactly one table, with the columns that
table's ORDER BY is built on: service on the log, trace_id on the span, and a
shared trace across both, which is the correlation the identical envelope buys.

Two things it found:

The analytics manifest row had lost /v1/event. The row is the fleet router now,
so a missing prefix is not a tidier list — it is every beacon in the fleet
falling through to a bare /v1 catch-all that does not serve it. Restored with the
ingest doors named, and the comment says why they are load-bearing.

The round-trip readback waited for NINE groups and then asserted TEN rows. The
poll returned the moment the ninth distinct name landed and the tenth row was
simply not there yet — reproducible under load, invisible when the drain was
fast. Waiting for a smaller number than the assertion checks is a race, not a
shortcut; it now waits for what it asserts. The signal test had the same shape
and now awaits each table before counting it, because landing is a consumer and
a count taken the instant a sibling landed asserts the synchronous insert this
design removed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 18:01:15 -07:00
hanzo-dev 3b49870bca event: one plane for every fact, and the writes that reach it
An event is the FACT; a message is the container it travels in. The schema now
says so: one database named for what it holds, one table per KIND of fact, and
the subject a fact travels on carries the same name as the table it lands in.

  event.event   what someone did (kind = track | page | identify | group)
  event.error   what broke, grouped for issue lists
  event.log     what a service said
  event.span    what a service did, and how long
  event.metric  a sample; event.series the dimension it resolves through

The first fifteen columns are identical across event/error/log/span, so a
cross-kind read is a UNION ALL and not a translation layer. org leads every sort
key, so a single-tenant read is a primary-key seek. This retires three schemes at
once: a brand (hanzo.*), a numeronym (o11y_*), and two product names used as a
storage layer (insights, analytics).

Ingest is a bus, not a handler. The door accepts, normalizes, authorizes and
enriches, then publishes; fan-out happens on JetStream so adding a consumer never
touches the ingest path. Limits retention, one durable pull consumer per sink,
commit-the-sink-then-ack, idempotent on event.id because an unacked message is
redelivered. The subject carries taxonomy, never a tenant.

Two writes that could not land:

SETTINGS rendered AFTER VALUES in every warehouse INSERT. The Values input format
reads everything after VALUES as data, so a trailing SETTINGS is not a setting —
it is a row the parser cannot read, and the store answered
CANNOT_PARSE_INPUT_ASSERTION_FAILED for all four tables while the door still
returned 200 and the shape test still passed. Silent total loss behind a green
receipt. Rendered before VALUES now, with a test that pins the ordering rather
than the prefix, because a prefix and a placeholder count are both satisfied by a
statement the store refuses.

The Guide's funnel and marketing's cohorts still named the old table. They would
have gone on reading a table that no longer takes writes the moment the writer
moved — the same failure that left the observability reads pointed at databases
that had been dropped. Both now name apps/datastore's constant, so the plane has
one home for its names and a rename cannot leave a reader behind.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 17:42:22 -07:00
hanzo-dev 890c96002b event: the fact, the stream, and the writer that lands it
The unified telemetry plane had a schema and no writer. event.log and event.span
have sat at 0 rows since the databases were created, because nothing in any
shipped binary knew how to fill them — these three files existed only as
untracked working-tree state and had never been committed, built, or run.

  fact.go       the fact. ONE envelope (org, time, id, name, kind, product,
                session/distinct/anonymous/person, url, path, attributes, el)
                plus what each signal adds. kind is a COLUMN, not a table:
                event.event carries track|page|identify|group, so a caller verb
                never becomes a schema decision.
  bus.go        publish to the EVENT stream, subject per signal (event.<kind>).
                LimitsPolicy, not WorkQueue — warehouse, alerts and replay are
                independent consumers and each needs its own copy.
  warehouse.go  durable pull consumer, one per table, ACK only AFTER the insert
                commits, idempotent on event.id, so a redelivery cannot double
                count and a crash cannot silently drop.

Org rides in the signed envelope, never in the subject, so wildcard
subscriptions stay stable and tenancy stays an authorization concern.

Committed alone, out of a tree carrying 551 files of other in-flight work.
Builds and vets clean on its own package.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 17:31:44 -07:00
hanzo-dev bf720faee1 manifest: correct a false claim I made about provisioning's /v1/s3
The prior comment said provisioning 'registered nothing there'. That is wrong:
provisioning.go:431-434 registers POST /v1/s3, GET /v1/s3, and GET+DELETE
/v1/s3/:name as the s3 slice of its 7-kind loop. Adversarial verification of the
doc sweep caught it.

Behaviour is unchanged either way — storage also claims bare /v1/s3 and won the
duplicate-pattern merge, so those four were already unreachable when declared.
But the comment asserted a fact about the code that is not true, and the four
dead routes are a real open question (provisioning creates S3 instances, storage
performs S3 operations) rather than a non-issue.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-29 15:34:45 -07:00
hanzo-dev 77e040ef90 manifest: one owner per prefix, and a gate that keeps it that way
The router registers All(prefix)+All(prefix/*) per app and MERGES identical
patterns, appending the later handler behind the earlier one. The earlier is a
proxy that never calls Next(), so a duplicated prefix does not conflict loudly —
the later app just never runs. Two shipped that way, out of 241 prefixes:

  /v1/s3  provisioning behind storage — pure shadow, provisioning registered
          nothing under it. Removed.
  /v1     zen behind commerce. Not a dead endpoint: zen mounts
          Group("/v1", z.Claim()) — spend/claim MIDDLEWARE (apps/zen/zen.go:84)
          — so the metering gate on the inference path is inert. Recorded in
          knownShadowed rather than flipped, because turning it on turns
          metering on and that needs a billing owner, not a refactor.

manifest_test.go asserted only that mounting does not panic, which is why both
shipped. TestNoShadowedPrefix asserts ownership, fails on any NEW collision, and
fails again if a knownShadowed entry stops colliding so the list cannot rot.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-29 10:06:01 -07:00
hanzo-dev 80b41c1ab8 feat(analytics,o11y): project /v1/event errors onto the Sentry plane
Add an error/Sentry sink to the canonical event fan-out (forward.go): every
type:'error' event is handed, o11y-free, to clients/o11y, which normalizes it
through the reused errortracking engine and ingests it via the embedded
Modules.Sentry into o11y_sentry_events + the o11y_issues lifecycle, so
/v1/event errors surface on sentry.hanzo.ai alongside the errors a Sentry SDK
posts to /v1/sentry directly.

The seam is the twin of the existing destinations SetSink: analytics imports
neither subsystem, each installs its own sink at Mount, and every sink is
detached + fail-soft + additive (a projection failure never touches the
hanzo.events write or the receipt). The org UUID is derived from the tenant
slug by the same UUIDv5 the o11y read side uses (iamidentn), so an error lands
under the exact org the console resolves; each org gets one canonical Sentry
project (get-or-create, cached). Default-on, gated by CLOUD_SENTRY_LENS;
active only when the in-process o11y runtime is up (Modules.Sentry).

Reuses the ONE datastore path already open (o11y's telemetrystore for the
events plane, its sqlstore for the issue lifecycle) via Modules.Sentry.Ingest;
opens no new connection.

Tests: error fan-out detection/extraction across every wire shape (folded
*Exception, native $exception map, bare error-typed); org-UUID pinned against
the o11y formula; ErrorEvent -> normalize -> fingerprinted occurrence; project
get-or-create + cache; and the pk_ ingest-key mint->use flow end-to-end
(POST /v1/ingest/keys -> POST /v1/event accepted, no 403), so anonymous site
ingest stops 403ing.
2026-07-23 02:52:36 -07:00
991 changed files with 109834 additions and 18647 deletions
+20
View File
@@ -39,3 +39,23 @@ Thumbs.db
# The Dockerfile itself doesn't need to be in the context it builds
Dockerfile
.dockerignore
# Agent worktrees. .claude/ is gitignored — it is a whole SECOND CHECKOUT of this
# repository, and COPY . . was shipping it into the image context: 92 MB of a
# copy of ourselves, cache-keyed so every agent run invalidated the layer. The
# same second-checkout problem the zipdoc gate hit when its walk read 203
# packages where there are 104.
.claude/
# Compiled binaries left at the ROOT by a local `make`. /bin/ was already
# excluded, but these do not live there — `hanzo` alone is 586 MB, and none of
# the three is tracked in git, so nothing in the build can want them. They are
# named rather than globbed because a glob here would also catch source.
# Measured: context 1087 MB -> 251 MB.
#
# NOT excluded: `sandboxes`, which IS tracked in git. COPY . . may legitimately
# carry it, and a build output that someone committed is a question for that
# commit, not something to silently drop from the image.
/hanzo
/o11y
/host
+10 -1
View File
@@ -59,7 +59,6 @@ __pycache__/
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,
@@ -75,3 +74,13 @@ tools
/authz
/smoke
/gen-app-cmds
# boxd gets both spellings because it is the one binary people build from inside
# its own directory (it runs standalone on a laptop), so `go build` drops it at
# cmd/boxd/boxd as often as at the root. Anchored, per the /tools lesson below.
/boxd
/cmd/boxd/boxd
# /tools was written `tools`, unanchored. A pattern with no slash matches at ANY
# depth, so it hid apps/tools (25 tracked files, the /v1/tools MCP catalogue) and
# plugin/tools: `git add apps/tools/new.go` refused, and refusing to add is how
# work disappears without a diff to notice. The leading slash is the whole fix.
/tools
+121
View File
@@ -0,0 +1,121 @@
# Published images that no git tag names. Read by .hanzo/scripts/orphans.sh.
#
# Each line states that an image was published without a receipt, that this is
# KNOWN, and that it cannot be repaired now. It is not permission for the next
# one: orphans.sh is red for any published version neither tagged nor listed
# here, so a new untagged image fails the release that follows it.
#
# TAGGED MEANS TAGGED ON A REMOTE. Two entries below (1.801.319, 1.801.340) look
# tagged from a checkout and are not: the tag exists only in a local clone and
# was never pushed anywhere. A ref on one machine is not a receipt, and reading
# `git tag` instead of the remotes is how they stayed invisible.
#
# WHY NONE OF THESE WERE TAGGED RETROACTIVELY. Fifteen of the 82 carry a real
# commit in org.opencontainers.image.revision, and several name a commit that is
# on main — those could be tagged, and deliberately were not. Here a v* tag is
# not a label, it is a RECEIPT: hanzo.yml defines it as minted only after build
# and smoke pass ("a RECEIPT for a proven image, never a build trigger"). Minting
# one now would assert that a pipeline proved something it never ran. That trades
# a gap everyone can see for a false claim nobody can detect later, which is the
# more expensive of the two mistakes.
#
# The image -> commit link those fifteen DO have is already recorded where it
# belongs — the image's own label, which .hanzo/scripts/image-revision.sh reads —
# and is repeated in column two so it is greppable without a registry call.
# Recording it costs nothing and claims nothing. The other 67 report
# revision=unknown: their commit is not recoverable from the image, and NO
# mapping is guessed for them. "We cannot tell what this was built from" is true
# and useful; a plausible-looking guess is not.
#
# v1.801.480 is the one that matters most: it served api.hanzo.ai in production
# and reported {"revision":"unknown"} on its own health endpoint. What source ran
# in production during that window is not knowable from anything that survives.
#
# 328 and 330-336 are the images hanzo.yml already names — the detached release
# that terminated the pod running it, mid-pipeline, before it could tag. That
# this list did not stop at 336 is the point: the same hole kept producing
# images, and 485 was published untagged while this file was being written.
#
# <version> <commit or "unknown"> # <built> <why>
v0.2.2 unknown # 2026-06-19T04:59:38Z no commit recorded — unreconstructable
v1.763.0 unknown # 2026-02-14T18:11:34Z no commit recorded — unreconstructable
v1.785.0 unknown # 2026-06-19T06:00:06Z no commit recorded — unreconstructable
v1.785.1 unknown # 2026-06-19T06:02:38Z no commit recorded — unreconstructable
v1.785.27 5fa3647a3be1e5660ad293dcefbf80da6bebfe57 # 2026-06-30T21:33:14Z names a commit not in main history (rebased away)
v1.797.0 3b137a474ba4ba840807824d080e932078d26c50 # 2026-07-03T21:58:04Z names a commit absent from forge and every fetched mirror
v1.799.1 49a20d69aca5f2a4f6b8f28594e35e3f8631d0ea # 2026-07-04T00:06:08Z names a commit absent from forge and every fetched mirror
v1.801.210 unknown # 2026-07-25T01:44:00Z no commit recorded — unreconstructable
v1.801.211 unknown # 2026-07-25T14:02:37Z no commit recorded — unreconstructable
v1.801.212 unknown # 2026-07-25T15:48:37Z no commit recorded — unreconstructable
v1.801.213 unknown # 2026-07-25T20:16:12Z no commit recorded — unreconstructable
v1.801.214 unknown # 2026-07-25T21:07:41Z no commit recorded — unreconstructable
v1.801.215 unknown # 2026-07-25T21:46:34Z no commit recorded — unreconstructable
v1.801.216 unknown # 2026-07-25T22:07:18Z no commit recorded — unreconstructable
v1.801.244 unknown # 2026-07-27T15:34:03Z no commit recorded — unreconstructable
v1.801.252 unknown # 2026-07-27T19:19:07Z no commit recorded — unreconstructable
v1.801.258 unknown # 2026-07-27T21:11:40Z no commit recorded — unreconstructable
v1.801.263 unknown # 2026-07-28T00:15:43Z no commit recorded — unreconstructable
v1.801.265 unknown # 2026-07-28T00:40:25Z no commit recorded — unreconstructable
v1.801.274 unknown # 2026-07-28T04:18:33Z no commit recorded — unreconstructable
v1.801.277 unknown # 2026-07-28T05:36:44Z no commit recorded — unreconstructable
v1.801.281 unknown # 2026-07-28T06:29:47Z no commit recorded — unreconstructable
v1.801.285 unknown # 2026-07-28T07:39:58Z no commit recorded — unreconstructable
v1.801.287 unknown # 2026-07-28T08:08:03Z no commit recorded — unreconstructable
v1.801.288 unknown # 2026-07-28T08:19:41Z no commit recorded — unreconstructable
v1.801.292 unknown # 2026-07-28T10:03:05Z no commit recorded — unreconstructable
v1.801.294 unknown # 2026-07-28T16:03:04Z no commit recorded — unreconstructable
v1.801.295 unknown # 2026-07-28T16:14:39Z no commit recorded — unreconstructable
v1.801.296 unknown # 2026-07-28T16:29:17Z no commit recorded — unreconstructable
v1.801.309 unknown # 2026-07-28T21:48:14Z no commit recorded — unreconstructable
v1.801.310 unknown # 2026-07-28T23:05:08Z no commit recorded — unreconstructable
v1.801.311 unknown # 2026-07-28T23:41:11Z no commit recorded — unreconstructable
v1.801.312 unknown # 2026-07-29T00:31:00Z no commit recorded — unreconstructable
v1.801.313 unknown # 2026-07-29T00:46:09Z no commit recorded — unreconstructable
v1.801.314 unknown # 2026-07-29T01:16:34Z no commit recorded — unreconstructable
v1.801.315 unknown # 2026-07-29T02:17:35Z no commit recorded — unreconstructable
v1.801.316 unknown # 2026-07-29T03:02:16Z no commit recorded — unreconstructable
v1.801.317 unknown # 2026-07-29T03:37:24Z no commit recorded — unreconstructable
v1.801.318 unknown # 2026-07-29T03:58:29Z no commit recorded — unreconstructable
v1.801.319 unknown # 2026-07-29T18:10:12Z no commit recorded — unreconstructable
v1.801.326 unknown # 2026-07-30T12:41:22Z no commit recorded — unreconstructable
v1.801.328 unknown # 2026-07-31T03:01:24Z no commit recorded — unreconstructable
v1.801.330 unknown # 2026-07-31T16:10:51Z no commit recorded — unreconstructable
v1.801.331 unknown # 2026-07-31T06:21:16Z no commit recorded — unreconstructable
v1.801.332 unknown # 2026-07-31T15:54:33Z no commit recorded — unreconstructable
v1.801.333 unknown # 2026-07-31T16:16:17Z no commit recorded — unreconstructable
v1.801.334 unknown # 2026-07-31T16:45:30Z no commit recorded — unreconstructable
v1.801.336 unknown # 2026-07-31T18:02:49Z no commit recorded — unreconstructable
v1.801.337 unknown # 2026-07-31T18:24:00Z no commit recorded — unreconstructable
v1.801.338 unknown # 2026-07-31T21:22:19Z no commit recorded — unreconstructable
v1.801.339 unknown # 2026-07-31T22:11:36Z no commit recorded — unreconstructable
v1.801.340 unknown # 2026-07-31T22:37:37Z no commit recorded — unreconstructable
v1.801.343 unknown # 2026-08-02T01:28:01Z no commit recorded — unreconstructable
v1.801.344 unknown # 2026-08-01T00:50:04Z no commit recorded — unreconstructable
v1.801.345 unknown # 2026-08-01T01:17:38Z no commit recorded — unreconstructable
v1.801.346 unknown # 2026-08-01T01:38:50Z no commit recorded — unreconstructable
v1.801.349 084ae0ba691f162bcf01da2d2619f0cc600129e9 # 2026-08-01T05:41:49Z names a commit absent from forge and every fetched mirror
v1.801.351 2cadb47bb2189252efdef1ad2e198df535d7c7e2 # 2026-08-01T12:19:10Z names a commit absent from forge and every fetched mirror
v1.801.352 9bd7c2a4b1e820671c8d01e0d3ca0dcc598d78a3 # 2026-08-01T13:01:18Z names a commit absent from forge and every fetched mirror
v1.801.353 34ed2f5d7c22f9c8a81524facaa11bb8ba016a0b # 2026-08-01T14:33:00Z names a commit absent from forge and every fetched mirror
v1.801.354 unknown # 2026-08-01T18:17:04Z no commit recorded — unreconstructable
v1.801.355 unknown # 2026-08-01T19:53:37Z no commit recorded — unreconstructable
v1.801.359 unknown # 2026-08-02T01:03:52Z no commit recorded — unreconstructable
v1.801.370 6965c345367856d2d8cd747c9c1b994bd1d14a15 # 2026-08-02T16:54:11Z names a commit absent from forge and every fetched mirror
v1.801.372 unknown # 2026-08-02T19:44:29Z no commit recorded — unreconstructable
v1.801.379 754fb82173fb81d6df232e8b5b5674ff1504f50a # 2026-08-02T22:57:59Z names a commit on main
v1.801.395 unknown # 2026-08-03T22:15:04Z no commit recorded — unreconstructable
v1.801.397 unknown # 2026-08-03T23:48:11Z no commit recorded — unreconstructable
v1.801.403 unknown # 2026-08-04T04:31:20Z no commit recorded — unreconstructable
v1.801.405 93a578e4ab5485861fca8a70d2a851769ddb1d8e # 2026-08-04T05:38:14Z names a commit on main
v1.801.406 5a8229c8f98f421de13b63b1cd4f6f25b17d6145 # 2026-08-04T06:02:35Z names a commit on main
v1.801.432 unknown # 2026-08-04T18:51:03Z no commit recorded — unreconstructable
v1.801.453 07ff50327a67fd71c0e06f023e648386ff15f282 # 2026-08-05T03:34:02Z names a commit on main
v1.801.457 unknown # 2026-08-05T05:55:53Z no commit recorded — unreconstructable
v1.801.478 unknown # 2026-08-05T23:57:07Z no commit recorded — unreconstructable
v1.801.479 unknown # 2026-08-06T00:25:57Z no commit recorded — unreconstructable
v1.801.480 unknown # 2026-08-06T01:00:10Z no commit recorded — unreconstructable
v1.801.481 a649d23fb69c807a2cb0aca7192a145b88fa2042 # 2026-08-06T02:17:42Z names a commit not in main history (rebased away)
v1.801.482 98b33ebb6cc413485d81c20fde5e9302086cea9b # 2026-08-06T02:46:00Z names a commit not in main history (rebased away)
v1.801.483 80723cc0004690ddb677b460b8814d972595ced1 # 2026-08-06T03:34:03Z names a commit not in main history (rebased away)
v1.801.484 unknown # 2026-08-06T06:50:36Z no commit recorded — unreconstructable
v1.801.485 unknown # 2026-08-06T07:26:36Z no commit recorded — unreconstructable
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# orphans — every published image must have a git tag naming its commit.
#
# WHY THIS EXISTS. hanzo.yml states the release invariant as "main push → build →
# smoke → tag → pin → prove live", and every job in cicd.yml upholds its own link
# in that chain. Nothing upheld the chain ITSELF. Each job can only see the
# release it is running; an image published by a lane that never entered cicd.yml
# is invisible to all of them, and that is not hypothetical — it is how
# v1.801.478, 479, 480 and 484 came to exist with no tag in any repo, while 480
# served production. The registry is the only place that knows what was actually
# published, so the registry is what has to be asked.
#
# The check is deliberately lane-agnostic. It does not ask WHO built an image or
# whether some workflow succeeded; it compares what is published against what is
# tagged. A gate phrased in terms of a lane can only catch that lane, and the
# lane that caused this outage was the one nobody thought to instrument.
#
# TAGS ARE READ FROM BOTH REPOS, and the union is what counts. cloud is canonical
# on git.hanzo.ai, but cicd.yml claims its version by creating refs/tags/v<N>
# through the GitHub API on hanzoai/cloud, so receipts exist in two namespaces.
# Asking only one would paint every release red from the other, and a gate that
# is always red is a gate someone turns off. Asking for the union answers the
# question actually worth asking — "does a receipt for this image exist anywhere"
# — and stays true whichever way the namespace split is later resolved.
#
# WHAT IS NOT A FAILURE. The images already published untagged cannot be fixed by
# this script, and pretending otherwise would make it red forever on day one.
# They are recorded, one per line with the reason, in .hanzo/orphans.txt — DATA,
# not a code path, so accepting one is a reviewable diff and the rule below stays
# a single rule. An entry there is a statement that the image is known and
# unreconstructable, not that untagged images are tolerated: anything published
# from now on that is not tagged is red.
#
# orphans.sh
#
# Exit 0 = every published image is tagged or recorded. Exit 1 = at least one is
# neither (the release invariant is broken). Exit 2 = a source could not be READ,
# which is not the same answer as "nothing was found" — collapsing those two is
# how a verifier comes to pass by accident, so they get separate codes and the
# caller can tell an outage from a clean run.
#
# Test seams, mirroring githubAPIBase in apps/platform/release.go — set to a file
# and that source is read from it instead of the network, so the comparison can
# be exercised with no registry, no tokens and no network at all:
# ORPHANS_IMAGES published image tags, one per line
# ORPHANS_TAGS git tags, one per line
# ORPHANS_ACCEPTED path to the recorded-orphans file
set -euo pipefail
IMAGE_PATH="${ORPHANS_IMAGE_PATH:-hanzoai/cloud}"
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ACCEPTED="${ORPHANS_ACCEPTED:-${HERE}/../orphans.txt}"
# A published version, e.g. v1.801.480. Anchored at both ends: a tag that merely
# CONTAINS a version (v1.801.480-rc1, sha-abc123) is not the release name this
# invariant is about, and matching it loosely would invent orphans that the
# release chain never claimed a number for.
SEMVER='^v[0-9]+\.[0-9]+\.[0-9]+$'
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# ── what is published ────────────────────────────────────────────────────────
# The registry is paginated and the Link header is the ONLY way to know there is
# more; stopping at the first page silently truncates the published set, and a
# truncated set is a green run that proved nothing about the images it never saw.
if [ -n "${ORPHANS_IMAGES:-}" ]; then
[ -r "$ORPHANS_IMAGES" ] || { echo "orphans: cannot read ORPHANS_IMAGES=$ORPHANS_IMAGES" >&2; exit 2; }
grep -E "$SEMVER" "$ORPHANS_IMAGES" | sort -u > "$work/images" || true
else
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')" || TOKEN=""
else
TOKEN="$(curl -fsSL --max-time 30 \
"https://ghcr.io/token?scope=repository:${IMAGE_PATH}:pull&service=ghcr.io" | jq -r '.token // empty')" || TOKEN=""
fi
[ -n "$TOKEN" ] || { echo "orphans: no ghcr pull token for ${IMAGE_PATH} — cannot read what is published" >&2; exit 2; }
: > "$work/images.raw"
page="https://ghcr.io/v2/${IMAGE_PATH}/tags/list?n=1000"
while [ -n "$page" ]; do
if ! curl -fsSL --max-time 60 -D "$work/hdr" -H "Authorization: Bearer $TOKEN" "$page" > "$work/body"; then
echo "orphans: cannot list tags for ${IMAGE_PATH} — refusing to report a clean run against a registry that did not answer" >&2
exit 2
fi
jq -r '.tags[]? // empty' < "$work/body" >> "$work/images.raw"
page="$(tr -d '\r' < "$work/hdr" | sed -n 's/^[Ll]ink: *<\([^>]*\)>.*rel="next".*/\1/p' | head -1)"
[ -n "$page" ] && page="https://ghcr.io${page}"
done
grep -E "$SEMVER" "$work/images.raw" | sort -u > "$work/images" || true
fi
[ -s "$work/images" ] || { echo "orphans: no published versions found for ${IMAGE_PATH} — that is not a clean run, it is a read that returned nothing" >&2; exit 2; }
# ── what is tagged ───────────────────────────────────────────────────────────
# `git ls-remote` failing and a repo having no tags produce the same empty list,
# so each remote is checked for the READ succeeding, separately from what it
# returned. At least one remote must answer: a network that answered nothing
# would otherwise make every published image look untagged and turn a broken
# gate into 400 spurious failures.
if [ -n "${ORPHANS_TAGS:-}" ]; then
[ -r "$ORPHANS_TAGS" ] || { echo "orphans: cannot read ORPHANS_TAGS=$ORPHANS_TAGS" >&2; exit 2; }
grep -E "$SEMVER" "$ORPHANS_TAGS" | sort -u > "$work/tags" || true
else
: > "$work/tags.raw"
answered=0
for remote in "https://git.hanzo.ai/hanzoai/cloud" "https://github.com/hanzoai/cloud"; do
url="$remote"
case "$remote" in
*github.com*) [ -n "${GH_PAT:-}" ] && url="https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" ;;
*git.hanzo.ai*) [ -n "${FORGE_TOKEN:-}" ] && url="https://x-access-token:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" ;;
esac
if git ls-remote --tags "$url" > "$work/ls" 2>/dev/null; then
answered=$((answered + 1))
sed 's|.*refs/tags/||; s|\^{}$||' < "$work/ls" >> "$work/tags.raw"
else
echo "orphans: ${remote} did not answer — its receipts are not in this comparison" >&2
fi
done
[ "$answered" -gt 0 ] || { echo "orphans: no tag remote answered — refusing to call every published image an orphan on a failed read" >&2; exit 2; }
grep -E "$SEMVER" "$work/tags.raw" | sort -u > "$work/tags" || true
fi
# ── what is already known and recorded ───────────────────────────────────────
if [ -r "$ACCEPTED" ]; then
sed 's/#.*//' "$ACCEPTED" | awk '{print $1}' | grep -E "$SEMVER" | sort -u > "$work/accepted" || true
else
: > "$work/accepted"
fi
comm -23 "$work/images" "$work/tags" > "$work/untagged"
comm -23 "$work/untagged" "$work/accepted" > "$work/new"
if [ -s "$work/new" ]; then
while read -r v; do
echo "::error::${v} is published but no git tag names it — an image no commit can be traced to is not a release. Tag it at the commit it was built from, or record it in .hanzo/orphans.txt with the reason it cannot be."
done < "$work/new"
echo "orphans: $(wc -l < "$work/new") published image(s) with no receipt; $(wc -l < "$work/accepted") previously recorded" >&2
exit 1
fi
echo "orphans: $(wc -l < "$work/images") published, $(wc -l < "$work/tags") tagged, $(wc -l < "$work/accepted") recorded — every published image has a receipt"
+360 -95
View File
@@ -15,10 +15,10 @@ name: CI/CD
# they were two files with the SAME TRIGGER. Actions cannot express `needs:`
# across workflow files, so `deploy` built, smoked, tagged and pinned while
# `gate` was still running — or after it had gone red. That is not a
# hypothetical: the drift gate (`make -f mk/fleet.mk surface-check`) was RED on
# hypothetical: the drift gate (`make -f mk/fleet.mk check`) was RED on
# main while 87 commits and 6 releases went out in 24 hours, and what shipped was
# a binary serving /v1/billing/gpu/eligibility and publishing
# /v1/billing/gpu-eligibility. One build, two answers, both signed off.
# a binary serving a renamed billing route under its new name while still
# publishing the old one. One build, two answers, both signed off.
#
# So deploy.yml is GONE and its jobs are here, behind `needs:`. The gate was
# always correct; it simply had no edge to the thing it was meant to stop.
@@ -63,17 +63,47 @@ on:
concurrency:
# ONE release at a time, queued not cancelled: the image is pushed early and
# the tag and pin come last, so killing a run midway leaves an orphan — ten of
# them between v1.801.335 and v1.801.350. Pull requests are a different ref, so
# they neither queue behind a release nor hold one up, and a new push to a PR
# cancels its own stale run.
group: cicd-${{ github.ref }}
# them between v1.801.335 and v1.801.350. A pull request gets a slot of its
# own, so it neither queues behind a release nor holds one up, and a new push
# to it cancels its own stale run.
#
# Two earlier readings of this are worth not repeating, because both blamed the
# setting and the setting was never what acted. One argued cancel-in-progress
# was "a literal false the forge cannot evaluate, and a non-empty string is
# truthy"; the other argued the cancelled runs "were superseded while still
# QUEUED, so cancel-in-progress was never the thing acting on them". The second
# is right about the mechanism and neither found the cause, which is one line
# lower and is the GROUP, not the flag.
# ── THE GROUP MUST NOT BE KEYED ON A VALUE THIS FORGE DOES NOT SET ──────────
#
# It was `cicd-${{ github.ref }}`, and act_runner leaves the ref EMPTY: the API
# returns head_branch null and no ref field on ordinary pushes to main. So the
# group rendered as the constant `cicd-` and EVERY run in the repository — main
# pushes, pull requests, tag pushes — shared ONE slot.
#
# That is the mechanism behind the cancellations above, and it is not the
# cancel-in-progress setting the paragraphs above blame. Gitea coalesces a
# concurrency queue to depth one: an in-progress run is left alone (which is
# what cancel-in-progress: false buys), but a QUEUED run is cancelled when a
# newer one arrives for the same group. That is correct behaviour — nobody
# wants an image per intermediate commit — and it only became destructive
# because one constant group meant every pull request coalesced away the
# release queued behind it, and vice versa. The tell is in the API: those runs
# carry started_at 1970-01-01, so they were never given a runner at all.
#
# Keyed properly, the original intent holds without depending on the ref.
# Releases share one slot and queue; each pull request gets its own slot and a
# new push to it cancels only its own stale run. cancel-in-progress is now an
# expression that evaluates to a real boolean rather than a literal whose
# truthiness was being argued about.
group: cicd-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'release' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ══ CAR 0 ══ THE DOCUMENT EQUALS THE CODE. Everything below `needs:` this.
#
# The test gate, driven by hanzo.yml, whose `app-contract` step is
# `make -f mk/fleet.mk surface-check`: regenerate all 116 app subsets FROM
# `make -f mk/fleet.mk check`: regenerate all 116 app subsets FROM
# SOURCE, re-weave openapi.yaml, and refuse any porcelain change. It
# regenerates rather than comparing two derived artifacts (which is how
# plugin/ingress silently lost eight paths) and checks with --porcelain rather
@@ -100,7 +130,35 @@ jobs:
# and the forge runner does not set it, and `set -u` turned the miss into an
# abort — so every step after it, build through deploy, reported skipped.
# v1.0.17 defaults the variable; nothing else about the lane changed.
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1.0.17
#
# PINNED TO @v1, NOT @v1.0.17, AND THE DIFFERENCE IS THAT THE TESTS RUN.
# v1.0.17 declares the reusable's `tests` input as `type: boolean`, and the
# forge substitutes a workflow_call input's DECLARED-TYPE ZERO VALUE — so
# `inputs.tests` was always false and the test step was SKIPPED on every run.
# Measured on this repo: 20 skipped, 0 executed. The most critical service in
# the fleet has been shipping without its suite ever running.
# @v1 declares it `type: string` with `default: 'true'` and guards on
# `inputs.tests != 'false'`, so the `tests:` expression below finally means
# what it says. Verified before moving: v1 (8e43277) CONTAINS v1.0.17
# (81b3c18), so the set -u default this pin was taken for comes with it.
# BACK ON @v1, and not because the alias is right.
#
# Pinning @v1.0.38 — which the paragraphs above call for — stopped the forge
# constructing a run AT ALL: no row, nothing to inspect, exactly the "dead CI
# is not red, it is absent" failure described above. A failing `gate` is worse
# than a passing one and better than no run, so this is reverted to the state
# that at least reports.
#
# The lane is still broken and was before this: `gate` never gets a runner
# (runner_id 0, zero steps, ~32 min, failure) while `containment` succeeds on
# the same run against an idle 10/10 fleet. Runs 925, 926 and 928 all died
# that way, so no release has minted since v1.801.490.
#
# Whoever picks this up: the question is why the forge declines to SCHEDULE a
# called workflow's jobs while scheduling sibling jobs in the same run, and
# why naming an immutable tag prevents run construction when that tag demonstrably
# carries .hanzo/workflows/build.yml. Both answers live in the forge, not here.
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
with:
# Not on a tag: the tag is minted below only after this gate passed on main
# AND the image built AND it smoked, so a tag build re-tests a proven
@@ -288,14 +346,42 @@ jobs:
# what makes a tag a receipt for an image that booted.
image:
needs: [gate, containment]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
# NAME WHAT IS REFUSED, NOT WHAT IS ALLOWED — because this job's ref is not
# reliably knowable and its absence must not read as "no".
#
# act_runner does not populate the ref on these runs: the API returns
# head_branch NULL and no ref field at all, on ordinary pushes to main and
# not merely on the API reruns the previous note blamed. The old condition
# allowed ref=='refs/heads/main' OR '' OR null, which looks like it covers
# exactly that — and does not, because an absent value satisfies none of the
# three, so the whole predicate is false and the job is SKIPPED.
#
# Skipped, not failed, is the entire reason this was invisible. Run 38052
# (7fbafa54a) is the proof: gate success, containment success, and then
# image/rollout/reach/fanout/receipt all `skipped`. A green tick on a run
# that shipped nothing, with no error anywhere to read. That is "the gate is
# green and nothing ships", and it had outlasted several fixes aimed at the
# gate, which was never the thing failing.
#
# Inverting it removes the dependence. `on:` already decides what reaches
# this workflow — pushes to main, v* tags, PRs, dispatch — so the job does
# not need to re-derive that, and re-deriving it is what broke. Two things
# must be refused: pull requests, and TAG pushes (the tag is minted BY the
# claim below, so building on a tag would take the next number and publish a
# second image for a commit that already has one). Everything else — main,
# dispatch, and the ref-less shape — is a release. startsWith() on an absent
# ref is false, so the missing value now means "not a tag", which is true.
if: github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/')
runs-on: [hanzo-build-linux-amd64]
# cloud builds ~28 subsystems and has run 15m, 17m and 20m42s.
timeout-minutes: 60
outputs:
version: ${{ steps.ver.outputs.version }}
spec_sha256: ${{ steps.ver.outputs.spec_sha256 }}
resumed: ${{ steps.ver.outputs.resumed }}
# THE BYTES, named by what they are rather than by what they are called. A
# tag is a name a registry can move; a digest is the content. rollout pins
# this and refuses to pin anything else.
digest: ${{ steps.img.outputs.digest }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
@@ -306,53 +392,26 @@ jobs:
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- 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
# THE CLAIM IS MADE WHERE THE CODE IS. There used to be a step here that
# published the commit to github first, because the claim was made against
# github and a ref can only point at an object the server has. That step is
# gone, and so is the race it existed to close: CI runs on git.hanzo.ai, so
# the commit is already there — being there is what started this run.
#
# It was also the last thing forcing the private tree onto a github repo on
# every release, and the thing that broke the lane twice: once when the repo
# it pushed to was public, and once when the credential could not see the
# private one it was moved to ("Repository not found", run 38319). A step
# that cannot fail is better than a step with two known ways to fail.
- 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 claim is written to git.hanzo.ai, so it is the forge credential
# that has to be able to write it. GH_PAT is gone from this step: it
# authenticated a register that no longer lives on github.
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
# 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
@@ -404,10 +463,20 @@ jobs:
done
# What has been TAGGED. ls-remote rather than /repos/../tags because the
# REST list paginates at 100 and this repo carries ~1700 refs. github.com,
# not the forge: that is where cloud's tags actually are; the forge copy
# trails and reading it would collide with fifty published images.
GIT=$(git ls-remote --tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
# REST list paginates at 100 and this repo carries ~1700 refs. The
# private github repo, not the forge: the claim is a compare-and-swap
# and only one server can arbitrate it, so the register of names has to
# be the one the claim writes to. Reading a different git than we claim
# against is how two lanes come to believe they both own a number.
#
# The register moved here from the public repo, so it starts from
# v1.801.491 — the reconciliation release — rather than from the
# public repo's v1.801.490. 487 through 490 are holes there: versions
# claimed by runs that then died in the build, which is the correct
# direction to fail (a hole is inert; a reused number serves wrong
# bytes). They are not carried over, because a name nothing published
# is not a release.
GIT=$(git ls-remote --tags "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
| awk '{print $2}' | sed 's|refs/tags/||' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' || true)
@@ -457,11 +526,11 @@ jobs:
# 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
-H "Authorization: token $FORGE_TOKEN" \
-H 'Content-Type: application/json' \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags" \
-d "{\"tag_name\":\"v${CAND}\",\"target\":\"${SHA}\"}")
if [ "$CODE" = "409" ]; 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
@@ -472,14 +541,14 @@ jobs:
# 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
case "$WHY" in *"already exists"*) : ;; *)
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}"
exit 1 ;;
esac
# 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')
HAVE=$(curl -fsS -H "Authorization: token $FORGE_TOKEN" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/v${CAND}" | jq -r '.commit.sha // empty')
if [ "$HAVE" = "${SHA}" ]; then
echo "v${CAND} is already claimed at our sha — this release, resumed"
CLAIMED="$CAND"; break
@@ -487,8 +556,17 @@ jobs:
echo "v${CAND} is held by ${HAVE:-another ref} — trying the next number"
continue
fi
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
# Say which credential and which scope, because the symptom
# otherwise reads as "the claim is broken" and the cause is one
# secret. FORGE_TOKEN has only ever been READ with in this
# workflow — go module fetches and ls-remote — so the first
# release to write with it is the first test of its scope.
echo "::error::the forge refused the claim for v${CAND} with $CODE. FORGE_TOKEN must carry write:repository on hanzoai/cloud — it creates refs/tags/v<N> and the refs/smoked/<digest> receipt. Re-issue it with that scope and set it as the hanzoai org actions secret; nothing else here needs changing."
cat /tmp/claim.json; exit 1
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"
echo "::error::claiming v${CAND} returned $CODE (expected 201 created or 409 taken) — refusing to build a version this run cannot prove it owns"
cat /tmp/claim.json; exit 1
fi
echo "claimed v${CAND} at ${SHA}"
@@ -499,18 +577,31 @@ jobs:
# 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
# two need opposite handling. `built` 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
#
# IT ANSWERS ONE QUESTION AND IT USED TO ANSWER TWO. This flag was
# called `resumed`, and the smoke step was skipped on it — so a run that
# PUSHED and then FAILED smoke came back, found its own bytes, declared
# itself resumed, and skipped both the build and the proof. rollout then
# pinned an image nothing had ever booted. The registry is the authority
# on whether bytes exist and it is the authority on nothing else: AN
# ARTIFACT EXISTING PROVES IT WAS BUILT, NEVER THAT IT WAS TESTED.
#
# So the second question is asked separately, of a separate authority,
# further down — and it is asked about the DIGEST, because a receipt
# that named only a version would prove a version was smoked and say
# nothing about which bytes are behind that name today.
BUILT=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" = "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
echo "v$NEXT is already published from our sha — skipping the build (whether it was SMOKED is a different question, asked below)"
BUILT=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
@@ -521,22 +612,37 @@ jobs:
fi
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "resumed=$RESUMED" >> "$GITHUB_OUTPUT"
echo "built=$BUILT" >> "$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'
if: steps.ver.outputs.built == '0'
with: { driver: docker-container, driver-opts: network=host }
- uses: docker/build-push-action@v6
if: steps.ver.outputs.resumed == '0'
if: steps.ver.outputs.built == '0'
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}
# `outputs:` in place of `push: true` — the only way to reach buildkit's
# exporter attributes through this action, and `push: true` cannot be set
# alongside it because both ask for an image exporter.
#
# zstd because this image is ONE 1.63GB layer (the per-app plugin
# binaries, 98.8% of it) and gzip writes a layer as a single stream on a
# single core: 175.0s to export, seven of eight CPUs idle. Measured on the
# same bytes, both orderings, 2026-08-06: gzip 245.5s/256.1s against zstd
# 58.1s/53.3s, and the zstd image is 1.2% smaller.
#
# This lane matches apps/platform/k8s.go deliberately. The two build the
# SAME Dockerfile into the SAME repository, and a compression setting that
# differs between them is not a preference — it decides the manifest media
# type, which decides whether imagePullable's Accept negotiation gets a
# 200 or a 404 (see the note in apps/platform/pin.go).
outputs: type=image,push=true,compression=zstd,force-compression=true,oci-mediatypes=true
# 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.
@@ -572,10 +678,18 @@ jobs:
# 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
#
# AND NAME THE BYTES, AND ASK WHETHER THEY HAVE EVER BOOTED. Both questions
# are about the same manifest and this is the only moment it is authoritative
# — the probe up in the claim ran BEFORE the build, so it can decide whether
# to build and nothing else. Everything downstream keys off the digest read
# here.
- name: Verify the pushed image resolves, is the commit we built, and whether it has ever booted
id: img
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
@@ -594,6 +708,49 @@ jobs:
fi
echo "$img is built from ${{ github.sha }} — tag, commit and image agree"
# THE DIGEST. Read from the registry's own Docker-Content-Digest, the
# same header universe/charts/app/pin.sh reads, so rollout can compare
# what it pinned against what this run smoked without either side
# deriving the value a second way.
TOKEN=$(curl -fsSL -u "$GHCR_USER:$GHCR_TOKEN" \
"https://ghcr.io/token?scope=repository:hanzoai/cloud:pull&service=ghcr.io" | jq -r .token)
DIGEST=$(curl -fsSL -I -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${{ steps.ver.outputs.version }}" \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: *//p' | head -1)
case "$DIGEST" in
sha256:*) : ;;
*) echo "::error::${img} resolves but the registry named no Docker-Content-Digest — these bytes have no name, so nothing may attest to them"; exit 1 ;;
esac
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "$img is $DIGEST"
# The receipt's address, derived ONCE. A ref name cannot hold a colon,
# and three places needing "the digest with the colon swapped" is three
# chances to swap it differently.
RECEIPT="smoked/$(printf '%s' "$DIGEST" | tr ':' '-')"
echo "receipt=$RECEIPT" >> "$GITHUB_OUTPUT"
# HAS ANYTHING EVER PROVEN THESE BYTES BOOT.
#
# Asked of git, not of the registry, and that is the whole point. The
# registry is the thing being attested, and ns hanzo-build alone has five
# ways to write it; an attestation kept inside the thing it attests can
# be written by anyone who can write the thing. Ref creation is also the
# ONE operation in this pipeline the server performs as a
# compare-and-swap, which is exactly why the version claim above uses it.
# One mechanism for recording a fact, used twice.
#
# Keyed by DIGEST, not by version. `refs/smoked/<digest>` -> the commit,
# so the receipt says "these bytes, from this source, booted" and stays
# false the moment the bytes change under the name.
SMOKED=0
if git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" "refs/${RECEIPT}" >/dev/null 2>&1; then
echo "$DIGEST already has a smoke receipt — these exact bytes have booted"
SMOKED=1
fi
echo "smoked=$SMOKED" >> "$GITHUB_OUTPUT"
# 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
# — a writable /data, CLOUD_ENV=smoke and a throwaway 32-byte master key so
@@ -602,11 +759,23 @@ jobs:
# The script is handed over base64 because it contains both single and
# double quotes (the `"message":"zip listening"` needle), and re-quoting it
# for `sh -c` is how a gate quietly stops matching what it is looking for.
#
# IT RUNS ON `smoked`, NEVER ON `built`, AND THE DIFFERENCE IS A RELEASE.
# These were one flag: skip the build, skip the proof. A run that pushed and
# then failed here came back, saw its own bytes in the registry, and skipped
# BOTH — handing rollout an image nothing had ever booted, with every job
# green. The two conditions coincide exactly once, on a first pass. On every
# resume they differ, and the resume is the only case this branch exists for.
#
# Pinned to the DIGEST, not the tag. The tag is what we asked the registry
# for; the digest is what it answered. Smoking `:v1.801.485` and attesting to
# the bytes that name resolved to a step earlier is how you attest to an
# image you did not run.
- name: Smoke the pushed image
if: steps.ver.outputs.resumed == '0'
if: steps.img.outputs.smoked == '0'
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
img="ghcr.io/hanzoai/cloud@${{ steps.img.outputs.digest }}"
SMOKE_B64=$(base64 -w0 <<'SMOKE'
set -u
@@ -640,6 +809,50 @@ jobs:
-e CLOUD_KMS_MASTER_KEY_REF="$(head -c 32 /dev/urandom | base64 -w0)" \
"$img" -c "echo $SMOKE_B64 | base64 -d | sh"
# THE RECEIPT. It exists because the step above it ran and exited 0, and it
# is the only thing that will ever say so. Written here rather than inferred
# later: the whole hole was inferring "tested" from an artifact that only
# ever proved "built".
#
# `refs/smoked/sha256-<hex>` at the commit — bytes, source, and the fact that
# they booted, in one object. 422 is success: another run already attested to
# the same digest, which is the same statement made twice, not a conflict.
- name: Record that these bytes booted
if: steps.img.outputs.smoked == '0'
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
# A push IS the compare-and-swap: the server refuses to move a ref that
# already names something else, and re-pushing the identical value is a
# no-op. Gitea has no POST for a ref outside refs/tags, and it does not
# need one — this is the operation that endpoint would have wrapped.
if git push "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"${{ github.sha }}:refs/${{ steps.img.outputs.receipt }}" 2>/tmp/receipt.err; then
echo "recorded: ${{ steps.img.outputs.digest }} booted, built from ${{ github.sha }}"
elif git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1; then
echo "already recorded by another run — same digest, same statement"
else
echo "::error::could not record the smoke receipt for ${{ steps.img.outputs.digest }}. These bytes booted and nothing can prove it, so a resume would smoke them again at best and skip them at worst — refusing to leave that state."
cat /tmp/receipt.err; exit 1
fi
# NOTHING LEAVES THIS JOB UNSMOKED, re-asked of the authority rather than
# remembered from a step output. Cheap, and it is the assertion `rollout`
# would otherwise have to trust: every later car declares `needs: image`, so
# this is the last place the question can still be answered before an
# unproven image becomes a pin.
- name: These bytes have a smoke receipt
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1 \
|| { echo "::error::no smoke receipt for ${{ steps.img.outputs.digest }} — these bytes have never been proven to boot. Nothing downstream may pin them."; exit 1; }
echo "${{ steps.img.outputs.digest }} is smoked"
# ══ CAR 2 ══ ROLL OUT, AND PROVE IT IS LIVE.
#
# The train used to stop at "pin pushed". A pin is not a release — the RUNNING
@@ -666,12 +879,12 @@ jobs:
# 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 }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.image.outputs.version }}"
HAVE=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/${TAG}" | jq -r '.object.sha // empty')
HAVE=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/${TAG}" | jq -r '.commit.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
@@ -710,7 +923,7 @@ jobs:
# 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)
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // .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}"
@@ -724,8 +937,24 @@ jobs:
for attempt in 1 2 3 4 5; do
/tmp/universe/charts/app/pin.sh cloud "$VERSION"
# THE PIN MUST NAME THE BYTES WE SMOKED. charts/app/_helpers.tpl builds
# `repo:tag@digest`, and in that form the registry resolves the DIGEST
# and the tag is prose a human reads — so a values file whose tag moved
# and whose digest did not deploys the old image and reports success.
# pin.sh writes both from one lookup and cannot write one alone, but
# what it wrote is still a claim about a name, made seconds after this
# run proved something about bytes. Compare them: they are the same
# image or this release does not ship.
PINNED=$(sed -n 's/^[[:space:]]*digest:[[:space:]]*//p' \
/tmp/universe/charts/app/values/hanzo/cloud.yaml | head -1)
if [ "$PINNED" != "${{ needs.image.outputs.digest }}" ]; then
echo "::error::pinned cloud.yaml names ${PINNED:-no digest}, but the bytes this run smoked are ${{ needs.image.outputs.digest }}. v${VERSION} resolved to a different image between the smoke and the pin — refusing to deploy an image nothing proved."
exit 1
fi
echo "pin names ${PINNED} — the digest this run smoked"
if git -C /tmp/universe push --quiet origin HEAD:main 2>/dev/null; then
echo "pinned cloud to v${VERSION}"; exit 0
echo "pinned cloud to v${VERSION} @ ${PINNED}"; exit 0
fi
echo "universe moved under us (attempt ${attempt}/5) — re-applying on the new tip"
@@ -753,7 +982,7 @@ jobs:
# ══ CAR 3 ══ THE DOCUMENT TELLS THE TRUTH ABOUT PRODUCTION.
#
# `surface-check` proves the document equals the code. Nothing proved the
# `check` proves the document equals the code. Nothing proved the
# address is REACHABLE, and three things break that independently: a stale
# subset, a prefix missing from manifest/apps.go, and an edge worker that
# intercepts before the origin. Every address here is a method in eight SDKs, a
@@ -882,7 +1111,7 @@ jobs:
# 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)
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // .secret.value // empty' || true)
if [ -z "$TOKEN" ]; then
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
@@ -1009,9 +1238,13 @@ jobs:
runs-on: [hanzo-build-linux-amd64]
timeout-minutes: 15
steps:
# This job used to need no source. It does now: the invariant re-checked at
# the end of it lives in .hanzo/scripts/orphans.sh, beside the list of
# images already known to breach it.
- uses: actions/checkout@v4
- name: Write release.json onto the tag
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.image.outputs.version }}"
@@ -1019,12 +1252,13 @@ jobs:
jq -n \
--arg tag "$TAG" --arg sha "${{ github.sha }}" \
--arg spec "${{ needs.image.outputs.spec_sha256 }}" \
--arg digest "${{ needs.image.outputs.digest }}" \
--arg image "${{ needs.image.result }}" \
--arg rollout "${{ needs.rollout.result }}" \
--arg reach "${{ needs.reach.result }}" \
--arg fanout "${{ needs.fanout.result }}" \
--arg n "${{ needs.fanout.outputs.dispatched }}" \
'{version:$tag, sha:$sha, spec_sha256:$spec,
'{version:$tag, sha:$sha, spec_sha256:$spec, digest:$digest,
cars:{image:$image, rollout:$rollout, reach:$reach, fanout:$fanout},
projections_dispatched:($n|tonumber? // 0)}' > /tmp/release.json
cat /tmp/release.json
@@ -1035,15 +1269,15 @@ jobs:
(.|tostring) + "\n```"' /tmp/release.json)
# Idempotent: create, or update the one that a previous attempt left.
ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
ID=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
if [ -n "$ID" ]; then
curl -fsS -X PATCH -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases/${ID}" \
curl -fsS -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/${ID}" \
-d "$(jq -nc --arg b "$BODY" '{body:$b}')" >/dev/null
else
curl -fsS -X POST -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases" \
curl -fsS -X POST -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases" \
-d "$(jq -nc --arg t "$TAG" --arg b "$BODY" '{tag_name:$t, name:$t, body:$b}')" >/dev/null
fi
echo "receipt written to the ${TAG} release"
@@ -1057,3 +1291,34 @@ jobs:
exit 1
fi
echo "${TAG} is complete: document, image, production, reachability and all nine projections."
# THE INVARIANT ITSELF, not this release's part in it.
#
# Every check above answers a question about the run it is in: did MY image
# build, did MY tag survive, did MY pin go live. None of them can see an
# image published by a lane that never entered this workflow — and that is
# not a hypothetical gap, it is the one that happened. v1.801.478, 479, 480
# and 484 were published with no tag in any repo while every cicd.yml run
# on either side of them was green, because none of those runs was looking
# at the registry as a whole. 480 served production, reporting
# {"revision":"unknown"} on its own health endpoint.
#
# So the last thing a release does is re-ask the question about EVERYTHING
# published: does every version in the registry have a receipt. It runs
# last because release.json above is this release's receipt and should be
# written whatever the wider state is, and it runs HERE rather than in
# rollout because a pre-existing orphan must not be able to block a deploy
# — a gate that wedges production on someone else's old mess gets switched
# off within a day, and a switched-off gate is how this started.
#
# It is on the main path, not a cron, for the same reason: a scheduled
# reconciliation is a thing that can quietly stop running and look
# identical to a clean fleet. This cannot stop running without the release
# train stopping with it.
- name: Every published image has a receipt
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: bash .hanzo/scripts/orphans.sh
+122 -78
View File
@@ -7,62 +7,57 @@
# fleet together, which is the whole point of this layout. cmd/cloud IS the one
# real binary; the fused monolith it replaced is gone.
#
# The console UI is compiled into the host via //go:embed (the light webui package,
# which cmd/cloud imports directly), so cmd/cloud — the front door — owns "/" and
# serves the white-labelled SPA for every path no app prefix claims. cmd/cloud also
# threads the deployment's brand/domain/data-dir/iam-issuer flags to the per-app
# children (it re-publishes them as CLOUD_* env the children read), and scopes
# credentials: it scrubs the KMS root key from its own environment and hands it to
# the kms broker child alone — see cmd/cloud.
# cmd/cloud — the front door — owns "/" and serves the white-labelled SPA for every
# path no app prefix claims. It does NOT carry the console: the bytes are a
# published site release, read at boot and re-read on a poll (webui/release), so
# this image ships no console at all. cmd/cloud also threads the deployment's
# brand/domain/data-dir/iam-issuer flags to the per-app children (it re-publishes
# them as CLOUD_* env the children read), and scopes credentials: it scrubs the KMS
# root key from its own environment and hands it to the kms broker child alone —
# see cmd/cloud.
#
# ── prebuilt decomplection artifacts (cloud compiles ONLY Go) ────────────────
# The console SPA and the agent-skills catalog are each built by THEIR OWN CI as
# a versioned immutable image and PULLED here, instead of rebuilding node +
# python from scratch every cloud release.
# The heavy one (console: a cold `npm install` + full Next.js static export,
# 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)
# The agent-skills catalog is built by ITS OWN CI as a versioned immutable image
# and PULLED here, instead of rebuilding python from scratch every cloud release.
# 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
# --build-arg <NAME>_IMAGE=… .
# GET-flow consumers (docker/kaniko/crane). Override the pin with
# --build-arg SKILLS_IMAGE=… .
#
# IMMUTABLE per-commit tags, never `:latest`. These defaults are LOAD-BEARING:
# the builder that actually runs our releases is the native one (POST /v1/runner
# launchDirectBuild → BuildKit), and it passes no --build-arg, so whatever is
# written here is what gets baked. `release.yml`, which the previous comment said
# IMMUTABLE per-commit tags, never `:latest`. This default is LOAD-BEARING: the
# builder that actually runs our releases is the native one (POST /v1/runner
# launchDirectBuild → BuildKit), and it passes no --build-arg, so whatever is
# written here is what gets baked. `release.yml`, which an older comment said
# would resolve a fresh digest, is a stub and resolves nothing.
#
# With `:latest` the embedded console was therefore decided by WHEN the build ran,
# not by what we shipped — and it bit: cloud v1.801.215 was built ~12 minutes
# before console CI finished publishing the console-embed carrying v8.5.26, so a
# release whose whole purpose was that console change silently baked the previous
# one and shipped green. Same image, two contents, no diff to show for it.
# THE CONSOLE IS NO LONGER HERE, and that is the change this file is carrying.
#
# 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.
# There was a CONSOLE_IMAGE pin (`ghcr.io/hanzoai/console-embed:8.5.58`) and a
# stage that copied its /dist into webui/dist for //go:embed to bake. It made a
# console release a CLOUD release: the pin had to move, an image had to be cut
# (~22 min), and the rollout is `strategy: Recreate` on a single replica, so
# shipping a CSS fix took api.hanzo.ai down for a measured 2m15s. It also had a
# whole discipline attached to keep it honest — never `:latest` (cloud v1.801.215
# built ~12 min before console CI published v8.5.26 and silently shipped the
# previous console, green), never re-point a cut tag — and that discipline
# existed only because the console's identity was welded to this image's.
#
# 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 console is a PUBLISHED SITE now (webui/release): the ACTIVE release of the
# `hanzo-console` site, read from S3 at boot and re-read on a poll. Publishing is
# under a second, rollback is faster, and neither builds nor restarts anything.
# "Which console is live" is answered by the site's active release — by the
# system that serves it — instead of by a line in a Dockerfile.
#
# 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
# What did NOT change: console.hanzo.ai IS THIS BINARY. The host still routes to
# `service: cloud` (universe hanzo-domains.yaml), the console still calls /v1 on
# its own origin, and its session cookie is still first-party. Only the source of
# the bytes moved.
#
# Still true, and still a trap: NO host routes to the standalone `console`
# Deployment. Learned the expensive way — console:v8.5.59 was built, pinned and
# rolled to Ready, and served no one. Rolling that Deployment reaches nobody;
# publishing a `hanzo-console` site release is what reaches users.
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:sha-b931a11-amd64
# ── toolchain base images: the golang + alpine FROMs below pull from our own
@@ -76,9 +71,6 @@ ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:sha-b931a11-amd64
# long-term home is registry.hanzo.ai/hanzoai/mirror/* — repoint once the runners
# carry its IAM pull credentials (follow-up).
# ── console SPA static export (prebuilt → /dist) ─────────────────────────────
FROM ${CONSOLE_IMAGE} AS console
# ── agent-skills catalog (prebuilt → /catalog) ──────────────────────────────
FROM ${SKILLS_IMAGE} AS skills
@@ -168,9 +160,8 @@ RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
fi && \
go mod download
COPY . .
# Drop the console static bundle into the embed path BEFORE `go build`, so
# //go:embed all:webui/dist bakes it into the binary (same-origin console).
COPY --from=console /dist/ /src/webui/dist/
# NO console overlay. The console is not in this binary — it is the active release
# of a published site, fetched at boot (webui/release). See the header.
# 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.
#
@@ -182,21 +173,25 @@ COPY --from=console /dist/ /src/webui/dist/
# 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/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
# not an error, it is a silently smaller binary. That is the whole failure above,
# and it survived because the only evidence was a number in a served document.
# Assert it here, where the destination is named, in the terms each fallback is
# defined by rather than a file count that drifts: the skills fallback is ONE
# 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.
# RED gate — the overlay landed WHERE THE EMBED READS. The COPY above writes 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 not an
# error, it is a silently smaller binary. That is the whole failure above, and it
# survived because the only evidence was a number in a served document. Asserted
# here, where the destination is named, in the terms the fallback is defined by
# rather than a file count that drifts: the fallback is ONE skill per brand.
#
# The CONSOLE half of this gate is gone with the thing it guarded. It asserted
# that webui/dist carried JavaScript, because an overlay that missed its embed
# path shipped a scriptless shell that looked like a console. There is no overlay
# and no embed now — the binary carries no console — so there is nothing here that
# could silently be the wrong bytes. The equivalent question ("is a real console
# live?") moved to where it can actually be answered: the site's ACTIVE RELEASE,
# which the binary refuses to boot without (cmd/cloud) and re-checks on a poll.
RUN set -eu; \
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"
echo ">> overlay landed: $n skills/brand"
# RED gate — modernc double-registration guard: 0 modernc under CGO=1 ACROSS EVERY
# per-app binary, else the "sqlite" driver is registered twice (mattn + modernc) →
# panic at init. The fused monolith that this once checked is gone; the union of
@@ -218,19 +213,31 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" \
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
github.com/hanzoai/sqlite
# Go drops comments at compile time, so this pass is the ONLY way a typed handler's
# prose reaches the document: zipdoc lifts it into zipdoc_gen.go, which registers it
# with zip.Describe at init. It must run BEFORE every build below, because the
# generated file is compiled INTO each binary — running it after would be too late.
# NO `go generate -run zipdoc` HERE, DELIBERATELY — and the reason is not that the
# lifted prose stopped mattering. It still is the only way a typed handler's words
# reach /v1/openapi.json: Go drops comments at compile time, zipdoc lifts them into
# zipdoc_gen.go, and that file is compiled INTO each binary below. An image whose
# binaries lack it serves the 1441 description-less operations this step was added
# to fix, and the SDK repos and the CLI read that document.
#
# mk/plugin.mk makes this a prerequisite of the per-app `build`, so the per-app path
# has always had it. This path did not, and the omission is measurable in production:
# api.hanzo.ai/v1/openapi.json serves 1441 operations with ZERO descriptions, which
# is exactly the binary mk/plugin.mk warns about. The SDK repos and the CLI read that
# document, so the prose never reached any of them either.
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 fix for that was never "regenerate during the build". All 99 zipdoc_gen.go
# files are COMMITTED — they are source, the way generated Go is source everywhere
# else — so the tree `COPY . .` just brought in already contains them, and every
# `go build` below compiles the real prose in whether or not anything regenerates.
# Running the generator here re-derived those 99 files from the same inputs to
# produce the same bytes, for 355.9s of a 17-minute build: 35% of the wall clock
# spent proving a file equals itself.
#
# Freshness is the real requirement, and it is a property of the COMMIT, not of the
# image. So it is enforced where commits are: `make zipdoc-check` regenerates from
# source and fails on any diff (hanzo.yml, step `zipdoc-current`), which runs in
# the test lane every later job already declares `needs:` on. A stale lift now
# cannot be merged — which is strictly stronger than this step, because this step
# would happily build a correct image from a stale commit and leave main wrong.
# That is not hypothetical: main carried a stale apps/agents lift while this ran.
#
# It must stay out. Re-adding it buys nothing a green `zipdoc-current` has not
# already proven, and costs the 355.9s back.
# 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).
@@ -277,6 +284,24 @@ 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 \
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /smoke ./plugin/smoke
# THERE IS NO BOX DAEMON. cmd/boxd was deleted with the design that needed it:
# a sandbox is a POD, and commands reach it over the Kubernetes exec subresource
# (SPDY to the apiserver), so there is nothing inside the pod to talk to and
# nothing to ship into it. The stage that built it outlived the source by exactly
# one commit, and `go build ./cmd/boxd` on a directory that does not exist is not
# a warning — it fails the build:
#
# #28 ERROR: process "/bin/sh -c CGO_ENABLED=0 go build ... -o /boxd ./cmd/boxd"
# did not complete successfully: exit code: 1
#
# So EVERY image from main failed here, after a green gate, which is why a
# proven-and-merged executor was never deployed: the lane could test the commit
# and could not build it.
#
# The consumer this fed — hanzo/bot's Dockerfile.box, `COPY --from=cloud:<pin>
# /boxd` — has to move to a pod it does not have to install anything into. Leaving
# a stage here that cannot compile does not keep that consumer working; it only
# stops anything else from shipping.
# 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:
@@ -288,6 +313,20 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
# one-line manifest edit and this Dockerfile does not change. An app with no
# plugin/<app> fails HERE (the generator's bijection would have caught it first).
#
# EXCEPT the CORESIDENT ones, which get no binary. Coresident means the app is not
# prefix-routed: it mounts as middleware on a sibling's router, and cmd/cloud's
# mount() returns before it can ever resolve a path or spawn a child. So its binary
# is linked, copied and pulled on every deploy to be executed never. zen is the one:
# 164.7 MB, 3.9% of this image, for a process that cannot start. Its behaviour ships
# in /ai, which links apps/zen and mounts the Claim ahead of ai's catch-all.
#
# TWO lists, because they answer two questions. `names` is every manifest app and
# still guards the bijection above — a coresident app must STILL have a plugin/<app>
# (gen-app-cmds requires it, and it is what runs standalone in dev). `spawned` is
# what the host can actually load, and that is what earns a binary. Flip
# Coresident:false in the manifest and the binary comes back on the next build,
# because both lists read the same source the host does.
#
# Each link is the ONE app's own graph (~6002200 packages), NEVER the ~3040-pkg
# fleet union the fused binary was. 112 lean links, sequential, none of them mega —
# which is the whole point of this change.
@@ -319,9 +358,12 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
[ -n "$names" ] || { echo "FATAL: no apps parsed from manifest/apps.go — the derivation broke, not the app list"; exit 1; }; \
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="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
done
done; \
coresident="$(sed -n '/Coresident: *true/{s/.*{Name: "\([^"]*\)".*/\1/p;}' manifest/apps.go)"; \
spawned="$(sed -n '/Coresident: *true/d; s/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)"; \
echo "building $(echo "$spawned" | wc -w) of $(echo "$names" | wc -w) plugins, $(nproc) at a time (coresident, never spawned: ${coresident:-none})"; \
printf '%s\n' $spawned | xargs -P "$(nproc)" -I{} sh -c \
'CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$1" "./plugin/$1" || { echo "FATAL: plugin $1 failed to build" >&2; exit 255; }' _ {}
# 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
@@ -390,6 +432,8 @@ COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group
COPY --from=build /cloud /cloud
COPY --from=build /smoke /smoke
# No /boxd: see the build stage. Nothing is carried for a daemon that no longer
# exists, and a COPY of a path the build stage never wrote is its own hard failure.
# The per-app plugin binaries, landing beside /cloud because that is where the host
# looks: manifest.App.Plugin resolves dir(os.Executable())+"/<name>". Copying the
# DIRECTORY's contents keeps this generic — a new app needs no line here, same as
+1087 -53
View File
File diff suppressed because it is too large Load Diff
+74 -40
View File
@@ -1,6 +1,11 @@
# hanzoai/cloud — developer ergonomics for the unified Hanzo Cloud binary (HIP-0106).
# Targets are intentionally minimal; deploy artifacts (compose, helm) live in deploy/ and helm/.
# Bare `make` shows help. Stated explicitly because make otherwise takes the FIRST
# target it parses, and `include mk/fleet.mk` below inserts three targets ahead of
# help — so without this line, typing `make` would silently run the openapi compose.
.DEFAULT_GOAL := help
GO ?= go
# cloud is a STANDALONE Go module — a self-contained deploy unit (its own go.mod,
@@ -44,8 +49,6 @@ REVISION ?= $(shell git describe --always --abbrev=40 --match='' --dirty
# 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.
OPENAPI_DIR ?= ../openapi
@@ -80,21 +83,30 @@ 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 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
# ONE DOOR. mk/fleet.mk defines weave, subsets and check, and
# without this include they were reachable only as `make -f mk/fleet.mk <target>` —
# a path nobody would guess and nothing in `make help` mentioned. Its own header
# always said it was meant to be included here; it just never was, so the drift
# gate (check) sat behind a door with no handle.
include mk/fleet.mk
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke zipdoc-check 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-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; }
@test -f "$(CONSOLE_DIR)/package.json" || { echo "console checkout not found at $(CONSOLE_DIR) — set CONSOLE_DIR=<path>"; exit 1; }
@test -d "$(CONSOLE_DIR)/node_modules" || (cd "$(CONSOLE_DIR)" && npm install --no-audit --no-fund)
cd "$(CONSOLE_DIR)" && NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192 npm run build:embed
# Overlay the fresh static export onto webui/dist, keeping only the tracked
# fallbacks (.gitignore + assets/.gitkeep); the real bundle is build-time-only.
find webui/dist -mindepth 1 -maxdepth 1 ! -name .gitignore ! -name assets -exec rm -rf {} +
cp -r "$(CONSOLE_DIR)/out/." webui/dist/
@echo ">> embedded real console bundle into webui/dist (index.html $$(wc -c < webui/dist/index.html) bytes)"
# THERE IS NO `webui` TARGET, and its absence is the change.
#
# It ran hanzoai/console's `npm run build:embed` and copied the static export into
# webui/dist for //go:embed to bake — which made shipping a console change a cloud
# BUILD (~22 min) plus a `strategy: Recreate` single-replica rollout, measured at
# 2m15s of api.hanzo.ai down. The console is a published site now (see the
# webui/release package): `hanzo sites publish` puts a release live in about a
# second and rolls it back faster, with no build here and no restart there.
#
# Nothing replaces it in this file because nothing in this repo builds the
# console any more. The e2e target that needed a localhost-pinned bundle now
# points the running binary at one with CLOUD_CONSOLE_SITE / CLOUD_CONSOLE_ORG.
deploy-ui: ## Build the monochrome ArgoCD dashboard bundle into apps/deploy/webui/dist (go:embed source). DEPLOY_DIR=<path to hanzoai/deploy>.
@command -v yarn >/dev/null 2>&1 || { echo "yarn is required to build the deploy dashboard bundle"; exit 1; }
@@ -225,15 +237,15 @@ e2e: ## Boot the binary locally and run the Playwright e2e suite against it.
# The console's IAM/cloud origins are NEXT_PUBLIC_* — inlined at BUILD time — so a
# bundle built for production points its login at hanzo.id and its reads at
# api.hanzo.ai. This rebuilds it against the loopback instance so the UI specs
# exercise the local binary end to end. It OVERWRITES webui/dist with a
# localhost-pinned bundle: run plain `make webui` before shipping anything.
E2E_ORIGIN ?= http://127.0.0.1:18080
e2e-ui: ## Rebuild the console pointed at the local instance, then run e2e.
NEXT_PUBLIC_IAM_URL=$(E2E_ORIGIN) NEXT_PUBLIC_CLOUD_URL=$(E2E_ORIGIN) \
NEXT_PUBLIC_IAM_CLIENT_ID=hanzo-cloud NEXT_PUBLIC_IAM_APP_NAME=hanzo-cloud \
NEXT_PUBLIC_IAM_ORG_NAME=hanzo $(MAKE) webui
@E2E_ARGS="$(E2E_ARGS)" ./e2e/run.sh
# api.hanzo.ai, and the UI specs need one pointed at the loopback instance
# instead. That used to mean rebuilding webui/dist here, which OVERWROTE the
# bundle the next `make build` would ship. It is a published site now, so the
# binary is POINTED at a loopback-built release rather than rebuilt around one:
# publish it once from a console checkout (`hanzo sites publish`) and name it.
E2E_ORIGIN ?= http://127.0.0.1:18080
E2E_CONSOLE ?= hanzo-console-e2e
e2e-ui: ## Run e2e against a console release built for the local instance. E2E_CONSOLE=<site slug>.
CLOUD_CONSOLE_SITE=$(E2E_CONSOLE) E2E_ARGS="$(E2E_ARGS)" ./e2e/run.sh
# The data plane has no plaintext-at-rest mode: cek refuses to open a store without
# a master key, on every build. The server makes that a boot decision (serve.go); a
@@ -250,20 +262,44 @@ TEST_ENV = CLOUD_KMS_MASTER_KEY_REF="$${CLOUD_KMS_MASTER_KEY_REF:-$(DEV_KMS_KEY)
# the shipped build carries, so the suite exercises the same schema surface.
TEST_TAGS := sqlite_fts5
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
# The lifted prose is COMMITTED (zipdoc_gen.go) because bare `go build` cannot
# regenerate it; -check writes nothing and goes red when a lift no longer
# matches its source, which is the drift being committed makes possible.
# Go drops comments at compile time, so cmd/zipdoc is the ONLY path from a typed
# handler's prose to /v1/openapi.json — the document the SDK repos and the CLI
# read. Its output is COMMITTED, and that is what lets a bare `go build` (and the
# release image) produce a binary that still describes itself without anyone
# paying to lift the prose again. The image used to pay: `go generate -run zipdoc
# ./...` ran on the build's critical path for 355.9s of a 17-minute build, to
# reproduce 99 files that were already in the tree.
#
# Committed means it can go STALE, so exactly one thing has to stay true:
# regenerating from source changes nothing. This asserts it by running THE
# GENERATOR and diffing, rather than asking a -check mode for a second opinion —
# a gate must never be able to disagree with the tool it polices. It is also the
# only form that catches the case below.
#
# `git status --porcelain`, not `git diff`: a NEW package's zipdoc_gen.go is
# untracked and therefore invisible to a diff, which is the failure that matters
# most. The pathspec scopes it to the generator's own files, so an unrelated
# dirty tree neither hides a stale lift nor invents one.
zipdoc-check: ## Regenerate the lifted prose FROM SOURCE and fail on any diff.
# Per PACKAGE, not ./...: the checker must load exactly the way `go generate`
# does, one package at a time — whole-module loading extracts differently
# (zap-proto/zip zipdoc: single-vs-module load divergence) and a gate must
# never disagree with the generator it polices.
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do (cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; done
# A dot-directory is not this module's source. An agent worktree at
# .claude/worktrees/<id>/ is a whole second checkout of this repository, and
# the walk read it: 203 packages where there are 104, and it went red on a
# copy's o11y while nothing here had changed.
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
done
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
$(MAKE) zipdoc-check
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
# The drift gate: regenerate the document FROM SOURCE and fail on any diff.
# The weave above proves the subsets compose; this proves they are still the
# routes. Only the second one catches a route added without regenerating.
$(MAKE) -f mk/fleet.mk surface-check
$(MAKE) -f mk/fleet.mk check
# The inner loop. Everything `test` runs EXCEPT the drift gate, which rebuilds one
# binary per app and dominates the wall clock.
@@ -275,10 +311,8 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only — CI runs `test`.
@echo ">> test-fast: NOT checking spec drift (openapi.yaml + plugin/*/openapi.json)."
@echo ">> a route added without regenerating will pass here and fail CI."
@echo ">> the real gate: make -f mk/fleet.mk surface-check"
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
done
@echo ">> the real gate: make -f mk/fleet.mk check"
$(MAKE) zipdoc-check
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
# THE spec, in three steps, in the only order they work in:
@@ -295,11 +329,17 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# 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.
# 4. the same run projects that document twice and writes public.yaml beside it
# (openapi/public.go): the PUBLIC contract, which is the operations that
# DECLARED themselves part of it and nothing else. Default-deny — an
# operation that says nothing is internal, so a product cannot reach a
# published SDK by anyone forgetting. openapi.yaml stays the internal
# document, admin included, and is what our own clients are cut from.
#
# openapi.yaml is a golden file: written here, and verified two different ways —
# and the difference between them is the whole lesson.
#
# The WEAVE (openapi-weave, run by `make test`) proves the subsets COMPOSE: no two
# The WEAVE (weave, run by `make test`) proves the subsets COMPOSE: no two
# apps claiming one path, no two claiming one schema name. It compares the subsets
# to the golden they weave into. Both are derived artifacts, and nothing in that
# comparison forces either back to the routes — so they agree with each other
@@ -309,16 +349,10 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# same stale subset, `make test` stayed green, and the entire ingress API was
# missing from the spec every SDK is generated from.
#
# The DRIFT GATE (surface-check) is the one that catches that: it REGENERATES
# The DRIFT GATE (check) is the one that catches that: it REGENERATES
# from source and fails on any diff. It is the expensive half — one binary per
# app — and it is in `make test` anyway, because the cheap half is exactly the
# check that passed while the published document was missing an entire API.
describe: ## Regenerate every app's projections, then weave them into openapi.yaml.
$(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. 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)" ./...
+280
View File
@@ -0,0 +1,280 @@
package cloud
// absent_test.go — what happens when the app you are calling is not there.
//
// THE BUG. One app reached another through a package global that the other app
// set in its Mount. Every plugin runs one app, so the writer and the reader are
// different processes and the global is always nil. The call compiled, found
// nil, and returned the zero value: nil error, empty map, false. Nothing said
// the work had not happened. OnGitPush and OnServiceRelease returned nil for
// every push and every release in the fleet, and a passing test said they should.
//
// THE RULE. Calling an app that is not there must return an ERROR. Not nil, not
// an empty result, not false.
//
// Two tests: one checks the rule, one keeps it checked.
import (
"context"
"errors"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// Where a capability lives.
const (
// remote — the app that registers it and the app that calls it are different,
// so they are different processes. It has to go over the plane and has to fail
// loudly when the owner is not deployed. Every one of these is called by
// TestAbsentErrors.
remote = iota
// local — one process telling itself something. A registration here is
// process-local on purpose, and turning it into an RPC would be worse. The
// reason is not optional.
local
)
// kinds is every Register* in this package and which of the two it is.
// TestAllListed proves the list is complete.
var kinds = map[string]struct {
where int
why string
}{
"RegisterGitImporter": {remote, "integrations decides to import; git holds the repos"},
"RegisterGitMirrorController": {remote, "sync declares the mirror; git holds the repo that pushes it"},
"RegisterIssueSink": {remote, "integrations feeds the items; tracker holds the store"},
"RegisterSync": {remote, "integrations and git trigger; sync holds the engine"},
"RegisterPushBuilder": {remote, "git takes the push; platform holds the builder"},
"RegisterServiceReleaser": {remote, "a build releases; platform holds the CR control plane"},
"RegisterOrgScopeResolver": {remote, "the identity check asks; projects holds the registry"},
"RegisterLifecycleSubscriber": {local,
"best-effort fan-out to reactors registered in the emitting process. A " +
"reactor in another process is that process's business, not a call this " +
"one should make"},
"RegisterTraceSink": {local,
"process-local by construction and already routed both ways: a co-resident " +
"o11y takes the cost-0 leg, a plugin o11y leaves this router empty and the " +
"same Send falls through to the ZAP wire. Absence is routed, not swallowed"},
"RegisterKMSClientFactory": {local,
"builds the embedded client; it is not the capability. Without it " +
"pickKMSClient already returns KMSPeer{}, which is the plane"},
"RegisterCommerceClientFactory": {local,
"builds the embedded client, exactly like the KMS factory above"},
}
// probes is every remote capability, as a call. Each runs with nothing
// registered, no socket and no router — what a process sees when the app it
// wants is simply not deployed beside it.
//
// The signatures differ, so each adapts its own to error. One that returns a
// value and an error returns both; the test reads the error, since a call that
// answered honestly cannot also have produced a usable value.
var probes = []struct {
from string // the Register* it belongs to, so a failure names what to fix
name string
call func(context.Context) error
}{
{"RegisterGitImporter", "ImportGitRepo", func(ctx context.Context) error {
return ImportGitRepo(ctx, GitImportReq{Org: "acme", Repo: "r", CloneURL: "https://x/y.git"})
}},
{"RegisterGitImporter", "InboundGitSync", func(ctx context.Context) error {
_, err := InboundGitSync(ctx, GitInboundReq{Org: "acme", Repo: "r", Ref: "refs/heads/main"})
return err
}},
{"RegisterGitImporter", "GitRepoStatuses", func(ctx context.Context) error {
_, err := GitRepoStatuses(ctx, "acme", "", []string{"r"})
return err
}},
{"RegisterGitMirrorController", "EnsureGitMirror", func(ctx context.Context) error {
return EnsureGitMirror(ctx, "acme", "p", "r", "https://x/y.git", true)
}},
{"RegisterIssueSink", "UpsertIssue", func(ctx context.Context) error {
_, err := UpsertIssue(ctx, IssueUpsert{Org: "acme", ExtRef: "github:o/r#1", Title: "t"})
return err
}},
{"RegisterSync", "Sync", func(ctx context.Context) error {
_, err := Sync(ctx, SyncEvent{Kind: "git", Provider: "github", Org: "acme"})
return err
}},
{"RegisterPushBuilder", "OnGitPush", func(ctx context.Context) error {
return OnGitPush(ctx, GitPushEvent{Org: "acme", Repo: "r", Ref: "refs/heads/main"})
}},
{"RegisterServiceReleaser", "OnServiceRelease", func(ctx context.Context) error {
return OnServiceRelease(ctx, ServiceReleaseEvent{Service: "cloud", Image: "ghcr.io/hanzoai/cloud:v1.0.0"})
}},
{"RegisterOrgScopeResolver", "ProjectOwnership", func(ctx context.Context) error {
_, _, err := ProjectOwnership(ctx, "acme", "some-project")
return err
}},
}
// isolate gives the process an empty runtime directory, so no app's socket
// resolves, and no ZIP_ADDR, so nothing claims a router could start one. reach()
// then answers ErrNoPeer at once instead of spending the 90s wake budget, which
// is also what keeps this fast.
func isolate(t *testing.T) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
t.Setenv("ZIP_ADDR", "")
t.Setenv("CLOUD_RUN_DIR", "")
}
// TestAbsentErrors: with the owning app gone, every call must say so.
//
// A nil here is not cosmetic. It is the shape of the outages this exists to end
// — a push that built nothing, a release that patched nothing, an issue that
// reached no tracker — each reported as success to a caller with no way to learn
// otherwise.
func TestAbsentErrors(t *testing.T) {
isolate(t)
unregister(t)
for _, c := range probes {
t.Run(c.name, func(t *testing.T) {
err := c.call(context.Background())
if err == nil {
t.Fatalf("%s returned nil with %s unregistered and no peer.\n"+
"That is a silent no-op: the caller is told it worked and the work "+
"never happened.", c.name, c.from)
}
t.Logf("%s → %v", c.name, err)
})
}
}
// TestAbsentIsNoPeer: the error must also be readable. A caller has to tell "not
// deployed here" from "deployed and broken" — those need opposite responses, and
// a fleet that cannot tell them apart has shipped that mistake both ways.
func TestAbsentIsNoPeer(t *testing.T) {
isolate(t)
unregister(t)
for _, c := range probes {
t.Run(c.name, func(t *testing.T) {
err := c.call(context.Background())
if err == nil {
t.Fatalf("%s: nil (see TestAbsentErrors)", c.name)
}
if !errors.Is(err, ErrNoPeer) {
t.Fatalf("%s returned %v, which does not wrap ErrNoPeer.\n"+
"A caller cannot tell 'not deployed here' from 'here and failing'.", c.name, err)
}
})
}
}
// TestAllListed is what keeps this enforced after today.
//
// It reads the package's own source for func Register* and fails if one is
// missing from kinds. A new one cannot be added without its author writing down
// whether it crosses a process boundary; if it does, it must also appear in
// probes, which is checked below. Adding a silent one now means deleting a test
// that says not to.
func TestAllListed(t *testing.T) {
found := registers(t)
for _, name := range found {
k, ok := kinds[name]
if !ok {
t.Errorf("%s is not listed.\n"+
"Add it to kinds: remote (goes over the plane, fails loudly when the "+
"owner is absent) or local (with the reason it is one process talking "+
"to itself).", name)
continue
}
if k.where == local && strings.TrimSpace(k.why) == "" {
t.Errorf("%s is listed local with no reason", name)
}
}
// The list may not outlive the code: a stale entry is a claim nobody checks.
for name := range kinds {
if !has(found, name) {
t.Errorf("kinds names %s, which this package no longer declares", name)
}
}
// Every remote one must actually be called by the tests above. One that is
// listed and never called is a claim, not a guarantee.
called := map[string]bool{}
for _, c := range probes {
called[c.from] = true
}
for name, k := range kinds {
if k.where == remote && !called[name] {
t.Errorf("%s is listed remote but nothing in probes exercises it.\n"+
"Add one, or its loud-failure property is asserted nowhere.", name)
}
}
}
// registers parses this package for exported Register* declarations. Source, not
// reflection: Go cannot enumerate a package's functions at runtime, and parsing
// is what makes the list keep itself honest.
func registers(t *testing.T) []string {
t.Helper()
paths, err := filepath.Glob("*.go")
if err != nil {
t.Fatalf("glob: %v", err)
}
fset := token.NewFileSet()
var out []string
for _, path := range paths {
if strings.HasSuffix(path, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv != nil {
continue
}
if name := fn.Name.Name; strings.HasPrefix(name, "Register") {
out = append(out, name)
}
}
}
sort.Strings(out)
if len(out) == 0 {
t.Fatal("found no Register* declarations; the parse is not reading this package")
}
return out
}
func has(all []string, name string) bool {
for _, s := range all {
if s == name {
return true
}
}
return false
}
// unregister clears every remote registration so the tests see a process with no
// co-resident owner. This package's tests share one process and registration is
// a package global, so an earlier test's registration would answer this one.
func unregister(t *testing.T) {
t.Helper()
drop := func() {
RegisterGitImporter(nil)
RegisterGitMirrorController(nil)
RegisterIssueSink(nil)
RegisterSync(nil)
RegisterPushBuilder(nil)
RegisterServiceReleaser(nil)
ResetOrgScopeResolvers()
}
drop()
t.Cleanup(drop)
_ = os.Unsetenv("ZIP_ADDR")
}
+8 -1
View File
@@ -12,6 +12,14 @@ import (
// UsageEvent mirrors the ai module's payload. Separate on purpose: sharing the
// type would reintroduce the import.
//
// IT CARRIES NO REF, and the absence is load-bearing. It used to, on the reading that
// the ai module filled it with a server-written message row id — but that id is
// `Owner + "/" + Name` and both halves come off the JSON body the client posts, so the
// field handed the ledger's idempotency key to the payer: one pinned owner/name and every
// completion after the first deduped into the first one's entry. There is no other
// candidate for it in that module, so the field is gone rather than guarded, and the
// entry's own server-minted id is the key.
type UsageEvent struct {
Subject string
Namespace string
@@ -19,7 +27,6 @@ type UsageEvent struct {
Currency string
Model string
Provider string
RequestID string
}
type (
+77 -53
View File
@@ -1,38 +1,73 @@
package cloud
// Inference reached over the peer's own socket.
// Where a sibling process reaches the model API.
//
// `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.
// `ai` is a plugin of this same binary running as its own process, and the
// question this file answers is the narrow one: from ANOTHER process of the same
// fleet, what address serves /v1/chat/completions?
//
// What that deletes is the whole reason the old path existed:
// # What the plane socket is, and what it is not
//
// 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
// This used to dial the peer's canonical socket — zip.SocketPath("ai"),
// /var/lib/cloud/run/ai.sock — and speak ordinary HTTP to it, on the belief that
// "an app's routes ride its unix socket exactly as they ride a public listener".
// That belief is wrong twice, and each half is independently fatal.
//
// 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.
// THE WIRE IS NOT HTTP. That socket is served by zaphttp.Server (zip
// transport.go: the "zap" scheme, the default for a bare address) — a framed
// binary protocol with its own codec. A cleartext HTTP request is not slower
// there, it is unintelligible: the peer reads a malformed frame and closes, so
// the caller gets `Post "http://ai/v1/chat/completions": EOF` on every request,
// any method, any path, first connection, peer perfectly healthy.
//
// THE SURFACE IS NOT THE APP'S. What binds there is the app's PLANE — the
// typed-op door at /.well-known/zip/op/<name>, which is what plane.Ask uses
// (plane/ask.go: "ServePlane binds before the app's own listener"). The app's
// own HTTP routes are on a listener the plane socket knows nothing about, so
// /v1/chat/completions is a 404 there even when the wire is spoken correctly.
// Measured on a healthy pod: over ZAP, ai.sock answers 404 for /v1/models and
// /v1/chat/completions alike, while ai's own listener answers 200 and 401.
//
// Together they are why @hanzo in Slack answered "the agent hit an error handling
// that": the model call EOF'd, agents recorded an honest error-status run, and the
// bridge turned that into its generic reply. `ai` never logged the request because
// the request never arrived.
//
// # The address that does serve it
//
// The fleet ROUTER's own HTTP listener — the one CLOUD_LISTEN names and
// api.hanzo.ai is merely the public face of. It owns the route table that sends
// /v1/* to `ai`, and it owns starting a cold app, so reaching the model API
// through it is not a special case: it is the same door every external caller
// uses, entered from inside.
//
// On LOOPBACK, which is the whole point. The router runs in this pod, so
// 127.0.0.1 never leaves the network namespace: no DNS, no Service hop, and
// above all no trip out through Cloudflare and back to the pod's own public
// address, which is what the configured base URL (https://api.hanzo.ai/v1) does
// and what made a completion depend on the edge being willing to loop. The
// credential is unchanged — same static key or same M2M identity, chosen the
// same way by the pickers in build.go — because who may ask is a different
// question from where the peer is.
//
// There is deliberately NO second mechanism here. A raw route reached
// process-to-process is not something this fleet offers; ops are (plane.Ask), and
// inventing a parallel path for the one surface that is not an op is what broke
// it. One door, entered from inside.
import (
"context"
"net"
"net/http"
"github.com/zap-proto/zip"
"strings"
)
// aiApp is the app name the socket is derived from. One spelling.
// aiApp is the app name this decision is about. 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"
// aiLoopbackPort is the port assumed when the listener address names none. It
// matches config.go's own default for CLOUD_LISTEN, so the two cannot drift into
// disagreeing about where this binary listens.
const aiLoopbackPort = "8080"
// 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
@@ -40,44 +75,33 @@ const aiPeerURL = "http://ai/v1"
// 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.
// `ai` is a SIBLING and the router's loopback listener 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.
//
// The transport is nil in both branches: an ordinary HTTP address is reached with
// the ordinary transport, and the pickers' "socket" log field reads false because
// no socket is involved. Neither branch needs a custom RoundTripper — waking a
// cold app is the router's job, and doing it again here would be a second
// mechanism for something that already has one.
func aiRoute(cfg *Config) (http.RoundTripper, string) {
if cfg.Enabled(aiApp) {
return nil, cfg.AIBaseURL
}
return newSocketTransport(aiApp), aiPeerURL
return nil, aiLoopbackURL(cfg)
}
// socketRoundTripper speaks HTTP to one app over its canonical unix socket.
// aiLoopbackURL is the router's HTTP listener as seen from inside its own pod.
//
// 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))
},
// Only the PORT is taken from the configured listener: the host half is whatever
// the process binds (":8000", "0.0.0.0:8000"), and neither is an address a client
// may dial. 127.0.0.1 is, and it is the one that cannot leave the pod.
func aiLoopbackURL(cfg *Config) string {
port := aiLoopbackPort
if addr := strings.TrimSpace(cfg.ListenAddr); addr != "" {
if _, p, err := net.SplitHostPort(addr); err == nil && p != "" {
port = p
}
}
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)
return "http://127.0.0.1:" + port + "/v1"
}
+51 -23
View File
@@ -1,42 +1,69 @@
package cloud
import "testing"
import (
"strings"
"testing"
)
// A SIBLING REACHES `ai` OVER ITS SOCKET, NOT THROUGH THE INTERNET.
// A SIBLING REACHES `ai` THROUGH THE ROUTER ON LOOPBACK.
//
// `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"}
// Two addresses are wrong here and this pins both.
//
// The pod's own PUBLIC url sends a completion out through Cloudflare and back,
// and makes the pod mint an OAuth token to authenticate to its own deployment.
//
// The peer's PLANE SOCKET (zip.SocketPath("ai")) cannot serve it at all: that
// socket speaks ZAP, not HTTP, and carries the typed-op door rather than the
// app's routes — so a raw /v1 request there is first unintelligible and then, if
// framed correctly, a 404. Reaching it that way is what made @hanzo answer "the
// agent hit an error handling that" for every Slack turn.
//
// What is left is the router's own listener, entered on 127.0.0.1 so it never
// leaves the pod. Which address a process gets is decided by WHAT IT IS, never
// by configuration.
func TestSiblingReachesAIThroughTheRouterOnLoopback(t *testing.T) {
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
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 via != nil {
t.Errorf("a sibling got a custom transport (%T) — an ordinary address is reached with the ordinary transport", via)
}
if base == "https://api.hanzo.ai/v1" {
t.Error("a sibling addressed `ai` by the pod's OWN public URL")
t.Error("a sibling addressed `ai` by the pod's OWN public URL — that leaves the host and comes back through the edge")
}
if base != aiPeerURL {
t.Errorf("sibling base = %q, want the named peer %q", base, aiPeerURL)
if want := "http://127.0.0.1:8000/v1"; base != want {
t.Errorf("sibling base = %q, want the router on loopback %q", base, want)
}
srt, ok := via.(*socketRoundTripper)
if !ok {
t.Fatalf("transport is %T, want the socket one", via)
}
// The port is READ from the configured listener rather than assumed, or a
// deployment that moves its listener would send every completion to a closed port.
func TestSiblingFollowsTheConfiguredListenerPort(t *testing.T) {
for _, listen := range []string{":9100", "0.0.0.0:9100", "127.0.0.1:9100"} {
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: listen})
if want := "http://127.0.0.1:9100/v1"; base != want {
t.Errorf("ListenAddr %q → %q, want %q", listen, base, want)
}
}
if srt.app != aiApp {
t.Errorf("socket targets %q, want %q — the peer is NAMED, never addressed", srt.app, aiApp)
}
// A sibling never dials the peer's plane socket. That door is zip's typed-op
// plane (plane.Ask), it does not speak HTTP, and the app's own routes are not on
// it — so naming it here can only ever produce an EOF or a 404.
func TestSiblingNeverDialsThePlaneSocket(t *testing.T) {
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: ":8000"})
if strings.Contains(base, ".sock") || strings.HasPrefix(base, "http://ai") {
t.Errorf("sibling base = %q — that is the plane socket, which serves ops and not /v1", base)
}
}
// 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"}
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
via, base := aiRoute(self)
if via != nil {
t.Error("the ai process resolved itself to its own socket — it would call itself")
t.Error("the ai process got a custom transport — it would call itself")
}
if base != "https://api.hanzo.ai/v1" {
t.Errorf("ai process base = %q, want its configured address", base)
@@ -45,8 +72,9 @@ func TestTheAIProcessDoesNotDialItself(t *testing.T) {
// 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")
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"} // empty Enable = carries all
_, base := aiRoute(host)
if base != "https://api.hanzo.ai/v1" {
t.Errorf("host base = %q, want its configured address", base)
}
}
+69 -5
View File
@@ -1,6 +1,10 @@
package cloud
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/apps/sites"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
@@ -29,16 +33,18 @@ import (
// 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.
// name is what the program calls itself in a diagnostic. tools is the per-caller
// half of this program's agent door — the tools that exist because of WHO is
// asking, which only a program holding a subsystem list can declare; everyone
// else passes nil and offers none. The door itself is not optional either way:
// see [callerTools].
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},
MCP: zip.MCPConfig{Source: callerTools(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.
@@ -135,13 +141,71 @@ func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
// 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))
//
// RATE LIMIT FIRST. EdgeCORS now resolves an unknown origin against the site-host
// store, which in production is a plane hop, and the Origin header is chosen by
// the caller — so an attacker rotating a fresh hostname per request would defeat
// the answer cache and turn each inbound request into an internal one. The
// per-IP counter is a map increment and bounds that structurally, with no second
// mechanism to tune. The cost is that a flood of PREFLIGHTS is capped too, which
// is the correct answer to a flood of preflights.
app.Use(EdgeRateLimit(deps.GatewayPolicy))
app.Use(EdgeCORS(deps.GatewayPolicy))
Identify(app, cfg)
return app
}
// callerTools is this program's per-caller tool half — and stating it, rather
// than leaving it nil, is what makes the agent door UNCONDITIONAL.
//
// zip mounts the door only for an app that has something to project: a typed op,
// a composed plugin's catalogue, or a per-caller Source. With all three absent it
// returns before registering the route at all (zip@v1.25.1 mcp.go:99). That is
// the right default for a program nobody interrogates, and the wrong one for
// every program built here, because the fleet's door ASKS EVERY COMPOSED
// SUBSYSTEM on each tools/list (fleet.Ask). A subsystem whose routes are all raw
// — a reverse proxy, or a surface owned by another module — projects no typed op,
// so nothing claimed POST /mcp in its process, so the ask fell through to the
// console's terminal handler and was answered with the signpost that is correct
// only on the front door: 308 → /v1/mcp, an address a child does not serve
// (webui/mcp.go:44). Thirty of the fleet's subsystems — the whole of exec, tasks,
// agent, ask, websearch, crawl, index, kms, billing, platform and twenty more —
// were reported UNREACHABLE that way while every one of them was up, healthy and
// serving its REST surface.
//
// A door whose registry is empty answers {"tools":[]}, and that is a REAL answer:
// "asked, and serves nothing" is a different fact from "could not be asked", and
// keeping those two apart is the whole of package fleet. Its hanzo.ai/unavailable
// list means nothing while a healthy subsystem has no way to say the first one.
//
// nil in, empty out. A program that declares no Plugin.Door still HAS a
// per-caller half; it simply holds no tools. It is consulted once per tools/list
// that names an org, answers nothing, and zip then returns the same pre-rendered
// bytes it always did — the memcpy that makes tools/list free is untouched (zip
// listTools: len(mine) == 0 ⇒ the build-time array, verbatim).
func callerTools(declared zip.Source) zip.Source {
if declared != nil {
return declared
}
return noCallerTools{}
}
// noCallerTools is the per-caller half of a program that declares none: no tools
// exist because of who is asking, and a name nobody projected is nobody's.
//
// Its Call is reached only for a name the build-time catalogue did not claim, and
// it answers with the same sentence zip's own miss does — the fleet's door never
// routes one here (it refuses an unlisted name itself, fleet/mcp.go), so this is
// the reply to a client that guessed.
type noCallerTools struct{}
func (noCallerTools) Tools(context.Context) []map[string]any { return nil }
func (noCallerTools) Call(_ context.Context, name string, _ json.RawMessage) (any, error) {
return nil, fmt.Errorf("unknown tool: %s", name)
}
// 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.
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := account
include ../../mk/plugin.mk
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := admin
include ../../mk/plugin.mk
+15 -1
View File
@@ -77,7 +77,21 @@ func Backfill(ctx context.Context, in *BackfillIn) (*BackfillOut, error) {
if bal == nil {
return &BackfillOut{Status: core.Err, Msg: "read commerce balance: commerce answered nothing"}, nil
}
balanceCents, err := bal.Amount.Minor()
// FLOOR, because Minor() REFUSES a sub-cent amount and this is a migration.
//
// The ledger keeps eighteen decimals and per-token charges are routinely finer
// than a cent, so any org that has spent anything carries a sub-cent tail.
// Minor() answers "is finer than its minor unit; round explicitly" for exactly
// those, and this returned that as an error — so the backfill migrated ZERO for
// every active org while reporting an honest-looking failure. Measured against
// the live value in balance.go's own comment:
//
// 149913.078983985999994361 -> Minor() ERR, Floor 14991307, Round 14991308
//
// Floor is the direction a migration must take: the amount MOVED must never
// exceed the amount held, or the migration mints the difference. The dust stays
// behind and settles.
balanceCents, err := bal.Amount.FloorMinor()
if err != nil {
return &BackfillOut{Status: core.Err, Msg: "read commerce balance: " + err.Error()}, nil
}
+9 -1
View File
@@ -197,7 +197,15 @@ func reserve(ctx context.Context, c *zip.Ctx) (money.Cents, error) {
if out == nil {
return 0, nil
}
cents, err := out.Amount.Minor()
// ROUND, because this is a DISPLAY and Minor() refuses a sub-cent amount.
//
// The treasury reserve carries the same eighteen-decimal tail every ledger
// amount does, so Minor() answers "is finer than its minor unit" and the board
// rendered that parse failure as SrcOf("treasury", err) — "could not reach the
// treasury" — for a treasury that was reachable and correct. Nothing is billed
// from this number; a half-cent either way in a headline figure is not a
// number anyone spends, and being unable to show the figure at all is worse.
cents, err := out.Amount.RoundMinor()
return money.Cents(cents), err
}
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := admission
include ../../mk/plugin.mk
+55 -104
View File
@@ -16,46 +16,51 @@ package admission
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/hanzoai/cloud"
iamplane "github.com/hanzoai/cloud/plane/iam"
"github.com/zap-proto/zip"
)
// approvalStatusPending is the ONE value that gates a user. This mirrors IAM's
// object.ApprovalPending (hanzoai/iam object/user.go) and User.IsApproved() —
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
// here (not importing IAM) keeps admission self-contained.
// approval semantics — approval is FAIL-OPEN: a user is approved unless
// approvalStatus is EXACTLY "pending" (absent / "approved" / "rejected" all read
// approved). Only "pending" holds a user on the waitlist. The literal lives HERE,
// with the gate that acts on it, rather than in the identity store: iam answers
// what it recorded, admission decides what that means, and two places can never
// disagree about who is on a waitlist.
const approvalStatusPending = "pending"
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
// the token and the gateway mints it as a validated header (the same trust model
// as X-User-IsAdmin), the enforcement points read approval for FREE with no IAM
// round-trip. Until then the resolver falls back to an IAM get-account lookup.
// round-trip. Until then the resolver falls back to asking iam over the plane.
// Values: "true" (approved) / "false" (pending). Any other value → fall through.
const approvedHeader = "X-User-Approved"
// accountLookup fetches a caller's approvalStatus by replaying the caller's own
// credentials to IAM get-account. Injected so the resolver is unit-testable
// without a live IAM. It returns (status, ok): ok=false on any IAM error, which
// the resolver treats FAIL-OPEN (approved) — the documented guard behavior
// (availability over a hard gate when IAM is unreachable).
type accountLookup func(ctx context.Context, cookie, auth string) (status string, ok bool)
// accountLookup fetches the CALLER'S approvalStatus from iam. Injected so the
// resolver is unit-testable without a live peer. It returns (status, ok): ok=false
// on any failure, which the resolver treats FAIL-OPEN (approved) — the documented
// guard behavior, availability over a hard gate when iam is unreachable.
//
// It takes only a context, and that is the change: it used to take the caller's
// Cookie and Authorization header, because HTTP gave it no way to say who was
// asking and REPLAYING the caller's own credential to iam was the way to make iam
// answer about them. The plane carries the validated principal, so the credential
// is no longer handled here — or anywhere between here and the store.
type accountLookup func(ctx context.Context) (status string, ok bool)
// Approvals resolves whether the current caller is off the waitlist. It is the ONE
// approval predicate the native middleware uses, DRY with the @file waitlist-guard
// (both read properties.approvalStatus == "pending"). Resolution order:
// (both read approvalStatus == "pending"). Resolution order:
//
// 1. global admin (c.IsAdmin()) → approved (admins are never gated)
// 2. validated header X-User-Approved → its bit (forward-perfect, no lookup)
// 3. IAM get-account (caller's creds) → approved unless approvalStatus=="pending"
// — cached per user for ttl; FAIL-OPEN on any IAM error.
// 3. the iam peer (plane.IAMApproval) → approved unless approvalStatus=="pending"
// — cached per user for ttl; FAIL-OPEN on any error.
type Approvals struct {
lookup accountLookup
ttl time.Duration
@@ -69,19 +74,17 @@ type approvalEntry struct {
at time.Time
}
// NewApprovals builds a resolver. iamBase is the in-cluster IAM base
// (e.g. http://iam.hanzo.svc.cluster.local:8000); ttl bounds the per-user cache.
// A zero iamBase yields a resolver whose lookup always fails-open (approved) —
// safe for a deployment where approval is enforced elsewhere (the guard).
func NewApprovals(iamBase string, ttl time.Duration) *Approvals {
// NewApprovals builds a resolver over the iam peer; ttl bounds the per-user cache.
//
// It takes no address. iam is reached by NAME over its own socket, so there is no
// base URL for a deployment to supply and no way for one to be wrong — the
// zero-iamBase case this used to carry (a resolver that always failed open
// because nobody had configured a URL) is not expressible any more.
func NewApprovals(ttl time.Duration) *Approvals {
if ttl <= 0 {
ttl = 30 * time.Second
}
return &Approvals{
lookup: httpAccountLookup(strings.TrimRight(iamBase, "/")),
ttl: ttl,
cache: map[string]approvalEntry{},
}
return &Approvals{lookup: planeApproval, ttl: ttl, cache: map[string]approvalEntry{}}
}
// newApprovalsWithLookup is the test seam: a resolver over an injected lookup.
@@ -98,14 +101,14 @@ func (a *Approvals) Approved(c *zip.Ctx) bool {
if c.IsAdmin() {
return true
}
// (2) Forward-perfect validated header — no IAM round-trip when present.
// (2) Forward-perfect validated header — no round-trip when present.
switch strings.ToLower(strings.TrimSpace(c.Header(approvedHeader))) {
case "true", "1", "approved":
return true
case "false", "0", "pending":
return false
}
// (3) IAM get-account lookup, cached per user, fail-open on error.
// (3) Ask iam, cached per user, fail-open on error.
user := strings.TrimSpace(c.User())
if user == "" {
// No validated principal — an unauthenticated caller. The middleware
@@ -119,11 +122,13 @@ func (a *Approvals) Approved(c *zip.Ctx) bool {
if e, ok := a.get(user); ok {
return e.approved
}
status, ok := a.lookup(c.Context(),
c.Header("Cookie"), c.Header("Authorization"))
// As(c, "") delegates THIS request's principal unchanged — the caller's own
// authority, and nothing more. It is what replaces forwarding the raw bearer:
// iam learns who is asking from the assertion the gateway already minted.
status, ok := a.lookup(cloud.As(c, ""))
if !ok {
// IAM unreachable → FAIL-OPEN (approved). Do NOT cache a fail-open so the
// next request re-probes and a recovered IAM re-gates promptly.
// iam unreachable → FAIL-OPEN (approved). Do NOT cache a fail-open so the
// next request re-probes and a recovered iam re-gates promptly.
return true
}
approved := strings.TrimSpace(strings.ToLower(status)) != approvalStatusPending
@@ -147,76 +152,22 @@ func (a *Approvals) put(user string, approved bool) {
a.cache[user] = approvalEntry{approved: approved, at: time.Now()}
}
// httpAccountLookup builds the real IAM get-account lookup. It replays the
// caller's Cookie / Authorization to IAM and reads data.properties.approvalStatus
// (the field GetAccount returns via GetMaskedUser). Bounded read + timeout mirror
// the guard's iamGet. Any non-200 / decode error → ok=false (fail-open upstream).
func httpAccountLookup(iamBase string) accountLookup {
if iamBase == "" {
return func(context.Context, string, string) (string, bool) { return "", false }
}
url := iamBase + "/v1/iam/get-account"
return func(ctx context.Context, cookie, auth string) (string, bool) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", false
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", false
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", false
}
return approvalStatusFromAccount(body)
// planeApproval is the real lookup: one ZAP call to iam, by name.
//
// Every failure is ok=false and the resolver fails open — an absent iam, a
// refused call, an unknown subject. That is unchanged behavior, deliberately: the
// gate's availability rule is a property of the gate, and moving the transport
// underneath it must not quietly turn a fail-open into a fail-closed.
func planeApproval(ctx context.Context) (string, bool) {
ctx, cancel := context.WithTimeout(ctx, approvalTimeout)
defer cancel()
out, err := iamplane.IAMApproval(ctx)
if err != nil || out == nil {
return "", false
}
return out.Status, true
}
// approvalStatusFromAccount extracts properties.approvalStatus from an IAM
// get-account response. The user object is at the top level or under `data`
// (the casibase { status, data } envelope). Returns ("", false) on an error
// envelope or a missing user (fail-open upstream). An ABSENT approvalStatus is
// returned as "" (ok=true) — which the resolver reads as approved (fail-open,
// matching IsApproved()).
func approvalStatusFromAccount(body []byte) (string, bool) {
type acct struct {
Owner string `json:"owner"`
Properties map[string]string `json:"properties"`
}
var top struct {
Status string `json:"status"`
acct
Data acct `json:"data"`
}
if err := json.Unmarshal(body, &top); err != nil {
return "", false
}
if top.Status == "error" {
return "", false
}
a := top.acct
if a.Owner == "" && top.Data.Owner != "" {
a = top.Data
}
if a.Owner == "" {
return "", false
}
if a.Properties == nil {
return "", true // no properties → approved (fail-open)
}
return a.Properties["approvalStatus"], true
}
// approvalTimeout bounds the lookup. It sits in front of a request, so a slow iam
// must resolve to the fail-open decision rather than holding the caller.
const approvalTimeout = 8 * time.Second
+216 -23
View File
@@ -4,12 +4,19 @@
package admission
import (
"bytes"
"context"
"io"
"net"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
@@ -37,7 +44,7 @@ func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
}
func TestApprovals_AdminAlwaysApproved(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
t.Fatal("admin must not trigger an IAM lookup")
return "", false
}, time.Minute)
@@ -49,7 +56,7 @@ func TestApprovals_AdminAlwaysApproved(t *testing.T) {
}
func TestApprovals_ForwardHeaderWins(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
t.Fatal("header path must not trigger an IAM lookup")
return "", false
}, time.Minute)
@@ -67,7 +74,7 @@ func TestApprovals_ForwardHeaderWins(t *testing.T) {
func TestApprovals_IAMLookup_PendingGates(t *testing.T) {
calls := 0
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
calls++
return "pending", true
}, time.Minute)
@@ -89,7 +96,7 @@ func TestApprovals_IAMLookup_PendingGates(t *testing.T) {
func TestApprovals_IAMLookup_ApprovedAndAbsentPass(t *testing.T) {
for _, status := range []string{"approved", "", "rejected"} {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
return status, true
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
@@ -101,7 +108,7 @@ func TestApprovals_IAMLookup_ApprovedAndAbsentPass(t *testing.T) {
}
func TestApprovals_FailOpenOnIAMError(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
return "", false // IAM unreachable
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
@@ -112,7 +119,7 @@ func TestApprovals_FailOpenOnIAMError(t *testing.T) {
}
func TestApprovals_UnauthenticatedNotApproved(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
a := newApprovalsWithLookup(func(context.Context) (string, bool) {
t.Fatal("no lookup for an unauthenticated caller")
return "", false
}, time.Minute)
@@ -123,25 +130,211 @@ func TestApprovals_UnauthenticatedNotApproved(t *testing.T) {
})
}
func TestApprovalStatusFromAccount(t *testing.T) {
cases := []struct {
name string
body string
wantStatus string
wantOK bool
// The waitlist read, over the real plane.
//
// This replaces a table test over an IAM JSON envelope — {status,data,properties}
// with its top-level and data-wrapped shapes — which went away with the HTTP
// client that had to parse it. What that test was really pinning is the
// distinction the gate turns on, and it is pinned here against a live socket:
//
// iam SAID "pending" → gated
// iam SAID nothing → approved (an absent approvalStatus is approved)
// iam could not be ASKED → approved (fail-open), and NOT cached
//
// The third is the one worth a socket. It is a security gate whose availability
// rule says an unreachable identity store must not lock everyone out, and moving
// the transport underneath it is exactly when that rule gets broken by accident.
func TestApprovals_PlaneAnswerDecidesTheGate(t *testing.T) {
for _, tc := range []struct {
name string
status string
approved bool
}{
{"top-level pending", `{"owner":"acme","properties":{"approvalStatus":"pending"}}`, "pending", true},
{"data-wrapped approved", `{"status":"ok","data":{"owner":"acme","properties":{"approvalStatus":"approved"}}}`, "approved", true},
{"no properties", `{"owner":"acme"}`, "", true},
{"error envelope", `{"status":"error","msg":"nope"}`, "", false},
{"no owner", `{"properties":{"approvalStatus":"pending"}}`, "", false},
}
for _, tc := range cases {
{"pending gates", "pending", false},
{"approved passes", "approved", true},
{"absent status is approved", "", true},
{"rejected is not pending, so it passes", "rejected", true},
} {
t.Run(tc.name, func(t *testing.T) {
got, ok := approvalStatusFromAccount([]byte(tc.body))
if got != tc.wantStatus || ok != tc.wantOK {
t.Fatalf("= (%q,%v), want (%q,%v)", got, ok, tc.wantStatus, tc.wantOK)
}
stop := servePeerApproval(t, tc.status)
t.Cleanup(func() { _ = stop(); cloud.ResetPlane() })
a := NewApprovals(time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u"}, func(c *zip.Ctx) {
if got := a.Approved(c); got != tc.approved {
t.Fatalf("iam said %q → Approved = %v, want %v", tc.status, got, tc.approved)
}
})
})
}
}
// TestApprovals_AbsentPeerFailsOpenAndIsNotCached is the availability rule, with
// the peer genuinely not there. A gate that locked every user out because iam was
// restarting would be a worse outage than the one it is guarding against — and
// caching that verdict would keep them locked out after iam came back.
func TestApprovals_AbsentPeerFailsOpenAndIsNotCached(t *testing.T) {
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
plane.Unbind()
cloud.ResetPlane() // nothing listening
a := NewApprovals(time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u"}, func(c *zip.Ctx) {
if !a.Approved(c) {
t.Fatal("an unreachable iam must FAIL OPEN; the gate locked a user out")
}
})
if _, cached := a.get("u"); cached {
t.Fatal("a fail-open verdict was CACHED; a recovered iam would not re-gate for a full ttl")
}
// iam comes back saying pending, and the very next request is gated.
stop := servePeerApproval(t, "pending")
t.Cleanup(func() { _ = stop(); cloud.ResetPlane() })
ctxWith(t, map[string]string{"X-User-Id": "u"}, func(c *zip.Ctx) {
if a.Approved(c) {
t.Fatal("iam recovered and said pending, but the caller was still approved")
}
})
}
// TestApprovals_NoCredentialCrossesTheWire is the point of the change, checked on
// the wire rather than by asking the callee.
//
// The lookup used to replay the caller's Cookie and Authorization to IAM. It
// cannot now, because it is handed neither — but "cannot" is a claim about source,
// and the bytes are the fact. A recording relay sits at the socket the caller
// dials, and the assertion is that the caller's secret is nowhere in the frames
// while the subject the gateway asserted is.
//
// (A plane handler has no header accessor at all, which is the structural half of
// the same guarantee: even a forwarded credential would have nothing to read it.)
func TestApprovals_NoCredentialCrossesTheWire(t *testing.T) {
const secret = "super-secret-credential"
front, back := t.TempDir(), t.TempDir()
t.Setenv("ZIP_RUNTIME_DIR", back)
plane.Unbind()
cloud.ResetPlane()
var sawUser string
zip.Post[struct{}, plane.Approval](cloud.Plane(), "/iam/approval",
func(ctx context.Context, _ *struct{}) (*plane.Approval, error) {
sawUser = cloud.Who(ctx).User
return &plane.Approval{Status: "approved"}, nil
},
zip.WithOperationID(plane.IAMApproval))
stop, err := cloud.ServePlane("iam", nil)
if err != nil {
t.Fatalf("ServePlane(iam): %v", err)
}
t.Cleanup(func() { _ = stop(); cloud.ResetPlane() })
peer := filepath.Join(back, "iam.sock")
waitAccept(t, peer)
var mu sync.Mutex
var seen bytes.Buffer
ln, err := net.Listen("unix", filepath.Join(front, "iam.sock"))
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, aerr := ln.Accept()
if aerr != nil {
return
}
go func() {
defer func() { _ = c.Close() }()
up, derr := net.Dial("unix", peer)
if derr != nil {
return
}
defer func() { _ = up.Close() }()
done := make(chan struct{})
go func() {
_, _ = io.Copy(io.MultiWriter(up, locked{&mu, &seen}), c)
_ = up.(*net.UnixConn).CloseWrite()
close(done)
}()
_, _ = io.Copy(c, up)
<-done
}()
}
}()
t.Setenv("ZIP_RUNTIME_DIR", front)
plane.Unbind()
a := NewApprovals(time.Minute)
ctxWith(t, map[string]string{
"X-User-Id": "u-real",
"Cookie": "session=" + secret,
"Authorization": "Bearer " + secret,
}, func(c *zip.Ctx) { _ = a.Approved(c) })
mu.Lock()
wire := seen.String()
mu.Unlock()
if wire == "" {
t.Fatal("the relay captured nothing; no bytes crossed the socket")
}
if strings.Contains(wire, secret) {
t.Fatalf("the caller's CREDENTIAL is on the wire to iam:\n%s", wire)
}
if !strings.Contains(wire, "u-real") {
t.Fatalf("the asserted subject is NOT on the wire; iam cannot know who is asking:\n%s", wire)
}
if sawUser != "u-real" {
t.Fatalf("iam saw subject %q, want u-real", sawUser)
}
}
// locked serializes writes into a capture buffer shared with the test goroutine.
type locked struct {
mu *sync.Mutex
b *bytes.Buffer
}
func (l locked) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
// waitAccept blocks until path accepts.
func waitAccept(t *testing.T, path string) {
t.Helper()
for i := 0; i < 200; i++ {
if c, err := net.Dial("unix", path); err == nil {
_ = c.Close()
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("socket %s never accepted", path)
}
// servePeerApproval stands iam up on a real socket answering one status. peek, when
// set, observes the call's context on the callee side.
func servePeerApproval(t *testing.T, status string) func() error {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
plane.Unbind()
cloud.ResetPlane()
zip.Post[struct{}, plane.Approval](cloud.Plane(), "/iam/approval",
func(context.Context, *struct{}) (*plane.Approval, error) {
return &plane.Approval{Status: status}, nil
},
zip.WithOperationID(plane.IAMApproval))
stop, err := cloud.ServePlane("iam", nil)
if err != nil {
t.Fatalf("ServePlane(iam): %v", err)
}
for i := 0; i < 200; i++ {
if c, derr := net.Dial("unix", zip.SocketPath("iam")); derr == nil {
_ = c.Close()
break
}
time.Sleep(10 * time.Millisecond)
}
return stop
}
+3 -5
View File
@@ -99,12 +99,10 @@ type EnforceConfig struct {
WaitlistURL string
// Approvals resolves whether the caller is off the waitlist. When nil, Enforce
// builds one from IAMBase.
// builds one over the iam peer — which takes no address, so there is nothing
// else for a deployment to supply.
Approvals *Approvals
// IAMBase is the in-cluster IAM base used to build Approvals when it is nil.
IAMBase string
// ExemptPrefixes are request-path prefixes never gated (health/metrics/auth).
// A sensible default set is used when empty.
ExemptPrefixes []string
@@ -135,7 +133,7 @@ var defaultExemptPrefixes = []string{
func Enforce(cfg EnforceConfig) zip.Handler {
approvals := cfg.Approvals
if approvals == nil {
approvals = NewApprovals(cfg.IAMBase, 0)
approvals = NewApprovals(0)
}
gate := cfg.Gate
if gate == nil {
+2 -2
View File
@@ -33,7 +33,7 @@ func testGate(_ context.Context, host string) (mode bool, service string, known
// injected approval status decides whether the caller is off the waitlist.
func gateApp(t *testing.T, approvalStatus string) *zip.App {
t.Helper()
approvals := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
approvals := newApprovalsWithLookup(func(context.Context) (string, bool) {
return approvalStatus, true
}, time.Minute)
@@ -217,7 +217,7 @@ 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)}))
Approvals: newApprovalsWithLookup(func(context.Context) (string, bool) { return "pending", true }, time.Minute)}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
if code != 200 {
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := ads
include ../../mk/plugin.mk
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := affiliates
include ../../mk/plugin.mk
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -37,13 +37,11 @@ func newFakeCommerce() *fakeCommerce {
return &fakeCommerce{balance: map[string]int64{}, spend: map[string]int64{}}
}
func (f *fakeCommerce) configured() bool { return true }
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failDep {
return "", errUnconfigured
return "", errNoLedger
}
f.balance[org] += amountCents
f.deposits++
@@ -51,7 +49,7 @@ func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int
return "txn_test_" + org + "_" + strconv.Itoa(f.seq), nil
}
func (f *fakeCommerce) spendCents(_ context.Context, org, _ string) (int64, error) {
func (f *fakeCommerce) spendCents(_ context.Context, org string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.spend[org], nil
@@ -95,6 +93,9 @@ func mount(t *testing.T) (*zip.App, *cloud.Service[state], *fakeCommerce) {
},
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// The composer's install, once at the root, ahead of every route it serves:
// cloud.Bridge parks the validated org on the context for the typed ops.
app.Use(cloud.Bridge())
routes(app, s)
return app, s, fc
}
@@ -547,7 +548,7 @@ func TestAdminGateAndDirectory(t *testing.T) {
if a0.AccruedCents != wantCommission || a0.PendingCents != wantCommission {
t.Fatalf("admin row accrual: accrued=%d pending=%d, want %d", a0.AccruedCents, a0.PendingCents, wantCommission)
}
var sum adminSummary
var sum totals
if err := json.Unmarshal(data["summary"], &sum); err != nil {
t.Fatalf("decode summary: %v", err)
}
+8 -9
View File
@@ -18,25 +18,24 @@ import (
// 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
spendCents(ctx context.Context, org, user string) (int64, error)
spendCents(ctx context.Context, org string) (int64, error)
}
// 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
var errNoLedger = payout.ErrNoLedger
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// 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) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
func (s commerceSeam) spendCents(ctx context.Context, org string) (int64, error) {
return s.c.SpendCents(ctx, org)
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce {
return commerceSeam{payout.NewClient(base, token)}
// newCommerceClient builds the production binding, delegating to apps/payout. It
// takes no address: a peer is reached by NAME.
func newCommerceClient() commerce {
return commerceSeam{payout.NewClient()}
}
+377 -273
View File
@@ -6,7 +6,8 @@ package affiliates
// scoped SERVER-SIDE to the caller's own affiliate (resolved from the validated org),
// so an affiliate can only ever see its OWN earnings, links, and downline; the
// leaderboard exposes only opt-in handles + aggregate share + the caller's own rank,
// never another org's identity or a referred org's raw usage.
// never another org's identity or a referred org's raw usage. Amounts are integer
// cents throughout, matching the store.
import (
"context"
@@ -19,13 +20,11 @@ import (
"unicode"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
// clicks coalesces public link-click pings in memory so a flood never reaches the money
// DB write path. clickLink folds a ping into pending[code] (O(1), no DB); a bounded map
// DB write path. click folds a ping into pending[code] (O(1), no DB); a bounded map
// drops the rare overflow. The tallies are flushed to affiliate_links — batched, one tx —
// lazily on the next authenticated links read and on shutdown, so the worst case is one
// coalesced UPDATE per code per read, regardless of click volume. Clicks are a pure vanity
@@ -78,99 +77,20 @@ const (
leaderboardLimit = 50
)
// The dashboard routes are raw handlers too, so their prose is declared here
// beside them. Amounts are integer cents throughout, matching the store.
func init() {
openapi.Describe("/v1/affiliates/me/earnings", http.MethodGet,
"Your commission ledger, by period and by referred org",
"The caller's own commission ledger: per period, the margin it earned against and "+
"the commission taken from that margin; and per referred org, that referral's "+
"aggregate contribution. Integer cents throughout.\n\n"+
"The per-org view deliberately carries the affiliate's OWN earned share and NOT "+
"the referred org's spend or margin. An affiliate is entitled to what it earned, "+
"not to a restatement of its customer's usage — the period view is where the "+
"margin base appears, aggregated across every referral.\n\n"+
"Scoped server-side to the validated caller's affiliate; a caller that is not one "+
"gets `isAffiliate:false`. An approved affiliate's ledger is refreshed by a "+
"bounded, best-effort sweep first, so the figures are current.")
openapi.Describe("/v1/affiliates/me/links", http.MethodGet,
"Your share links and their funnel",
"The caller's share links, each with its URL and its funnel: clicks tracked, signups "+
"— orgs attributed with that code — and conversions, meaning how many of those "+
"signups have actually produced commission.\n\n"+
"Signups and conversions are DERIVED from the commission ledger and never stored, "+
"so they cannot drift from the money. Clicks are the one stored counter and the "+
"one that is pure vanity.\n\n"+
"Any pending public click pings are folded into the store before the read, in one "+
"batch — which is how the counters stay current without a database write per "+
"click. Scoped to the validated caller's own affiliate; a non-affiliate gets "+
"`isAffiliate:false` and the link cap.")
openapi.Describe("/v1/affiliates/me/links", http.MethodPost,
"Mint a new share link",
"Mints a new share link for the caller's own affiliate and answers it with its full "+
"URL, 201.\n\n"+
"APPROVAL IS REQUIRED: an org that has applied but is not approved is refused, "+
"because a link that cannot accrue is a link that quietly loses the referral. A "+
"requested vanity code must be valid and free across the WHOLE directory — codes "+
"are one global namespace, so a taken code is a 409 rather than a silent alias. "+
"Omit the code and a random one is minted.\n\n"+
"Bounded per affiliate. The label is cosmetic: it is trimmed, stripped of control "+
"characters and capped, and it is never part of a code.")
openapi.Describe("/v1/affiliates/me/handle", http.MethodPost,
"Set or clear your public leaderboard name",
"Sets the caller's public leaderboard display name, or clears it.\n\n"+
"The handle IS the opt-in. An empty handle opts out: the affiliate keeps its rank "+
"and can still see its own row, it simply stops being listed to anyone else. That "+
"is the whole privacy control — there is no separate visibility flag, and no way to "+
"be listed without choosing a name.\n\n"+
"Requires a validated principal and an existing affiliate record; apply first. The "+
"handle is bounded and restricted to letters, digits, space, hyphen, underscore "+
"and dot.")
openapi.Describe("/v1/affiliates/click", http.MethodPost,
"Count a click on a share link",
"Counts a click on a share link. PUBLIC — it takes no principal, because a visitor "+
"clicking a shareable link has no session yet.\n\n"+
"The ping folds into an in-memory buffer and NEVER writes the money database "+
"synchronously, so a click flood cannot contend with the accrual and payout write "+
"path; tallies are flushed in one batch on the next authenticated links read and "+
"at shutdown. Clicks are a vanity metric: no accrual and no payout ever reads them "+
"— those key on real metered spend — so click inflation cannot move money.\n\n"+
"Any well-formed code is accepted WITHOUT checking that it exists, deliberately: "+
"this is not a code-existence oracle. `counted` reports that the buffer took the "+
"ping, not that the code is real; an unknown code simply no-ops at flush time.")
openapi.Describe("/v1/affiliates/leaderboard", http.MethodGet,
"The partner leaderboard, plus your own rank",
"The top affiliates by lifetime accrued commission, shown by OPT-IN HANDLE with "+
"aggregate figures only, plus the caller's own exact rank.\n\n"+
"It never discloses an org identity and never a referred org's usage. An affiliate "+
"that has set no handle still OCCUPIES its rank but is not listed — so opting out "+
"hides the name, not the position, and the visible board must not be read as a "+
"complete roster.\n\n"+
"The caller's own row carries its exact GLOBAL rank, computed over the whole "+
"approved set rather than over the page, so it is right well outside the top of the "+
"board. Only an approved affiliate has a rank. Requires a validated principal; a "+
"signed-in non-affiliate may read the board but gets no personal row.")
openapi.Describe("/v1/admin/affiliates/:id/rate", http.MethodPost,
"Set an affiliate's direct commission rate",
"Sets one affiliate's DIRECT commission rate, in basis points of Hanzo's margin.\n\n"+
"The rate is CAPPED so that the direct rate plus the platform-wide second- and "+
"third-level rates can never exceed the whole margin — the structural guarantee "+
"that everything paid on one source event stays inside the margin actually earned. "+
"The cap is resolved from the rates in force at the moment of the call and quoted "+
"in the refusal, because those switches move; a hardcoded bound would start lying "+
"the moment somebody edits the schedule.\n\n"+
"Only the direct level is per-affiliate. The second and third levels are platform "+
"switches and are not settable here. The change applies to FUTURE accruals — "+
"commission already latched for a period is not recomputed. PLATFORM SUDO ONLY. "+
"Audited.")
}
// ── earnings (the per-affiliate share-ledger projection) ────────────────────────
type periodEarningView struct {
Period string `json:"period"`
MarginCents int64 `json:"marginCents"`
CommissionCents int64 `json:"commissionCents"`
// Period is the accrual bucket: the UTC year-month, "YYYY-MM". Commission is
// latched at most once per referred org per period, so one row is one month.
Period string `json:"period"`
// MarginCents is the margin Hanzo earned in that period on the spend of every
// org the caller referred, in cents — the base commission is a rate OF. It is
// the aggregate base, never any one customer's bill.
MarginCents int64 `json:"marginCents"`
// CommissionCents is what the caller earned that period, in cents: the sum over
// each referred org and upline level of margin × that level's rate. Always ≤
// marginCents, by construction.
CommissionCents int64 `json:"commissionCents"`
}
// orgEarningView is the affiliate's per-referred-org contribution: the affiliate's OWN
@@ -178,33 +98,68 @@ type periodEarningView struct {
// referred org's gross usage is never restated to the affiliate (only the affiliate's
// own earned share, which it is entitled to).
type orgEarningView struct {
ReferredOrg string `json:"referredOrg"`
CommissionCents int64 `json:"commissionCents"`
// ReferredOrg is the org slug this contribution came from — one the caller
// referred, directly or up to three levels down.
ReferredOrg string `json:"referredOrg"`
// CommissionCents is what the caller earned from that org across ALL periods, in
// cents. Deliberately the caller's own share and nothing else: that org's spend
// and the margin on it are not restated here.
CommissionCents int64 `json:"commissionCents"`
}
// myEarnings answers GET /v1/affiliates/me/earnings the caller's per-period share
// ledger (margin base + share) and its per-referred-org aggregate share. Approved
// affiliates get an opportunistic lazy sweep first so the numbers are current.
func myEarnings(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
// affiliateEarnings is the caller's commission ledger, or the honest
// `isAffiliate:false` for a caller that is not one. Integer cents.
type affiliateEarnings struct {
// AccruedCents is lifetime commission accrued, in cents.
AccruedCents *int64 `json:"accruedCents,omitempty"`
// ByPeriod is the per-period ledger: the margin earned against and the
// commission taken from it.
ByPeriod *[]periodEarningView `json:"byPeriod,omitempty"`
// ByReferredOrg is each referral's aggregate contribution — the affiliate's
// OWN share, never the referred org's spend.
ByReferredOrg *[]orgEarningView `json:"byReferredOrg,omitempty"`
// IsAffiliate says whether the caller org has an affiliate record. On false it
// is the ONLY field present — there is no ledger to report, and the zeros you
// might expect are absent rather than reported as earnings of nothing.
IsAffiliate bool `json:"isAffiliate"`
// MarginBps is the platform gross-margin fraction commission is a rate OF.
MarginBps *int64 `json:"marginBps,omitempty"`
// PaidCents is lifetime commission already paid out, in cents.
PaidCents *int64 `json:"paidCents,omitempty"`
// PendingCents is accrued minus paid — what the platform still owes.
PendingCents *int64 `json:"pendingCents,omitempty"`
}
// earnings answers the caller's own commission ledger: per period, the margin it
// earned against and the commission taken from that margin; and per referred
// org, that referral's aggregate contribution. Integer cents throughout.
//
// The per-org view deliberately carries the affiliate's OWN earned share and NOT
// the referred org's spend or margin. An affiliate is entitled to what it
// earned, not to a restatement of its customer's usage — the period view is
// where the margin base appears, aggregated across every referral.
//
// Scoped server-side to the validated caller's affiliate; a caller that is not
// one gets `isAffiliate:false`.
func (o ops) earnings(ctx context.Context, _ *noInput) (*affiliateEarnings, error) {
org, ok := tenant(ctx)
if !ok {
return zip.ErrForbidden("sign in to view your affiliate earnings")
return nil, zip.ErrForbidden("sign in to view your affiliate earnings")
}
ctx := c.Context()
a, err := s.State.store.GetByOrg(ctx, org)
a, err := o.s.State.store.GetByOrg(ctx, org)
if err == errNotFound {
return c.JSON(http.StatusOK, map[string]any{"isAffiliate": false})
return &affiliateEarnings{IsAffiliate: false}, nil
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
byPeriod, err := s.State.store.EarningsByPeriod(ctx, a.ID, earningsLimit)
byPeriod, err := o.s.State.store.EarningsByPeriod(ctx, a.ID, earningsLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "earnings by period: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "earnings by period: %v", err)
}
byOrg, err := s.State.store.EarningsByReferredOrg(ctx, a.ID, earningsLimit)
byOrg, err := o.s.State.store.EarningsByReferredOrg(ctx, a.ID, earningsLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "earnings by org: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "earnings by org: %v", err)
}
periods := make([]periodEarningView, 0, len(byPeriod))
@@ -212,77 +167,121 @@ func myEarnings(s *cloud.Service[state], c *zip.Ctx) error {
periods = append(periods, periodEarningView{Period: p.Period, MarginCents: p.MarginCents, CommissionCents: p.CommissionCents})
}
orgs := make([]orgEarningView, 0, len(byOrg))
for _, o := range byOrg {
orgs = append(orgs, orgEarningView{ReferredOrg: o.ReferredOrg, CommissionCents: o.CommissionCents})
for _, e := range byOrg {
orgs = append(orgs, orgEarningView{ReferredOrg: e.ReferredOrg, CommissionCents: e.CommissionCents})
}
return c.JSON(http.StatusOK, map[string]any{
"isAffiliate": true,
"marginBps": affiliateMarginBps(),
"accruedCents": a.AccruedCents,
"pendingCents": a.PendingCents(),
"paidCents": a.PaidCents,
"byPeriod": periods,
"byReferredOrg": orgs,
})
return &affiliateEarnings{
IsAffiliate: true,
MarginBps: opt(affiliateMarginBps()),
AccruedCents: opt(a.AccruedCents),
PendingCents: opt(a.PendingCents()),
PaidCents: opt(a.PaidCents),
ByPeriod: opt(periods),
ByReferredOrg: opt(orgs),
}, nil
}
// ── shareable links ─────────────────────────────────────────────────────────────
// linkView is one shareable link with its derived stats: clicks (tracked), signups
// codeView is one shareable link with its derived stats: clicks (tracked), signups
// (orgs attributed with this code), conversions (of those, how many produced a
// commission). Signups/conversions are DERIVED from the ledger, never stored.
type linkView struct {
Code string `json:"code"`
Label string `json:"label"`
URL string `json:"url"`
Clicks int64 `json:"clicks"`
Signups int `json:"signups"`
Conversions int `json:"conversions"`
CreatedAt int64 `json:"createdAt"`
type codeView struct {
// Code is the link's slug — 332 chars of az, 09 and hyphen — unique across
// the WHOLE directory, so any affiliate's code resolves an attribution.
Code string `json:"code"`
// Label is the caller's own note for the link ("twitter", "newsletter").
// Cosmetic: trimmed, stripped of control characters, capped at 48 bytes, and
// never part of the code. "primary" on the link mirrored at approval.
Label string `json:"label"`
// URL is the full shareable link, the brand host plus ?aff=<code>. The host is
// the deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai
// link.
URL string `json:"url"`
// Clicks is how many pings this code has taken. The one STORED counter here and
// pure vanity: no accrual or payout reads it, pings are coalesced in memory and
// flushed in batches, and a dropped tally is accepted rather than contending
// with the money write path. Do not reconcile it against anything.
Clicks int64 `json:"clicks"`
// Signups is how many orgs were attributed with this code — DERIVED by counting
// attribution edges, never stored, so it cannot drift from the ledger.
Signups int `json:"signups"`
// Conversions is how many of those signups have actually produced positive
// commission for the caller. Also derived, from the accrual rows, so it is
// ≤ signups and lags a referral until the first sweep after it spends.
Conversions int `json:"conversions"`
// CreatedAt is when the link was minted, Unix seconds UTC.
CreatedAt int64 `json:"createdAt"`
}
// myLinks answers GET /v1/affiliates/me/links the caller's shareable links with
// per-link click/signup/conversion stats.
func myLinks(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
// affiliateLinks is the caller's share links with their funnel, or the honest
// `isAffiliate:false` beside the link cap.
type affiliateLinks struct {
// IsAffiliate says whether the caller org has an affiliate record. On false only
// maxLinks comes back — there are no links, and there is no link to mint until
// the org applies and is approved.
IsAffiliate bool `json:"isAffiliate"`
// Links is the caller's share links, each with its URL and funnel.
Links *[]codeView `json:"links,omitempty"`
// MaxLinks is how many share links one affiliate may hold.
MaxLinks int `json:"maxLinks"`
// Status is the caller's affiliate status: "applied", "approved" or
// "suspended"; absent for a non-affiliate. Minting a link requires "approved",
// because a link that cannot accrue quietly loses the referral.
Status string `json:"status,omitempty"`
}
// links answers the caller's share links, each with its URL and its funnel:
// clicks tracked, signups — orgs attributed with that code — and conversions,
// meaning how many of those signups have actually produced commission.
//
// Signups and conversions are DERIVED from the commission ledger and never
// stored, so they cannot drift from the money. Clicks are the one stored counter
// and the one that is pure vanity.
//
// Any pending public click pings are folded into the store before the read, in
// one batch — which is how the counters stay current without a database write
// per click. Scoped to the validated caller's own affiliate; a non-affiliate
// gets `isAffiliate:false` and the link cap.
func (o ops) links(ctx context.Context, _ *noInput) (*affiliateLinks, error) {
org, ok := tenant(ctx)
if !ok {
return zip.ErrForbidden("sign in to view your referral links")
return nil, zip.ErrForbidden("sign in to view your referral links")
}
ctx := c.Context()
// Fold any pending public clicks into the money DB before reading (batched, bounded), so
// the counters are current without a per-click money-DB write.
flushClicks(s, ctx)
a, err := s.State.store.GetByOrg(ctx, org)
flushClicks(o.s, ctx)
a, err := o.s.State.store.GetByOrg(ctx, org)
if err == errNotFound {
return c.JSON(http.StatusOK, map[string]any{"isAffiliate": false, "maxLinks": maxLinksPerAffiliate})
return &affiliateLinks{IsAffiliate: false, MaxLinks: maxLinksPerAffiliate}, nil
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
links, err := s.State.store.ListLinks(ctx, a.ID, linkLimit)
rows, err := o.s.State.store.ListLinks(ctx, a.ID, linkLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list links: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "list links: %v", err)
}
signups, err := s.State.store.SignupsByCode(ctx, a.ID)
signups, err := o.s.State.store.SignupsByCode(ctx, a.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "signups: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "signups: %v", err)
}
conversions, err := s.State.store.ConversionsByCode(ctx, a.ID)
conversions, err := o.s.State.store.ConversionsByCode(ctx, a.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "conversions: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "conversions: %v", err)
}
return c.JSON(http.StatusOK, map[string]any{
"isAffiliate": true,
"status": a.Status,
"maxLinks": maxLinksPerAffiliate,
"links": linkViews(s, links, signups, conversions),
})
return &affiliateLinks{
IsAffiliate: true,
Status: a.Status,
MaxLinks: maxLinksPerAffiliate,
Links: opt(linkViews(o.s, rows, signups, conversions)),
}, nil
}
func linkViews(s *cloud.Service[state], links []Link, signups, conversions map[string]int) []linkView {
out := make([]linkView, 0, len(links))
func linkViews(s *cloud.Service[state], links []Link, signups, conversions map[string]int) []codeView {
out := make([]codeView, 0, len(links))
for _, l := range links {
out = append(out, linkView{
out = append(out, codeView{
Code: l.Code, Label: l.Label, URL: affiliateLink(s, l.Code), Clicks: l.Clicks,
Signups: signups[l.Code], Conversions: conversions[l.Code], CreatedAt: l.CreatedAt,
})
@@ -293,63 +292,82 @@ func linkViews(s *cloud.Service[state], links []Link, signups, conversions map[s
// createLinkRequest is POST /v1/affiliates/me/links: an optional label + optional
// vanity code (a free code is minted when omitted).
type createLinkRequest struct {
Label string `json:"label"`
Code string `json:"code"`
// Label is cosmetic — trimmed, stripped of control characters, capped — and
// never part of a code. Body-only: the URL cannot supply it.
Label string `json:"label" url:"-"`
// Code is an optional vanity code; it must be free across the whole
// directory, and omitting it mints a random one. Body-only.
Code string `json:"code" url:"-"`
}
// createLink answers POST /v1/affiliates/me/links — mint a new shareable link for the
// caller's (approved) affiliate. A requested vanity code must be valid + free across
// the global directory; an omitted code is minted randomly.
func createLink(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
// linkMint is the minted share link, answered 201.
type linkMint struct {
// Link is the link just minted, with its full shareable URL. Its funnel counters
// all start at zero — nothing has clicked or signed up through it yet.
Link codeView `json:"link"`
}
// mintLink mints a new share link for the caller's own affiliate and answers it
// with its full URL, 201.
//
// APPROVAL IS REQUIRED: an org that has applied but is not approved is refused,
// because a link that cannot accrue is a link that quietly loses the referral. A
// requested vanity code must be valid and free across the WHOLE directory —
// codes are one global namespace, so a taken code is a 409 rather than a silent
// alias. Omit the code and a random one is minted.
//
// Bounded per affiliate. The label is cosmetic: it is trimmed, stripped of
// control characters and capped, and it is never part of a code.
//
// Example: {"label": "twitter"}
func (o ops) mintLink(ctx context.Context, in *createLinkRequest) (*linkMint, error) {
org, ok := tenant(ctx)
if !ok {
return zip.ErrForbidden("sign in to create a referral link")
return nil, zip.ErrForbidden("sign in to create a referral link")
}
var body createLinkRequest
if err := c.Bind(&body); err != nil {
return err
if err := requireBody(ctx); err != nil {
return nil, err
}
ctx := c.Context()
a, err := s.State.store.GetByOrg(ctx, org)
a, err := o.s.State.store.GetByOrg(ctx, org)
if err == errNotFound {
return zip.ErrForbidden("apply to the affiliate program first")
return nil, zip.ErrForbidden("apply to the affiliate program first")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
if a.Status != StatusApproved {
return zip.ErrBadRequest("your affiliate application must be approved before you can create links")
return nil, zip.ErrBadRequest("your affiliate application must be approved before you can create links")
}
n, err := s.State.store.CountLinks(ctx, a.ID)
n, err := o.s.State.store.CountLinks(ctx, a.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "count links: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "count links: %v", err)
}
if n >= maxLinksPerAffiliate {
return zip.ErrBadRequest("link limit reached")
return nil, zip.ErrBadRequest("link limit reached")
}
label := sanitizeLabel(body.Label)
label := sanitizeLabel(in.Label)
// A requested vanity code is validated + minted; an omitted code is minted randomly
// (retry a handful of times on the vanishingly rare random collision).
if req := normalizeCode(body.Code); req != "" {
link, err := mintLink(s, ctx, a.ID, req, label)
return createLinkResult(s, c, link, err)
if req := normalizeCode(in.Code); req != "" {
link, err := newLink(o.s, ctx, a.ID, req, label)
return mintResult(o.s, link, err)
}
for attempt := 0; attempt < 8; attempt++ {
code, gerr := randomLinkCode()
if gerr != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", gerr)
return nil, zip.Errorf(http.StatusInternalServerError, "rng: %v", gerr)
}
link, err := mintLink(s, ctx, a.ID, code, label)
link, err := newLink(o.s, ctx, a.ID, code, label)
if err == errCodeTaken {
continue
}
return createLinkResult(s, c, link, err)
return mintResult(o.s, link, err)
}
return zip.Errorf(http.StatusInternalServerError, "could not mint a unique link code")
return nil, zip.Errorf(http.StatusInternalServerError, "could not mint a unique link code")
}
func mintLink(s *cloud.Service[state], ctx context.Context, affiliateID, code, label string) (Link, error) {
func newLink(s *cloud.Service[state], ctx context.Context, affiliateID, code, label string) (Link, error) {
id, err := genID("aln")
if err != nil {
return Link{}, err
@@ -357,45 +375,64 @@ func mintLink(s *cloud.Service[state], ctx context.Context, affiliateID, code, l
return s.State.store.CreateLink(ctx, id, affiliateID, code, label, time.Now().Unix())
}
func createLinkResult(s *cloud.Service[state], c *zip.Ctx, link Link, err error) error {
// mintResult translates a store outcome into the mint's answer, keeping each
// refusal at the status it has always carried.
func mintResult(s *cloud.Service[state], link Link, err error) (*linkMint, error) {
switch err {
case nil:
return c.JSON(http.StatusCreated, map[string]any{
"link": linkView{Code: link.Code, Label: link.Label, URL: affiliateLink(s, link.Code), CreatedAt: link.CreatedAt},
})
return &linkMint{
Link: codeView{Code: link.Code, Label: link.Label, URL: affiliateLink(s, link.Code), CreatedAt: link.CreatedAt},
}, nil
case errInvalidCode:
return zip.ErrBadRequest("code must be 332 chars of az, 09, hyphen")
return nil, zip.ErrBadRequest("code must be 332 chars of az, 09, hyphen")
case errCodeTaken:
return zip.ErrConflict("that code is already taken")
return nil, zip.ErrConflict("that code is already taken")
default:
return zip.Errorf(http.StatusInternalServerError, "create link: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "create link: %v", err)
}
}
// clickRequest is POST /v1/affiliates/click: the code a public visitor clicked.
type clickRequest struct {
Code string `json:"code"`
// Code is the share-link code that was clicked. Body-only: the URL cannot
// supply it.
Code string `json:"code" url:"-"`
}
// clickLink answers POST /v1/affiliates/click — a PUBLIC (no-principal) ping that bumps
// a link's click counter. The ping folds into an in-memory coalescing buffer and NEVER
// writes the money DB synchronously, so a click flood cannot contend with the accrual /
// payout write path; the buffer is flushed, batched, on the next links read + on shutdown.
// The counter is a vanity metric only — it never touches accrual or payout (those key on
// real metered spend), so click inflation is harmless to the money. Codes are public by
// design (they live in shareable links), so this accepts any code without checking
// existence: it is intentionally NOT a code-existence oracle (an unknown code simply
// no-ops at flush time), and "counted" reports buffer acceptance, not that the code is real.
func clickLink(s *cloud.Service[state], c *zip.Ctx) error {
var body clickRequest
if err := c.Bind(&body); err != nil {
return err
// clickCount reports that the buffer took the ping — not that the code is real.
type clickCount struct {
// Counted says the in-memory buffer took the ping. It does NOT say the code
// exists — this is deliberately not a code-existence oracle, and an unknown code
// simply no-ops at flush time. false means the buffer was full and the ping was
// dropped, which is harmless: clicks are vanity and move no money.
Counted bool `json:"counted"`
}
// click counts a click on a share link. PUBLIC — it takes no principal, because
// a visitor clicking a shareable link has no session yet.
//
// The ping folds into an in-memory buffer and NEVER writes the money database
// synchronously, so a click flood cannot contend with the accrual and payout
// write path; tallies are flushed in one batch on the next authenticated links
// read and at shutdown. Clicks are a vanity metric: no accrual and no payout
// ever reads them — those key on real metered spend — so click inflation cannot
// move money.
//
// Any well-formed code is accepted WITHOUT checking that it exists,
// deliberately: this is not a code-existence oracle. `counted` reports that the
// buffer took the ping, not that the code is real; an unknown code simply no-ops
// at flush time.
//
// Example: {"code": "acme"}
func (o ops) click(ctx context.Context, in *clickRequest) (*clickCount, error) {
if err := requireBody(ctx); err != nil {
return nil, err
}
code := normalizeCode(body.Code)
code := normalizeCode(in.Code)
if code == "" {
return zip.ErrBadRequest("code is required")
return nil, zip.ErrBadRequest("code is required")
}
return c.JSON(http.StatusOK, map[string]any{"counted": s.State.clicks.add(code)})
return &clickCount{Counted: o.s.State.clicks.add(code)}, nil
}
// flushClicks folds any pending public clicks into the money DB (batched, one tx) before a
@@ -412,38 +449,55 @@ func flushClicks(s *cloud.Service[state], ctx context.Context) {
// ── opt-in leaderboard handle ───────────────────────────────────────────────────
type handleRequest struct {
// Handle is the public leaderboard display name; empty opts out. Body-only:
// the URL cannot supply it.
Handle string `json:"handle" url:"-"`
}
// handleSet echoes the handle as stored — empty when the caller opted out.
type handleSet struct {
// Handle is the display name as STORED, echoed back after trimming. Empty means
// the caller opted out: it keeps its rank and still sees its own row, it is just
// no longer listed to anyone else.
Handle string `json:"handle"`
}
// setHandle answers POST /v1/affiliates/me/handle — set (or clear) the caller's opt-in
// public leaderboard display name. An empty handle opts the affiliate OUT of the
// public board by name (its own rank stays private-visible).
func setHandle(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
// setHandle sets the caller's public leaderboard display name, or clears it.
//
// The handle IS the opt-in. An empty handle opts out: the affiliate keeps its
// rank and can still see its own row, it simply stops being listed to anyone
// else. That is the whole privacy control — there is no separate visibility
// flag, and no way to be listed without choosing a name.
//
// Requires a validated principal and an existing affiliate record; apply first.
// The handle is bounded and restricted to letters, digits, space, hyphen,
// underscore and dot.
//
// Example: {"handle": "acme partners"}
func (o ops) setHandle(ctx context.Context, in *handleRequest) (*handleSet, error) {
org, ok := tenant(ctx)
if !ok {
return zip.ErrForbidden("sign in to set your leaderboard handle")
return nil, zip.ErrForbidden("sign in to set your leaderboard handle")
}
var body handleRequest
if err := c.Bind(&body); err != nil {
return err
if err := requireBody(ctx); err != nil {
return nil, err
}
handle := strings.TrimSpace(body.Handle)
handle := strings.TrimSpace(in.Handle)
if handle != "" && !validHandle(handle) {
return zip.ErrBadRequest("handle must be 224 chars of letters, digits, space, or - _ .")
return nil, zip.ErrBadRequest("handle must be 224 chars of letters, digits, space, or - _ .")
}
ctx := c.Context()
a, err := s.State.store.GetByOrg(ctx, org)
a, err := o.s.State.store.GetByOrg(ctx, org)
if err == errNotFound {
return zip.ErrForbidden("apply to the affiliate program first")
return nil, zip.ErrForbidden("apply to the affiliate program first")
}
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", err)
}
updated, err := s.State.store.SetHandle(ctx, a.ID, handle)
updated, err := o.s.State.store.SetHandle(ctx, a.ID, handle)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "set handle: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "set handle: %v", err)
}
return c.JSON(http.StatusOK, map[string]any{"handle": updated.Handle})
return &handleSet{Handle: updated.Handle}, nil
}
// ── leaderboard (privacy-preserving) ────────────────────────────────────────────
@@ -451,35 +505,68 @@ func setHandle(s *cloud.Service[state], c *zip.Ctx) error {
// leaderboardRow is one public leaderboard entry: rank + opt-in handle + aggregate
// share + referred count. NEVER an org identity. IsYou flags the caller's own row.
type leaderboardRow struct {
Rank int `json:"rank"`
Handle string `json:"handle"`
AccruedCents int64 `json:"accruedCents"`
ReferredCount int `json:"referredCount"`
IsYou bool `json:"isYou,omitempty"`
// Rank is the position in the GLOBAL approved set ordered by lifetime accrued
// commission, 1-based. Affiliates that set no handle still occupy their rank and
// are simply not listed, so the visible ranks have gaps and the board is not a
// complete roster. On the caller's own row the rank is computed over the whole
// set, so it is exact well outside the top page.
Rank int `json:"rank"`
// Handle is the affiliate's self-chosen display name — the only identity the
// board ever carries. The org behind it is never disclosed.
Handle string `json:"handle"`
// AccruedCents is that affiliate's lifetime commission accrued, in cents, and
// what the board is ordered by. An aggregate: no per-customer figure is exposed.
AccruedCents int64 `json:"accruedCents"`
// ReferredCount is how many orgs that affiliate directly referred — a count
// only, never which orgs.
ReferredCount int `json:"referredCount"`
// IsYou marks the caller's own row, so a client can highlight it without
// matching on a handle. Absent on every other row.
IsYou bool `json:"isYou,omitempty"`
}
// leaderboard answers GET /v1/affiliates/leaderboard the privacy-preserving board:
// the top OPT-IN affiliates by lifetime accrued share (by handle, aggregate only) plus
// the CALLER'S OWN exact rank (always visible, even when the caller is anonymous or
// outside the top N). No org identity, no referred-org data, ever.
func leaderboard(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("sign in to view the leaderboard")
}
ctx := c.Context()
// affiliateBoard is the public board plus the caller's own exact rank.
type affiliateBoard struct {
// Leaders are the top opt-in affiliates, by handle and aggregate figures only.
Leaders []leaderboardRow `json:"leaders"`
// Total is the approved population where it is known; omitted where the top
// page truncated and the caller has no rank to derive it from.
Total *int `json:"total,omitempty"`
// You is the caller's own row with its exact global rank; only an approved
// affiliate has one.
You *leaderboardRow `json:"you,omitempty"`
}
top, err := s.State.store.LeaderboardTop(ctx, leaderboardLimit)
// board answers the top affiliates by lifetime accrued commission, shown by
// OPT-IN HANDLE with aggregate figures only, plus the caller's own exact rank.
//
// It never discloses an org identity and never a referred org's usage. An
// affiliate that has set no handle still OCCUPIES its rank but is not listed —
// so opting out hides the name, not the position, and the visible board must not
// be read as a complete roster.
//
// The caller's own row carries its exact GLOBAL rank, computed over the whole
// approved set rather than over the page, so it is right well outside the top of
// the board. Only an approved affiliate has a rank. Requires a validated
// principal; a signed-in non-affiliate may read the board but gets no personal
// row.
func (o ops) board(ctx context.Context, _ *noInput) (*affiliateBoard, error) {
org, ok := tenant(ctx)
if !ok {
return nil, zip.ErrForbidden("sign in to view the leaderboard")
}
top, err := o.s.State.store.LeaderboardTop(ctx, leaderboardLimit)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "leaderboard: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "leaderboard: %v", err)
}
// The caller's own affiliate (for the "you" row + isYou flagging). A non-affiliate
// caller may view the public board but has no personal rank.
me, meErr := s.State.store.GetByOrg(ctx, org)
me, meErr := o.s.State.store.GetByOrg(ctx, org)
haveMe := meErr == nil
if meErr != nil && meErr != errNotFound {
return zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", meErr)
return nil, zip.Errorf(http.StatusInternalServerError, "load affiliate: %v", meErr)
}
// The public rows carry the affiliate's GLOBAL rank (its index in the accrued-
@@ -497,28 +584,28 @@ func leaderboard(s *cloud.Service[state], c *zip.Ctx) error {
})
}
resp := map[string]any{"leaders": leaders}
resp := affiliateBoard{Leaders: leaders}
if total := leaderboardTotal(top); total >= 0 {
resp["total"] = total
resp.Total = opt(total)
}
// The caller's own row: exact global rank computed over the WHOLE approved set, so
// it is accurate even outside the top N. Only an APPROVED affiliate has a rank.
if haveMe && me.Status == StatusApproved {
rank, total, rerr := s.State.store.RankOf(ctx, me.ID, me.AccruedCents)
rank, total, rerr := o.s.State.store.RankOf(ctx, me.ID, me.AccruedCents)
if rerr != nil {
return zip.Errorf(http.StatusInternalServerError, "rank: %v", rerr)
return nil, zip.Errorf(http.StatusInternalServerError, "rank: %v", rerr)
}
count, cerr := s.State.store.CountReferrals(ctx, me.ID)
count, cerr := o.s.State.store.CountReferrals(ctx, me.ID)
if cerr != nil {
return zip.Errorf(http.StatusInternalServerError, "count referrals: %v", cerr)
return nil, zip.Errorf(http.StatusInternalServerError, "count referrals: %v", cerr)
}
resp["total"] = total
resp["you"] = leaderboardRow{
resp.Total = opt(total)
resp.You = &leaderboardRow{
Rank: rank, Handle: me.Handle, AccruedCents: me.AccruedCents, ReferredCount: count, IsYou: true,
}
}
return c.JSON(http.StatusOK, resp)
return &resp, nil
}
// leaderboardTotal returns the number of rows the top query saw (a lower bound on the
@@ -533,39 +620,56 @@ func leaderboardTotal(top []LeaderboardEntry) int {
// ── SuperAdmin set-rate ─────────────────────────────────────────────────────────
type setRateRequest struct {
RateBps int64 `json:"rateBps"`
// rateSet is the POST /v1/admin/affiliates/:id/rate input.
type rateSet struct {
// ID is the affiliate whose direct rate moves, from the path.
ID string `json:"id"`
// RateBps is the direct commission rate, in basis points of Hanzo's margin;
// capped so the whole L1+L2+L3 schedule never exceeds the margin. Body-only
// (`url:"-"`): a money parameter must never ride the URL into access logs.
RateBps int64 `json:"rateBps" url:"-"`
}
// adminSetRate answers POST /v1/admin/affiliates/:id/rate — set an affiliate's DIRECT
// (L1) commission rate. It is capped at maxL1RateBps so the whole L1+L2+L3 schedule
// can never exceed 100% of the margin (the share ≤ margin guarantee). SuperAdmin only.
func adminSetRate(s *cloud.Service[state], c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("SuperAdmin required")
// adminSetRate sets one affiliate's DIRECT commission rate, in basis points of
// Hanzo's margin.
//
// The rate is CAPPED so that the direct rate plus the platform-wide second- and
// third-level rates can never exceed the whole margin — the structural guarantee
// that everything paid on one source event stays inside the margin actually
// earned. The cap is resolved from the rates in force at the moment of the call
// and quoted in the refusal, because those switches move; a hardcoded bound
// would start lying the moment somebody edits the schedule.
//
// Only the direct level is per-affiliate. The second and third levels are
// platform switches and are not settable here. The change applies to FUTURE
// accruals — commission already latched for a period is not recomputed. PLATFORM
// SUDO ONLY. Audited.
//
// Example: {"rateBps": 2500}
func (o ops) adminSetRate(ctx context.Context, in *rateSet) (*affiliateOut, error) {
if !sudo(ctx) {
return nil, zip.ErrForbidden("SuperAdmin required")
}
id := strings.TrimSpace(c.Param("id"))
var body setRateRequest
if err := c.Bind(&body); err != nil {
return err
if err := requireBody(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(in.ID)
// The cap moves with the L2/L3 switches, so it is resolved per request and quoted
// in the refusal — a hardcoded 9300 would start lying the moment an owner edits the
// upline schedule, and the caller would have no way to learn the real bound.
if cap := maxL1RateBps(); body.RateBps < 0 || body.RateBps > cap {
return zip.ErrBadRequest(fmt.Sprintf(
if cap := maxL1RateBps(); in.RateBps < 0 || in.RateBps > cap {
return nil, zip.ErrBadRequest(fmt.Sprintf(
"rateBps must be between 0 and %d (leaving headroom for the L2+L3 upline so a share can never exceed the margin)", cap))
}
ctx := c.Context()
a, err := s.State.store.SetRate(ctx, id, body.RateBps)
a, err := o.s.State.store.SetRate(ctx, id, in.RateBps)
if err != nil {
if err == errNotFound {
return zip.ErrNotFound("affiliate not found")
return nil, zip.ErrNotFound("affiliate not found")
}
return zip.Errorf(http.StatusInternalServerError, "set rate: %v", err)
return nil, zip.Errorf(http.StatusInternalServerError, "set rate: %v", err)
}
emitAudit(s, ctx, "affiliate.rate", a, map[string]any{"rateBps": a.RateBps})
return adminOK(c, map[string]any{"affiliate": adminViewOf(a, 0)})
emitAudit(o.s, ctx, "affiliate.rate", a, map[string]any{"rateBps": a.RateBps})
return &affiliateOut{Data: affiliateData{Affiliate: adminViewOf(a, 0)}, envelope: ok()}, nil
}
// ── helpers ─────────────────────────────────────────────────────────────────────
+83
View File
@@ -0,0 +1,83 @@
package affiliates
// typed.go is the ONE place this package's typed ops reach for the request, and
// the only file here that calls cloud.Request. A typed op receives a context and
// its decoded input and nothing else, so the per-request facts the handlers turn
// on are resolved here and nowhere else:
//
// - the TENANT comes off the context (principal.OrgFrom, parked by
// cloud.Bridge, which the composer installs — never this package). It is
// never an In field: an In field is caller-supplied, so a tenant key read
// from one is a cross-tenant read the caller asserted for itself.
// - PLATFORM SUDO (X-User-IsAdmin) gates every /v1/admin route here, and that
// claim rides a header the org does not carry.
// - the ACTOR (X-User-Id) is what an application row and the user-level
// referral mirror record — an attribution, never an authority.
// - requireBody replays the c.Bind refusal the raw write handlers answered on
// a bodyless request: zip's typed decode is tolerant and would otherwise
// turn that 400 into a write of zero values.
//
// Every resolver fails closed off the HTTP path: no request means no attested
// admin, no actor, and nothing to require a body of.
import (
"context"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
)
// zipdoc lifts the doc comment off each typed op into zipdoc_gen.go — the only
// way that prose reaches the published document, the MCP tool list and the CLI.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// ops carries the mounted service into the typed handlers, exactly as the raw
// handlers reached it through cloud.Handle.
type ops struct{ s *cloud.Service[state] }
// noInput is the In of an op whose whole input is its URL and its principal.
type noInput struct{}
// tenant is the validated org for a typed op — the one the gateway asserted and
// cloud.Bridge parked on the context. Each caller keeps its own refusal text, so
// the wire stays exactly what the raw handlers answered.
func tenant(ctx context.Context) (string, bool) { return principal.OrgFrom(ctx) }
// sudo reports whether the caller is a validated platform SuperAdmin
// (X-User-IsAdmin, set only for a verified SuperAdmin after SanitizeIdentity) —
// the gate every /v1/admin route here fails closed on. False off the HTTP path:
// no request, no attested admin.
func sudo(ctx context.Context) bool {
if c, ok := cloud.Request(ctx); ok {
return c.IsAdmin()
}
return false
}
// actor is the validated user id (X-User-Id) an application or a user-level
// referral edge is attributed to. Empty off the HTTP path, where the write
// records no actor rather than inventing one — exactly what the raw handlers
// did with an absent header.
func actor(ctx context.Context) string {
if c, ok := cloud.Request(ctx); ok {
return c.User()
}
return ""
}
// requireBody replays, at the point in the sequence the raw handler reached it,
// the c.Bind refusal a bodyless (or unparseable-content-type) request has always
// answered. zip's typed decode is tolerant by construction — it skips an empty
// body and leaves the In at its zero value — so without this a bodyless apply
// would enroll, a bodyless handle post would opt the caller out, and a bodyless
// rate post would set a rate of zero. It calls the SAME c.Bind over an empty
// target, so it is one decision rather than a second implementation free to
// drift; a no-op off the HTTP path, where there is no body to require.
func requireBody(ctx context.Context) error {
c, ok := cloud.Request(ctx)
if !ok {
return nil
}
return c.Bind(&struct{}{})
}
+119
View File
@@ -0,0 +1,119 @@
package affiliates
// typed_compat_test.go pins what the conversion to typed ops must not move:
// the contract. The dual-shape reads keep their EXACT key sets in both shapes — an
// enrolled zero still renders and a not-enrolled caller never grows a field —
// and every write that refused a bodyless request with c.Bind's 400 still
// refuses one, because zip's tolerant decode would otherwise turn that refusal
// into a write of zero values.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"testing"
)
// keysOf returns a body's top-level JSON keys, sorted.
func keysOf(t *testing.T, body []byte) []string {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func wantKeys(t *testing.T, path string, body []byte, want ...string) {
t.Helper()
got := keysOf(t, body)
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%s keys = %v, want %v (%s)", path, got, want, body)
}
}
// TestShapesStayExact proves both shapes of every dual-shape read carry exactly
// the keys they always carried: nothing omitted because it was zero, nothing
// added because a type now names it.
func TestShapesStayExact(t *testing.T) {
app, s, _ := mount(t)
// The not-enrolled shapes.
for _, tc := range []struct {
path string
want []string
}{
{"/v1/affiliates", []string{"defaultRateBps", "isAffiliate"}},
{"/v1/affiliates/me", []string{"defaultRateBps", "isAffiliate", "schedule"}},
{"/v1/affiliates/me/earnings", []string{"isAffiliate"}},
{"/v1/affiliates/me/links", []string{"isAffiliate", "maxLinks"}},
} {
st, body := req(t, app, http.MethodGet, tc.path, "orgZ", false, nil)
if st != http.StatusOK {
t.Fatalf("%s want 200, got %d (%s)", tc.path, st, body)
}
wantKeys(t, tc.path, body, tc.want...)
}
// The enrolled shapes, with ZERO money — every money key must still render.
applyAndApprove(t, app, s, "orgA", "acme", "")
_, body := req(t, app, http.MethodGet, "/v1/affiliates", "orgA", false, nil)
wantKeys(t, "/v1/affiliates", body,
"accruedCents", "code", "handle", "id", "isAffiliate", "link", "marginBps",
"paidCents", "payouts", "pendingCents", "rateBps", "referredCount",
"requestedCode", "status")
_, body = req(t, app, http.MethodGet, "/v1/affiliates/me", "orgA", false, nil)
wantKeys(t, "/v1/affiliates/me", body,
"accruedCents", "code", "downlineTotal", "handle", "id", "isAffiliate",
"levels", "link", "marginBps", "paidCents", "payouts", "pendingCents",
"rateBps", "status")
_, body = req(t, app, http.MethodGet, "/v1/affiliates/me/earnings", "orgA", false, nil)
wantKeys(t, "/v1/affiliates/me/earnings", body,
"accruedCents", "byPeriod", "byReferredOrg", "isAffiliate", "marginBps",
"paidCents", "pendingCents")
_, body = req(t, app, http.MethodGet, "/v1/affiliates/me/links", "orgA", false, nil)
wantKeys(t, "/v1/affiliates/me/links", body,
"isAffiliate", "links", "maxLinks", "status")
}
// TestBodylessWritesStillRefuse proves each write that bound a body keeps
// c.Bind's 400 on a bodyless request — and that the refusal really did refuse:
// a bodyless apply enrolls nobody.
func TestBodylessWritesStillRefuse(t *testing.T) {
app, s, _ := mount(t)
idA, _ := applyAndApprove(t, app, s, "orgA", "acme", "")
for _, tc := range []struct {
method, path, org string
admin bool
}{
{http.MethodPost, "/v1/affiliates/apply", "orgN", false},
{http.MethodPost, "/v1/affiliates/attribute", "orgN", false},
{http.MethodPost, "/v1/affiliates/click", "", false},
{http.MethodPost, "/v1/affiliates/me/links", "orgA", false},
{http.MethodPost, "/v1/affiliates/me/handle", "orgA", false},
{http.MethodPost, "/v1/admin/affiliates/" + idA + "/rate", "admin", true},
{http.MethodPost, "/v1/admin/affiliates/" + idA + "/payout", "admin", true},
} {
if st, body := req(t, app, tc.method, tc.path, tc.org, tc.admin, nil); st != http.StatusBadRequest {
t.Fatalf("bodyless %s %s want 400, got %d (%s)", tc.method, tc.path, st, body)
}
}
// The refused apply wrote nothing.
if _, err := s.State.store.GetByOrg(context.Background(), "orgN"); err != errNotFound {
t.Fatalf("a bodyless apply must enroll nobody, got %v", err)
}
// The refused rate post moved nothing: the approved default still stands.
a, err := s.State.store.GetByID(context.Background(), idA)
if err != nil || a.RateBps != defaultRateBps {
t.Fatalf("a bodyless rate post must not set a rate: rate=%d err=%v", a.RateBps, err)
}
}
+348
View File
@@ -0,0 +1,348 @@
// Code generated by zipdoc; DO NOT EDIT.
package affiliates
import (
"encoding/json"
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("GET /v1/admin/affiliates", zip.Doc{
Description: "Lists every affiliate across the fleet with its ORG exposed, plus a\nfleet summary of lifetime accrued, still-pending and paid commission in\ninteger cents.\n\nPLATFORM SUDO ONLY, and a non-admin is refused outright. This is the\ncross-tenant view and it names orgs — exactly what the partner-facing\nleaderboard refuses to do. There is deliberately no org-scoped variant of this\nread; a partner sees its own standing through its own dashboard. Bounded per\nrequest.",
Fields: map[string]string{
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
"directoryData.affiliates": "Affiliates is one row per affiliate across the whole fleet, ORG EXPOSED,\noldest first and bounded by the request's limit.",
"directoryData.summary": "Summary tallies exactly the rows above — not the whole table — so a limit that\ntruncates the page truncates the tally with it.",
"directoryOut.data": "Data is the affiliate directory and its tally.",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
"page.limit": "Limit caps the rows returned. Absent or non-positive means the default of\n500; anything above 1000 is clamped to 1000.",
"totals.accruedCents": "AccruedCents is lifetime commission accrued summed over those rows, in cents.",
"totals.applied": "Applied is how many of those rows are still awaiting approval — no code, no\naccrual yet.",
"totals.approved": "Approved is how many are approved: the only rows whose code resolves for\nattribution and whose balance can still grow.",
"totals.paidCents": "PaidCents is lifetime commission already paid out summed over those rows, in\ncents.",
"totals.pendingCents": "PendingCents is accrued minus paid summed over those rows, in cents — the\noutstanding liability across the page.",
"totals.suspended": "Suspended is how many were suspended. What they already accrued stays accrued\nand stays payable.",
"totals.total": "Total is how many affiliate rows this page covered, at every status. It is the\npage, not the table: a limit that truncates truncates this too.",
},
})
zip.Describe("GET /v1/admin/referrals", zip.Doc{
Description: "Answers the referral board: the top referrers by lifetime\ncommission, the funnel conversion rate (referred orgs that have actually\nproduced commission, over all referred orgs), and the accrual LIABILITY the\nplatform owes, broken out by upline level.\n\nRead the liability figure carefully — it is commission accrued and NOT yet\npaid, so it is money owed, not money spent, and the per-level split says how\nmuch of it comes from direct referrals versus the second and third levels.\n\nPLATFORM SUDO ONLY, cross-tenant, and it names orgs. It reads the SAME single\nattribution spine the accrual itself walks, so the board and the ledger cannot\ndisagree. Amounts are integer cents.",
Fields: map[string]string{
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
"funnel.convertedOrgs": "ConvertedOrgs is how many distinct referred orgs have produced positive\ncommission at least once — a referral that actually spent.",
"funnel.ratePct": "RatePct is convertedOrgs over referredOrgs as a PERCENTAGE, 0100, and the one\nnon-integer figure on this board. It is 0 when nothing has been referred yet,\nnot undefined.",
"funnel.referredOrgs": "ReferredOrgs is how many attribution edges exist fleet-wide — one per referred\norg, first-touch, so it is also the count of distinct referred orgs.",
"levelSplit.l1Cents": "L1Cents is lifetime commission accrued to DIRECT referrers, in cents.",
"levelSplit.l2Cents": "L2Cents is lifetime commission accrued one step above the direct referrer, in\ncents, at the platform-wide level-2 rate.",
"levelSplit.l3Cents": "L3Cents is lifetime commission accrued two steps above, in cents. Nothing\naccrues past level 3, so l1+l2+l3 is the whole accrual.",
"referralBoard.accrualByLevel": "AccrualByLevel splits the lifetime accrual across the three upline levels —\nhow much of the liability comes from direct referrals versus the chain above.",
"referralBoard.conversion": "Conversion is the funnel: referred orgs against those that actually earned.",
"referralBoard.summary": "Summary is the fleet tally — population by status, and lifetime accrued, paid\nand still-owed commission.",
"referralBoard.topReferrers": "TopReferrers is the 25 affiliates with the most lifetime accrued commission,\ndescending, orgs named.",
"referralsOut.data": "Data is the referral board: leaders, funnel, tally and per-level liability.",
"referrerRow.accruedCents": "AccruedCents is lifetime commission accrued, in cents. The board is sorted by\nthis, descending.",
"referrerRow.code": "Code is that affiliate's minted referral code; empty if it is not approved.",
"referrerRow.org": "Org is the partner's own org slug. Named only here, on the SuperAdmin board —\nthe partner-facing leaderboard shows an opt-in handle and never an org.",
"referrerRow.pendingCents": "PendingCents is accrued minus paid, in cents — what is still owed to this\naffiliate. Never negative.",
"referrerRow.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of —\nits level-1 downline, not the whole three-level chain.",
"referrerRow.status": "Status is \"applied\", \"approved\" or \"suspended\".",
"tally.accruedLifetimeCents": "AccruedLifetimeCents is all commission ever accrued, summed across every\naffiliate, in cents. It only grows; a payout does not reduce it.",
"tally.affiliates": "Affiliates is how many affiliate rows the board read, at every status. The\nread is bounded at 1000 rows, so a larger fleet reports the bound.",
"tally.approved": "Approved is how many of those rows are approved — the only ones whose code\nresolves for attribution and whose balance can grow.",
"tally.paidLifetimeCents": "PaidLifetimeCents is all commission ever paid out, in cents: credits grants\nplus record-only cash disbursements.",
"tally.pendingLiabilityCents": "PendingLiabilityCents is accrued minus paid across every affiliate, in cents.\nRead it as money OWED and not yet disbursed — a liability, not spend.",
},
})
zip.Describe("GET /v1/affiliates", zip.Doc{
Description: "Answers the caller org's OWN affiliate standing: status, referral\ncode and share link, commission rate, how many orgs it has referred, and its\nlifetime accrued, still-pending and already-paid commission in integer cents,\nwith its payout history.\n\nAn org that never applied gets an honest `isAffiliate:false` and the default\nrate rather than a 404 — the console renders the apply form off that answer.\n\nThe affiliate is resolved from the VALIDATED org, never from a field, so this\ncan only ever read the caller's own row; without a principal it is refused. It\nis a PURE READ: nothing accrues until the sweep runs. Commission is earned on\nHanzo's MARGIN, never on the referred customer's bill, so nothing here changes\nwhat that customer pays.",
Fields: map[string]string{
"affiliateStanding.accruedCents": "AccruedCents is lifetime commission accrued, in cents.",
"affiliateStanding.code": "Code is the minted referral code; empty until staff approve.",
"affiliateStanding.defaultRateBps": "DefaultRateBps is the direct rate a new affiliate would get, answered only\nto a caller that has not applied.",
"affiliateStanding.handle": "Handle is the opt-in public leaderboard name; empty means opted out.",
"affiliateStanding.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — what staff\napprove, suspend, re-rate and pay against. Absent until the org applies.",
"affiliateStanding.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record at all. It is\nthe ONE field an org that never applied gets besides defaultRateBps: on false,\nread nothing else here — every other field is absent, not zero.",
"affiliateStanding.link": "Link is the shareable ?aff URL; empty until a code is minted.",
"affiliateStanding.marginBps": "MarginBps is the platform gross-margin fraction commission is a rate OF.",
"affiliateStanding.paidCents": "PaidCents is lifetime commission already paid out, in cents.",
"affiliateStanding.payouts": "Payouts is the payout history, newest rows bounded.",
"affiliateStanding.pendingCents": "PendingCents is accrued minus paid — what the platform still owes.",
"affiliateStanding.rateBps": "RateBps is the affiliate's own direct commission rate, in basis points.",
"affiliateStanding.referredCount": "ReferredCount is how many orgs this affiliate has referred.",
"affiliateStanding.requestedCode": "RequestedCode is the vanity code asked for at apply time — a request, not an\nallocation. Approval mints `code`, which may be a different slug if this one\nwas already taken.",
"affiliateStanding.status": "Status is \"applied\", \"approved\" or \"suspended\". Only an approved affiliate has\na code that resolves for attribution and accrues commission; suspended keeps\nwhat it already earned but stops earning more.",
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
},
})
zip.Describe("GET /v1/affiliates/leaderboard", zip.Doc{
Description: "Answers the top affiliates by lifetime accrued commission, shown by\nOPT-IN HANDLE with aggregate figures only, plus the caller's own exact rank.\n\nIt never discloses an org identity and never a referred org's usage. An\naffiliate that has set no handle still OCCUPIES its rank but is not listed —\nso opting out hides the name, not the position, and the visible board must not\nbe read as a complete roster.\n\nThe caller's own row carries its exact GLOBAL rank, computed over the whole\napproved set rather than over the page, so it is right well outside the top of\nthe board. Only an approved affiliate has a rank. Requires a validated\nprincipal; a signed-in non-affiliate may read the board but gets no personal\nrow.",
Fields: map[string]string{
"affiliateBoard.leaders": "Leaders are the top opt-in affiliates, by handle and aggregate figures only.",
"affiliateBoard.total": "Total is the approved population where it is known; omitted where the top\npage truncated and the caller has no rank to derive it from.",
"affiliateBoard.you": "You is the caller's own row with its exact global rank; only an approved\naffiliate has one.",
"leaderboardRow.accruedCents": "AccruedCents is that affiliate's lifetime commission accrued, in cents, and\nwhat the board is ordered by. An aggregate: no per-customer figure is exposed.",
"leaderboardRow.handle": "Handle is the affiliate's self-chosen display name — the only identity the\nboard ever carries. The org behind it is never disclosed.",
"leaderboardRow.isYou": "IsYou marks the caller's own row, so a client can highlight it without\nmatching on a handle. Absent on every other row.",
"leaderboardRow.rank": "Rank is the position in the GLOBAL approved set ordered by lifetime accrued\ncommission, 1-based. Affiliates that set no handle still occupy their rank and\nare simply not listed, so the visible ranks have gaps and the board is not a\ncomplete roster. On the caller's own row the rank is computed over the whole\nset, so it is exact well outside the top page.",
"leaderboardRow.referredCount": "ReferredCount is how many orgs that affiliate directly referred — a count\nonly, never which orgs.",
},
})
zip.Describe("GET /v1/affiliates/me", zip.Doc{
Description: "Answers the richer self-view: the same lifetime accrued, pending and paid\ncommission and payout history, plus the caller's downline broken out by upline\nLEVEL — direct, second, third — each with the rate paid at that level and how\nmany orgs sit there.\n\nCommission is MULTI-LEVEL: a referred org's spend pays up its referral chain,\nthree levels deep and no further. The direct level is the affiliate's own\nnegotiated rate; the second and third are platform-wide switches, read live,\nso the schedule shown is the one actually in force rather than one compiled\nin. A caller that has not applied still gets that schedule alongside\n`isAffiliate:false`, so the console can show what it would earn.\n\nScoped to the validated org and nothing else, and refused without a\nprincipal. A PURE READ — it reports the downline but accrues nothing.",
Fields: map[string]string{
"affiliateSelf.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout is recorded against paidCents and never reduces this.",
"affiliateSelf.code": "Code is the minted referral code, the slug the ?aff link carries. Absent until\nstaff approve; codes live in ONE global namespace across all affiliates.",
"affiliateSelf.defaultRateBps": "DefaultRateBps is the direct rate a new affiliate starts at, in basis points\nof margin (2000 = 20%). Answered ONLY to a caller that has not applied, as the\nquote beside `schedule`.",
"affiliateSelf.downlineTotal": "DownlineTotal counts every org in the caller's downline across the levels.",
"affiliateSelf.handle": "Handle is the opt-in public leaderboard name. Empty means opted out: the\ncaller keeps its rank and still sees its own row, it is just not listed.",
"affiliateSelf.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed. Absent until the\norg applies.",
"affiliateSelf.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false the\nanswer carries the rate SCHEDULE and the default rate instead of a downline,\nso the console can show what the caller would earn.",
"affiliateSelf.levels": "Levels is the caller's downline per upline level, with the rate paid there.",
"affiliateSelf.link": "Link is the shareable ?aff URL built from the code. Empty until a code is\nminted, since there is nothing to share before approval.",
"affiliateSelf.marginBps": "MarginBps is the platform gross-margin fraction, in basis points, that every\nrate here is a rate OF. Read live per request, so it is the value in force\nnow, not the one that applied to commission already accrued.",
"affiliateSelf.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"affiliateSelf.payouts": "Payouts is the payout history, newest first, bounded to the last 100 rows.",
"affiliateSelf.pendingCents": "PendingCents is accrued minus paid, in cents — what the platform still owes\nand the ceiling on the next payout. Never negative.",
"affiliateSelf.rateBps": "RateBps is the caller's OWN direct (level 1) commission rate, in basis points\nof margin. Levels 2 and 3 are platform-wide and appear in `levels`.",
"affiliateSelf.schedule": "Schedule is the rate schedule quoted to a caller that has not applied.",
"affiliateSelf.status": "Status is \"applied\", \"approved\" or \"suspended\"; absent for a caller that never\napplied. Only \"approved\" mints links and accrues.",
"levelView.downlineCount": "DownlineCount is how many orgs sit exactly this many hops below the caller. It\nis 0 in the schedule quoted to a caller that has not applied, which has no\ndownline to count.",
"levelView.level": "Level is the upline distance from the org whose spend is being shared: 1 is\nthe direct referrer, 2 and 3 the referrers above it. Nothing accrues past 3.",
"levelView.rateBps": "RateBps is the commission paid at this level, in basis points OF Hanzo's\nmargin (2000 = 20% of margin, never of the customer's bill). Level 1 is the\naffiliate's own negotiated rate; 2 and 3 are platform switches read live, so\nthis is the schedule actually in force, not one compiled in.",
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
},
})
zip.Describe("GET /v1/affiliates/me/earnings", zip.Doc{
Description: "Answers the caller's own commission ledger: per period, the margin it\nearned against and the commission taken from that margin; and per referred\norg, that referral's aggregate contribution. Integer cents throughout.\n\nThe per-org view deliberately carries the affiliate's OWN earned share and NOT\nthe referred org's spend or margin. An affiliate is entitled to what it\nearned, not to a restatement of its customer's usage — the period view is\nwhere the margin base appears, aggregated across every referral.\n\nScoped server-side to the validated caller's affiliate; a caller that is not\none gets `isAffiliate:false`.",
Fields: map[string]string{
"affiliateEarnings.accruedCents": "AccruedCents is lifetime commission accrued, in cents.",
"affiliateEarnings.byPeriod": "ByPeriod is the per-period ledger: the margin earned against and the\ncommission taken from it.",
"affiliateEarnings.byReferredOrg": "ByReferredOrg is each referral's aggregate contribution — the affiliate's\nOWN share, never the referred org's spend.",
"affiliateEarnings.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false it\nis the ONLY field present — there is no ledger to report, and the zeros you\nmight expect are absent rather than reported as earnings of nothing.",
"affiliateEarnings.marginBps": "MarginBps is the platform gross-margin fraction commission is a rate OF.",
"affiliateEarnings.paidCents": "PaidCents is lifetime commission already paid out, in cents.",
"affiliateEarnings.pendingCents": "PendingCents is accrued minus paid — what the platform still owes.",
"orgEarningView.commissionCents": "CommissionCents is what the caller earned from that org across ALL periods, in\ncents. Deliberately the caller's own share and nothing else: that org's spend\nand the margin on it are not restated here.",
"orgEarningView.referredOrg": "ReferredOrg is the org slug this contribution came from — one the caller\nreferred, directly or up to three levels down.",
"periodEarningView.commissionCents": "CommissionCents is what the caller earned that period, in cents: the sum over\neach referred org and upline level of margin × that level's rate. Always ≤\nmarginCents, by construction.",
"periodEarningView.marginCents": "MarginCents is the margin Hanzo earned in that period on the spend of every\norg the caller referred, in cents — the base commission is a rate OF. It is\nthe aggregate base, never any one customer's bill.",
"periodEarningView.period": "Period is the accrual bucket: the UTC year-month, \"YYYY-MM\". Commission is\nlatched at most once per referred org per period, so one row is one month.",
},
})
zip.Describe("GET /v1/affiliates/me/links", zip.Doc{
Description: "Answers the caller's share links, each with its URL and its funnel:\nclicks tracked, signups — orgs attributed with that code — and conversions,\nmeaning how many of those signups have actually produced commission.\n\nSignups and conversions are DERIVED from the commission ledger and never\nstored, so they cannot drift from the money. Clicks are the one stored counter\nand the one that is pure vanity.\n\nAny pending public click pings are folded into the store before the read, in\none batch — which is how the counters stay current without a database write\nper click. Scoped to the validated caller's own affiliate; a non-affiliate\ngets `isAffiliate:false` and the link cap.",
Fields: map[string]string{
"affiliateLinks.isAffiliate": "IsAffiliate says whether the caller org has an affiliate record. On false only\nmaxLinks comes back — there are no links, and there is no link to mint until\nthe org applies and is approved.",
"affiliateLinks.links": "Links is the caller's share links, each with its URL and funnel.",
"affiliateLinks.maxLinks": "MaxLinks is how many share links one affiliate may hold.",
"affiliateLinks.status": "Status is the caller's affiliate status: \"applied\", \"approved\" or\n\"suspended\"; absent for a non-affiliate. Minting a link requires \"approved\",\nbecause a link that cannot accrue quietly loses the referral.",
"codeView.clicks": "Clicks is how many pings this code has taken. The one STORED counter here and\npure vanity: no accrual or payout reads it, pings are coalesced in memory and\nflushed in batches, and a dropped tally is accepted rather than contending\nwith the money write path. Do not reconcile it against anything.",
"codeView.code": "Code is the link's slug — 332 chars of az, 09 and hyphen — unique across\nthe WHOLE directory, so any affiliate's code resolves an attribution.",
"codeView.conversions": "Conversions is how many of those signups have actually produced positive\ncommission for the caller. Also derived, from the accrual rows, so it is\n≤ signups and lags a referral until the first sweep after it spends.",
"codeView.createdAt": "CreatedAt is when the link was minted, Unix seconds UTC.",
"codeView.label": "Label is the caller's own note for the link (\"twitter\", \"newsletter\").\nCosmetic: trimmed, stripped of control characters, capped at 48 bytes, and\nnever part of the code. \"primary\" on the link mirrored at approval.",
"codeView.signups": "Signups is how many orgs were attributed with this code — DERIVED by counting\nattribution edges, never stored, so it cannot drift from the ledger.",
"codeView.url": "URL is the full shareable link, the brand host plus ?aff=<code>. The host is\nthe deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai\nlink.",
},
})
zip.Describe("POST /v1/admin/affiliates/:id/approve", zip.Doc{
Description: "Approves an affiliate and MINTS its referral code — the moment\nthe partner has a working share link and starts accruing.\n\nThe code is taken from the body if one is given, else the vanity code the\napplicant requested, else a slug derived for them. Codes are ONE global\nnamespace, so a taken code is a 409 and nothing is approved. The minted code\nis also mirrored as a link row so click tracking is uniform across every code\nthe affiliate holds; that mirror is best-effort and its failure never fails\nthe approval.\n\nApproval is what makes an affiliate eligible: before it, attribution against\nits code does not resolve and no sweep accrues to it. PLATFORM SUDO ONLY.\nAudited.",
Fields: map[string]string{
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
"approval.code": "Code overrides the minted code; else the requested vanity code, else a\nderived slug.",
"approval.id": "ID is the affiliate to approve, from the path.",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
},
})
zip.Describe("POST /v1/admin/affiliates/:id/payout", zip.Doc{
Description: "Pays out accrued commission and answers the payout row with the\naffiliate's updated balances.\n\nThe amount is reserved atomically against the affiliate's PENDING commission —\naccrued minus paid — so a payout can never exceed what is owed. The METHOD\ndecides whether money actually moves: `credits` issues a commerce grant into\nthe affiliate ORG's own wallet, tagged so the ledger can tell an affiliate\npayout apart from an admin or referral grant; every other method — wire,\npaypal and the rest — is RECORD-ONLY: the payout row and the balances move,\nthe cash is disbursed out of band.\n\nThe amount is integer cents and must be positive. PLATFORM SUDO ONLY.\nAudited.",
Fields: map[string]string{
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
"disbursal.amountCents": "AmountCents is the payout, integer cents; it must be positive and can\nnever exceed the affiliate's pending commission. Body-only (`url:\"-\"`,\nlike every money field here): a payout must never ride the URL into\naccess logs, and the raw handler read only the body.",
"disbursal.id": "ID is the affiliate to pay, from the path.",
"disbursal.method": "Method decides whether money moves: `credits` issues a commerce grant,\nevery other method (wire, paypal, …) is record-only.",
"disbursal.reference": "Reference is the operator's settlement note (a bank id, a ledger ref).",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
"payoutOut.data": "Data is the recorded payout and the balances it left behind.",
"remittance.amountCents": "AmountCents is the amount disbursed, in cents. It was reserved against pending\ncommission atomically when recorded, so it never exceeds what was owed.",
"remittance.createdAt": "CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance\nmoved, not necessarily when the cash landed.",
"remittance.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed.",
"remittance.method": "Method is how it was settled. \"credits\" issued a commerce grant into the\naffiliate org's own wallet; any other value (wire, paypal, check, …) is a\nRECORD of cash a human moved out of band.",
"remittance.reference": "Reference is the operator's settlement note — a bank id, a ledger ref. Free\ntext, absent when none was given.",
"remittance.txn": "Txn is the commerce ledger transaction id, set ONLY where a \"credits\" payout\nactually issued the grant. Absent for cash methods, which write no ledger row.",
"settlement.affiliate": "Affiliate is the row re-read AFTER the payout, so its paidCents and\npendingCents already account for the row beside it.",
"settlement.payout": "Payout is the payout row just recorded.",
},
Example: json.RawMessage(`{"amountCents":1200,"method":"credits","reference":"ledger-1"}`),
})
zip.Describe("POST /v1/admin/affiliates/:id/rate", zip.Doc{
Description: "Sets one affiliate's DIRECT commission rate, in basis points of\nHanzo's margin.\n\nThe rate is CAPPED so that the direct rate plus the platform-wide second- and\nthird-level rates can never exceed the whole margin — the structural guarantee\nthat everything paid on one source event stays inside the margin actually\nearned. The cap is resolved from the rates in force at the moment of the call\nand quoted in the refusal, because those switches move; a hardcoded bound\nwould start lying the moment somebody edits the schedule.\n\nOnly the direct level is per-affiliate. The second and third levels are\nplatform switches and are not settable here. The change applies to FUTURE\naccruals — commission already latched for a period is not recomputed. PLATFORM\nSUDO ONLY. Audited.",
Fields: map[string]string{
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
"rateSet.id": "ID is the affiliate whose direct rate moves, from the path.",
"rateSet.rateBps": "RateBps is the direct commission rate, in basis points of Hanzo's margin;\ncapped so the whole L1+L2+L3 schedule never exceeds the margin. Body-only\n(`url:\"-\"`): a money parameter must never ride the URL into access logs.",
},
Example: json.RawMessage(`{"rateBps":2500}`),
})
zip.Describe("POST /v1/admin/affiliates/:id/suspend", zip.Doc{
Description: "Suspends an affiliate: it stops accruing on the next sweep, and\nits code stops resolving for new attributions.\n\nIt CLAWS NOTHING BACK. Commission already accrued stays accrued and stays\npayable, and existing attribution edges are left standing — suspension ends\nearning, it does not unwind history. PLATFORM SUDO ONLY. Audited.",
Fields: map[string]string{
"adminAffiliateView.accruedCents": "AccruedCents is lifetime commission accrued, in cents. It only grows — a\npayout moves paidCents, never this.",
"adminAffiliateView.approvedAt": "ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.",
"adminAffiliateView.code": "Code is the minted referral code, the slug the ?aff link carries. Empty until\napproval mints it. Codes are one global namespace across all affiliates.",
"adminAffiliateView.createdAt": "CreatedAt is when the org applied, Unix seconds UTC.",
"adminAffiliateView.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id the\napprove, suspend, rate and payout routes address.",
"adminAffiliateView.org": "Org is the partner's own org slug. It appears ONLY on this cross-tenant admin\nview; no partner-facing read ever names another org.",
"adminAffiliateView.paidCents": "PaidCents is lifetime commission already paid out, in cents — credits grants\nand record-only cash disbursements alike.",
"adminAffiliateView.pendingCents": "PendingCents is accrued minus paid, in cents: what is still owed, and the hard\nceiling the next payout is reserved against. Never negative.",
"adminAffiliateView.rateBps": "RateBps is this affiliate's DIRECT (level 1) commission rate in basis points\nOF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels\n2 and 3 are platform-wide switches and are not carried per affiliate.",
"adminAffiliateView.referredCount": "ReferredCount is how many orgs this affiliate is the DIRECT referrer of,\ncounted from the attribution edges. It is 0 on the single-affiliate answers\n(approve, suspend, rate, payout), which do not run the count.",
"adminAffiliateView.requestedCode": "RequestedCode is the vanity code the applicant asked for. A request, not an\nallocation: approval mints a different slug if this one was taken. Absent when\nnone was asked for.",
"adminAffiliateView.status": "Status is \"applied\", \"approved\" or \"suspended\". Only \"approved\" resolves for\nattribution and accrues; \"suspended\" stops future earning and claws nothing\nback.",
"adminAffiliateView.suspendedAt": "SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never\nsuspended; it is not cleared by a later re-approval.",
"affiliateData.affiliate": "Affiliate is the row as it stands AFTER the action that returned it. Its\nreferredCount is 0 here: these single-affiliate answers do not run the count.",
"affiliateOut.data": "Data carries the affiliate row the action just wrote.",
"affiliateRef.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed.",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
},
})
zip.Describe("POST /v1/admin/affiliates/sweep", zip.Doc{
Description: "Runs the accrual: for each referred org it reads that org's metered\nspend for the current period and accrues commission to every affiliate up its\nreferral chain, then answers how many sources were swept and how many NEW\naccruals landed.\n\nThis is the cron path, and it is LATCHED at most once per affiliate, source\norg and period — so re-running it inside the same period accrues nothing\nfurther. Safe to retry, and safe to run by hand beside the schedule.\n\nCommission is a rate of Hanzo's MARGIN on that spend, never of the customer's\ngross bill, so every level's share summed over one source event stays within\nthe margin actually earned and the customer's charge is untouched. Nothing\naccrues past the third upline level, and only an APPROVED affiliate accrues at\nall.\n\nThe same spend read drives the OSS author royalty — one read, both programs —\nso the answer reports royalties accrued alongside. PLATFORM SUDO ONLY. Bounded\nper run; a source whose spend cannot be read is skipped and picked up next\ntime, never half-accrued.",
Fields: map[string]string{
"accruals.accrued": "Accrued is how many NEW commission accruals this run created, counted across\nevery upline level. The accrual is latched at most once per (affiliate, source\norg, period), so a re-run inside the same month reports 0 having changed\nnothing — 0 means \"already accrued\", not \"failed\".",
"accruals.royaltiesAccrued": "RoyaltiesAccrued is how many OSS-author royalty accruals the SAME spend read\nproduced in the sibling authors program. One read drives both.",
"accruals.royaltyFailures": "RoyaltyFailures is reported, not swallowed: a sweep that could not reach\nthe royalty store must not read as one that found nothing owed. The count\nwas already computed and then dropped on the floor, which is the same\nsilence the typed leg was added to end.",
"accruals.swept": "Swept is how many source (referred) orgs the run visited, bounded at 500 per\nrun. A source with no spend this period, or one whose spend could not be read,\nstill counts as swept.",
"accrualsOut.data": "Data is what the run did: sources visited, new accruals, royalties alongside.",
"envelope.msg": "Msg is an operator-facing note. Empty on every success here — it exists\nbecause the console's admin unwrapper reads the shape cloud.OK writes.",
"envelope.status": "Status is \"ok\" on every 2xx from this surface; a failure is an HTTP error with\nzip's error body, not this envelope carrying a different word.",
},
})
zip.Describe("POST /v1/affiliates/apply", zip.Doc{
Description: "Enrolls the caller's OWN org as an affiliate at status `applied`,\noptionally requesting a vanity code, and answers the record — 201 on the first\napply, 200 with `created:false` afterwards.\n\nIDEMPOTENT, first apply wins: one affiliate per org, so re-applying never\ncreates a second row and never resets an existing approval. Applying is not\njoining — no code is minted and nothing accrues until staff approve, which is\nwhere both the code and the commission rate come from.\n\nThe org is the validated caller's, never a field. A malformed vanity code is\nrefused up front; the code is only REQUESTED here, and approval may mint a\ndifferent one if the requested code is taken.",
Fields: map[string]string{
"application.code": "Code is the minted referral code. Empty on a first apply — applying does not\nmint a code, approval does; a re-apply echoes whatever the row already holds.",
"application.created": "Created says whether THIS call made the row. false means the org had already\napplied and nothing changed — no second row, no reset of an existing approval.\nThe HTTP status states the same fact: 201 when true, 200 when false.",
"application.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed — the id staff\napprove, suspend, re-rate and pay against.",
"application.rateBps": "RateBps is the direct (level 1) commission rate the row carries, in basis\npoints OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill).",
"application.requestedCode": "RequestedCode echoes the vanity code asked for, normalized to lower case. It\nis a request only: approval mints a different slug if this one is taken.",
"application.status": "Status is \"applied\" for a row this call created. A re-apply echoes the\nexisting row's status, which may already be \"approved\" or \"suspended\".",
"applyRequest.requestedCode": "RequestedCode is the vanity code the applicant asks for; approval may mint\na different one if it is taken. Body-only: the URL cannot supply it.",
},
Example: json.RawMessage(`{"requestedCode":"acme"}`),
})
zip.Describe("POST /v1/affiliates/attribute", zip.Doc{
Description: "Records the first-touch edge every later commission is computed\nfrom: the caller's org was referred by the affiliate that owns this code.\n\nThe REFERRED org is the validated caller, never a field. A caller that could\nname the referred org could attach itself to somebody else's revenue. The\naffiliate is resolved from the code, and only an APPROVED affiliate's code\nresolves.\n\nFIRST TOUCH WINS, set once: one affiliate per referred org, so a re-post\nanswers the existing edge with `created:false` rather than moving the\nattribution. Self-attribution is refused, and so is a code that would make a\ncycle in the upline chain. An unknown code is a 404, deliberately: an\naffiliate code IS a public shareable link, so whether one is real is public by\ndesign, and the caller legitimately needs to know its link resolved.\n\nA user-level mirror of the edge is written best-effort; a conflict there never\nfails the org attribution, which is the money-bearing one.",
Fields: map[string]string{
"attributeRequest.code": "Code is the affiliate code the referred org arrived with. Body-only: the\nURL cannot supply it.",
"attribution.code": "Code is the affiliate code the edge was recorded under, normalized to lower\ncase. On a re-post it is the code of the STANDING edge, which may differ from\nthe one just sent — first touch wins.",
"attribution.created": "Created says whether THIS call made the edge. false means the caller org was\nalready attributed and nothing moved. The HTTP status says the same: 201 when\ntrue, 200 when false.",
"attribution.createdAt": "CreatedAt is when the edge was FIRST recorded, Unix seconds UTC. On a re-post\nit is the original time, not now.",
"attribution.id": "ID is the attribution edge's server-minted handle, \"afr_\"-prefixed.",
},
Example: json.RawMessage(`{"code":"acme"}`),
})
zip.Describe("POST /v1/affiliates/click", zip.Doc{
Description: "Counts a click on a share link. PUBLIC — it takes no principal, because\na visitor clicking a shareable link has no session yet.\n\nThe ping folds into an in-memory buffer and NEVER writes the money database\nsynchronously, so a click flood cannot contend with the accrual and payout\nwrite path; tallies are flushed in one batch on the next authenticated links\nread and at shutdown. Clicks are a vanity metric: no accrual and no payout\never reads them — those key on real metered spend — so click inflation cannot\nmove money.\n\nAny well-formed code is accepted WITHOUT checking that it exists,\ndeliberately: this is not a code-existence oracle. `counted` reports that the\nbuffer took the ping, not that the code is real; an unknown code simply no-ops\nat flush time.",
Fields: map[string]string{
"clickCount.counted": "Counted says the in-memory buffer took the ping. It does NOT say the code\nexists — this is deliberately not a code-existence oracle, and an unknown code\nsimply no-ops at flush time. false means the buffer was full and the ping was\ndropped, which is harmless: clicks are vanity and move no money.",
"clickRequest.code": "Code is the share-link code that was clicked. Body-only: the URL cannot\nsupply it.",
},
Example: json.RawMessage(`{"code":"acme"}`),
})
zip.Describe("POST /v1/affiliates/me/handle", zip.Doc{
Description: "Sets the caller's public leaderboard display name, or clears it.\n\nThe handle IS the opt-in. An empty handle opts out: the affiliate keeps its\nrank and can still see its own row, it simply stops being listed to anyone\nelse. That is the whole privacy control — there is no separate visibility\nflag, and no way to be listed without choosing a name.\n\nRequires a validated principal and an existing affiliate record; apply first.\nThe handle is bounded and restricted to letters, digits, space, hyphen,\nunderscore and dot.",
Fields: map[string]string{
"handleRequest.handle": "Handle is the public leaderboard display name; empty opts out. Body-only:\nthe URL cannot supply it.",
"handleSet.handle": "Handle is the display name as STORED, echoed back after trimming. Empty means\nthe caller opted out: it keeps its rank and still sees its own row, it is just\nno longer listed to anyone else.",
},
Example: json.RawMessage(`{"handle":"acme partners"}`),
})
zip.Describe("POST /v1/affiliates/me/links", zip.Doc{
Description: "Mints a new share link for the caller's own affiliate and answers it\nwith its full URL, 201.\n\nAPPROVAL IS REQUIRED: an org that has applied but is not approved is refused,\nbecause a link that cannot accrue is a link that quietly loses the referral. A\nrequested vanity code must be valid and free across the WHOLE directory —\ncodes are one global namespace, so a taken code is a 409 rather than a silent\nalias. Omit the code and a random one is minted.\n\nBounded per affiliate. The label is cosmetic: it is trimmed, stripped of\ncontrol characters and capped, and it is never part of a code.",
Fields: map[string]string{
"codeView.clicks": "Clicks is how many pings this code has taken. The one STORED counter here and\npure vanity: no accrual or payout reads it, pings are coalesced in memory and\nflushed in batches, and a dropped tally is accepted rather than contending\nwith the money write path. Do not reconcile it against anything.",
"codeView.code": "Code is the link's slug — 332 chars of az, 09 and hyphen — unique across\nthe WHOLE directory, so any affiliate's code resolves an attribution.",
"codeView.conversions": "Conversions is how many of those signups have actually produced positive\ncommission for the caller. Also derived, from the accrual rows, so it is\n≤ signups and lags a referral until the first sweep after it spends.",
"codeView.createdAt": "CreatedAt is when the link was minted, Unix seconds UTC.",
"codeView.label": "Label is the caller's own note for the link (\"twitter\", \"newsletter\").\nCosmetic: trimmed, stripped of control characters, capped at 48 bytes, and\nnever part of the code. \"primary\" on the link mirrored at approval.",
"codeView.signups": "Signups is how many orgs were attributed with this code — DERIVED by counting\nattribution edges, never stored, so it cannot drift from the ledger.",
"codeView.url": "URL is the full shareable link, the brand host plus ?aff=<code>. The host is\nthe deployment's own brand, so a Lux or Zoo install never mints a hanzo.ai\nlink.",
"createLinkRequest.code": "Code is an optional vanity code; it must be free across the whole\ndirectory, and omitting it mints a random one. Body-only.",
"createLinkRequest.label": "Label is cosmetic — trimmed, stripped of control characters, capped — and\nnever part of a code. Body-only: the URL cannot supply it.",
"linkMint.link": "Link is the link just minted, with its full shareable URL. Its funnel counters\nall start at zero — nothing has clicked or signed up through it yet.",
},
Example: json.RawMessage(`{"label":"twitter"}`),
})
}
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := agent
include ../../mk/plugin.mk
+1 -1
View File
@@ -135,7 +135,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
Logger: deps.Logger,
DataDir: deps.DataDir,
Brand: deps.Brand,
Model: deps.AIDefaultModel,
Model: cloud.DefaultModel,
Principal: func(c *zip.Ctx) (hz.Principal, bool) {
p, ok := tools.PrincipalFrom(c)
if !ok {
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := agents
include ../../mk/plugin.mk
+206 -38
View File
@@ -117,12 +117,6 @@ type state struct {
// tenancy.go for why that is the whole isolation argument.
stores *cloud.OrgStore[*Store]
ai types.AIClient
// defaultModel is the deployment's configured default served model
// (deps.AIDefaultModel). An agent created without an explicit model is
// stored with it, so the ONE model default lives in config, never hardcoded
// per subsystem. Empty only on a deployment that configured no default, in
// which case create still requires an explicit model.
defaultModel string
// failoverModel is the reliable model a run falls over to when the agent's own
// model stays throttled (429/overloaded) after bounded retries
// (deps.AIFallbackModel, default "best"). It makes an autonomous bot reply
@@ -149,6 +143,13 @@ type state struct {
var mounted *cloud.Service[state]
// Ready reports whether the session store is in THIS process, so a caller can
// tell "no sessions" from "ask the process that owns them" before it reads a
// count as a fact. It is the same question apps/projects.Ready answers for the
// site catalog, and it exists here for the same reason: agents ships as its own
// binary, so the honest answer for an in-process caller is usually "no".
func Ready() bool { return mounted != nil }
// ---- HTTP response shapes (the published contract) ----
type agentView struct {
@@ -195,6 +196,21 @@ type agentRunView struct {
Error string `json:"error,omitempty"`
DurationMs int64 `json:"durationMs"`
CreatedAt string `json:"createdAt"`
// What an operator needs to answer "what ran, for whom, and what did it do" —
// and, through traceId, to leave this record for the waterfall of the very
// same run rather than a search that hopefully lands near it.
//
// Agent is on the row because the org-wide feed lists runs across agents, and
// a run that cannot name its agent is an orphan in exactly the view built to
// make sense of many of them. Every field is omitempty: a run recorded before
// these columns existed reports absence rather than a zero it never measured.
Agent string `json:"agent,omitempty"`
Actor string `json:"actor,omitempty"`
TraceID string `json:"traceId,omitempty"`
PromptTokens int `json:"promptTokens,omitempty"`
CompletionTokens int `json:"completionTokens,omitempty"`
ToolCalls int `json:"toolCalls,omitempty"`
}
// ---- overview shapes (console Agents dashboard: metrics + activity) ----
@@ -269,6 +285,8 @@ func toRunView(r Run) agentRunView {
return agentRunView{
ID: r.ID, Status: r.Status, Model: cloud.ZenModel(r.Model), Input: r.Input, Output: r.Output,
Error: r.Error, DurationMs: r.DurationMs, CreatedAt: rfc3339(r.CreatedAt),
Agent: r.AgentName, Actor: r.Actor, TraceID: r.TraceID,
PromptTokens: r.PromptTokens, CompletionTokens: r.CompletionTokens, ToolCalls: r.ToolCalls,
}
}
@@ -307,14 +325,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
s := &cloud.Service[state]{
Base: b,
State: state{
stores: cloud.NewOrgStore[*Store](b, "agents", openStore),
ai: deps.AI,
// cloud.ZenModel guards the CONFIG boundary: an operator who points
// CLOUD_AI_DEFAULT_MODEL at an upstream name still gets the Hanzo name
// stamped on every agent seeded or created without one. The caller
// boundary is guarded separately, in create/update.
defaultModel: cloud.ZenModel(deps.AIDefaultModel),
failoverModel: strings.TrimSpace(deps.AIFallbackModel),
stores: cloud.NewOrgStore[*Store](b, "agents", openStore),
ai: deps.AI,
failoverModel: cloud.FallbackModel,
bill: cloud.NewResourceMeter(deps, meterKind),
bus: newBus(),
// TASKS PLUG-IN POINT: durable execution rides hanzoai/tasks, not a
@@ -332,6 +345,10 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return fmt.Errorf("agents.Mount: %w", err)
}
mounted = s
// The login-manager teardown, for the link process that has no session store
// in it — two doors onto the ONE StopSessions (sessions_rpc.go).
exposeSessions()
exposeRunOnBehalf()
o := agentOps{s: s}
// Bridge FIRST, and at the door this SUBSYSTEM is, not on one node inside it: a
@@ -368,6 +385,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// collision, not a precedence.
zip.Get(g, "/metrics", o.metrics)
zip.Get(g, "/activity", o.activity)
zip.Get(g, "/runs", o.orgRuns)
// Live agent-session control plane: /v1/agents/sessions[/...].
mountSessions(s, app)
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
@@ -445,6 +463,18 @@ type runList struct {
Runs []agentRunView `json:"runs"`
}
// orgRunsQuery pages the org's runs across every agent.
type orgRunsQuery struct {
// Limit caps how many runs come back, newest first. Absent, zero or out of
// range (1..200) reads as 50.
Limit int `json:"limit"`
// Status keeps only runs with this outcome ("ok" or "error"). Empty keeps
// both. It is the filter an operator reaches for first — "show me what broke"
// — and answering it here rather than by paging the whole history client-side
// is the difference between a usable feed and a download.
Status string `json:"status"`
}
// metricsQuery selects the dashboard window.
type metricsQuery struct {
// Range is the window to bucket: 24H, 7D or 30D. Anything else reads as 30D.
@@ -506,9 +536,7 @@ func (o agentOps) create(ctx context.Context, in *createAgentIn) (*agentView, er
// rather than a lie about what the agent runs on.
model := strings.TrimSpace(body.Model)
if model == "" {
if model = s.State.defaultModel; model == "" {
return nil, zip.ErrBadRequest("model is required")
}
model = cloud.DefaultModel
} else if err := validateModel(s, ctx, model); err != nil {
return nil, err
}
@@ -878,11 +906,32 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
// nests under it, shipped over ZAP to o11y.
ctx, span := agentTracer.Start(ctx, "agent.run "+a.Name, trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
// The run's NAME, minted before the work rather than after it.
//
// It used to be minted at the end of executeRun, beside the row it fills in,
// which reads naturally and made the run unobservable: every span the run
// produced — the step, each tool call, each LLM call — was already finished
// and exported by the time the run had a name, so none of them could carry
// it, and neither could the per-token debits the metering decorator makes
// round by round. The id existed only on the record of a thing that was
// already over. Minting it here is what lets one value be on the span, on the
// row and on the money, which is the whole of "drill into this run".
id, _ := genID("run")
span.SetAttributes(
attribute.String("hanzo.agent.name", a.Name),
attribute.String("hanzo.agent.org", a.Org),
attribute.String("gen_ai.request.model", a.Model),
attribute.String("hanzo.agent.run_id", id),
)
// WHO, not just which tenant. org answers "whose ledger"; actor answers "which
// person", and an operator asking why a run happened needs the second. A
// scheduled run has no person and says so by carrying no attribute, rather
// than by naming one that does not exist.
if sub := actorSub(a.Org, actor); sub != "" {
span.SetAttributes(attribute.String("hanzo.user", sub))
}
fee := cloud.ResourceFeeCents(agentFeeEnvPrefix, meterKind)
// Gate the AGENT's own org — never a caller default, never another tenant.
@@ -894,12 +943,25 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
return Run{}, err
}
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
r := executeRun(ctx, s.State.ai, a.Org, actor, a, input, s.State.failoverModel, id)
// The trace this run IS, written onto the run itself. Without it the console
// has a run with no way to reach its spans and a trace with no way to name its
// run: two records of one event that cannot be joined. It is read off the live
// span context, so it is the real id o11y stored, never a second one minted here.
if sc := span.SpanContext(); sc.HasTraceID() {
r.TraceID = sc.TraceID().String()
}
span.SetAttributes(
attribute.String("hanzo.agent.run_id", r.ID),
attribute.String("hanzo.agent.run_status", r.Status),
attribute.Int64("hanzo.agent.duration_ms", r.DurationMs),
attribute.String("gen_ai.response.model", r.Model),
// The run's own token account, on the run's own span. The per-call gen_ai
// spans carry each round's usage; a run is the sum of its rounds, and an
// operator asking "how many tokens did this run cost" should not have to
// add up a waterfall to find out.
attribute.Int("gen_ai.usage.input_tokens", r.PromptTokens),
attribute.Int("gen_ai.usage.output_tokens", r.CompletionTokens),
attribute.Int("hanzo.agent.tool_calls", r.ToolCalls),
)
if r.Status == "error" {
span.SetStatus(codes.Error, r.Error)
@@ -952,19 +1014,35 @@ const (
)
// executeRun composes the agent's instructions with the caller input and runs
// one chat completion through the AI client — with a bounded retry on transient
// upstream overload and, if the agent's own model stays throttled, ONE failover
// to the deployment's reliable model (fallback) so an autonomous bot reply still
// lands. It returns the resulting Run — status "ok" with output and Model set to
// the model that ACTUALLY answered (so metering bills that model), or "error"
// with the final upstream failure. Pure of HTTP and persistence so it is directly
// testable; the caller records + responds. This reliability policy is the agent
// runner's ALONE — the interactive user-facing chat path is untouched.
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
// the agent — with a bounded retry on transient upstream overload and, if the
// agent's own model stays throttled, ONE failover to the deployment's reliable
// model (fallback) so an autonomous bot reply still lands. It returns the
// resulting Run — status "ok" with output and Model set to the model that
// ACTUALLY answered (so metering bills that model), or "error" with the final
// upstream failure. Pure of HTTP and persistence so it is directly testable; the
// caller records + responds. This reliability policy is the agent runner's ALONE
// — the interactive user-facing chat path is untouched.
//
// An agent that declares TOOLS and whose tools the plane actually offers runs the
// bounded tool loop instead of a single completion (tools.go). One with none —
// or one whose declared names resolve to nothing — takes the single completion
// this has always been, unchanged.
//
// actor is the run's billing identity (billingActor's "org/sub"), threaded so a
// tool dispatch runs as the principal the run is charged to.
func executeRun(ctx context.Context, ai types.AIClient, org, actor string, a Agent, input, fallback, runID string) Run {
// Child step span; the AI client opens its own GenAI span nested under this.
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
span.SetAttributes(attribute.String("gen_ai.request.model", a.Model))
// The run's name on every span it produces, not only on the root. A trace
// query that finds a slow LLM call or a failing tool should answer "which run"
// from the row it already has, rather than by walking parents up a waterfall —
// and a step whose parent was dropped (a sampled or truncated trace) is still
// attributable rather than orphaned.
span.SetAttributes(
attribute.String("gen_ai.request.model", a.Model),
attribute.String("hanzo.agent.run_id", runID),
)
prompt := a.Instructions
if in := strings.TrimSpace(input); in != "" {
@@ -974,12 +1052,38 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
prompt += in
}
start := time.Now()
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
var (
resp *types.ChatResponse
used string
aiErr error
)
// An agent nested at the depth limit is offered nothing and has to answer for
// itself — the one thing that stops a cycle of agents-as-tools, since each
// level would otherwise start its round cap over (tools.go).
var offer []string
if agentDepth(ctx) < maxAgentDepth {
offer = callableTools(a)
}
defs := runTools.catalog(ctx, org, actor, offer)
// BOTH numbers, always. An agent that declares tools and is offered none is
// the exact shape of the split-fleet gap tools.go describes, and it is only
// diagnosable if the span says "declared 3, offered 0" rather than staying
// silent about a run that quietly had no hands.
span.SetAttributes(
attribute.Int("hanzo.agent.tools_declared", len(a.Tools)),
attribute.Int("hanzo.agent.tools", len(defs)),
)
var tools int
if len(defs) > 0 {
resp, used, aiErr, tools = completeWithTools(ctx, ai, org, actor, prompt, a.Model, fallback, defs, runID)
} else {
resp, used, aiErr = completeWithFailover(ctx, ai,
&types.ChatRequest{Model: a.Model, Org: org, Prompt: prompt, RunID: runID}, fallback)
}
dur := time.Since(start).Milliseconds()
id, _ := genID("run")
r := Run{
ID: id, Org: org, AgentName: a.Name, Model: used, Input: input,
DurationMs: dur, CreatedAt: time.Now().Unix(),
ID: runID, Org: org, AgentName: a.Name, Model: used, Input: input, Actor: actor,
DurationMs: dur, CreatedAt: time.Now().Unix(), ToolCalls: tools,
}
if aiErr != nil {
span.RecordError(aiErr)
@@ -990,25 +1094,37 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
r.Status = "ok"
if resp != nil {
r.Output = resp.Content
// The tokens the gateway actually reported. They were already in hand
// here and thrown away, which is why a run could be billed for an
// amount nothing on the run could explain.
r.PromptTokens, r.CompletionTokens = resp.PromptTokens, resp.CompletionTokens
}
}
return r
}
// completeWithFailover runs the completion on the agent's model with a bounded
// completeWithFailover runs one completion on req's own model with a bounded
// retry (completeWithRetry), then — only if that model is STILL throttled after
// its retries — fails over ONCE to fallback, a reliable model. It returns the
// response, the model that actually produced it (for honest metering), and the
// final error. A non-transient failure on either model returns immediately (the
// next model would fail identically). ONE ordered mechanism, no config sprawl.
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
//
// It takes the whole request rather than a prompt string because a tool round IS
// the request: the transcript so far and the tools on offer are part of what is
// being retried, and a helper that only knew a prompt would have to grow a second
// copy of this policy for the loop to reuse (tools.go). req.Model is set per
// attempt; everything else is the caller's.
func completeWithFailover(ctx context.Context, ai types.AIClient, req *types.ChatRequest, fallback string) (*types.ChatResponse, string, error) {
model := req.Model
models := []string{model}
if f := strings.TrimSpace(fallback); f != "" && f != model {
models = append(models, f)
}
var lastErr error
for _, m := range models {
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
req.Model = m
resp, err := completeWithRetry(ctx, ai, req)
if err == nil {
return resp, m, nil
}
@@ -1025,10 +1141,10 @@ func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, m
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
// respecting context cancellation. A non-transient error returns immediately.
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
func completeWithRetry(ctx context.Context, ai types.AIClient, req *types.ChatRequest) (*types.ChatResponse, error) {
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
resp, err := ai.ChatCompletion(ctx, req)
if err == nil {
return resp, nil
}
@@ -1098,6 +1214,58 @@ func (o agentOps) runs(ctx context.Context, in *runsQuery) (*runList, error) {
return &runList{Runs: out}, nil
}
// ListOrgRuns returns the org's agent runs across EVERY agent, newest first —
// what ran here, for whom, on which model, how long it took, and why it failed.
//
// It is the feed the per-agent history could not be: an operator asking "what is
// this tenant's agent plane doing" does not start out knowing an agent ref, and
// answering by listing the agents and then paging each one's history is N+1 round
// trips to reconstruct one ordering the database already has (RunsSince, ordered
// by created_at over the org index).
//
// The org is the CALLER's, resolved from identity by tenantStore — never a
// parameter. There is deliberately no org field on orgRunsQuery to forge: run
// history is the tenant's own record, and the only tenant this can answer for is
// the one asking.
//
// Example: {"limit": 20, "status": "error"}
func (o agentOps) orgRuns(ctx context.Context, in *orgRunsQuery) (*runList, error) {
s := o.s
sto, org, err := tenantStore(ctx, &s.State)
if err != nil {
return nil, err
}
limit := in.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
// since=0 is "no lower bound" — the newest runs regardless of age, which is
// what a feed means. A status filter reads more rows than it returns, so it
// asks for a bounded multiple rather than scanning the whole history: the cap
// keeps a tenant with a million clean runs from paying a full scan to find no
// failures, and the page it returns is still exactly `limit` when they exist.
scan := limit
if strings.TrimSpace(in.Status) != "" {
scan = limit * 20
}
runs, err := sto.RunsSince(ctx, org, 0, scan)
if err != nil {
return nil, zip.Errorf(http.StatusInternalServerError, "runs: %v", err)
}
want := strings.TrimSpace(in.Status)
out := make([]agentRunView, 0, limit)
for _, r := range runs {
if want != "" && r.Status != want {
continue
}
if len(out) == limit {
break
}
out = append(out, toRunView(r))
}
return &runList{Runs: out}, nil
}
// AgentMetrics serves the invocations-over-time histogram for the org's Agents
// dashboard. Every point is a REAL count of recorded runs in that time bucket —
// one series line per agent that ran in the window. The Resource Usage rollup is
+2 -2
View File
@@ -156,7 +156,7 @@ func TestExecuteRunOK(t *testing.T) {
ai := &fakeAI{content: "hi there"}
a := mk("maxpower", "greeter")
a.Instructions = "You are a greeter."
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "", "run_test")
if r.Status != "ok" {
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
@@ -177,7 +177,7 @@ func TestExecuteRunOK(t *testing.T) {
func TestExecuteRunRecordsError(t *testing.T) {
ai := &fakeAI{err: errors.New("model unavailable")}
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", mk("maxpower", "x"), "in", "", "run_test")
if r.Status != "error" {
t.Fatalf("want error status, got %q", r.Status)
}
+24 -39
View File
@@ -4,16 +4,15 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/metering"
"github.com/hanzoai/cloud/internal/planetest"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
@@ -22,59 +21,44 @@ import (
// errTest is the model-failure the "failed run is not billed" case injects.
var errTest = errors.New("model unavailable")
// billServer is a minimal commerce double: it returns a fixed balance and
// records the X-Org-Id header (the tenant the debit lands on) + the usage body
// of every debit. X-Org-Id is the header commerce's service-token auth reads
// (metering >= v0.1.2), so a wrong tenant here would prove a cross-tenant leak.
// billServer is a minimal commerce double. The balance READ is still HTTP and is
// answered here; the usage DEBIT crosses the internal plane and is recorded by the
// shared money peer (internal/planetest), because metering.Usage.Ref is `json:"-"`
// and could not survive a JSON body — see that package's doc comment.
//
// It counted HTTP hits on /v1/billing/usage until the debit moved off HTTP, at
// which point it counted an endpoint nothing calls and every assertion below read
// zero.
type billServer struct {
available int64
mu sync.Mutex
usageOrg string
usageBody []byte
usages int32
balances int32
peer *planetest.Commerce
balances int32
}
func (b *billServer) start(t *testing.T) string {
t.Helper()
b.peer = planetest.Serve(t)
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&b.balances, 1)
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
})
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&b.usages, 1)
body, _ := io.ReadAll(r.Body)
b.mu.Lock()
b.usageOrg, b.usageBody = r.Header.Get("X-Org-Id"), body
b.mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv.URL
}
func (b *billServer) debits() int32 { return atomic.LoadInt32(&b.usages) }
func (b *billServer) lastDebit() (string, []byte) {
b.mu.Lock()
defer b.mu.Unlock()
return b.usageOrg, b.usageBody
}
func (b *billServer) debits() int32 { return b.peer.Count() }
// lastDebit is (billed org, the debit as commerce would row it). The org is the
// CALLER's, so a wrong value here is still exactly the cross-tenant leak the old
// X-Org-Id assertion was watching for.
func (b *billServer) lastDebit() (string, []byte) { return b.peer.Org(), b.peer.Body() }
// waitForDebit polls a condition briefly — debits are recorded on a detached
// goroutine, so the assertion must wait for the async write.
func waitForDebit(cond func() bool) bool {
for i := 0; i < 200; i++ {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
func waitForDebit(cond func() bool) bool { return planetest.Wait(cond) }
// mountBilled mounts the agents surface with a REAL metering client pointed at
// the fake commerce (default org "hanzo", so every "acme is billed" assertion
@@ -89,10 +73,11 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
}
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.
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m, AIFallbackModel: "best"}
// The agent runner's failover is armed by cloud.FallbackModel, so the
// retry/failover tests exercise the real escalation path with no fixture to
// set; it never fires for a run whose model answers (or fails
// non-transiently), so the other billed tests are unaffected.
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai, Metering: m}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
+9 -8
View File
@@ -38,11 +38,12 @@ func scanUpstream(t *testing.T, what string, body []byte) {
// TestNoUpstreamNameOnTheWire is the regression guard. It walks every
// customer-visible read of the agents registry and scans the raw response.
func TestNoUpstreamNameOnTheWire(t *testing.T) {
// The adversarial deployment: an operator who set CLOUD_AI_DEFAULT_MODEL to an
// upstream name, and a gateway whose catalog serves upstream names — exactly
// the configuration that produced the live leak.
// A gateway whose catalog serves upstream names — half of the configuration
// that produced the live leak. The other half, an operator pointing
// CLOUD_AI_DEFAULT_MODEL at an upstream name, is no longer expressible: the
// default is cloud.DefaultModel and nothing can move it.
ai := &catalogAI{content: "pong", ids: []string{"enso", "enso-flash", "deepseek-v4-flash", "glm-5.2"}}
app := mountAppModel(t, ai, "deepseek-v4-flash")
app := mountAppModel(t, ai)
// 1. An agent created with NO model. The configured default is an upstream
// name; normalization must still store and answer the Hanzo name.
@@ -195,11 +196,11 @@ func TestMigrateModelRewritesStoredRows(t *testing.T) {
}
// TestSeedPersonalitiesUsesHanzoModel proves the built-in crew (dev/des/vi) is
// seeded on a Hanzo model even when the deployment default is an upstream name —
// the seed path that put deepseek-v4-flash on three live agents.
// seeded on a Hanzo model — the seed path that put deepseek-v4-flash on three
// live agents.
func TestSeedPersonalitiesUsesHanzoModel(t *testing.T) {
ai := &catalogAI{content: "pong", ids: []string{"enso", "deepseek-v4-flash"}}
app := mountAppModel(t, ai, "deepseek-v4-flash")
ai := &catalogAI{content: "pong", ids: []string{"enso", cloud.DefaultModel, "deepseek-v4-flash"}}
app := mountAppModel(t, ai)
n, err := SeedPersonalities(context.Background(), "acme")
if err != nil {
+95
View File
@@ -0,0 +1,95 @@
package agents
import (
"testing"
"github.com/hanzoai/cloud"
)
// An org that connected Slack and did nothing else has NO agent rows, and the
// bridges ask for the conventional ref — so without a built-in default @hanzo
// answers "the agent hit an error handling that" in every fresh workspace.
func TestBuiltinResolvesTheConventionalRef(t *testing.T) {
a, ok := builtinAgent("acme", "hanzo", "zen-70b")
if !ok {
t.Fatal("the conventional ref must resolve to the built-in default")
}
if a.Org != "acme" {
t.Errorf("the default must be scoped to the asking org, got %q", a.Org)
}
if a.Model != "zen-70b" {
t.Errorf("the default must use the deployment's model, got %q", a.Model)
}
// The default declares the fleet's whole door. The tool loop still decides what
// is OFFERED per run, but an agent that declares nothing is offered nothing —
// which is how the assistant came to report it could not reach a cloud that was
// one socket away.
if len(a.Tools) != 1 || a.Tools[0] != ToolsAll {
t.Errorf("the default must declare the whole door (%q), got %v", ToolsAll, a.Tools)
}
if a.Instructions == "" {
t.Error("the default must know what it is")
}
}
// Case is not a reason to fail: Slack sends whatever the user typed.
func TestBuiltinIsCaseInsensitive(t *testing.T) {
if _, ok := builtinAgent("acme", "Hanzo", "m"); !ok {
t.Error("the ref must match case-insensitively")
}
}
// An UNKNOWN ref stays unknown. Silently substituting the chat agent would make
// a typo in `code: repo` run the wrong thing and look like it worked.
func TestUnknownRefIsStillAMiss(t *testing.T) {
for _, ref := range []string{"deployer", "hanzo-coder", "", "hanz"} {
if _, ok := builtinAgent("acme", ref, "m"); ok {
t.Errorf("%q must not resolve to the default", ref)
}
}
}
// No model configured is an honest miss, not a run that fails deeper in.
func TestNoModelIsAMiss(t *testing.T) {
if _, ok := builtinAgent("acme", "hanzo", " "); ok {
t.Error("with no model configured the default must not resolve")
}
}
// The chat brain is cloud.ChatModel — one constant in the file that owns model
// policy, not a literal here plus a BRIDGE_AGENT_MODEL knob beside it.
//
// The knob was never set in any deployment, and the literal was justified by the
// claim that enso auto-routes per query, which it does not. This test is the guard
// against a second place regrowing: there is exactly one line that names the tier
// and it is not in this package.
func TestBuiltinModelIsTheChatConstant(t *testing.T) {
a, ok := builtinAgent("acme", "hanzo", cloud.ChatModel)
if !ok {
t.Fatal("the conventional ref must resolve to the built-in")
}
if a.Model != cloud.ChatModel {
t.Errorf("the chat brain must be cloud.ChatModel (%q), got %q", cloud.ChatModel, a.Model)
}
if a.Model == cloud.FallbackModel {
t.Error(`"best" is the degraded fallback tier, never the interactive default`)
}
// The menu must be able to express the default, or a person who opens App Home
// sees a blank selector and their own model looks lost.
if !knownChatModel(cloud.ChatModel) {
t.Errorf("the App Home menu must offer the default tier %q", cloud.ChatModel)
}
}
// The App Home pin still wins over the default — a person's explicit choice is
// the one thing that may override it, and only for the built-in.
func TestAppHomePinBeatsTheDefault(t *testing.T) {
for _, m := range []string{"enso", "enso-flash", "enso-ultra"} {
if !knownChatModel(m) {
t.Errorf("App Home offers %q, so the turn must accept it", m)
}
}
if knownChatModel("gpt-4o") || knownChatModel("best") {
t.Error("only the enso family may be pinned from a client")
}
}
+465
View File
@@ -0,0 +1,465 @@
package agents
// door.go — where a run's tools come from once the fleet is more than one
// process: the fleet's OWN agent door, asked over the internal socket.
//
// # Why this is not a new mechanism
//
// The fleet already aggregates. fleet.Door asks every composed app what it
// serves right now, merges the answers, remembers which app listed which name,
// and forwards a tools/call to that app (fleet/mcp.go). It is what serves
// api.hanzo.ai/v1/mcp and what a Slack MCP client already talks to. Building a
// tools_catalog/tools_call op pair on the tool plane would have been a SECOND
// aggregation over the same children, with a second place for the curation rule
// to be applied — or forgotten.
//
// So nothing here aggregates. The host publishes the door it already built on
// the socket every child can already reach (cmd/cloud/wake.go), and an agent is
// simply another MCP client of it. Same JSON-RPC, same union, same order, same
// [fleet] denylist — which is enforced inside gather, where the routing table is
// written, so a name the door will not project is not routable for anyone. An
// agent therefore CANNOT see a surface an external client cannot; there is no
// second surface to see.
//
// # The address, and what "no door" means
//
// plane.HostApp is the router's own socket — the one plane.Reach dials to wake a
// cold app — and the door rides it at manifest.MCPPath. Reaching for it answers
// one of exactly three things, which is the rule plane/ask.go already states:
//
// listening ask it; this is production
// no listener THIS PROCESS IS THE FLEET — a single-app binary, a test, a dev
// box. Fall back to [registryTools], which is the real answer
// there and empty everywhere else.
// unusable an outage. Zero tools, recorded on the run's span, never
// laundered into "this fleet has no tools".
//
// # Identity is stated by the RUN, and the model never touches it
//
// The org and actor a dispatch carries are the run's own — the pair its fee is
// billed under — passed as arguments from executeRun and written onto the
// request as zip's identity headers here. The model contributes a tool NAME and
// an ARGUMENTS object and nothing else, so there is no path by which it can name
// a tenant. The inbound caller's headers are deliberately NOT forwarded: a
// scheduled run has no inbound request at all, and a nested one may be running
// for a different principal than whoever made the outermost HTTP call.
//
// The socket carries no credential and needs none: it is 0700 in the fleet's own
// run directory and the kernel attests the peer, which is the same trust
// zip.WithCaller rides on for every other internal call.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"github.com/hanzoai/cloud/fleet"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
"github.com/zap-proto/zip"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// doorTools is the tool plane read from the FLEET's composed agent door.
type doorTools struct{}
// errNoDoor reports that this process is not part of a fleet: nothing is
// listening on the router's socket, so there is no composed door to ask.
//
// It is the ONE error a caller may read as "fall back", exactly as plane.ErrNoPeer
// is on the peer plane. Every other failure is an outage and is reported as one —
// a door that is present and broken must never read as a fleet with no tools.
var errNoDoor = errors.New("agents: no fleet door on this host")
// catalog resolves the agent's declared names against the fleet's own surface.
//
// It asks the door WHAT IS OFFERED and then, for the handful of names this agent
// declared, what each one takes. Those are two questions because the door's
// tools/list answers only the first: it publishes one tool per subsystem, whose
// `op` enum carries the operation names and no schemas, since the flat list of
// this fleet's operations was 977 KB that no model can hold and every client
// truncates (fleet/grouped.go). fleet.Describe answers the second, one operation
// at a time, out of the same gathered set — so a declared name the door does not
// offer is simply absent, which is the same rule registryTools follows: offering
// a tool that would be refused at dispatch teaches the model a lie.
func (doorTools) catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef {
if org == "" || len(want) == 0 {
return nil
}
// ToolsAll is the ONE way to say "whatever the fleet serves", and it has to be
// said rather than implied.
//
// An agent that declares nothing gets nothing — that default is correct and
// stays, because a user-defined agent's tool list is its authority and an
// empty one means it asked for none. But the DEFAULT ASSISTANT cannot enumerate
// its tools: the door's surface is discovered at runtime (88 grouped tools
// today, and the whole point of grouping was that the set changes without a
// code edit), so any list written here would be stale the next time a
// subsystem ships.
//
// Not stating it cost a full turn of user-visible wrongness: the assistant was
// told in its instructions that it had tools and how to call them, then handed
// an empty offer by this function, so it correctly reported that it could not
// reach the cloud — while the door was serving 88 tools one socket away. The
// two halves have to agree, and this is the half that was missing.
all := false
for _, n := range want {
if strings.TrimSpace(n) == ToolsAll {
all = true
break
}
}
wanted := make(map[string]bool, len(want))
for _, n := range want {
if n = strings.TrimSpace(n); n != "" && n != ToolsAll {
wanted[n] = true
}
}
if len(wanted) == 0 && !all {
return nil
}
res, err := askDoor(ctx, org, actor, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if errors.Is(err, errNoDoor) {
return registryTools{}.catalog(ctx, org, actor, want)
}
if err != nil {
// An outage, and it is SAID so. The run continues with no tools — killing
// a turn the org has paid for because a sibling is down is the worse
// answer — but "declared 3, offered 0" is already a number on the step
// span, and this is the reason beside it.
trace.SpanFromContext(ctx).RecordError(err)
return nil
}
var listed struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
// Meta is the door's own account of why its list may be SHORT: the
// subsystems it could not ask, and how many names policy withheld. The door
// went to the trouble of never shortening quietly, so throwing it away here
// would put the silence back one layer down.
Meta json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(res, &listed); err != nil {
trace.SpanFromContext(ctx).RecordError(fmt.Errorf("agents: the fleet door's tools/list is not a tool list: %w", err))
return nil
}
offered := map[string]bool{}
for _, t := range listed.Tools {
for _, op := range opsOf(t.InputSchema) {
offered[op] = true
}
}
// ToolsAll offers the door's tools AS THE DOOR GROUPS THEM — one per subsystem,
// named for it, carrying an `op` enum, plus [fleet.Describe] — and not the ops
// flattened back out.
//
// The grouping is the whole reason the surface is affordable: 1,189 flat tools
// were 977 KB (~244k tokens) merely to LIST, and the same operations grouped
// are 88 tools in 63 KB. Expanding them here would hand back every byte the
// door just saved and blow the context before the question is read.
//
// It is also what the assistant's instructions describe — pick a subsystem,
// choose an op from its enum, call [fleet.Describe] for a shape you do not know.
// The prose and the offer have to be the same surface or the model is being
// taught a protocol it cannot practise.
if all {
out := make([]types.ToolDef, 0, len(listed.Tools))
for _, t := range listed.Tools {
out = append(out, types.ToolDef{
Name: t.Name, Description: t.Description, Schema: t.InputSchema,
})
}
return out
}
// In the agent's own declared order, which is the order the model meets them
// in, and once each however often it was declared.
out := make([]types.ToolDef, 0, len(wanted))
done := make(map[string]bool, len(wanted))
for _, n := range want {
n = strings.TrimSpace(n)
if !wanted[n] || done[n] || !offered[n] {
continue
}
done[n] = true
def, err := describe(ctx, org, actor, n)
if err != nil {
// It was offered a moment ago, so this is an outage between the two
// asks and not a refusal. Same policy as above: the turn goes on with
// one fewer tool, and the reason is on the span.
trace.SpanFromContext(ctx).RecordError(err)
continue
}
out = append(out, def)
}
// A declared name that resolved to nothing has two very different causes — a
// subsystem that is DOWN and a tool the fleet REFUSES to project — and the
// door already distinguishes them. Carrying its answer onto the span is what
// makes "declared 3, offered 1" diagnosable instead of a shrug.
if len(out) < len(wanted) && len(listed.Meta) > 0 {
trace.SpanFromContext(ctx).SetAttributes(
attribute.String("hanzo.agent.tools_meta", clip(string(listed.Meta), maxDoorMeta)))
}
return out
}
// opsOf reads the operation names out of one subsystem tool's schema — its `op`
// enum, which is where the door carries them.
func opsOf(schema json.RawMessage) []string {
var s struct {
Properties struct {
Op struct {
Enum []string `json:"enum"`
} `json:"op"`
} `json:"properties"`
}
if json.Unmarshal(schema, &s) != nil {
return nil
}
return s.Properties.Op.Enum
}
// describe fetches ONE operation's descriptor through the door's own
// fleet.Describe, and reads the owning subsystem's bytes back out of it.
//
// What the seam guarantees is that the model is offered exactly what it will
// CALL, and op is that name: it came out of a subsystem tool's `op` enum a
// moment ago, and a tools/call naming it reaches the operation's own handler.
// So the offer is named op, with the owner's own description and schema behind
// it.
//
// The descriptor's own `name` is NOT compared to op, and that is a change. The
// door publishes an operation as a verb on an object — `deploy_project` for
// `post_v1_projects_by_slug_deploy` (fleet/verbs.go) — while the descriptor it
// hands back is the owning subsystem's, carried verbatim, so it still says the
// id. Requiring the two to match would reject 1,730 of the fleet's 2,229
// operations for being correctly named.
func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error) {
args, err := json.Marshal(map[string]string{"op": op})
if err != nil {
return types.ToolDef{}, err
}
body, err := toolCallBody(fleet.Describe, string(args))
if err != nil {
return types.ToolDef{}, err
}
res, err := askDoor(ctx, org, actor, body)
if err != nil {
return types.ToolDef{}, err
}
text, err := toolResult(res)
if err != nil {
return types.ToolDef{}, err
}
var d struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name == "" || len(d.InputSchema) == 0 {
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
}
return types.ToolDef{Name: op, Description: d.Description, Schema: d.InputSchema}, nil
}
// maxDoorMeta bounds what one span attribute may carry: `_meta` names every
// subsystem that did not answer, and a fleet-wide outage would otherwise put a
// hundred rows on every run's trace.
const maxDoorMeta = 1024
func clip(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// call runs one tool through the door's own dispatch: the door names the app
// that listed it and forwards this message verbatim to that app's registry, so
// the host can only ever ROUTE a call and never invoke something the owner did
// not declare.
func (doorTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
body, err := toolCallBody(name, args)
if err != nil {
return "", err
}
res, err := askDoor(ctx, org, actor, body)
if errors.Is(err, errNoDoor) {
return registryTools{}.call(ctx, org, actor, name, args)
}
if err != nil {
return "", err
}
return toolResult(res)
}
// toolCallBody builds one MCP tools/call, with the model's arguments carried
// VERBATIM.
//
// The arguments are validated as a JSON OBJECT and then embedded unparsed: they
// belong to the tool that declared the schema, which is the only thing that
// knows how to read them, and re-encoding them here would be this process having
// an opinion about a shape it does not own. A model that emits something else is
// told so — the same sentence registryTools gives it — and the turn goes on.
func toolCallBody(name, args string) ([]byte, error) {
raw := json.RawMessage("{}")
if s := strings.TrimSpace(args); s != "" && s != "null" {
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(s), &probe); err != nil {
return nil, fmt.Errorf("arguments are not a JSON object: %w", err)
}
raw = json.RawMessage(s)
}
return json.Marshal(struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"params"`
}{
JSONRPC: "2.0", ID: 1, Method: "tools/call",
Params: struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}{Name: name, Arguments: raw},
})
}
// toolResult reads one MCP tool result into the text the model is handed.
//
// isError is a FAILURE and comes back as one, so dispatchOne renders it as a
// tool result the model can react to rather than as a success it would believe.
// That is the same distinction the door itself draws when a hop fails.
func toolResult(res json.RawMessage) (string, error) {
var out struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(res, &out); err != nil {
return "", fmt.Errorf("agents: the fleet door answered a tool result that will not decode: %w", err)
}
parts := make([]string, 0, len(out.Content))
for _, c := range out.Content {
if c.Text != "" {
parts = append(parts, c.Text)
}
}
text := strings.Join(parts, "\n")
if out.IsError {
// The tool's OWN sentence, so the model reads what actually went wrong.
if text == "" {
text = "the tool reported a failure with no message"
}
return "", errors.New(truncateToolResult(text))
}
return truncateToolResult(text), nil
}
// askDoor puts one JSON-RPC message to the fleet's door as (org, actor) and
// returns the `result` member.
//
// A JSON-RPC ERROR is an error here, deliberately: a tool the door will not
// route answers -32602, and folding that into an empty result would make "this
// tool is not yours to call" indistinguishable from "it ran and said nothing".
func askDoor(ctx context.Context, org, actor string, body []byte) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
addr, err := doorAddr()
if err != nil {
return nil, err
}
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.Header.SetMethod(fasthttp.MethodPost)
req.Header.SetContentType("application/json")
req.SetHost(plane.HostApp)
req.URI().SetPath(manifest.MCPPath)
// The RUN's identity, in zip's own spelling, and nothing else. The door
// copies these onto every hop it makes, so a subsystem whose tools depend on
// the tenant answers for the org this run is billed to. A blank subject is a
// run with no person behind it (a schedule, a service token); the org is the
// authority either way and inventing a user would attribute the call to
// nobody.
req.Header.Set(zip.HeaderOrg, org)
if sub := actorSub(org, actor); sub != "" {
req.Header.Set(zip.HeaderUser, sub)
}
req.SetBody(body)
if err := doorClient(addr).Do(req, resp); err != nil {
return nil, fmt.Errorf("agents: the fleet door at %s did not answer: %w", addr, err)
}
if code := resp.StatusCode(); code < 200 || code > 299 {
return nil, fmt.Errorf("agents: the fleet door answered %d", code)
}
var env struct {
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(resp.Body(), &env); err != nil {
return nil, fmt.Errorf("agents: the fleet door answered something that is not JSON-RPC: %w", err)
}
if env.Error != nil {
return nil, errors.New(env.Error.Message)
}
return append(json.RawMessage(nil), env.Result...), nil
}
// doorAddr resolves the fleet door's socket, or says which of the two failures
// it is. See [errNoDoor].
//
// It probes by CONNECTING, because the file does not answer the question: a
// socket path outlives the process that bound it wherever the run directory is a
// volume. plane.Listening is the one implementation of that rule.
func doorAddr() (string, error) {
plane.Bind()
path := zip.SocketPath(plane.HostApp)
up, err := plane.Listening(path)
if err != nil {
return "", fmt.Errorf("agents: the fleet door's socket is unusable: %w", err)
}
if !up {
return "", fmt.Errorf("%w (%s)", errNoDoor, path)
}
return path, nil
}
// doorClients is one pooled transport per ADDRESS, for the reason fleet keeps
// one: a transport holds a connection pool, so dialing per ask turns every tool
// call into a fresh connect. Keyed by address rather than kept in a single var
// because a test points the run directory somewhere else.
var doorClients sync.Map // addr -> *zaphttp.Transport
func doorClient(addr string) *zaphttp.Transport {
if c, ok := doorClients.Load(addr); ok {
return c.(*zaphttp.Transport)
}
t := zaphttp.Dial("unix", addr)
// The whole run's ceiling, not the library's 30s. A tools/list is a fan-out
// across every composed app and the first ask of a cold one pays that app's
// startup, so a transport that gave up sooner than the run does would report
// an outage for a fleet that was merely waking up.
t.SetReadTimeout(toolRunBudget)
c, _ := doorClients.LoadOrStore(addr, t)
return c.(*zaphttp.Transport)
}
+314
View File
@@ -0,0 +1,314 @@
package agents
// door_test.go — the tool plane, over the wire it actually uses.
//
// Nothing is stubbed at the seam under test. Every test here brings up real
// subsystem apps on real ZAP sockets, composes the REAL fleet.Door over them,
// publishes it on the router's socket exactly as cmd/cloud/wake.go does, and
// then drives doorTools — so what is asserted is what a deployed agent gets.
//
// A fake door would have proved nothing: the two properties worth having are
// that the agent inherits the door's CURATION and that the run's org reaches the
// owning subsystem, and both live in code a stub would have replaced.
import (
"context"
"net"
"os"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/fleet"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
"github.com/zap-proto/zip"
)
type echoIn struct {
Say string `json:"say"`
}
type echoOut struct {
App string `json:"app"`
Say string `json:"say"`
Org string `json:"org"`
}
// subsystem starts one app serving the named ops on its own socket, the shape
// cloud.Serve gives every plugin binary. Each op echoes its input AND the org it
// was reached as, so a test can prove the run's tenant travelled the whole way.
func subsystem(t *testing.T, name string, ops ...string) string {
t.Helper()
sock := shortDir(t) + "/" + name + ".sock"
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
for _, id := range ops {
zip.Post(app, "/v1/"+name+"/"+id, func(ctx context.Context, in *echoIn) (*echoOut, error) {
return &echoOut{App: name, Say: in.Say, Org: zip.CallerOf(ctx).Org}, nil
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
}
go func() { _ = app.Listen(sock) }()
t.Cleanup(func() { _ = app.Shutdown() })
accepts(t, sock)
return sock
}
// fleetDoor composes the real door over those apps and puts it where a child
// looks for it — plane.HostApp's socket, at manifest.MCPPath. This is
// serveWake's two lines, not a reimplementation of them.
//
// at names each app's EDGE door and inside names the PLANE door of the ones that
// have one, which is the pair cmd/cloud registers (locate / inside). An app with
// no entry in inside is reached at its edge door — production's remotely-mounted
// case, where there is no local plane socket to reach.
func fleetDoor(t *testing.T, at map[string]string, inside map[string]string) {
t.Helper()
run := shortDir(t)
t.Setenv("ZIP_RUNTIME_DIR", run)
plane.Unbind()
t.Cleanup(plane.Unbind)
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true, MCP: zip.MCPConfig{Disabled: true}})
apps := make([]string, 0, len(at))
for name := range at {
apps = append(apps, name)
}
edge := func(app string) (addr, path string, err error) {
sock, ok := at[app]
if !ok {
return "", "", &net.AddrError{Err: "no instance running", Addr: app}
}
return sock, manifest.FrameworkMCPPath, nil
}
d := fleet.Mount(host, manifest.MCPPath, apps, edge)
door := zip.New(zip.Config{AppName: "plane", DisableStartupMessage: true})
d.Serve(door, manifest.MCPPath, func(app string) (addr, path string, err error) {
if sock, ok := inside[app]; ok {
return sock, manifest.MCPPath, nil
}
return edge(app)
})
path := zip.SocketPath(plane.HostApp)
go func() { _ = door.Listen(path) }()
t.Cleanup(func() { _ = door.Shutdown() })
accepts(t, path)
}
// noFleetDoor points the run directory at an empty one: nothing is listening, so
// this process is the whole fleet.
func noFleetDoor(t *testing.T) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", shortDir(t))
plane.Unbind()
t.Cleanup(plane.Unbind)
}
// shortDir is a temp directory with a SHORT name, because a unix socket path is
// capped near 104 bytes and t.TempDir() spends most of that on the test's name.
func shortDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func accepts(t *testing.T, sock string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if c, err := net.Dial("unix", sock); err == nil {
_ = c.Close()
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("%s never accepted", sock)
}
// TestAgentResolvesItsToolsFromTheFleetDoor is the whole claim: a declared name
// resolves to the OWNING subsystem's own descriptor, across a process boundary,
// with nothing in this binary that knows what that subsystem serves.
func TestAgentResolvesItsToolsFromTheFleetDoor(t *testing.T) {
fleetDoor(t, map[string]string{
"alpha": subsystem(t, "alpha", "alpha_echo", "alpha_other"),
"beta": subsystem(t, "beta", "beta_echo"),
}, nil)
door := doorTools{}
defs := door.catalog(context.Background(), "acme", "acme/u-1", []string{"alpha_echo", "beta_echo"})
if len(defs) != 2 {
t.Fatalf("the agent declared two tools the fleet serves and was offered %d: %+v", len(defs), defs)
}
got := map[string]bool{}
for _, d := range defs {
got[d.Name] = true
if d.Description == "" {
t.Errorf("%s came back with no description, so the model is offered a tool it cannot choose", d.Name)
}
if len(d.Schema) == 0 {
t.Errorf("%s came back with no schema, so the model cannot fill its arguments", d.Name)
}
}
if !got["alpha_echo"] || !got["beta_echo"] {
t.Fatalf("offered %v, want alpha_echo and beta_echo", got)
}
}
// A declared name NOTHING in the fleet serves is absent, never offered. Offering
// a tool that would be refused at dispatch teaches the model a lie.
func TestUnservedNamesAreNotOffered(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
defs := door.catalog(context.Background(), "acme", "acme/u-1",
[]string{"alpha_echo", "slack_post_message"})
if len(defs) != 1 || defs[0].Name != "alpha_echo" {
t.Fatalf("offered %+v, want alpha_echo alone", defs)
}
}
// TestTheAgentInheritsTheDoorsDenylist is the security bar, as a test.
//
// The curation rule lives in fleet/surface.go and is applied inside gather,
// where the routing table is written. An agent reaching the door through any
// other path would have seen a surface external MCP clients cannot — so this
// asserts BOTH halves: the credential-minting op is not offered, and naming it
// anyway does not run it.
func TestTheAgentInheritsTheDoorsDenylist(t *testing.T) {
fleetDoor(t, map[string]string{
"iam": subsystem(t, "iam", "CreateServiceAccountKey", "GetRole"),
}, nil)
door := doorTools{}
ctx := context.Background()
defs := door.catalog(ctx, "acme", "acme/u-1", []string{"CreateServiceAccountKey", "GetRole"})
if len(defs) != 1 || defs[0].Name != "GetRole" {
t.Fatalf("the agent was offered %+v; the door projects GetRole and refuses CreateServiceAccountKey", defs)
}
if _, err := door.call(ctx, "acme", "acme/u-1", "CreateServiceAccountKey", `{"say":"hi"}`); err == nil {
t.Fatal("a refused tool RAN for an agent that named it directly — the denylist is a suggestion, not a boundary")
}
}
// TestADispatchCarriesTheRunsOrg: the tenant reaches the subsystem that owns the
// tool, and it comes from the run rather than from anything the model emitted.
func TestADispatchCarriesTheRunsOrg(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
out, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `{"say":"pong"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
for _, want := range []string{`"app":"alpha"`, `"say":"pong"`, `"org":"acme"`} {
if !strings.Contains(out, want) {
t.Fatalf("alpha answered %q, which does not carry %s", out, want)
}
}
}
// guarded is one subsystem in its PRODUCTION shape, which is the shape the
// fixture above deliberately is not: the identity boundary in front of it, and its
// tool resolving the tenant the way every org-scoped op does — principal.OrgFrom,
// never a header. It returns its EDGE socket and its PLANE socket.
//
// The bare fixture answers zip.CallerOf directly, so it reports the org for any
// caller that names one. That is why every test above passed while every tool the
// @hanzo Slack agent called refused it: production has a boundary, the boundary
// deletes an authority header no credential backs, and nothing in this file had
// one.
func guarded(t *testing.T, name, op string) (edge, plane string) {
t.Helper()
cloud.ResetPlane()
t.Cleanup(cloud.ResetPlane)
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
cloud.Identify(app, &cloud.Config{})
zip.Post(app, "/v1/"+name+"/"+op, func(ctx context.Context, in *echoIn) (*echoOut, error) {
org, ok := principal.OrgFrom(ctx)
if !ok {
return nil, zip.ErrForbidden("X-Org-Id required")
}
return &echoOut{App: name, Say: in.Say, Org: org}, nil
}, zip.WithOperationID(op), zip.WithSummary("what "+name+" does at "+op))
cloud.Door(app)
dir := shortDir(t)
edge, plane = dir+"/"+name+".sock", dir+"/"+name+"-plane.sock"
go func() { _ = app.Listen(edge) }()
go func() { _ = cloud.Plane().Listen(plane) }()
t.Cleanup(func() { _ = app.Shutdown() })
accepts(t, edge)
accepts(t, plane)
return edge, plane
}
// TestATenantedToolAnswersTheRun is the @hanzo Slack failure, as a test: a run
// with a principal the fleet resolved server-side calls a tool that scopes by
// tenant, and gets an answer instead of `X-Org-Id required`.
//
// It is the same call TestADispatchCarriesTheRunsOrg makes, against a subsystem
// that has the boundary production has. Point the door's internal reach at the
// EDGE door instead and it fails exactly the way the deployed fleet did.
func TestATenantedToolAnswersTheRun(t *testing.T) {
edge, plane := guarded(t, "alpha", "alpha_tenant")
fleetDoor(t, map[string]string{"alpha": edge}, map[string]string{"alpha": plane})
out, err := doorTools{}.call(context.Background(), "acme", "acme/u-1", "alpha_tenant", `{"say":"pong"}`)
if err != nil {
t.Fatalf("a tool that scopes by tenant refused the run it belongs to: %v", err)
}
if !strings.Contains(out, `"org":"acme"`) {
t.Fatalf("alpha answered %q, which does not carry the run's tenant", out)
}
}
// A tool the door cannot route is an ERROR the model reads, never a silent empty
// result and never a killed turn.
func TestAnUnroutableToolIsAnErrorNotAnEmptyResult(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
out, err := door.call(context.Background(), "acme", "acme/u-1", "nobody_serves_this", `{}`)
if err == nil {
t.Fatalf("an unroutable tool answered %q with no error", out)
}
if got := dispatchOne(context.Background(), "acme", "acme/u-1",
types.ToolCall{ID: "c1", Name: "nobody_serves_this", Arguments: `{}`}, "run_test", 0); !strings.Contains(got, "error:") {
t.Fatalf("the model was handed %q for a tool that cannot run", got)
}
}
// Arguments that are not a JSON object are refused HERE, before the wire, and
// the model is told so — the same sentence the co-resident plane gives it.
func TestMalformedArgumentsNeverReachTheDoor(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
if _, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `["not","an","object"]`); err == nil {
t.Fatal("a JSON array was accepted as a tool's arguments")
}
}
// With no router on this host, this process IS the fleet: doorTools falls back
// to the in-process registry rather than reporting an outage — and a run in a
// single-app binary keeps working instead of crashing.
func TestNoFleetDoorFallsBackToThisProcesssRegistry(t *testing.T) {
noFleetDoor(t)
door := doorTools{}
ctx := context.Background()
if defs := door.catalog(ctx, "acme", "acme/u-1", []string{"alpha_echo"}); len(defs) != 0 {
t.Fatalf("this process serves no such tool, so nothing may be offered: %+v", defs)
}
if _, err := door.call(ctx, "acme", "acme/u-1", "alpha_echo", `{}`); err == nil {
t.Fatal("a tool nothing in this process registers reported success")
}
}
+28 -14
View File
@@ -26,28 +26,29 @@ func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// to exercise the no-inference fail-closed path.
func mountApp(t *testing.T, ai types.AIClient) *zip.App {
t.Helper()
return mountAppModel(t, ai, "")
return mountAppModel(t, ai)
}
// mountAppModel is mountApp with an explicit deployment default model
// (deps.AIDefaultModel), so a test can exercise the empty-model → default path.
func mountAppModel(t *testing.T, ai types.AIClient, defaultModel string) *zip.App {
// mountAppModel mounts with a catalog-aware AI client. It took a deployment
// default model until that knob was deleted: the default is cloud.DefaultModel,
// full stop, so there is no per-deployment value left for a test to vary.
func mountAppModel(t *testing.T, ai types.AIClient) *zip.App {
t.Helper()
return mountAppIn(t, t.TempDir(), ai, defaultModel)
return mountAppIn(t, t.TempDir(), ai)
}
// mountAppDir mounts over an EXISTING data dir, so a test can stand up what a
// deployment already has on disk (a pre-split agents.db) and boot over it.
func mountAppDir(t *testing.T, dir string) *zip.App {
t.Helper()
return mountAppIn(t, dir, &fakeAI{content: "x"}, "")
return mountAppIn(t, dir, &fakeAI{content: "x"})
}
func mountAppIn(t *testing.T, dir string, ai types.AIClient, defaultModel string) *zip.App {
func mountAppIn(t *testing.T, dir string, ai types.AIClient) *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 {
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir, AI: ai}); err != nil {
t.Fatalf("Mount: %v", err)
}
// Mount starts the scheduler goroutine when AI is non-nil and sets the global
@@ -97,20 +98,33 @@ func TestHTTPGateIsolationAndRun(t *testing.T) {
map[string]any{"name": "helper", "model": "gpt-4o-mini", "instructions": "be terse"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d", code)
}
// model is required — creating without one is a 400.
// Creating without a model is a 201 on cloud.DefaultModel. This asserted 400
// ("model is required") while the default came from deps.AIDefaultModel, which
// a hand-built test Deps left empty — but LoadConfig never did, so the 400 was
// reachable only from a fixture and NEVER from a deployment. The test pinned a
// state production could not be in; with the field gone there is one behaviour.
if code, _ := do(t, app, http.MethodPost, "/v1/agents", "maxpower",
map[string]any{"name": "nomodel"}); code != http.StatusBadRequest {
t.Fatalf("create without model want 400, got %d", code)
map[string]any{"name": "nomodel"}); code != http.StatusCreated {
t.Fatalf("create without model want 201 on the default model, got %d", code)
}
// List shape is {agents:[...]}.
// List shape is {agents:[...]}. maxpower owns BOTH creates above — the
// explicit-model one and the defaulted one — and sees neither org's rows but
// its own.
code, body := do(t, app, http.MethodGet, "/v1/agents", "maxpower", nil)
var listed struct {
Agents []agentView `json:"agents"`
}
_ = json.Unmarshal(body, &listed)
if code != http.StatusOK || len(listed.Agents) != 1 || listed.Agents[0].Name != "helper" {
t.Fatalf("maxpower should see [helper], got %d %+v", code, listed.Agents)
names := map[string]string{}
for _, a := range listed.Agents {
names[a.Name] = a.Model
}
if code != http.StatusOK || len(listed.Agents) != 2 || names["helper"] != "gpt-4o-mini" {
t.Fatalf("maxpower should see [helper nomodel], got %d %+v", code, listed.Agents)
}
if names["nomodel"] != cloud.DefaultModel {
t.Fatalf("defaulted agent stored model %q, want %q", names["nomodel"], cloud.DefaultModel)
}
// run executes via the (fake) AI and returns a real recorded run.
+1 -1
View File
@@ -148,7 +148,7 @@ func (s *Store) copyOrgTo(ctx context.Context, org string, dst *Store) error {
`INSERT OR IGNORE INTO agents (` + agentCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
{"runs",
`SELECT ` + runCols + ` FROM agent_runs WHERE org=?`,
`INSERT OR IGNORE INTO agent_runs (` + runCols + `) VALUES (?,?,?,?,?,?,?,?,?,?)`},
`INSERT OR IGNORE INTO agent_runs (` + runCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
{"sessions",
`SELECT ` + sessionCols + ` FROM agent_sessions WHERE org=?`,
`INSERT OR IGNORE INTO agent_sessions (` + sessionCols + `) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
+7 -7
View File
@@ -34,9 +34,8 @@ func (c *catalogAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float32
// the model is omitted — stores the deployment default so the agent is still
// runnable. Update is guarded identically.
func TestHTTPCreateModelValidation(t *testing.T) {
const defaultModel = "deepseek-v4-flash"
ai := &catalogAI{content: "pong", ids: []string{"zen-flash", "deepseek-v4-flash"}}
app := mountAppModel(t, ai, defaultModel)
ai := &catalogAI{content: "pong", ids: []string{"zen-flash", "deepseek-v4-flash", cloud.DefaultModel}}
app := mountAppModel(t, ai)
// A model this gateway never serves → a clean 400 at create (was a run-time 502).
code, body := do(t, app, http.MethodPost, "/v1/agents", "acme",
@@ -51,10 +50,11 @@ func TestHTTPCreateModelValidation(t *testing.T) {
t.Fatalf("catalog model want 201, got %d (%s)", code, body)
}
// An OMITTED model falls back to the deployment default, so the agent is
// created AND runnable — not a 400. This deployment's default is an UPSTREAM
// name, so what actually lands is cloud.DefaultModel: the brand boundary holds
// even against an operator who misconfigured CLOUD_AI_DEFAULT_MODEL.
// An OMITTED model falls back to cloud.DefaultModel, so the agent is created
// AND runnable — not a 400. An operator can no longer misconfigure this: the
// deployment knob that used to supply it (CLOUD_AI_DEFAULT_MODEL) is gone, so
// "the default is an upstream name" is now unrepresentable rather than merely
// defended against.
code, body = do(t, app, http.MethodPost, "/v1/agents", "acme",
map[string]any{"name": "defaulted", "instructions": "be terse"})
if code != http.StatusCreated {
+453
View File
@@ -0,0 +1,453 @@
package agents
// observability_test.go — the proof that ONE agent turn is readable afterwards.
//
// These tests run a real turn (real HTTP handler, real tool loop, real OpenAI-wire
// client against a stand-in gateway) with the real span pipeline installed, and
// assert on the spans that actually reached a sink plus the record the console
// reads. They exist because every fact below was, at one point, emitted by code
// that looked correct and observable by nobody: the run's own id was minted AFTER
// the work finished, so no span or debit the run produced could carry it.
//
// What is asserted is the operator's question, not the implementation's shape:
// what ran, for which org and which user, on which model, how many tokens, which
// tools it called, how long it took, and why it failed.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/hanzoai/cloud/clients"
"github.com/hanzoai/cloud/types"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// spanSink captures the batches the tracer provider exports, which is the only
// honest place to assert from: a span that is created but never exported is
// exactly the failure mode this file exists to catch.
type spanSink struct {
mu sync.Mutex
spans []sdktrace.ReadOnlySpan
}
func (s *spanSink) export(_ context.Context, batch []sdktrace.ReadOnlySpan) error {
s.mu.Lock()
defer s.mu.Unlock()
s.spans = append(s.spans, batch...)
return nil
}
// find returns the first captured span whose name matches, and whether there was one.
func (s *spanSink) find(name string) (sdktrace.ReadOnlySpan, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, sp := range s.spans {
if sp.Name() == name {
return sp, true
}
}
return nil, false
}
// all returns every captured span with the given name.
func (s *spanSink) all(name string) []sdktrace.ReadOnlySpan {
s.mu.Lock()
defer s.mu.Unlock()
var out []sdktrace.ReadOnlySpan
for _, sp := range s.spans {
if sp.Name() == name {
out = append(out, sp)
}
}
return out
}
// attr reads one string/int attribute off a span as text. A missing attribute is
// "", which is what the assertions below are checking for.
func attr(sp sdktrace.ReadOnlySpan, key string) string {
for _, kv := range sp.Attributes() {
if string(kv.Key) == key {
return kv.Value.Emit()
}
}
return ""
}
// traced makes this process's spans observable to the test, and returns the sink
// they land in.
//
// The provider is installed ONCE per process, and only the SINK is swapped per
// test. That is not tidiness — it is the contract OTel's global actually has: a
// tracer handle taken at package init (agentTracer here, aiTracer in clients)
// binds to the FIRST provider installed and keeps it forever, so a second install
// does not rebind those handles. A test that installed its own provider and shut
// it down on cleanup therefore left every later span-asserting test in the same
// binary exporting into a dead provider, and seeing nothing. Measured: the
// six-tool test below failed with "produced no span" for spans that were in fact
// created, purely because it ran second.
//
// Export is SYNCHRONOUS (WithSyncer): a span is in the sink when End() returns, so
// an assertion never races a batch timer and no test has to sleep to be correct.
var (
obsOnce sync.Once
obsSink atomic.Pointer[spanSink]
)
// obsExporter forwards to whichever sink is currently installed. Nil sink means a
// test that is not asserting on spans is running; its spans are discarded rather
// than accumulated into another test's assertions.
type obsExporter struct{}
func (obsExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
if s := obsSink.Load(); s != nil {
return s.export(ctx, spans)
}
return nil
}
func (obsExporter) Shutdown(context.Context) error { return nil }
func traced(t *testing.T) *spanSink {
t.Helper()
obsOnce.Do(func() {
otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSyncer(obsExporter{})))
})
sink := &spanSink{}
obsSink.Store(sink)
t.Cleanup(func() { obsSink.Store(nil) })
return sink
}
// stubPlane is a deterministic tool plane: it offers the named tools and fails
// exactly the ones named in fail.
type stubPlane struct {
mu sync.Mutex
offer []string
fail map[string]bool
called []string
}
func (p *stubPlane) catalog(context.Context, string, string, []string) []types.ToolDef {
out := make([]types.ToolDef, 0, len(p.offer))
for _, n := range p.offer {
out = append(out, types.ToolDef{Name: n, Description: "d", Schema: json.RawMessage(`{"type":"object"}`)})
}
return out
}
func (p *stubPlane) call(_ context.Context, _, _, name, _ string) (string, error) {
p.mu.Lock()
p.called = append(p.called, name)
p.mu.Unlock()
if p.fail[name] {
return "", fmt.Errorf("upstream refused %s", name)
}
return "result of " + name, nil
}
// toolGateway answers the REQUEST rather than a call counter: a turn already
// holding tool results gets the final answer; a turn offered tools asks for all of
// them at once. Answering a counter makes the fixture depend on whoever happened
// to call the gateway first, which is not a property of the code under test.
func toolGateway(t *testing.T, ask []string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req := string(body)
w.Header().Set("Content-Type", "application/json")
if strings.Contains(req, `"role":"tool"`) || !strings.Contains(req, `"tools":[`) {
fmt.Fprint(w, `{"id":"c2","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"stop",`+
`"message":{"role":"assistant","content":"the answer"}}],`+
`"usage":{"prompt_tokens":40,"completion_tokens":9,"total_tokens":49}}`)
return
}
calls := make([]string, 0, len(ask))
for i, n := range ask {
calls = append(calls, fmt.Sprintf(
`{"id":"tc%d","type":"function","function":{"name":%q,"arguments":"{}"}}`, i, n))
}
fmt.Fprintf(w, `{"id":"c1","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"tool_calls",`+
`"message":{"role":"assistant","tool_calls":[%s]}}],`+
`"usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}`, strings.Join(calls, ","))
}))
}
// TestOneRunIsObservableEndToEnd: after one turn an operator can answer every
// question from the spans plus the run record, and can get from one to the other.
func TestOneRunIsObservableEndToEnd(t *testing.T) {
sink := traced(t)
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
old := runTools
runTools = plane
t.Cleanup(func() { runTools = old })
gw := toolGateway(t, []string{"post_v1_search_query"})
defer gw.Close()
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_search_query"}})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run want 200, got %d (%s)", code, body)
}
var rec struct {
ID string `json:"id"`
Status string `json:"status"`
Model string `json:"model"`
Agent string `json:"agent"`
Actor string `json:"actor"`
TraceID string `json:"traceId"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
ToolCalls int `json:"toolCalls"`
}
if err := json.Unmarshal(body, &rec); err != nil {
t.Fatalf("run body: %v (%s)", err, body)
}
// WHO and WHAT, on the record the console reads.
if rec.Actor != "acme/u-acme" {
t.Fatalf("run record must name the person who ran it, got actor %q", rec.Actor)
}
if rec.Agent != "a" {
t.Fatalf("run record must name its agent, got %q", rec.Agent)
}
if rec.PromptTokens == 0 || rec.CompletionTokens == 0 {
t.Fatalf("run record must carry the tokens the gateway reported, got %d/%d",
rec.PromptTokens, rec.CompletionTokens)
}
if rec.ToolCalls != 1 {
t.Fatalf("run record must count its tool calls, got %d", rec.ToolCalls)
}
// THE JOIN. Without this the run history and the trace store hold two accounts
// of one event with no key in common, and "drill into this run" has no target.
if rec.TraceID == "" {
t.Fatal("run record carries no traceId: the console cannot reach this run's spans")
}
root, ok := sink.find("agent.run a")
if !ok {
t.Fatal("no agent.run span was exported for a run that happened")
}
if got := root.SpanContext().TraceID().String(); got != rec.TraceID {
t.Fatalf("run record points at trace %q but the run span is in %q", rec.TraceID, got)
}
if got := attr(root, "hanzo.agent.run_id"); got != rec.ID {
t.Fatalf("run span names run %q, record is %q", got, rec.ID)
}
if got := attr(root, "hanzo.user"); got != "u-acme" {
t.Fatalf("run span must name the user, got %q", got)
}
if got := attr(root, "hanzo.agent.org"); got != "acme" {
t.Fatalf("run span must name the org, got %q", got)
}
// EVERY span the run produced names the run, so attribution never depends on
// walking a parent chain that sampling or a truncated batch may have broken.
for _, name := range []string{"agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"} {
sp, ok := sink.find(name)
if !ok {
t.Fatalf("no %q span was exported", name)
}
if got := attr(sp, "hanzo.agent.run_id"); got != rec.ID {
t.Fatalf("%s names run %q, want %q", name, got, rec.ID)
}
if got := sp.SpanContext().TraceID().String(); got != rec.TraceID {
t.Fatalf("%s is in trace %q, want the run's %q", name, got, rec.TraceID)
}
}
// The model calls carry the token usage, per call.
for _, sp := range sink.all("chat gpt-4o-mini") {
if attr(sp, "gen_ai.usage.input_tokens") == "" {
t.Fatal("a gen_ai span carries no input token count")
}
}
// The tool dispatch is readable as a dispatch: which tool, whose, which
// subsystem answers for it, and how it turned out.
tool, _ := sink.find("agent.tool post_v1_search_query")
if got := attr(tool, "hanzo.agent.tool_subsystem"); got != "search" {
t.Fatalf("tool span must name the owning subsystem, got %q", got)
}
if got := attr(tool, "hanzo.agent.tool_outcome"); got != "ok" {
t.Fatalf("a tool that worked must say so, got outcome %q", got)
}
if got := attr(tool, "hanzo.user"); got != "u-acme" {
t.Fatalf("tool span must name the actor it ran as, got %q", got)
}
}
// TestFailedToolIsReadableAsSuchOnItsRun: a run that called six tools and failed
// on the fourth must be readable as exactly that — the failing dispatch names its
// round and its outcome, and the run still completes, because a tool failure is a
// fact the model acts on rather than an aborted turn.
func TestFailedToolIsReadableAsSuchOnItsRun(t *testing.T) {
sink := traced(t)
names := []string{
"post_v1_search_query", "get_v1_git_repos", "post_v1_exec_run",
"get_v1_kms_secrets", "post_v1_notify_send", "get_v1_index_docs",
}
plane := &stubPlane{offer: names, fail: map[string]bool{"get_v1_kms_secrets": true}}
old := runTools
runTools = plane
t.Cleanup(func() { runTools = old })
gw := toolGateway(t, names)
defer gw.Close()
app := mountApp(t, clients.AIHTTPAt(gw.URL+"/v1", "k", "gpt-4o-mini"))
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": names})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "go"})
if code != http.StatusOK {
t.Fatalf("a run whose tool failed still answers 200, got %d (%s)", code, body)
}
var rec struct {
ID string `json:"id"`
ToolCalls int `json:"toolCalls"`
}
_ = json.Unmarshal(body, &rec)
if rec.ToolCalls != len(names) {
t.Fatalf("the run must count all %d dispatches, got %d", len(names), rec.ToolCalls)
}
// The one that failed says so, names itself, and is attributable to this run.
bad, ok := sink.find("agent.tool get_v1_kms_secrets")
if !ok {
t.Fatal("the failing tool produced no span")
}
if got := attr(bad, "hanzo.agent.tool_outcome"); got != "error" {
t.Fatalf("the failing dispatch must record outcome=error, got %q", got)
}
if bad.Status().Code.String() != "Error" {
t.Fatalf("the failing dispatch must carry error status, got %s", bad.Status().Code)
}
if !strings.Contains(bad.Status().Description+fmt.Sprint(bad.Events()), "tool call failed") &&
bad.Status().Description == "" {
t.Fatalf("the failing dispatch records no reason")
}
if got := attr(bad, "hanzo.agent.run_id"); got != rec.ID {
t.Fatalf("the failing dispatch names run %q, want %q", got, rec.ID)
}
if got := attr(bad, "hanzo.agent.tool_subsystem"); got != "kms" {
t.Fatalf("the failing dispatch must name the subsystem that refused, got %q", got)
}
// Its five siblings succeeded, in the same run and the same round — so the
// operator reads "six called, one failed", not "the run broke".
okCount := 0
for _, n := range names {
sp, found := sink.find("agent.tool " + n)
if !found {
t.Fatalf("no span for dispatched tool %s", n)
}
if attr(sp, "hanzo.agent.tool_outcome") == "ok" {
okCount++
}
if got := attr(sp, "hanzo.agent.tool_round"); got != "0" {
t.Fatalf("%s reports round %q, want 0", n, got)
}
}
if okCount != len(names)-1 {
t.Fatalf("want %d successful dispatches beside the failure, got %d", len(names)-1, okCount)
}
}
// TestRunIDReachesTheModelCall: the run's name is on the request the metering
// decorator prices, on EVERY round of a tool loop. That is what lets the ledger's
// per-token rows be summed back to the run that caused them — the agents-side half
// of "what did this run cost".
func TestRunIDReachesTheModelCall(t *testing.T) {
rec := &runIDRecorder{}
plane := &stubPlane{offer: []string{"post_v1_search_query"}}
old := runTools
runTools = plane
t.Cleanup(func() { runTools = old })
app := mountApp(t, rec)
do(t, app, http.MethodPost, "/v1/agents", "acme", map[string]any{
"name": "a", "model": "m", "instructions": "x", "tools": []string{"post_v1_search_query"}})
code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"})
if code != http.StatusOK {
t.Fatalf("run want 200, got %d (%s)", code, body)
}
var out struct {
ID string `json:"id"`
}
_ = json.Unmarshal(body, &out)
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.runIDs) < 2 {
t.Fatalf("want at least 2 completion rounds, got %d", len(rec.runIDs))
}
for i, got := range rec.runIDs {
if got != out.ID {
t.Fatalf("round %d priced under run %q, want %q — its cost would not sum to this run",
i, got, out.ID)
}
}
}
// runIDRecorder answers one tool call then a final answer, recording the RunID it
// was asked under on every round.
type runIDRecorder struct {
mu sync.Mutex
runIDs []string
rounds int
}
func (r *runIDRecorder) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.runIDs = append(r.runIDs, req.RunID)
r.rounds++
if r.rounds == 1 {
return &types.ChatResponse{
ToolCalls: []types.ToolCall{{ID: "tc0", Name: "post_v1_search_query", Arguments: "{}"}},
PromptTokens: 5, CompletionTokens: 6, TotalTokens: 11,
}, nil
}
return &types.ChatResponse{Content: "done", PromptTokens: 7, CompletionTokens: 8, TotalTokens: 15}, nil
}
func (r *runIDRecorder) Embed(context.Context, *types.EmbedRequest) ([][]float32, error) {
return nil, nil
}
// TestToolSubsystemReadsTheNameNotAnIndex pins the derivation: the owner is a fact
// the operation name already states, so it answers the same in a fused binary and
// in a single-app plugin process — where a mount-index lookup would answer "" for
// every sibling's tool.
func TestToolSubsystemReadsTheNameNotAnIndex(t *testing.T) {
cases := map[string]string{
"post_v1_search_query": "search",
"get_v1_git_repos": "git",
"delete_v1_kms_secrets": "kms",
"get_v1_agents_sessions": "agents",
"http": "", // a registry-local tool owns no subsystem
"": "",
"v1": "", // "v1" with nothing after it names nothing
}
for in, want := range cases {
if got := toolSubsystem(in); got != want {
t.Fatalf("toolSubsystem(%q) = %q, want %q", in, got, want)
}
}
}
+127 -3
View File
@@ -2,11 +2,14 @@ package agents
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/fleet"
)
// RunOnBehalf runs agent `ref` for `org` ON BEHALF OF `userSub`, IN-PROCESS —
@@ -28,12 +31,24 @@ import (
// whose model failed returns a recorded error-status Run and a nil error.
func RunOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, error) {
if mounted == nil {
return Run{}, fmt.Errorf("agents: not mounted")
return Run{}, fmt.Errorf("%w: agents", cloud.ErrNoPeer)
}
return runOnBehalf(mounted, ctx, org, userSub, ref, input)
}
func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input string) (Run, error) {
return runOnBehalfModel(s, ctx, org, userSub, ref, input, "")
}
// runOnBehalfModel is runOnBehalf with the ASKER's model preference.
//
// It overrides the agent's own Model only when the caller named one AND the
// agent is the built-in default — a person's Slack preference must not silently
// re-point an agent their org deliberately configured. An unrecognised value is
// ignored rather than forwarded: the menu came from us, so anything else is a
// stale client or a forged payload, and it would bill this org for a model it
// never offered.
func runOnBehalfModel(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input, model string) (Run, error) {
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
return Run{}, fmt.Errorf("agents: invalid org")
@@ -49,14 +64,123 @@ func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref
return Run{}, err
}
a, err := sto.Resolve(ctx, org, strings.TrimSpace(ref))
if err != nil {
return Run{}, err // errNotFound or a real DB error — caller replies generically
if errors.Is(err, errNotFound) {
// An org that has never opened the agents UI has NO rows, and the chat
// bridges ask for the conventional ref ("hanzo") — so @hanzo answered
// "the agent hit an error handling that" in every workspace that connected
// Slack and did nothing else. Measured: `agents: agent not found`, for the
// org that had just linked successfully.
//
// The conventional ref therefore resolves to a BUILT-IN default rather than
// requiring an org to create a row before the front door works. It is not
// persisted: writing a row here would fork the definition per org and make
// a later product change unable to reach the orgs that had already been
// seeded. A row the org DOES create wins, because Resolve is tried first.
if def, ok := builtinAgent(org, ref, cloud.ChatModel); ok {
a = def
} else {
return Run{}, err
}
} else if err != nil {
return Run{}, err // a real DB error — caller replies generically
}
// The actor attributes the spend to the acting principal (org/userSub) for the
// audit trail; the BALANCE gated + debited is always a.Org (== org), never the
// caller. Synthetic request id: in-process, there is no HTTP X-Request-Id; the
// client IP is empty (no socket).
if m := strings.TrimSpace(model); m != "" && strings.HasPrefix(a.ID, "builtin-") && knownChatModel(m) {
a.Model = m
}
actor := billingActor(org, userSub)
reqID, _ := genID("obh")
return runAgent(s, ctx, a, input, actor, reqID, "")
}
// builtinAgent is the definition the conventional chat ref resolves to when an
// org has not defined its own.
//
// ONE name, the convention the bridges already default to (bridgeAgentRef →
// "hanzo"). Anything else is a real miss and stays a miss: an unknown ref must
// not silently become the default agent, or a typo in `code: repo` would run the
// chat agent and look like it worked.
//
// The model is cloud.ChatModel, which is where the tier and the evidence for it
// now live — one constant in the file that owns model policy, instead of a literal
// here behind a BRIDGE_AGENT_MODEL knob no deployment ever set.
//
// The tier did not change. Its JUSTIFICATION did, because the old one was false:
// this called `enso` "the auto-routing SKU that selects per query in the gateway's
// own catalog", and enso does no such thing — one fixed route entry
// (deepseek-v4-pro, reasoning: medium), no ladder, no escalation. Measurement says
// it is nonetheless the right tier for a tool-driving turn, and cloud.ChatModel
// carries those numbers.
//
// It is also NOT cloud.FallbackModel ("best"): that constant's own doc says it
// "keeps a bot's reply landing when the flash tier is saturated; the interactive
// chat path never uses it" — it is the degraded path, and a Slack turn IS the
// interactive chat path.
//
// A person who wants a different tier pins one in the App Home menu, and that pin
// still wins below.
//
// Tools is the fleet's whole door (ToolsAll), not empty: the tool-calling loop
// decides what may be OFFERED, but an agent that declares nothing is offered
// nothing, which is how the default assistant came to report it could not reach a
// cloud that was one socket away.
func builtinAgent(org, ref, model string) (Agent, bool) {
if !strings.EqualFold(strings.TrimSpace(ref), builtinAgentName) {
return Agent{}, false
}
if strings.TrimSpace(model) == "" {
return Agent{}, false // no model configured: an honest miss, not a broken run
}
now := time.Now().Unix()
return Agent{
ID: "builtin-" + builtinAgentName, Org: org, Name: builtinAgentName, Model: model,
Instructions: builtinAgentInstructions,
Description: "The default Hanzo assistant that answers in chat.",
Status: "ready", ExecutionMode: ModeOneShot,
// The default assistant is offered the fleet's whole door. Its instructions
// tell it the tools exist and how to call them; without this it was handed
// an empty offer and correctly reported it could not reach the cloud, while
// the door served 88 tools one socket away.
Tools: []string{ToolsAll},
CreatedAt: now, UpdatedAt: now,
}, true
}
const builtinAgentName = "hanzo"
// builtinAgentInstructions is what the default assistant is TOLD it is. Kept
// short on purpose: a long persona spends context a user's actual question needs,
// and every sentence here is one the model reads on every turn.
const builtinAgentInstructions = "You are Hanzo, the assistant for the Hanzo cloud. " +
"Answer in Slack: be brief, concrete, and say plainly when you do not know or " +
"cannot reach something rather than guessing.\n\n" +
// THE TOOL PROTOCOL. Without this the tools are unusable, and the failure is
// silent: the model sees 88 tools whose only argument is an `op` enum of bare
// names with no schemas, cannot tell what any of them take, and answers from
// memory instead — which reads as "the assistant is stupid" rather than as a
// missing sentence. The surface was collapsed from 1,189 flat tools (977 KB,
// ~244k tokens just to list) to 88 grouped ones precisely so the schemas could
// be fetched on demand; the fetch has to be described or the trade is a loss.
"Your tools are grouped one per subsystem, and a tool IS its subsystem's name. " +
"Each takes an `op` (choose from its enum) and an `input` object. The enum lists " +
"operation names only — to see what an operation accepts or returns, call `" +
fleet.Describe + "` with that op name first, then call it. " +
"Prefer looking something up with a tool " +
"over answering from memory: you are answering about THIS organization's live " +
"cloud, and your training data does not contain it."
// knownChatModel accepts only a model this deployment offers for chat.
//
// The enso family is the SKU set the App Home menu is built from. Anything else
// is refused rather than forwarded — an arbitrary string from a client would let
// a caller pick what their org pays for.
func knownChatModel(m string) bool {
switch m {
case "enso", "enso-flash", "enso-ultra":
return true
}
return false
}
+84
View File
@@ -0,0 +1,84 @@
// 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.
package agents
// onbehalf_rpc.go carries an agent turn across a PROCESS boundary, exactly as
// sessions_rpc.go carries a teardown. onbehalf.go stays the in-process seam and
// keeps its promise to know nothing of zip.Ctx or the wire; this file is the
// door, and both run the same runOnBehalf underneath.
import (
"context"
"fmt"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// exposeRunOnBehalf publishes the on-behalf-of run on the internal plane, so a
// chat bridge in ANOTHER PROCESS can reach it.
//
// RunOnBehalf above gates on `mounted`, a package global, and a package global
// is per-PROCESS. When agents and integrations are separate plugins — which is
// the normal deployment, not an exotic one — that global is nil on the bridge's
// side and every @hanzo turn died with ErrNoPeer. The in-process seam is not
// wrong; it was simply the ONLY door, so co-residency had quietly become a
// requirement nothing declared.
//
// Both doors run the SAME runOnBehalf, so the org isolation, the linked-subject
// attribution and the billing that hang off it are identical whichever way the
// call arrived.
func exposeRunOnBehalf() {
zip.Post[plane.RunOnBehalfIn, plane.RunOnBehalfOut](cloud.Plane(), "/agents/run-on-behalf", planeRunOnBehalf,
zip.WithOperationID(plane.AgentsRunOnBehalf),
zip.WithSummary("Run one agent turn as a linked user, for a chat bridge in another process"))
}
// planeRunOnBehalf answers a bridge's turn.
//
// Unlike the session ops, the org travels IN the request rather than being taken
// from the caller's plane identity: the tenant here is the one that connected the
// Slack workspace, resolved by the bridge from the signed team_id, and the bridge
// plugin's own identity is not it. That is safe because this op only SPENDS the
// named org's own balance under its own agent — it reads nothing across tenants —
// and because the subject must be a link the bridge already proved.
//
// An empty subject is refused rather than defaulted. A turn that lost its caller
// must not run AS THE ORG: that would bill the tenant for an unattributable act
// and hand an unlinked user the org's agent.
func planeRunOnBehalf(ctx context.Context, in *plane.RunOnBehalfIn) (*plane.RunOnBehalfOut, error) {
if mounted == nil {
return nil, fmt.Errorf("%w: agents", cloud.ErrNoPeer)
}
if strings.TrimSpace(in.Subject) == "" {
return nil, fmt.Errorf("agents: run-on-behalf requires a linked subject")
}
// The tenant this run bills is NOT stated here, and the reason is worth writing
// down because the obvious fix is wrong and was shipped once.
//
// A run bills: the balance gate is a plane call to commerce, which takes the org
// from the CALLER's identity and never from an argument (balance_rpc.go:36), so
// no caller can name the books it charges. It is tempting to satisfy that with
// cloud.For(ctx, in.Org) right here. That is a NO-OP. This op is reached over the
// plane, which is a real request, and zip reads a STATED caller only where there
// is NO request (caller.go:352-356) — otherwise CallerOf reads the request's own
// headers. The statement is silently discarded and the gate still answers
// `authorize: no org on the call`. That is exactly what production did.
//
// The org must therefore be on the WIRE, stated by the dispatcher on a detached
// context before the hop (Caller.headers renders it, caller.go:302). The bridge
// does that — see the cloud.For(context.Background(), org) at the plane.Ask in
// apps/integrations/bridge.go. By the time we are here it has already arrived as
// a header and rides onward for free. in.Org remains in the payload because the
// run RECORD needs it; it is not what authorizes the spend.
run, err := runOnBehalfModel(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input, in.Model)
if err != nil {
return nil, err
}
return &plane.RunOnBehalfOut{Status: run.Status, Output: run.Output, RunID: run.ID}, nil
}
+3 -5
View File
@@ -15,8 +15,9 @@ package agents
import (
"context"
"errors"
"strings"
"time"
"github.com/hanzoai/cloud"
)
// persona is one built-in agent definition. Name is the lowercase @-handle;
@@ -79,10 +80,7 @@ func SeedPersonalities(ctx context.Context, org string) (int, error) {
if serr != nil {
return 0, nil // never mounted, or an org this deployment cannot place: no-op
}
model := strings.TrimSpace(mounted.State.defaultModel)
if model == "" {
return 0, nil
}
model := cloud.DefaultModel
created := 0
now := time.Now().Unix()
+33 -12
View File
@@ -9,23 +9,24 @@ import (
"github.com/hanzoai/cloud"
)
// mountSeedTest wires the `mounted` singleton to a fresh store with a default
// model, so SeedPersonalities has both a store to write and a model to attach.
func mountSeedTest(t *testing.T, defaultModel string) {
// mountSeedTest wires the `mounted` singleton to a fresh store, so
// SeedPersonalities has a store to write. It took a default model until that
// knob was deleted; the seed model is cloud.DefaultModel and cannot vary.
func mountSeedTest(t *testing.T) {
t.Helper()
prev := mounted
mounted = &cloud.Service[state]{
Base: cloud.Base{Log: luxlog.New("test")},
State: state{stores: testStores(t), defaultModel: defaultModel},
State: state{stores: testStores(t)},
}
t.Cleanup(func() { mounted = prev })
}
// TestSeedPersonalities proves the built-in crew is created once, is idempotent,
// projects the exact @-handles a human mentions in Team, and no-ops without a
// model — the full contract of the one-way seed.
// projects the exact @-handles a human mentions in Team, and seeds every persona
// on cloud.DefaultModel — the full contract of the one-way seed.
func TestSeedPersonalities(t *testing.T) {
mountSeedTest(t, "zen-1")
mountSeedTest(t)
ctx := context.Background()
const org = "acme"
@@ -52,7 +53,7 @@ func TestSeedPersonalities(t *testing.T) {
if !ok {
t.Fatalf("@%s not seeded; have %v", want, keysOf(got))
}
if a.Model != "zen-1" || a.Status != "ready" || a.Instructions == "" {
if a.Model != cloud.DefaultModel || a.Status != "ready" || a.Instructions == "" {
t.Fatalf("@%s malformed: model=%q status=%q instr=%dB", want, a.Model, a.Status, len(a.Instructions))
}
}
@@ -69,10 +70,20 @@ func TestSeedPersonalities(t *testing.T) {
t.Fatalf("after re-seed: %d agents, want %d (no dup)", len(list2), len(personalities))
}
// No default model → no-op, never a half-seeded org.
mountSeedTest(t, "")
if n3, err := SeedPersonalities(ctx, "globex"); err != nil || n3 != 0 {
t.Fatalf("no-model seed = (%d,%v), want (0,nil)", n3, err)
// A fresh org seeds its full crew, every persona on cloud.DefaultModel. This
// asserted the opposite — that an empty default model made the seed a no-op —
// which was reachable only by hand-building the state with an empty field. A
// deployment always had a default, so the no-op never happened in production
// and now cannot be expressed at all.
mountSeedTest(t)
n3, err := SeedPersonalities(ctx, "globex")
if err != nil || n3 != len(personalities) {
t.Fatalf("fresh-org seed = (%d,%v), want (%d,nil)", n3, err, len(personalities))
}
for _, a := range mustList(t, ctx, "globex") {
if a.Model != cloud.DefaultModel {
t.Fatalf("seeded %q on model %q, want %q", a.Name, a.Model, cloud.DefaultModel)
}
}
}
@@ -83,3 +94,13 @@ func keysOf(m map[string]Agent) []string {
}
return out
}
// mustList is ListForOrg with the error folded into a fatal.
func mustList(t *testing.T, ctx context.Context, org string) []Agent {
t.Helper()
list, err := ListForOrg(ctx, org)
if err != nil {
t.Fatalf("ListForOrg(%s): %v", org, err)
}
return list
}
+63
View File
@@ -0,0 +1,63 @@
package agents
import (
"context"
"errors"
"testing"
"github.com/hanzoai/cloud"
)
// The defect: agents ships as its own binary, so the link process that revokes a
// credential has never had the session store in it. StopSessions answered
// (0, nil) for that, and the revoke handler reads a count — so every revoke on
// the fleet returned 200 {"sessionsStopped":0} having torn down nothing while
// the sessions kept running under the revoked account.
//
// A zero is only readable as an answer if failure cannot produce one. These pin
// that: absence is ErrNoPeer, which is also what lets the caller take the plane
// leg instead of believing the zero.
func TestSessionsAbsentIsAnErrorNotAZero(t *testing.T) {
prev := mounted
mounted = nil
t.Cleanup(func() { mounted = prev })
m := SessionMatch{Actor: "acme/alice", Provider: "claude"}
n, err := StopSessions(context.Background(), "acme", m)
if err == nil {
t.Fatal("unmounted StopSessions returned no error — a revoke that stopped " +
"nothing must not report success")
}
if !errors.Is(err, cloud.ErrNoPeer) {
t.Errorf("StopSessions err = %v, want ErrNoPeer so the caller can take the plane leg", err)
}
if n != 0 {
t.Errorf("StopSessions n = %d, want 0 alongside the error", n)
}
n, err = CountActiveSessions(context.Background(), "acme", m)
if err == nil {
t.Fatal("unmounted CountActiveSessions returned no error")
}
if !errors.Is(err, cloud.ErrNoPeer) {
t.Errorf("CountActiveSessions err = %v, want ErrNoPeer", err)
}
if n != 0 {
t.Errorf("CountActiveSessions n = %d, want 0 alongside the error", n)
}
}
// An empty match is a different fact from an absent store: it is fail-closed
// ("stop nothing"), it is a real answer, and it must NOT become an error — or a
// revoke that legitimately matches nothing starts reporting a fault.
func TestEmptyMatchStaysAnAnswer(t *testing.T) {
mountInproc(t)
n, err := StopSessions(context.Background(), "acme", SessionMatch{})
if err != nil {
t.Fatalf("empty match must be an answer, not an error: %v", err)
}
if n != 0 {
t.Fatalf("empty match stopped %d sessions — it must stop nothing", n)
}
}
+95
View File
@@ -0,0 +1,95 @@
package agents
import (
"context"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// sessions_rpc.go carries the login-manager teardown across a PROCESS boundary.
//
// The SESSIONS are this app's. The REVOKE is link's — it owns the credential row
// and is the surface a human logs out from — and agents ships as its own binary,
// so the direct call in apps/link/adapters.go reached a package that was never
// mounted in the link process. StopSessions answered (0, nil) for that, the
// revoke handler read it as "there were none", and every credential revoke on
// the fleet returned 200 {"sessionsStopped":0} while the sessions it was meant
// to tear down kept running under the revoked account.
//
// The match travels instead, through the SAME StopSessions the co-resident call
// uses — two doors, one teardown — so the actor scoping that bounds a revoke to
// its own user's sessions holds identically across the boundary.
// exposeSessions publishes the teardown and its count on the internal plane.
// Mount calls it, beside the in-process seam.
func exposeSessions() {
zip.Post[plane.SessionMatchIn, plane.SessionCount](cloud.Plane(), "/agents/sessions/stop", planeStopSessions,
zip.WithOperationID(plane.AgentsSessionsStop),
zip.WithSummary("Stop the live sessions a credential revoke tears down"))
zip.Post[plane.SessionMatchIn, plane.SessionCount](cloud.Plane(), "/agents/sessions/count", planeCountSessions,
zip.WithOperationID(plane.AgentsSessionsCount),
zip.WithSummary("Count the live sessions a match selects"))
}
// planeStopSessions tears down every live session of the CALLER's org matching
// the revoking subject, and reports how many it stopped.
//
// The org is the caller's plane identity and never the argument — plane
// .SessionMatchIn has no org field, deliberately, because this op STOPS things
// and a caller able to state the org could stop a co-tenant's work. Anonymous is
// refused rather than defaulted: a teardown arriving with no principal must
// fail, not pick a tenant.
//
// The actor is built HERE, from the org the plane proved and the subject the
// caller names, so the HIGH-1 actor scoping (a revoke stops only that user's own
// sessions) is enforced by the side that owns the store rather than trusted from
// the wire.
//
// A named handler, not a closure, so zipdoc can lift this prose into the registry.
func planeStopSessions(ctx context.Context, in *plane.SessionMatchIn) (*plane.SessionCount, error) {
m, err := matchFor(ctx, in)
if err != nil {
return nil, err
}
n, err := StopSessions(ctx, cloud.Who(ctx).Org, m)
if err != nil {
return nil, err
}
return &plane.SessionCount{Count: n}, nil
}
// planeCountSessions answers the active-session count the device view shows,
// under the same tenancy and actor rules as the stop above.
func planeCountSessions(ctx context.Context, in *plane.SessionMatchIn) (*plane.SessionCount, error) {
m, err := matchFor(ctx, in)
if err != nil {
return nil, err
}
n, err := CountActiveSessions(ctx, cloud.Who(ctx).Org, m)
if err != nil {
return nil, err
}
return &plane.SessionCount{Count: n}, nil
}
// matchFor resolves the caller's proven org and qualifies the subject into an
// actor. It is the ONE place the wire shape becomes a SessionMatch, so the two
// ops cannot come to disagree about which sessions a caller may name.
func matchFor(ctx context.Context, in *plane.SessionMatchIn) (SessionMatch, error) {
org := cloud.Who(ctx).Org
if org == "" {
return SessionMatch{}, zip.ErrForbidden("agents sessions: org required")
}
actor := ""
if s := strings.TrimSpace(in.Subject); s != "" {
actor = BillingActor(org, s)
}
// An empty actor is left empty on purpose: the guard reads it as "match
// nothing", so a request that lost its caller identity tears down nothing
// instead of the org.
return SessionMatch{Actor: actor, Host: in.Host, Provider: in.Provider, Account: in.Account}, nil
}
+19 -6
View File
@@ -94,12 +94,22 @@ func (s *Store) countActiveMatch(ctx context.Context, org string, m SessionMatch
// scope it: the caller passes their own actor (org/user), so a revoke can only ever
// stop the caller's OWN sessions — never a co-tenant's, never an org's every session
// — even though m's Host/Provider/Account come from an attacker-controllable link
// row. A match with no actor stops nothing (fail-closed). Not-mounted → (0, nil), so
// a revoke tolerates a deployment with no session plane.
// row. A match with no actor stops nothing (fail-closed).
//
// Absence is an ERROR (ErrNoPeer), never (0, nil). It was the latter, and agents
// ships as its own binary — so in the fleet the link process that calls this has
// never had the session store in it, and every credential revoke answered 200
// with {"sessionsStopped":0} having torn down nothing, while the sessions kept
// running under the revoked account. A zero that means "I could not ask" is
// indistinguishable from "there were none", and this is the seam where that
// distinction is the security property. Callers take the plane leg (apps/link).
func StopSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
if m.empty() {
return 0, nil // fail-closed: a match with no actor stops nothing, and that is an answer
}
sto, org, serr := mountedStore(org)
if serr != nil || m.empty() {
return 0, nil
if serr != nil {
return 0, serr
}
live, err := sto.listActiveMatch(ctx, org, m)
if err != nil {
@@ -152,9 +162,12 @@ func stopOne(ctx context.Context, sto *Store, x Session) error {
// (running|paused) — the device view's "active sessions". Org-scoped; 0 when not
// mounted or the match is empty.
func CountActiveSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
if m.empty() {
return 0, nil // the same fail-closed answer as StopSessions
}
sto, org, serr := mountedStore(org)
if serr != nil || m.empty() {
return 0, nil
if serr != nil {
return 0, serr
}
return sto.countActiveMatch(ctx, org, m)
}
+86 -12
View File
@@ -55,6 +55,13 @@ type Agent struct {
// Execution modes. One-shot agents run only on an explicit POST; long-running
// agents are additionally invoked by the scheduler on their Schedule.
const (
// ToolsAll in an agent's Tools means "whatever the fleet's door serves",
// resolved per run rather than enumerated. It exists because the default
// assistant cannot list a surface that is discovered at runtime and changes
// whenever a subsystem ships. An agent that declares nothing still gets
// nothing — that default is its authority, and it is unchanged.
ToolsAll = "*"
ModeOneShot = "one-shot"
ModeLongRunning = "long-running"
)
@@ -73,6 +80,32 @@ type Run struct {
Error string
DurationMs int64
CreatedAt int64
// Actor is the "org/sub" identity the run was executed and billed AS. The row
// already recorded which tenant paid; it never recorded which person asked,
// so "who ran this" was answerable only from an HTTP audit line that a
// scheduled or on-behalf run never produces. Empty means there was no person
// — a schedule or a service token — which is a different fact from unknown.
Actor string
// TraceID is the trace this run IS, so the record and its spans are one thing
// an operator can move between. Without it the run history and the trace store
// hold two accounts of the same event with no key in common: you can see that
// a run took nine seconds but not which call spent them.
//
// Empty when the process had no tracer installed — an honest "not recorded",
// never a fabricated id.
TraceID string
// PromptTokens/CompletionTokens are what the gateway reported for the run's
// FINAL completion, and ToolCalls is how many tool dispatches it made. They
// are what the run itself knows. The per-round token spend of a tool loop is
// the metering ledger's account, joined by this run's id (see
// types.ChatRequest.RunID) — recorded there once, rather than re-totalled here
// into a second number that could disagree with the money.
PromptTokens int
CompletionTokens int
ToolCalls int
}
// Store is ONE ORG's agents database — the file at
@@ -133,9 +166,18 @@ CREATE TABLE IF NOT EXISTS agent_runs (
output TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
actor TEXT NOT NULL DEFAULT '',
trace_id TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
tool_calls INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_name, created_at);
-- The org-wide feed: "what ran here lately", across every agent. The per-agent
-- index cannot serve it its leading column after org is agent_name, so an
-- org-wide scan by recency would sort every row the org ever produced.
CREATE INDEX IF NOT EXISTS ix_runs_org_created ON agent_runs(org, created_at);
`
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate: %w", err)
@@ -154,6 +196,24 @@ CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_na
}); err != nil {
return err
}
// Same forward, idempotent upgrade for the run attribution columns, so a
// deployment's existing history keeps working and every run recorded from
// here on can name its actor, its trace and its token account.
if err := s.addColumns("agent_runs", map[string]string{
"actor": "TEXT NOT NULL DEFAULT ''",
"trace_id": "TEXT NOT NULL DEFAULT ''",
"prompt_tokens": "INTEGER NOT NULL DEFAULT 0",
"completion_tokens": "INTEGER NOT NULL DEFAULT 0",
"tool_calls": "INTEGER NOT NULL DEFAULT 0",
}); err != nil {
return err
}
// The org-wide recency index, created AFTER the columns above for the same
// reason the scheduler's partial index is: a legacy DB gains them just now.
if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS ix_runs_org_created
ON agent_runs(org, created_at)`); err != nil {
return fmt.Errorf("migrate: org runs index: %w", err)
}
// Partial index for the once-a-minute scheduler scan — created AFTER the
// lifecycle columns exist (a legacy DB gains them just above), so it selects
// only the (typically few) scheduled long-running agents instead of
@@ -316,7 +376,7 @@ const agentCols = `id,org,name,model,instructions,description,tools,status,execu
// runCols is the run projection, named ONCE so the insert, the two reads and the
// legacy fan-out cannot drift apart on a column added to only some of them.
const runCols = `id,org,agent_name,status,model,input,output,error,duration_ms,created_at`
const runCols = `id,org,agent_name,status,model,input,output,error,duration_ms,created_at,actor,trace_id,prompt_tokens,completion_tokens,tool_calls`
func scanAgent(sc interface{ Scan(...any) error }) (Agent, error) {
var a Agent
@@ -492,14 +552,30 @@ func (s *Store) Delete(ctx context.Context, org, name string) (bool, error) {
// InsertRun records one agent execution.
func (s *Store) InsertRun(ctx context.Context, r Run) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_runs (`+runCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Org, r.AgentName, r.Status, r.Model, r.Input, r.Output, r.Error, r.DurationMs, r.CreatedAt)
`INSERT INTO agent_runs (`+runCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Org, r.AgentName, r.Status, r.Model, r.Input, r.Output, r.Error, r.DurationMs, r.CreatedAt,
r.Actor, r.TraceID, r.PromptTokens, r.CompletionTokens, r.ToolCalls)
if err != nil {
return fmt.Errorf("insert run: %w", err)
}
return nil
}
// scanRun reads one row of the runCols projection. It exists because there are
// two readers of that projection and they were each spelling the column order out
// by hand — which is the drift runCols was named once to prevent, reintroduced one
// layer down. One scanner means a column added to the projection is added to every
// read of it, or to none.
func scanRun(sc interface{ Scan(...any) error }) (Run, error) {
var r Run
if err := sc.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt,
&r.Actor, &r.TraceID, &r.PromptTokens, &r.CompletionTokens, &r.ToolCalls); err != nil {
return Run{}, fmt.Errorf("scan run: %w", err)
}
return r, nil
}
// ListRuns returns the run history for (org,agent), newest first, capped.
func (s *Store) ListRuns(ctx context.Context, org, agent string, limit int) ([]Run, error) {
if limit <= 0 || limit > 200 {
@@ -513,10 +589,9 @@ func (s *Store) ListRuns(ctx context.Context, org, agent string, limit int) ([]R
defer func() { _ = rows.Close() }()
var out []Run
for rows.Next() {
var r Run
if err := rows.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan run: %w", err)
r, err := scanRun(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
@@ -541,10 +616,9 @@ func (s *Store) RunsSince(ctx context.Context, org string, since int64, limit in
defer func() { _ = rows.Close() }()
var out []Run
for rows.Next() {
var r Run
if err := rows.Scan(&r.ID, &r.Org, &r.AgentName, &r.Status, &r.Model, &r.Input,
&r.Output, &r.Error, &r.DurationMs, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan run: %w", err)
r, err := scanRun(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
+8 -3
View File
@@ -54,7 +54,7 @@ func (st *state) storeFor(org string) (*Store, error) {
// place to read to know what a store can be named after.
func (st *state) namespaceFor(org string) (namespace.Namespace, error) {
if st == nil || st.stores == nil {
return namespace.Namespace{}, fmt.Errorf("agents: not mounted")
return namespace.Namespace{}, fmt.Errorf("%w: agents", cloud.ErrNoPeer)
}
return cloud.OrgNamespace(org, "")
}
@@ -95,7 +95,12 @@ func tenantStore(ctx context.Context, st *state) (*Store, string, error) {
// path would have refused.
func mountedStore(org string) (*Store, string, error) {
if mounted == nil {
return nil, "", fmt.Errorf("agents: not mounted")
// ErrNoPeer, not a bare string: "this process does not own the session
// store" is a routable fact — a caller can take the plane leg — and every
// other absence on this estate is spelled the same way. A caller that
// cannot tell absence from failure is how StopSessions came to report a
// revoke that stopped nothing as a success.
return nil, "", fmt.Errorf("%w: agents (this process does not own the session store)", cloud.ErrNoPeer)
}
org = strings.TrimSpace(org)
if org == "" || len(org) > principal.MaxOrgLen {
@@ -141,7 +146,7 @@ func (st *state) storeForPublic(org string) (*Store, bool) {
// unreadable org's file cannot take down the scheduler for every other org.
func (st *state) eachStore(fn func(ns namespace.Namespace, sto *Store, err error)) error {
if st == nil || st.stores == nil {
return fmt.Errorf("agents: not mounted")
return fmt.Errorf("%w: agents", cloud.ErrNoPeer)
}
return st.stores.Each(fn)
}
+386
View File
@@ -0,0 +1,386 @@
package agents
// tools.go is the part of a run that was missing: Agent.Tools was stored,
// updated and shown, and the run never read it. An agent with "slack_post_message"
// in its tool list ran one chat completion against a model that had never been
// told the tool exists, so every @hanzo turn was a chatbot with no hands.
//
// A tool call is a CONVERSATION, not a call: the model asks for a tool, something
// runs it, the result goes back, and the model decides again. Three things make
// that loop safe to run on someone else's money —
//
// BOUNDED maxToolRounds model turns and one wall-clock budget for the whole
// run. The last turn is offered NO tools, so the loop cannot end in
// anything but words.
// ATTRIBUTED every dispatch carries the run's own (org, actor) — the same pair
// the run's fee is billed under — so a tool runs as the principal
// that asked for it and the tool plane meters it there.
// RECOVERABLE a tool that fails is reported TO THE MODEL as a tool result, not
// raised. A broken connector makes the agent explain itself; it does
// not kill the turn.
//
// An agent with an empty Tools list never enters any of this: executeRun takes
// the same single completion it always did.
//
// ── WHERE THE TOOLS COME FROM ─────────────────────────────────────────────────
//
// A PLUGIN IS A PROCESS. `agents` ships as its own binary (plugin/agents/main.go)
// and `tools` as another (manifest/apps.go), so tools.Default() HERE holds only
// what agents itself registered — the agentToolProvider at agents.go:399 — and
// its activation store is nil, which makes ActivationStore.IsActivated report
// false for everything (apps/tools/activation.go:83) and Registry.Dispatch
// refuse every name. In the split fleet this process's own registry is not an
// answer; it is a fact about this process.
//
// The thing that CAN answer already exists, and it was already deployed: the
// fleet's composed agent door (fleet/mcp.go), which asks every app what it
// serves right now, merges the union, and forwards a call to the app that listed
// the name. It is what api.hanzo.ai/v1/mcp is. So there is one tool surface in
// this fleet and an agent reads THAT one — door.go is the client, over the
// host's own socket, and it is the plane a real deployment uses.
//
// registryTools stays as what this process's own registry says, which is the
// whole answer exactly where this process is the whole fleet: a single-app
// binary, a dev box, a test. doorTools falls back to it there and nowhere else,
// on the one signal that means it — nothing listening on the router's socket.
//
// The degradation that remains is an OUTAGE, and it is visible: every run's step
// span carries both hanzo.agent.tools_declared and hanzo.agent.tools, so
// "declared 3, offered 0" is a number in o11y rather than a silence, and the
// door's own error is recorded beside it.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/cloud/apps/tools"
"github.com/hanzoai/cloud/types"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
const (
// maxToolRounds is how many times the model may ask for tools in one run.
// A loop is only as safe as its bound: each round is a completion the org
// pays for, and a model that has decided to call the same tool forever will
// do exactly that. Eight is deep enough for read-then-act-then-confirm and
// shallow enough that a wedged agent costs a known amount.
maxToolRounds = 8
// toolRunBudget is the wall clock for the WHOLE loop, tools included. A Slack
// turn is waiting on this, and a caller carrying a tighter deadline still
// wins — this is a ceiling, never an extension.
toolRunBudget = 90 * time.Second
// toolCallTimeout bounds ONE dispatch, so a single hung connector cannot eat
// the whole run's budget and starve the turn of its answer.
toolCallTimeout = 30 * time.Second
// maxToolResult bounds what one tool may put back into the transcript. A tool
// that returns a megabyte would be paid for as prompt tokens on every
// remaining round; the model is told the result was truncated.
maxToolResult = 16 * 1024
// maxToolArgs bounds the arguments a model may emit for one call, before they
// are ever parsed.
maxToolArgs = 32 * 1024
// maxAgentDepth bounds how deep AGENTS may nest, which is a different bound
// from maxToolRounds and is not covered by it: an agent is itself a tool
// (agentToolProvider, agents.go:399), so A calling B calling A is a cycle in
// which every level gets a FRESH round cap and a fresh fee. Three levels is
// an agent delegating to a specialist that delegates once more; deeper than
// that is a loop, and at the bottom an agent is simply offered no tools and
// has to answer for itself.
maxAgentDepth = 3
)
// depthKey carries how many agents deep this run is. Unexported zero-size type,
// so nothing outside this package can forge a shallower depth.
type depthKey struct{}
// agentDepth reads the nesting depth off the context; a top-level run is 0.
func agentDepth(ctx context.Context) int {
d, _ := ctx.Value(depthKey{}).(int)
return d
}
// deeper marks the context one agent deeper. It is applied at the DISPATCH, so
// the depth travels with the call that creates the nesting — a nested run reads
// it from the context its parent's tool call handed it.
func deeper(ctx context.Context) context.Context {
return context.WithValue(ctx, depthKey{}, agentDepth(ctx)+1)
}
// callableTools is what an agent may actually be offered: its declared names,
// minus the one that would call the agent ITSELF. A self-call is a recursion no
// round cap bounds, because each level starts its cap over.
func callableTools(a Agent) []string {
self := "agent_" + a.Name
out := make([]string, 0, len(a.Tools))
for _, n := range a.Tools {
if strings.TrimSpace(n) == self {
continue
}
out = append(out, n)
}
return out
}
// toolPlane is where a run's callable tools come from: what may be offered to
// the model, and what happens when it asks for one.
//
// It is an interface for the reason the package comment gives — the answer is
// per-DEPLOYMENT, not per-run — and it is deliberately narrow: names, prose,
// schemas, and one call that takes raw JSON in and returns text out. Nothing in
// it is a map, which is what let the same shape cross a process boundary
// unchanged (door.go) rather than being redesigned at the seam.
type toolPlane interface {
// catalog resolves the tool NAMES an agent declares into definitions the
// model can be offered. A name that resolves to nothing is simply absent —
// offering a tool that would be refused at dispatch teaches the model a lie.
catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef
// call runs one tool as (org, actor) and returns its result as text. args is
// the raw JSON object the model emitted, verbatim.
call(ctx context.Context, org, actor, name, args string) (string, error)
}
// runTools is the tool plane a run uses: the fleet's own agent door, which
// answers with this process's registry wherever this process IS the fleet
// (door.go). A package var so a test can substitute a deterministic one; there
// is no exported setter, because which plane answers is a property of the
// deployment and not something a caller may choose.
var runTools toolPlane = doorTools{}
// registryTools is the tool plane read IN THIS PROCESS: tools.Default(), the same
// registry POST /v1/tools/call dispatches through, with the same activation gate,
// the same source precedence and the same x402 settlement. It is the whole answer
// where the tool plane is co-resident, and it is honest where it is not — the
// registry simply offers nothing.
type registryTools struct{}
// catalog keeps a declared name only when the plane offers it to this org AND it
// is dispatchable AND it is activated. All three are the conditions dispatch
// itself enforces (apps/tools/registry.go:231), so a tool that survives this
// filter is one the model can actually call — which is the only kind worth
// spending a prompt on.
func (registryTools) catalog(ctx context.Context, org, _ string, want []string) []types.ToolDef {
if org == "" || len(want) == 0 {
return nil
}
wanted := make(map[string]bool, len(want))
for _, n := range want {
if n = strings.TrimSpace(n); n != "" {
wanted[n] = true
}
}
if len(wanted) == 0 {
return nil
}
out := make([]types.ToolDef, 0, len(wanted))
for _, t := range tools.Default().List(ctx, tools.Scope{Org: org}) {
if !wanted[t.Name] || !t.Dispatchable || !t.Activated {
continue
}
out = append(out, types.ToolDef{Name: t.Name, Description: t.Description, Schema: t.Schema})
}
return out
}
// call dispatches through the registry's ONE policy path, bound to the run's own
// principal. The arguments are decoded into a map HERE, at the in-process seam
// that requires one, and nowhere else — the map never appears on a type that has
// to cross a process boundary.
func (registryTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
var decoded map[string]any
if s := strings.TrimSpace(args); s != "" && s != "null" {
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return "", fmt.Errorf("arguments are not a JSON object: %w", err)
}
}
out, err := tools.Default().Dispatch(ctx, tools.Principal{Org: org, User: actorSub(org, actor)}, name, decoded)
if err != nil {
return "", err
}
return renderToolResult(out), nil
}
// toolSubsystem names the app that answers for a tool, read out of the tool's OWN
// name rather than looked up anywhere.
//
// The fleet door groups one tool per subsystem and carries the operation names in
// its `op` enum (fleet/grouped.go), and those names are spelled
// <method>_v1_<subsystem>_<rest> — so the owner is a fact the name already
// states. Deriving it here keeps this a pure function of the value: it answers
// the same way in the fused binary and in a single-app plugin process, whereas
// cloud.SubsystemOf reads a boot-time mount index that in a plugin knows only
// that plugin's own routes and would answer "" for every sibling's tool.
//
// A name that is not in that shape (a registry-local tool like "http") owns no
// subsystem and says so with "", rather than with a guess.
func toolSubsystem(op string) string {
parts := strings.Split(op, "_")
for i, p := range parts {
if p == "v1" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// actorSub reads the user subject back out of the run's "org/sub" billing actor,
// so a dispatch runs as the person the run is billed to. A bare org (a scheduled
// run, a service token) has no subject and lends none: the org is the authority
// either way, and inventing a user would be attributing the call to nobody.
func actorSub(org, actor string) string {
actor = strings.TrimSpace(actor)
if actor == "" || actor == org {
return ""
}
if sub, ok := strings.CutPrefix(actor, org+"/"); ok {
return sub
}
return actor
}
// renderToolResult turns whatever a tool returned into the text the model reads.
// A string is already text; anything else is its JSON, which is the shape the
// tool's own schema describes. A value that will not marshal is reported as that
// fact rather than as an empty result the model would read as success.
func renderToolResult(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return truncateToolResult(t)
case []byte:
return truncateToolResult(string(t))
}
b, err := json.Marshal(v)
if err != nil {
return "the tool returned a value that could not be encoded"
}
return truncateToolResult(string(b))
}
// truncateToolResult bounds one tool result and SAYS SO. A silently clipped result is a
// result the model believes it read in full.
func truncateToolResult(s string) string {
if len(s) <= maxToolResult {
return s
}
return s[:maxToolResult] + "\n…[truncated: the result was longer than this agent may read]"
}
// completeWithTools is the loop.
//
// It runs the conversation forward: complete, run whatever the model asked for,
// append the results, complete again — until the model answers in words, the
// round budget runs out, or the deadline does. Every completion goes through
// completeWithFailover, so the retry-and-fail-over reliability policy the run
// path already had applies to EVERY round rather than only the first, and the
// model reported back is the one that produced the final answer.
//
// The last round is offered no tools at all. A cap that simply stopped would
// return the model's last tool REQUEST as if it were an answer; offering nothing
// forces the model to say what it has, which is a real reply to the person
// waiting on it.
func completeWithTools(ctx context.Context, ai types.AIClient, org, actor, prompt, model, fallback string, defs []types.ToolDef, runID string) (*types.ChatResponse, string, error, int) {
ctx, cancel := context.WithTimeout(ctx, toolRunBudget)
defer cancel()
msgs := []types.ChatMessage{{Role: types.RoleUser, Content: prompt}}
used := model
calls := 0
for round := 0; round <= maxToolRounds; round++ {
offer := defs
if round == maxToolRounds {
offer = nil // budget spent — answer in words
}
resp, m, err := completeWithFailover(ctx, ai,
&types.ChatRequest{Model: model, Org: org, Messages: msgs, Tools: offer, RunID: runID}, fallback)
used = m
if err != nil {
return nil, used, err, calls
}
if resp == nil || len(resp.ToolCalls) == 0 || offer == nil {
return resp, used, nil, calls
}
msgs = append(msgs, types.ChatMessage{
Role: types.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
})
for _, tc := range resp.ToolCalls {
calls++
msgs = append(msgs, types.ChatMessage{
Role: types.RoleTool,
ToolCallID: tc.ID,
Name: tc.Name,
Content: dispatchOne(ctx, org, actor, tc, runID, round),
})
}
}
// Unreachable: the round==maxToolRounds pass returns above whatever the model
// does. Stated rather than assumed, so the loop has one exit per outcome.
return nil, used, errors.New("agents: tool loop ended without an answer"), calls
}
// dispatchOne runs one tool call and returns the text the model is handed —
// SUCCESS OR FAILURE, always as a tool result. A tool that fails is a fact the
// model can act on (try another one, or explain), and raising it instead would
// throw away a turn the org has already paid for.
//
// It carries no credential. Tool credentials live in KMS behind the tool plane
// and are resolved by the source that owns them, so nothing secret is in scope
// here to leak into a transcript: what goes back is the tool's own output or our
// own sentence about why there is none.
func dispatchOne(ctx context.Context, org, actor string, tc types.ToolCall, runID string, round int) string {
ctx, span := agentTracer.Start(ctx, "agent.tool "+tc.Name, trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
// Everything an operator needs to read one dispatch out of a run: which run,
// which tenant, which person, which tool, which subsystem answers for it, and
// where in the loop it happened. The round is what makes "it called six tools
// and failed on the fourth" a readable fact rather than an ordering guess
// across spans that may be exported out of order.
span.SetAttributes(
attribute.String("gen_ai.tool.name", tc.Name),
attribute.String("gen_ai.tool.call.id", tc.ID),
attribute.String("hanzo.agent.org", org),
attribute.String("hanzo.agent.run_id", runID),
attribute.String("hanzo.agent.tool_subsystem", toolSubsystem(tc.Name)),
attribute.Int("hanzo.agent.tool_round", round),
)
if sub := actorSub(org, actor); sub != "" {
span.SetAttributes(attribute.String("hanzo.user", sub))
}
// The outcome is SET on every exit, including the happy one. A span whose
// status is only ever written on failure cannot distinguish "succeeded" from
// "never finished" — and a tool that hangs until the run's budget expires is
// exactly the case an operator is looking for.
outcome := "ok"
defer func() { span.SetAttributes(attribute.String("hanzo.agent.tool_outcome", outcome)) }()
if len(tc.Arguments) > maxToolArgs {
outcome = "rejected"
span.SetStatus(codes.Error, "arguments too large")
return "error: the arguments for this call were too large to run"
}
ctx, cancel := context.WithTimeout(deeper(ctx), toolCallTimeout)
defer cancel()
out, err := runTools.call(ctx, org, actor, tc.Name, tc.Arguments)
if err != nil {
outcome = "error"
span.RecordError(err)
span.SetStatus(codes.Error, "tool call failed")
return "error: " + err.Error()
}
span.SetStatus(codes.Ok, "")
if strings.TrimSpace(out) == "" {
// An empty result and a failure look identical to a model reading a blank
// string, and only one of them means "it worked".
return "(the tool ran and returned nothing)"
}
return out
}
+329
View File
@@ -0,0 +1,329 @@
package agents
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/hanzoai/cloud/types"
)
// fakePlane is a deterministic tool plane: it offers exactly what it is given and
// records every dispatch, so a test can assert WHAT ran and WHO it ran as.
type fakePlane struct {
offer []types.ToolDef
// calls records (name, args, org, actor) in order.
calls []planeCall
err error
out string
}
type planeCall struct{ name, args, org, actor string }
func (f *fakePlane) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
if org == "" || len(want) == 0 {
return nil
}
wanted := map[string]bool{}
for _, n := range want {
wanted[n] = true
}
var out []types.ToolDef
for _, d := range f.offer {
if wanted[d.Name] {
out = append(out, d)
}
}
return out
}
func (f *fakePlane) call(_ context.Context, org, actor, name, args string) (string, error) {
f.calls = append(f.calls, planeCall{name: name, args: args, org: org, actor: actor})
if f.err != nil {
return "", f.err
}
return f.out, nil
}
// withPlane swaps the process tool plane for the duration of one test.
func withPlane(t *testing.T, p toolPlane) {
t.Helper()
prev := runTools
runTools = p
t.Cleanup(func() { runTools = prev })
}
// scriptAI answers from a script, one entry per completion, and records every
// request it was given — which is how a test proves the tools were OFFERED and
// the results were fed back.
type scriptAI struct {
replies []types.ChatResponse
seen []types.ChatRequest
n int
}
func (s *scriptAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
cp := *req
cp.Messages = append([]types.ChatMessage(nil), req.Messages...)
cp.Tools = append([]types.ToolDef(nil), req.Tools...)
s.seen = append(s.seen, cp)
if s.n >= len(s.replies) {
return nil, errors.New("scriptAI: no reply scripted")
}
r := s.replies[s.n]
s.n++
return &r, nil
}
func (s *scriptAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float32, error) {
return nil, nil
}
func toolDef(name string) types.ToolDef {
return types.ToolDef{Name: name, Description: "d", Schema: json.RawMessage(`{"type":"object"}`)}
}
// A run whose agent declares a tool the plane offers must OFFER it to the model,
// EXECUTE what the model asks for, feed the result back, and answer from the
// second completion. This is the whole point of the change: before it, Agent.Tools
// was never read by the run.
func TestRunCallsTools(t *testing.T) {
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: `{"temp":21}`}
withPlane(t, plane)
ai := &scriptAI{replies: []types.ChatResponse{
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{"city":"Tokyo"}`}}, FinishReason: "tool_calls"},
{Content: "It is 21 degrees in Tokyo."},
}}
a := mk("maxpower", "greeter")
a.Tools = []string{"weather"}
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather in Tokyo?", "", "run_test")
if r.Status != "ok" {
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
}
if r.Output != "It is 21 degrees in Tokyo." {
t.Fatalf("output must be the model's answer AFTER the tool ran, got %q", r.Output)
}
if len(plane.calls) != 1 {
t.Fatalf("want exactly one dispatch, got %d (%+v)", len(plane.calls), plane.calls)
}
got := plane.calls[0]
if got.name != "weather" || got.args != `{"city":"Tokyo"}` {
t.Fatalf("dispatch must carry the model's own call, got %+v", got)
}
if got.org != "maxpower" || got.actor != "maxpower/u1" {
t.Fatalf("dispatch must be attributable to the run's org+actor, got %+v", got)
}
if len(ai.seen) != 2 {
t.Fatalf("want two completions (ask, then answer), got %d", len(ai.seen))
}
if len(ai.seen[0].Tools) != 1 || ai.seen[0].Tools[0].Name != "weather" {
t.Fatalf("the first completion must OFFER the declared tool, got %+v", ai.seen[0].Tools)
}
// The second completion must carry the whole transcript: the user turn, the
// assistant's tool call, and the tool result linked back by id.
msgs := ai.seen[1].Messages
if len(msgs) != 3 {
t.Fatalf("want user+assistant+tool in the second turn, got %d: %+v", len(msgs), msgs)
}
if msgs[1].Role != types.RoleAssistant || len(msgs[1].ToolCalls) != 1 {
t.Fatalf("assistant turn must carry its tool calls, got %+v", msgs[1])
}
if msgs[2].Role != types.RoleTool || msgs[2].ToolCallID != "c1" || msgs[2].Content != `{"temp":21}` {
t.Fatalf("tool result must be linked to the call by id, got %+v", msgs[2])
}
}
// An agent with no tools — or one whose declared names the plane does not offer —
// must behave EXACTLY as before: one completion, a flat prompt, no tools field.
func TestRunWithoutToolsIsUnchanged(t *testing.T) {
plane := &fakePlane{} // offers nothing
withPlane(t, plane)
ai := &scriptAI{replies: []types.ChatResponse{{Content: "hi there"}}}
a := mk("maxpower", "greeter")
a.Instructions = "You are a greeter."
a.Tools = []string{"weather"} // declared, but the plane offers nothing
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "", "run_test")
if r.Status != "ok" || r.Output != "hi there" {
t.Fatalf("want the plain completion, got %+v", r)
}
if len(ai.seen) != 1 {
t.Fatalf("want exactly one completion, got %d", len(ai.seen))
}
if len(ai.seen[0].Tools) != 0 || len(ai.seen[0].Messages) != 0 {
t.Fatalf("no-tools path must send the flat prompt and no tools, got %+v", ai.seen[0])
}
if ai.seen[0].Prompt != "You are a greeter.\n\nsay hi" {
t.Fatalf("prompt must compose instructions + input, got %q", ai.seen[0].Prompt)
}
if len(plane.calls) != 0 {
t.Fatalf("nothing may be dispatched, got %+v", plane.calls)
}
}
// A tool that fails must NOT kill the turn: the error goes back to the model as
// the tool's result, and the model still answers.
func TestToolFailureReachesTheModel(t *testing.T) {
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, err: errors.New("connector offline")}
withPlane(t, plane)
ai := &scriptAI{replies: []types.ChatResponse{
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
{Content: "I could not reach the weather service."},
}}
a := mk("maxpower", "greeter")
a.Tools = []string{"weather"}
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather?", "", "run_test")
if r.Status != "ok" {
t.Fatalf("a failed tool must not fail the run, got %q err=%q", r.Status, r.Error)
}
if r.Output != "I could not reach the weather service." {
t.Fatalf("the model must get to answer, got %q", r.Output)
}
result := ai.seen[1].Messages[2]
if result.Role != types.RoleTool || !strings.Contains(result.Content, "connector offline") {
t.Fatalf("the failure must be handed back as the tool result, got %+v", result)
}
}
// The loop is BOUNDED. A model that only ever asks for tools gets maxToolRounds
// tool-bearing turns and then one final turn with NO tools, which is what forces
// an answer instead of an unbounded spend.
func TestToolLoopIsBounded(t *testing.T) {
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
withPlane(t, plane)
always := types.ChatResponse{ToolCalls: []types.ToolCall{{ID: "c", Name: "weather", Arguments: `{}`}}}
replies := make([]types.ChatResponse, maxToolRounds)
for i := range replies {
replies[i] = always
}
// The final, tool-less turn answers in words.
replies = append(replies, types.ChatResponse{Content: "done"})
ai := &scriptAI{replies: replies}
a := mk("maxpower", "greeter")
a.Tools = []string{"weather"}
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", "", "run_test")
if r.Status != "ok" || r.Output != "done" {
t.Fatalf("bounded loop must still answer, got %+v", r)
}
if len(ai.seen) != maxToolRounds+1 {
t.Fatalf("want %d completions, got %d", maxToolRounds+1, len(ai.seen))
}
if len(plane.calls) != maxToolRounds {
t.Fatalf("want %d dispatches, got %d", maxToolRounds, len(plane.calls))
}
if len(ai.seen[maxToolRounds].Tools) != 0 {
t.Fatalf("the last turn must be offered NO tools so it has to answer in words")
}
}
// The dispatch principal is the run's own actor, and a run with no user subject
// (a scheduled run) lends none rather than inventing one.
func TestActorSub(t *testing.T) {
for _, c := range []struct{ org, actor, want string }{
{"acme", "acme/U123", "U123"},
{"acme", "acme", ""},
{"acme", "", ""},
{"acme", "scheduler", "scheduler"},
} {
if got := actorSub(c.org, c.actor); got != c.want {
t.Fatalf("actorSub(%q,%q) = %q, want %q", c.org, c.actor, got, c.want)
}
}
}
// An agent is itself a tool, so an agent that declares ITSELF would recurse with
// a fresh round cap at every level. It is never offered to itself.
func TestAgentIsNeverOfferedItself(t *testing.T) {
a := mk("maxpower", "greeter")
a.Tools = []string{"agent_greeter", "weather"}
got := callableTools(a)
if len(got) != 1 || got[0] != "weather" {
t.Fatalf("an agent must not be offered itself, got %v", got)
}
}
// A cycle of agents-as-tools (A → B → A) is bounded by DEPTH, which the round cap
// cannot bound: each nested run starts its own. At the limit an agent is offered
// no tools at all and has to answer for itself.
func TestNestedAgentsAreBoundedByDepth(t *testing.T) {
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
withPlane(t, plane)
ai := &scriptAI{replies: []types.ChatResponse{{Content: "at the bottom"}}}
ctx := context.Background()
for i := 0; i < maxAgentDepth; i++ {
ctx = deeper(ctx)
}
a := mk("maxpower", "greeter")
a.Tools = []string{"weather"}
r := executeRun(ctx, ai, "maxpower", "maxpower/u1", a, "go", "", "run_test")
if r.Status != "ok" || r.Output != "at the bottom" {
t.Fatalf("a run at the depth limit must still answer, got %+v", r)
}
if len(ai.seen) != 1 || len(ai.seen[0].Tools) != 0 {
t.Fatalf("at the depth limit no tools may be offered, got %d completions %+v", len(ai.seen), ai.seen[0].Tools)
}
if len(plane.calls) != 0 {
t.Fatalf("nothing may be dispatched at the depth limit, got %+v", plane.calls)
}
}
// A dispatch carries the run one level deeper, which is what makes the depth
// bound reachable at all: the nested run reads it off the context it was handed.
func TestDispatchDeepensTheContext(t *testing.T) {
var saw int
withPlane(t, &depthProbe{seen: &saw, offer: []types.ToolDef{toolDef("weather")}})
ai := &scriptAI{replies: []types.ChatResponse{
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
{Content: "done"},
}}
a := mk("maxpower", "greeter")
a.Tools = []string{"weather"}
if r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", "", "run_test"); r.Status != "ok" {
t.Fatalf("run failed: %+v", r)
}
if saw != 1 {
t.Fatalf("a dispatch from a top-level run must be at depth 1, got %d", saw)
}
}
// depthProbe records the nesting depth the dispatch context carries.
type depthProbe struct {
seen *int
offer []types.ToolDef
}
func (d *depthProbe) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
if org == "" || len(want) == 0 {
return nil
}
return d.offer
}
func (d *depthProbe) call(ctx context.Context, _, _, _, _ string) (string, error) {
*d.seen = agentDepth(ctx)
return "ok", nil
}
// A tool result longer than the transcript budget is clipped AND SAID to be
// clipped — a silently truncated result is one the model believes it read whole.
func TestToolResultTruncationIsStated(t *testing.T) {
long := strings.Repeat("x", maxToolResult+100)
got := renderToolResult(long)
if len(got) <= maxToolResult || !strings.Contains(got, "truncated") {
t.Fatalf("a clipped result must say so, got %d bytes", len(got))
}
if s := renderToolResult(map[string]any{"a": 1}); s != `{"a":1}` {
t.Fatalf("a structured result must reach the model as its JSON, got %q", s)
}
}
+40 -4
View File
@@ -34,16 +34,18 @@ func init() {
zip.Describe("GET /v1/agents/:ref", zip.Doc{
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.",
"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.",
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
},
Example: json.RawMessage(`{"ref":"helper"}`),
})
zip.Describe("GET /v1/agents/:ref/runs", zip.Doc{
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.",
"runsQuery.ref": "Ref is the agent's public id or its org-unique name, from the path.",
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
"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.",
"runsQuery.ref": "Ref is the agent's public id or its org-unique name, from the path.",
},
Example: json.RawMessage(`{"ref":"helper","limit":20}`),
})
@@ -84,6 +86,16 @@ func init() {
},
Example: json.RawMessage(`{"range":"7D"}`),
})
zip.Describe("GET /v1/agents/runs", zip.Doc{
Description: "Returns the org's agent runs across EVERY agent, newest first —\nwhat ran here, for whom, on which model, how long it took, and why it failed.\n\nIt is the feed the per-agent history could not be: an operator asking \"what is\nthis tenant's agent plane doing\" does not start out knowing an agent ref, and\nanswering by listing the agents and then paging each one's history is N+1 round\ntrips to reconstruct one ordering the database already has (RunsSince, ordered\nby created_at over the org index).\n\nThe org is the CALLER's, resolved from identity by tenantStore — never a\nparameter. There is deliberately no org field on orgRunsQuery to forge: run\nhistory is the tenant's own record, and the only tenant this can answer for is\nthe one asking.",
Fields: map[string]string{
"agentRunView.agent": "What an operator needs to answer \"what ran, for whom, and what did it do\" —\nand, through traceId, to leave this record for the waterfall of the very\nsame run rather than a search that hopefully lands near it.\n\nAgent is on the row because the org-wide feed lists runs across agents, and\na run that cannot name its agent is an orphan in exactly the view built to\nmake sense of many of them. Every field is omitempty: a run recorded before\nthese columns existed reports absence rather than a zero it never measured.",
"orgRunsQuery.limit": "Limit caps how many runs come back, newest first. Absent, zero or out of\nrange (1..200) reads as 50.",
"orgRunsQuery.status": "Status keeps only runs with this outcome (\"ok\" or \"error\"). Empty keeps\nboth. It is the filter an operator reaches for first — \"show me what broke\"\n— and answering it here rather than by paging the whole history client-side\nis the difference between a usable feed and a download.",
"runList.runs": "Runs is the agent's executions, newest first.",
},
Example: json.RawMessage(`{"limit":20,"status":"error"}`),
})
zip.Describe("GET /v1/agents/sessions", zip.Doc{
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{
@@ -215,6 +227,30 @@ func init() {
},
Example: json.RawMessage(`{"id":"tgt_1","status":"draining"}`),
})
zip.Describe("POST /agents/run-on-behalf", zip.Doc{
Description: "Answers a bridge's turn.\n\nUnlike the session ops, the org travels IN the request rather than being taken\nfrom the caller's plane identity: the tenant here is the one that connected the\nSlack workspace, resolved by the bridge from the signed team_id, and the bridge\nplugin's own identity is not it. That is safe because this op only SPENDS the\nnamed org's own balance under its own agent — it reads nothing across tenants —\nand because the subject must be a link the bridge already proved.\n\nAn empty subject is refused rather than defaulted. A turn that lost its caller\nmust not run AS THE ORG: that would bill the tenant for an unattributable act\nand hand an unlinked user the org's agent.",
Fields: map[string]string{
"RunOnBehalfIn.input": "Input is the user's message, already stripped of the leading @mention.",
"RunOnBehalfIn.model": "Model is the ASKER's own choice, empty when they have not made one. It is a\npreference of the person, not a property of the agent, which is why it rides\nthe turn instead of being written into an agent row: two people in one\nworkspace can prefer different models of the same assistant.",
"RunOnBehalfIn.org": "Org is the isolation gate, the tenant, and the balance the run bills.",
"RunOnBehalfIn.ref": "Ref names the agent to run.",
"RunOnBehalfIn.subject": "Subject is the caller's LINKED Hanzo identity, unqualified. Attribution and\nauthorization both hang off it, so a turn can never run as nobody: the\nanswering side refuses an empty subject rather than falling back to the org.",
},
})
zip.Describe("POST /agents/sessions/count", zip.Doc{
Description: "Answers the active-session count the device view shows,\nunder the same tenancy and actor rules as the stop above.",
Fields: map[string]string{
"SessionMatchIn.host": "Host/Provider/Account narrow WITHIN the actor's own sessions; empty is any.",
"SessionMatchIn.subject": "Subject is the revoking user, unqualified. The answering side qualifies it.",
},
})
zip.Describe("POST /agents/sessions/stop", zip.Doc{
Description: "Tears down every live session of the CALLER's org matching\nthe revoking subject, and reports how many it stopped.\n\nThe org is the caller's plane identity and never the argument — plane\n.SessionMatchIn has no org field, deliberately, because this op STOPS things\nand a caller able to state the org could stop a co-tenant's work. Anonymous is\nrefused rather than defaulted: a teardown arriving with no principal must\nfail, not pick a tenant.\n\nThe actor is built HERE, from the org the plane proved and the subject the\ncaller names, so the HIGH-1 actor scoping (a revoke stops only that user's own\nsessions) is enforced by the side that owns the store rather than trusted from\nthe wire.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
Fields: map[string]string{
"SessionMatchIn.host": "Host/Provider/Account narrow WITHIN the actor's own sessions; empty is any.",
"SessionMatchIn.subject": "Subject is the revoking user, unqualified. The answering side qualifies it.",
},
})
zip.Describe("POST /v1/agents", zip.Doc{
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"}`),
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := ai
include ../../mk/plugin.mk
+145 -37
View File
@@ -25,6 +25,7 @@ import (
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
aiweb "github.com/hanzoai/ai/web"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/websearch"
"github.com/hanzoai/cloud/manifest"
@@ -91,6 +92,72 @@ func aiProse() map[string]openapi.Said {
return out
}
// installWebSearch closes the web-search seam over a meta-search function.
//
// It takes the searcher as a PARAMETER rather than calling websearch.Search
// directly so the adapter — the part with the truncation and the field mapping —
// can be exercised without a network round trip. The production call site passes
// the real one; a test passes its own and asserts on what the tool actually
// receives.
//
// The mapping is the whole of it: websearch.Result carries Content, the tool
// contract calls that field Snippet, and a rename that goes unnoticed hands every
// agent results with empty snippets — which reads as "the web had nothing to say
// about this" rather than as a bug.
func installWebSearch(search func(ctx context.Context, query, lang string) []websearch.Result) {
webtools.SetSearch(func(ctx context.Context, query string, limit int) ([]webtools.SearchResult, error) {
hits := search(ctx, query, "")
// Truncate to what the caller asked for. The tool clamps its own limit to a
// sane maximum before it ever reaches here; this only honours it.
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
})
}
// debitOverPlane charges one ai completion to the process that owns the ledger.
//
// A NAMED function rather than the closure it came out of, because it is the only line of
// this file that decides what a customer is charged and by what key, and a closure inside
// Mount can be read but not exercised: mounting ai to reach one field means standing the
// whole model API up. This can be handed a crafted event and asked what actually crosses.
//
// IT NAMES NO REF, and that is the point. The event's RequestID is the ai module's message
// row id — `Owner + "/" + Name` — and both halves are fields of the JSON body the client
// posts, so sending it as the debit's Ref handed the ledger's idempotency key to the payer:
// pin one owner/name pair and every completion after the first deduped into the first one's
// entry. An absent Ref is minted at the far end, per debit, by the server (Usage.Seal), so
// two answers are two acts however identical the request that asked for them.
//
// Nothing is lost by not naming one. This debit is made once per streamed answer and never
// re-driven, and the sibling debit on the OpenAI surface already keys on a fresh uuid per
// call — the two surfaces now mint the same way.
//
// The currency default lives here for the same reason the amount does: the peer records
// what it is sent, so the value has to be complete at the point it is built.
func debitOverPlane(ctx context.Context, u aiobject.UsageEvent) error {
cur := u.Currency
if cur == "" {
cur = "usd"
}
_, err := cloud.Ask[plane.RecordIn, plane.Recorded](
cloud.For(ctx, u.Namespace), "commerce", plane.FinanceRecord,
&plane.RecordIn{
Subject: u.Subject,
Amount: plane.Money{Decimal: u.USD, Currency: cur},
Usage: plane.Usage{Model: u.Model, Provider: u.Provider},
})
if err != nil {
return fmt.Errorf("plane usage debit: %w", err)
}
return nil
}
// 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.
@@ -133,23 +200,33 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// 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 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, because an agent told "no
// results" concludes the web holds nothing and answers from memory.
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
})
// 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.
installWebSearch(websearch.Search)
// THE PREPAID GATE'S COMPLETION CEILING, PER MODEL, FROM THE CATALOG.
//
@@ -243,34 +320,19 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
// The DEBIT crosses the same way, for the same reason — and it must key on the SAME
// wallet the gate read, or spend can outrun the balance that admitted it.
//
// Neither branch names the act. cloud.UsageEvent has no Ref to carry one and
// debitOverPlane sends none, so on both paths the ledger's key is minted by whoever
// writes the entry — never by the request that asked for the work.
if f := cloud.UsageRecorder(); f != nil {
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
return f(ctx, cloud.UsageEvent{
Subject: u.Subject, Namespace: u.Namespace, USD: u.USD,
Currency: u.Currency, Model: u.Model, Provider: u.Provider,
RequestID: u.RequestID,
})
})
} else {
aiobject.SetUsageRecorder(func(ctx context.Context, u aiobject.UsageEvent) error {
cur := u.Currency
if cur == "" {
cur = "usd"
}
_, err := cloud.Ask[plane.RecordIn, plane.Recorded](
cloud.For(ctx, u.Namespace), "commerce", plane.FinanceRecord,
&plane.RecordIn{
Subject: u.Subject,
Amount: plane.Money{Decimal: u.USD, Currency: cur},
Usage: plane.Usage{
Model: u.Model, Provider: u.Provider, RequestID: u.RequestID,
},
})
if err != nil {
return fmt.Errorf("plane usage debit: %w", err)
}
return nil
})
aiobject.SetUsageRecorder(debitOverPlane)
}
if d := cloud.IngestDialer(); d != nil {
aiobject.SetIngestDialer(d)
@@ -286,6 +348,52 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
return f(ctx, subject, namespace)
})
// ONE CORS AUTHORITY. cloud.EdgeCORS decides which browser origins may read
// this edge; this takes ai's own answer out of the request.
//
// hanzoai/ai carries routers.CorsFilter, a filter it inserts ahead of every
// route, which REFUSES with 403 any origin outside `allowedOriginSuffixes` — 21
// apex domains compiled into the module. That list cannot name a customer's
// domain, and a deployment cannot change it, so the shipped feature (fork
// hanzoai/console, deploy it on your own domain, call this API) was structurally
// impossible: cloud would admit the origin, ai would refuse the call. The
// preflight short-circuits in EdgeCORS and never reaches ai, so the browser saw
// a clean 204 followed by a 403 — allowed preflight, denied request, the classic
// asymmetry.
//
// The filter's POSITIVE half is already dead in production: setCorsHeaders
// returns early whenever X-Forwarded-Host is set, which the ingress always sets,
// so ai has not added a CORS header at the edge in a long time. Only its refusal
// is live. Clearing Origin takes its own `origin == ""` early return, which adds
// nothing and refuses nothing — so what is removed is exactly the second verdict,
// and nothing else.
//
// SCOPED AND CONDITIONAL, both deliberately:
//
// - only for origins cloud ALREADY ADMITTED (cloud.CORSAllows — the same
// predicate EdgeCORS used, same instance, same cache, so the two cannot
// disagree). A denied origin keeps its header and ai still answers 403
// exactly as it does today: this changes the allow path only.
// - only for non-upgrade requests. controllers/dev_bridge.go guards cross-site
// WebSocket hijacking with CheckOrigin, which returns TRUE on an empty Origin
// to admit CLI clients — clearing it there would fail OPEN. A socket has no
// preflight and no ACAO; its Origin check is a different mechanism and stays
// with the handler that owns the socket.
//
// BeforeStatic, so it runs ahead of every BeforeRouter filter including
// CorsFilter regardless of the order InstallFilters ran in.
airouters.App.InsertFilter("*", aiweb.BeforeStatic, func(ctx *aiweb.Context) {
r := ctx.Request
if r == nil || r.Header.Get("Origin") == "" {
return
}
if r.Header.Get("Upgrade") != "" {
return
}
if cloud.CORSAllows(r.Context(), r.Header.Get("Origin")) {
r.Header.Del("Origin")
}
})
// 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).
+94
View File
@@ -0,0 +1,94 @@
package ai
// THE PUBLIC CONTRACT, v1: inference.
//
// Every line below puts one operation into the published SDKs, the public CLI,
// the MCP tool list and docs.hanzo.ai. Nothing else in the fleet's 1782 paths is
// there, because the projection is default-deny and this is the only file in the
// repository that has said anything (openapi/public.go).
//
// # The rule that earns a line
//
// AN OPERATION IS IN v1 IF IT IS A MODEL CALL, OR IF IT IS THE CATALOG OF MODELS
// TO CALL. That is the whole of it, and everything below is one of the two. It is
// NOT "everything hanzoai/ai serves": that module also answers 109 paths under
// /v1/ai/* — articles, assets, deployments, connections, dashboards — which are
// the app builder's CRUD and not inference, and which the rule therefore leaves
// out without anyone having to name them.
//
// # What is deliberately NOT here
//
// Each of these was measured and left out, and each is one line away if that is
// wrong. Default-deny means the cost of leaving something out is that somebody
// asks for it, and the cost of leaving something in is that we support it
// forever.
//
// POST /v1/chat a second spelling of /v1/chat/completions,
// down to the same summary. One operation
// has one address in a published contract;
// two would be two SDK methods and two CLI
// verbs for one call. The right fix is for
// hanzoai/ai to tag it openapi.Compat.
// POST /v1/generate-text-to-speech-audio the inherited casdoor-shaped
// GET /v1/generate-text-to-speech-audio-stream spellings of /v1/audio/speech.
// Same call, older name; same answer.
// GET /v1/models/{model}/access entitlement, not inference — the waitlist
// POST /v1/models/{model}/access standing for a gated model. It is console
// workflow, and a customer reaches it in the
// console. Add it the day an SDK needs it.
//
// # Adding the next product
//
// One line per operation, in the app that serves it, in a file named public.go
// beside the routes. Then `make describe`, which regenerates public.yaml, and the
// diff shows exactly what was published next to the line that published it.
import (
"net/http"
"github.com/hanzoai/cloud/openapi"
)
func init() {
// THE CATALOG — what can be called, and by whom. Both are unauthenticated and
// secret-free by their own declaration; a client that cannot read the catalog
// before it holds a credential cannot show a model picker.
openapi.Public("/v1/models", http.MethodGet)
openapi.Public("/v1/models/providers", http.MethodGet)
// TEXT. Four wire formats over one router: OpenAI's chat and legacy
// completions, OpenAI's Responses, and Anthropic's Messages. They are not
// duplicates of each other — a caller picks the one its SDK already speaks,
// which is the entire reason all four are served — and count_tokens is part of
// the Messages contract rather than an operation of its own (Claude Code calls
// it before every request).
openapi.Public("/v1/chat/completions", http.MethodPost)
openapi.Public("/v1/completions", http.MethodPost)
openapi.Public("/v1/responses", http.MethodPost)
openapi.Public("/v1/messages", http.MethodPost)
openapi.Public("/v1/messages/count_tokens", http.MethodPost)
// VECTORS — the two operations that turn text into numbers and numbers back
// into an order. zen-rerank is a first-party model and /v1/rerank is its door.
openapi.Public("/v1/embeddings", http.MethodPost)
openapi.Public("/v1/rerank", http.MethodPost)
// MEDIA. zen-image, zen-video and zen-music are first-party models on the live
// catalog, and each has its own door — they are NOT reachable through
// /v1/chat/completions, so publishing the text surface alone would ship a
// catalog listing models no generated client can call.
openapi.Public("/v1/images/generations", http.MethodPost)
openapi.Public("/v1/audio/speech", http.MethodPost)
openapi.Public("/v1/audio/transcriptions", http.MethodPost)
openapi.Public("/v1/audio/voice", http.MethodPost)
openapi.Public("/v1/audio/music", http.MethodPost)
openapi.Public("/v1/audio/foley", http.MethodPost)
// Video generation is ASYNC — create returns a job, and the client polls it and
// then downloads the result. All three are the one operation from a caller's
// side, so publishing the create alone would publish a call whose answer
// nothing in the SDK can resolve.
openapi.Public("/v1/videos/generations", http.MethodPost)
openapi.Public("/v1/videos/{id}", http.MethodGet)
openapi.Public("/v1/videos/{id}/content", http.MethodGet)
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright © 2026 Hanzo AI. MIT License.
package ai
import (
"context"
"fmt"
"net"
"reflect"
"testing"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/metering"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
"github.com/zap-proto/zip"
// devmaster keys this test binary: cek opens no ledger file without a master, and a
// test process has no KMS to resolve one from.
_ "github.com/hanzoai/cloud/internal/devmaster"
)
// serveCommerce stands the REAL money peer up on a real socket: the finance ledger the
// process owns, behind the metering client, behind plane.FinanceRecord — the same three
// layers apps/commerce puts behind that op, and the same rule for where the billed org
// comes from (the CALLER, never the argument).
//
// It is the peer and not a spy on purpose. What is under test is whether a client can
// name the key the LEDGER dedups on, and only a ledger can answer that: a recording fake
// would show the ref arriving and say nothing about the money.
func serveCommerce(t *testing.T, seedSubject string, seedCents int64) finance.Client {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
cloud.ResetPlane()
t.Cleanup(cloud.ResetPlane)
fin := finance.New(t.TempDir())
finance.Publish(fin)
t.Cleanup(func() { finance.Publish(nil); _ = fin.Close() })
if _, err := fin.Deposit(context.Background(), types.DepositInput{
Org: "acme", Subject: seedSubject, Amount: money.FromCents(seedCents),
}); err != nil {
t.Fatalf("seed deposit: %v", err)
}
// A configured meter that never speaks HTTP: finance is published, so Record takes
// the co-resident native path — the shape a fused binary runs.
meter, err := metering.New(metering.Config{BaseURL: "http://127.0.0.1:1", Token: "svc"})
if err != nil {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{AppName: "commerce"})
zip.Post[plane.RecordIn, plane.Recorded](app, "/finance/record",
func(ctx context.Context, in *plane.RecordIn) (*plane.Recorded, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrForbidden("no org on the debit")
}
amount, perr := in.Amount.Parse()
if perr != nil {
return nil, zip.ErrBadRequest(perr.Error())
}
if _, rerr := meter.Record(ctx, metering.Usage{
User: in.Subject, Org: org,
Amount: money.FromDecimal(amount.Decimal()),
Model: in.Usage.Model,
Provider: in.Usage.Provider,
Ref: in.Usage.Ref,
RequestID: in.Usage.RequestID,
Currency: amount.Currency().Code,
}); rerr != nil {
return nil, rerr
}
return &plane.Recorded{Amount: in.Amount}, nil
}, zip.WithOperationID(plane.FinanceRecord))
// RESOLVE THE ADDRESS HERE, NOT IN THE GOROUTINE — zip.SocketPath reads
// ZIP_RUNTIME_DIR on every call and each test points it at its own temp dir, so a
// listener that resolved its own address could bind at the NEXT test's address and
// answer that test's debits with "unknown op".
path := zip.SocketPath("commerce")
go func() { _ = app.Listen(path) }()
t.Cleanup(func() { _ = app.Shutdown() })
waitForCommerce(t, path)
return fin
}
// waitForCommerce blocks until the peer's socket ACCEPTS. Listen runs in a goroutine, so
// without this the first debit races the bind and the test reports a money bug that is
// really a harness bug — which it did, on the first run of this file.
func waitForCommerce(t *testing.T, path string) {
t.Helper()
for range 400 {
if c, err := net.Dial("unix", path); err == nil {
_ = c.Close()
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("commerce never began listening on %s", path)
}
func walletCents(t *testing.T, fin finance.Client, subject string) int64 {
t.Helper()
bal, err := fin.Balance(context.Background(), "acme", subject, "usd", false)
if err != nil {
t.Fatalf("balance(%s): %v", subject, err)
}
return bal.Cents()
}
// EVERY ANSWER IS PAID FOR, however the caller names the message.
//
// THE BUG. The ai module debits a completion under its message row's id, and that id is
// `Owner + "/" + Name` (object.Message.GetId) — both of them fields of the JSON body the
// CLIENT posts to AddMessage, which unmarshals the whole Message off the wire. Riding that
// value across as the debit's Ref made it the ledger's idempotency key, so a client that
// pinned one owner/name pair got the first completion billed and every completion after it
// deduped into that first entry: free inference, at whatever volume, from two body fields.
//
// THE PROPERTY. Twenty answers under ONE pinned owner/name are twenty acts and bill twenty
// times. The key is the server's, minted per debit at the far end (metering.Usage.Seal),
// and nothing a caller can write reaches it.
//
// It is not a downgrade from a stable key either: this debit is made once per streamed
// answer and is never re-driven (the ai module's retry sweep matches only its own HTTP
// fallback's error text), and the sibling debit on the OpenAI surface already keys on a
// fresh uuid per call. Both surfaces now mint.
//
// MUTATION PROOF: put the client's value back in debitOverPlane —
//
// Model: u.Model, Provider: u.Provider, Ref: u.RequestID,
//
// and the wallet ends at 99¢ instead of 80¢: nineteen of twenty answers billed nobody.
func TestPinnedMessageIDBillsEveryAnswer(t *testing.T) {
const subject = "acme/bob"
fin := serveCommerce(t, subject, 100)
// One owner/name pair, posted on every request — the whole of the attack.
const pinned = "acme/msg-pinned"
const answers = 20
for i := range answers {
if err := debitOverPlane(context.Background(), aiobject.UsageEvent{
Subject: subject,
Namespace: "acme",
USD: "0.01",
Currency: "usd",
Model: fmt.Sprintf("zen-%d", i),
Provider: "hanzo",
RequestID: pinned,
}); err != nil {
t.Fatalf("answer %d: %v", i, err)
}
}
if got := walletCents(t, fin, subject); got != 100-answers {
t.Fatalf("wallet after %d answers under one pinned message id = %d¢; want %d¢ — every answer must bill",
answers, got, 100-answers)
}
}
// The debit acts for the org the event names, and the peer bills the wallet the gate read.
// Both are asserted here because the pinning fix must not quietly re-point either: money
// billed to the wrong books is the same failure as money not billed at all.
func TestTheDebitCarriesTheOrgAndBillsTheSubjectsWallet(t *testing.T) {
const subject = "acme/bob"
fin := serveCommerce(t, subject, 100)
if err := debitOverPlane(context.Background(), aiobject.UsageEvent{
Subject: subject, Namespace: "acme", USD: "0.25", Currency: "usd", Model: "zen", Provider: "hanzo",
}); err != nil {
t.Fatalf("debit: %v", err)
}
if got := walletCents(t, fin, subject); got != 75 {
t.Fatalf("bob's wallet = %d¢; want 75¢", got)
}
// The org POOL is a different wallet in the same file and must be untouched.
if got := walletCents(t, fin, "acme"); got != 0 {
t.Fatalf("the org pool was billed %d¢ for a user's completion", 0-got)
}
}
// An event with NO currency still bills in USD rather than failing the debit: the ai
// module leaves it empty on some paths, and a debit that errored there would be an unbilled
// completion — the same hole from the other end.
func TestAnAbsentCurrencyStillBills(t *testing.T) {
const subject = "acme/bob"
fin := serveCommerce(t, subject, 100)
if err := debitOverPlane(context.Background(), aiobject.UsageEvent{
Subject: subject, Namespace: "acme", USD: "0.10", Model: "zen", Provider: "hanzo",
}); err != nil {
t.Fatalf("debit with no currency: %v", err)
}
if got := walletCents(t, fin, subject); got != 90 {
t.Fatalf("wallet = %d¢; want 90¢ — an empty currency defaults to usd", got)
}
}
// THE CO-RESIDENT HALF, STRUCTURALLY.
//
// When cloud owns the ledger the debit does not cross a socket — it is handed to the host
// as a cloud.UsageEvent — so the same question has to be answered about that value: can
// anything a client wrote reach the ledger's key through it? The answer is that the field
// no longer exists. There is no channel to police, no site to review, and no way to
// re-open one without deleting this test.
func TestTheHostUsageEventCarriesNoRef(t *testing.T) {
typ := reflect.TypeOf(cloud.UsageEvent{})
if _, found := typ.FieldByName("Ref"); found {
t.Error("cloud.UsageEvent has a Ref again — the ai module's only candidate for it is " +
"the message row's id, which is two fields of the client's own request body")
}
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright © 2026 Hanzo AI. MIT License.
package ai
import (
"context"
"os"
"strings"
"testing"
webtools "github.com/hanzoai/ai/agent/builtin_tool/web"
"github.com/hanzoai/cloud/apps/websearch"
)
// THE SEAM IS A LINE OF CODE NOTHING ELSE DEPENDS ON, WHICH IS EXACTLY THE KIND
// THAT GETS DELETED.
//
// ai's builtin registry DECLARES web_search unconditionally and holds no backend
// for it; this host installs one. Remove the install and nothing fails to compile,
// no route 404s, and no test goes red — the tool simply starts answering "not
// available in this deployment" to every agent, forever. That is the same shape as
// every other "wired but never consulted" defect in this tree.
func TestInstallWebSearchClosesTheSeam(t *testing.T) {
t.Cleanup(func() { webtools.SetSearch(nil) })
webtools.SetSearch(nil)
if webtools.Search() != nil {
t.Fatal("precondition: the seam should start empty")
}
installWebSearch(func(context.Context, string, string) []websearch.Result { return nil })
if webtools.Search() == nil {
t.Fatal("installWebSearch left the seam empty — every agent's web_search would " +
"report the capability as unavailable")
}
}
// The field mapping is the whole adapter, and it is a RENAME waiting to happen:
// websearch.Result calls the body Content, the tool contract calls it Snippet. Lose
// that and every agent gets results with empty snippets — which a model reads as
// "the web had nothing to say about this", not as a bug.
func TestInstallWebSearchMapsEveryFieldTheToolPublishes(t *testing.T) {
t.Cleanup(func() { webtools.SetSearch(nil) })
installWebSearch(func(context.Context, string, string) []websearch.Result {
return []websearch.Result{{
URL: "https://example.com/a",
Title: "A title",
Content: "the snippet body",
Engine: "duckduckgo",
}}
})
got, err := webtools.Search()(context.Background(), "q", 0)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d results, want 1", len(got))
}
if got[0].Title != "A title" {
t.Errorf("Title = %q", got[0].Title)
}
if got[0].URL != "https://example.com/a" {
t.Errorf("URL = %q", got[0].URL)
}
if got[0].Snippet != "the snippet body" {
t.Errorf("Snippet = %q, want the Content field — a model reads an empty snippet "+
"as the web having nothing to say", got[0].Snippet)
}
}
// The limit is HONOURED. The tool clamps its own maximum before it reaches here, so
// this only has to not ignore it — but ignoring it would hand an agent ten results
// when it asked for two, and context windows are the budget being spent.
func TestInstallWebSearchHonoursTheLimit(t *testing.T) {
t.Cleanup(func() { webtools.SetSearch(nil) })
many := make([]websearch.Result, 10)
for i := range many {
many[i] = websearch.Result{URL: "https://e.com/", Title: "t"}
}
installWebSearch(func(context.Context, string, string) []websearch.Result { return many })
for _, tc := range []struct{ limit, want int }{
{2, 2},
{0, 10}, // 0 means "unset" — do not silently truncate to nothing
{-1, 10}, // nor does a negative
{99, 10}, // asking for more than exists yields what exists, not an error
} {
got, err := webtools.Search()(context.Background(), "q", tc.limit)
if err != nil {
t.Fatalf("limit %d: %v", tc.limit, err)
}
if len(got) != tc.want {
t.Errorf("limit %d returned %d results, want %d", tc.limit, len(got), tc.want)
}
}
}
// An empty upstream result is an empty SLICE and not an error. The distinction is
// the one the tool layer is built on: an error means the capability is unavailable
// and a model should say so, while an empty list is a genuine finding.
func TestInstallWebSearchReturnsEmptyNotError(t *testing.T) {
t.Cleanup(func() { webtools.SetSearch(nil) })
installWebSearch(func(context.Context, string, string) []websearch.Result { return nil })
got, err := webtools.Search()(context.Background(), "q", 5)
if err != nil {
t.Fatalf("an empty result set surfaced as an error: %v — an agent would report the "+
"capability broken rather than the search empty", err)
}
if len(got) != 0 {
t.Errorf("got %d results from an empty upstream", len(got))
}
}
// And Mount must still CALL it. This half is a source assertion and cannot be
// anything better here: Mount needs a real zip.App and cloud.Deps (it runs ai's
// Bootstrap), so no test in this package constructs one. It pins the call site
// against deletion; the behaviour above is what pins the adapter.
func TestMountInstallsTheWebSearchSeam(t *testing.T) {
src, err := os.ReadFile("ai.go")
if err != nil {
t.Fatalf("read ai.go: %v", err)
}
if !strings.Contains(string(src), "installWebSearch(websearch.Search)") {
t.Error("Mount no longer installs the web-search backend — ai declares web_search " +
"either way, so every agent would be told the capability is unavailable and " +
"nothing else would report it")
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := analytics
include ../../mk/plugin.mk
+80 -31
View File
@@ -130,6 +130,9 @@ func build(b cloud.Base) (state, error) {
// currentKeyResolver prefers it.
SetFallbackKeyResolver(planeKeys{})
startSink(b.Log)
// The event door for a peer in another process, published beside the HTTP
// doors and reaching the same write core — see event_rpc.go.
exposeCapture()
return state{}, nil
}
@@ -173,6 +176,10 @@ func stopSink() {
func Shutdown(context.Context) error {
stopSink()
closeBus()
// The replay door's Kafka client (replay.go) is the subsystem's OTHER outbound
// connection, and it is released here for the same reason the bus is: Mount does
// not return a handle, so what the package opened the package has to close.
closeProducer()
return nil
}
@@ -183,8 +190,8 @@ func Shutdown(context.Context) error {
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// routes registers the analytics surface: six TYPED read ops, and the writes plus
// the health probe as untyped handlers because their wire cannot be declared.
// routes registers the analytics surface: the TYPED read ops and the health
// probe, and the writes as untyped handlers because their shape cannot be declared.
//
// Health owns /v1/analytics/health explicitly (not JWT-gated: liveness must be
// probe-able); every read lens is org-gated on the validated principal.
@@ -213,14 +220,12 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
zip.Get(g, "/timeseries", o.timeseries)
zip.Get(g, "/top", o.top)
// UNTYPED, and it has to be: this probe answers 503 CARRYING the degraded
// REPORT as its body, and no typed op can say that. zip stamps a non-nil Out
// with cmp.Or(op.Status, 200) (zip typed.go), WithStatus refuses a non-2xx, and
// an error is rendered as the flat {status,code,error} HTTPError — so typing it
// would either turn the 503 into a 200 or drop the report. Writing the body
// from inside the op and returning nil does not escape it either: a nil Out is
// stamped cmp.Or(op.Status, 204).
app.Get("/v1/analytics/health", cloud.Handle(s, health))
// TYPED, declaring BOTH statuses this probe answers with: the report's own
// StatusCode picks 200 or 503, so the degraded answer CARRIES the degraded
// report as its body — the pair that kept this route raw before zip could
// declare a non-2xx with a typed body.
zip.Get(g, "/health", o.health,
zip.WithStatus(http.StatusOK, http.StatusServiceUnavailable))
// Capture (WRITE) side — the ingest that fills the event plane. Every ingest door
// is registered HERE and only here, from doors (event.go): one Post per declared
@@ -285,6 +290,19 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
app.Post("/v1/event/:project/envelope", obsError)
app.Post("/v1/event/:project/store", obsError)
// The session-replay snapshot door (replay.go). Registered HERE, by hand, for
// the same reason the Sentry wire above is: it is not an entry in `doors`, and
// it cannot be. A door in that table is a wire that decodes to []CaptureEvent
// and flows through the ONE write core onto the event plane; a snapshot batch is
// an opaque rrweb recording bound for a different consumer on a different
// transport, and it lands no row in the warehouse at all.
//
// What it DOES share is the thing worth sharing: admission. replayIngest
// resolves its credential through eventTenant and refuses through the same
// cannotAttribute/cannotWrite vocabulary as every door, so there is no second
// resolver on this surface.
app.Post(replayPath, cloud.Handle(s, replayIngest))
// /v1/errors is the type:'error' read lens over the same rows — a validated
// principal, since a read never accepts the write-only publishable key. MINTING is
// a different concern and lives on the key resource, not here: POST /v1/keys with
@@ -692,12 +710,9 @@ func (o readOps) top(ctx context.Context, in *topQuery) (*Top, error) {
// ── /v1/analytics/health ────────────────────────────────────────────────────
// healthReport is the probe's answer, and it is the SAME object at 200 and at 503
// which is precisely why this route cannot be a typed op: the STATUS is the signal
// and the report is the detail, and zip can declare only one of the two. Stating it
// as a struct rather than building a map is what lets openapi.Register (event.go)
// derive the shape from the code that produces it, instead of a hand-written schema
// beside it that drifts.
// healthReport is the probe's answer, and it is the SAME object at 200 and at 503:
// the STATUS is the signal and the report is the detail, and the op declares both
// statuses so its own StatusCode says which this answer carries.
type healthReport struct {
// Service names the subsystem answering, so a probe aggregating several health
// endpoints can attribute a degraded one.
@@ -771,22 +786,58 @@ type healthLens struct {
Available bool `json:"available"`
}
// health is a REAL probe of BOTH directions: the warehouse this subsystem reads and
// the event plane it writes. Either one down is a 503, because either one down is a
// subsystem that cannot do its job — and reporting only the read half is what let a
// total ingest outage sit behind a green probe.
// StatusCode is how the answer states which of the op's two declared statuses it
// carries: ok is the 200, degraded is the 503.
func (r *healthReport) StatusCode() int {
if r.Status == "ok" {
return http.StatusOK
}
return http.StatusServiceUnavailable
}
// Health reports whether the event plane can take a write and the warehouse can
// answer a read.
//
// The two are probed INDEPENDENTLY and reported side by side, so the answer says
// WHICH half broke rather than collapsing both into one bit. Not JWT-gated (liveness
// must be probe-able) and it NEVER reads tenant data — only table existence and
// stream presence. 200 even if the events lens is not yet provisioned (that is
// honest-empty, not a failure).
func health(s *cloud.Service[state], c *zip.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), probeTimeout)
// It reports the analytics subsystem's own liveness in BOTH directions: plane is
// the event plane it WRITES (the bus and the JetStream stream every accepted
// event is published to, both named in the report), and datastore is the
// warehouse it READS, with each read lens's table reported as it is provisioned
// (the LLM usage ledger and the product-event table).
//
// EITHER ONE DOWN IS A 503, and the report says WHICH — they are probed
// independently and never collapse into a single bit. This endpoint used to
// report the read half only, and answered 200/ok while every POST /v1/event
// failed on a stream that could not bind: a total ingest outage behind a green
// probe. A readiness gate here now gates on the write path too.
//
// plane.ready IS A REAL PROBE and walks the ingest path itself — the same
// connection and the same stream a publish uses — so it cannot answer ready while
// a publish would 503. plane.reason carries the plane's own error text when it is
// false.
//
// datastore IS NOT PROBED WITH A QUERY. It is the state of the process's own
// shared client — established, and not since closed — so a warehouse accepting
// connections and failing reads still reports true. Degraded CARRIES the report
// (status, the failing half, reason) as its body rather than an error envelope,
// so a gate reads the cause off the same object it got at 200.
//
// A MISSING LENS TABLE IS NOT A FAILURE and never moves the status: a lens
// reported available:false answers honest-empty rather than erroring, so a fresh
// deployment whose collector has not emitted yet is legitimately 200 with the
// product-event lens unavailable. The lens block is reported whenever the
// warehouse is REACHABLE — including on a report degraded by the plane, where the
// tables genuinely were probed — and is absent only when the warehouse is not,
// having nothing to say about tables it could not reach.
//
// Unauthenticated on purpose — liveness has to be probe-able — and it reads NO
// tenant data: table existence and stream presence only, never a row and never an
// event.
func (o readOps) health(ctx context.Context, _ *noArgs) (*healthReport, error) {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
connected := datastore.Ready()
res := healthReport{Service: "analytics", Status: "ok", Datastore: connected, Warehouse: "hanzo",
res := &healthReport{Service: "analytics", Status: "ok", Datastore: connected, Warehouse: "hanzo",
Plane: healthPlane{Bus: busURL(), Stream: EventStream, Ready: true},
Lost: lossReport()}
if err := planeReady(ctx); err != nil {
@@ -806,10 +857,8 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
res.Status, res.Reason = "degraded", "datastore (datastore) not connected"
case !res.Plane.Ready:
res.Status, res.Reason = "degraded", res.Plane.Reason
default:
return c.JSON(http.StatusOK, res)
}
return c.JSON(http.StatusServiceUnavailable, res)
return res, nil
}
// tableExists probes datastore for a table's presence. The name is a package
+6
View File
@@ -613,6 +613,12 @@ func projectKey(c *zip.Ctx) string {
const (
sourceEvent = "event" // canonical POST /v1/event (canonical wire)
sourcePostHog = "posthog" // POST /v1/insights/e (PostHog wire)
// sourcePlane is the INTERNAL plane door (event_rpc.go): an occurrence stated
// by a peer app over the socket rather than by a client over HTTP. It is a
// wire like the others and it is tagged like the others, so "which rows did
// the fleet write about itself" is a filter on $source and never a second
// table.
sourcePlane = "plane"
)
// withSource returns a copy of p carrying $source=source (the ingest adapter), so
+17 -5
View File
@@ -412,11 +412,23 @@ func TestRoutedPostSetIsExactlyTheDoors(t *testing.T) {
posts = append(posts, r.Path)
}
}
// The POST surface is the doors PLUS the obs error wire the door carries:
// /v1/event/{project}/envelope|store forwards to the o11y plane's installed
// consumer (cloud.ObsErrorIngest) and is DSN-authenticated there — a wire on
// the one event door, not a new door for handle to admit.
want := append(doorPaths(), "/v1/event/:project/envelope", "/v1/event/:project/store")
// The POST surface is the doors PLUS the two routes on this surface that are
// registered by hand, each because it is NOT a door:
//
// - /v1/event/{project}/envelope|store — the obs error wire the event door
// carries. It forwards to the o11y plane's installed consumer
// (cloud.ObsErrorIngest) and is DSN-authenticated there: a wire on the one
// event door, not a new door for handle to admit.
// - /v1/replay — the session-replay snapshot door (replay.go). A `doors` row is
// a wire that decodes to []CaptureEvent and flows through the ONE write core
// onto the event plane; a snapshot batch is an opaque rrweb recording
// produced to a different consumer on a different transport, and it lands no
// warehouse row at all. It cannot be a row here without either a decoder that
// returns nothing (a door that always drops) or a second meaning for
// CaptureEvent. It shares ADMISSION — eventTenant, and the same refusals —
// which is the part this file exists to hold shut, and replay_test.go
// quantifies that gate over it directly.
want := append(doorPaths(), "/v1/event/:project/envelope", "/v1/event/:project/store", replayPath)
if !sameSet(posts, want) {
t.Fatalf("registered POST routes = %v, want doors + the obs error wire = %v — every other\n"+
"ingest route must come from doors, and nothing else may be registered as a POST here", posts, want)
+34 -33
View File
@@ -908,39 +908,6 @@ func init() {
openapi.Register(d.path, http.MethodPost, d.wire, CaptureResult{})
openapi.Describe(d.path, http.MethodPost, d.summary, d.description)
}
openapi.Register("/v1/analytics/health", http.MethodGet, nil, healthReport{})
// What "healthy" ASSERTS, stated exactly, because a probe whose prose overclaims
// is worse than one with none: an operator wires a readiness gate to it and gets a
// green pod in front of a warehouse that cannot answer a query.
openapi.Describe("/v1/analytics/health", http.MethodGet,
"Whether the event plane can take a write and the warehouse can answer a read",
"Reports the analytics subsystem's own liveness in BOTH directions: `plane` is the "+
"event plane it WRITES (the bus and the JetStream stream every accepted event is "+
"published to, both named in the report), and `datastore` is the warehouse it READS, "+
"with each read lens's table reported as it is provisioned (the LLM usage ledger and "+
"the product-event table).\n\n"+
"EITHER ONE DOWN IS A 503, and the report says WHICH — they are probed independently "+
"and never collapse into a single bit. This endpoint used to report the read half "+
"only, and answered 200/ok while every POST /v1/event failed on a stream that could "+
"not bind: a total ingest outage behind a green probe. A readiness gate wired here "+
"now gates on the write path too.\n\n"+
"`plane.ready` IS A REAL PROBE and walks the ingest path itself — the same connection "+
"and the same stream a publish uses — so it cannot answer ready while a publish would "+
"503. `plane.reason` carries the plane's own error text when it is false.\n\n"+
"`datastore` IS NOT PROBED WITH A QUERY. It is the state of the process's own shared "+
"client — established, and not since closed — so a warehouse accepting connections and "+
"failing reads still reports true. Degraded CARRIES the report (status, the failing "+
"half, reason) as its body rather than an error envelope, so a gate reads the cause "+
"off the same object it got at 200.\n\n"+
"A MISSING LENS TABLE IS NOT A FAILURE and never moves the status: a lens reported "+
"available:false answers honest-empty rather than erroring, so a fresh deployment "+
"whose collector has not emitted yet is legitimately 200 with the product-event lens "+
"unavailable. The lens block is reported whenever the warehouse is REACHABLE — "+
"including on a report degraded by the plane, where the tables genuinely were probed — "+
"and is absent only when the warehouse is not, having nothing to say about tables it "+
"could not reach.\n\n"+
"Unauthenticated on purpose — liveness has to be probe-able — and it reads NO tenant "+
"data: table existence and stream presence only, never a row and never an event.")
// The Sentry error wire (registered in analytics.go's routes, on the same
// /v1/event door). Its body is an opaque envelope stream the o11y consumer reads
// itself, so openapi.Binary is the whole truth — no struct describes it, exactly
@@ -968,6 +935,40 @@ func init() {
openapi.Register(d.path, http.MethodPost, openapi.Binary{}, nil)
openapi.Describe(d.path, http.MethodPost, d.summary, d.description+sentryWire)
}
// The session-replay snapshot door (replay.go). It is not a `doors` row — its
// body is not the canonical wire and it lands no warehouse row — so it declares
// itself here beside the other route on this surface that is registered by hand.
// Its RESPONSE is the same CaptureResult every door answers, because the receipt
// is the one thing every write on this surface does share.
openapi.Register(replayPath, http.MethodPost, replayBody{}, CaptureResult{})
openapi.Describe(replayPath, http.MethodPost,
"Record a session-replay snapshot batch",
"Accepts a batch of rrweb events from a browser recorder and hands it to the session-replay "+
"pipeline, which stores the recording and derives the session summary a player reads back.\n\n"+
"ONE REQUEST IS ONE BATCH, and it is all-or-nothing: the recording is made durable before "+
"this answers, so a 200 {\"accepted\":1} means stored and never \"buffered somewhere\". "+
"There is no partial count, because a half-written recording is not a recording.\n\n"+
"`sessionId` is REQUIRED and bounded — at most 70 characters of ASCII letters, digits or "+
"'-'. It is the key every batch of one visit is grouped and ordered by, so an id outside "+
"that grammar is refused 400 here rather than accepted and dropped further down. "+
"`windowId` separates two tabs of one session and `distinctId` attributes the recording to "+
"a person; both are optional. `events` is the rrweb batch, each element a raw eventWithTime "+
"object, carried VERBATIM — the summary (click, keypress and mouse-activity counts, size) "+
"is derived downstream from exactly these bytes, so nothing is re-encoded or dropped.\n\n"+
"THE CALLER'S CREDENTIAL DECIDES THE TENANT, and the body never does: the recording lands "+
"in the org the presented credential resolves to. It takes the SAME credentials as "+
"/v1/event — a validated bearer, an org API key, or a publishable pk- key on "+
"Authorization: Bearer, x-hanzo-ingest-key or ?ingest_key= — so a browser bundle already "+
"holding a pk- for events needs nothing new to record. A caller that presents nothing is "+
"401 `ingest_key_required`; one whose key resolves to no project is 403 "+
"`ingest_key_unknown`; a reduced principal (a Hanzo Team workspace token) is 403 "+
"`insufficient_capability`, because a full-fidelity screen recording has no projected form "+
"that is safe for a guest to write into a host org.\n\n"+
"BOUNDS: 413 over 512 KiB of body, and that is the only bound on one batch — a recorder is "+
"expected to chunk a long session rather than send it whole, and the cap is the size one "+
"message can carry rather than an arbitrary number. 503 when the pipeline cannot take the "+
"batch: honest unavailability the caller can retry, never a 200 over a discarded "+
"recording.")
}
// sentryWire is the half of both Sentry doors' prose that is identical because the
+122
View File
@@ -0,0 +1,122 @@
// 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.
package analytics
// event_rpc.go — the event door, for a peer in ANOTHER PROCESS.
//
// This package owns POST /v1/event, event.fact and the /v1/insights reads over
// them. The pod forks one process per app, so an app that wanted its OWN facts
// answerable in the same query as the product's had two ways to get there and
// neither one was this:
//
// IN-PROCESS ingestEvents is the write core and it is unreachable from
// another pid. This is the shape apps/o11y/obs_rpc.go names in its
// own header — cloud.SetObsErrorIngest was written in one process
// and read as nil in the one that needed it — and it is the same
// mistake one layer over.
// OVER HTTP the public edge: a second gate, a second credential and a hop
// through the fleet's front door to reach a table one socket away.
// A peer with no browser and no bearer would have to be issued one
// to state a fact about its own work.
//
// So the peer ASKS, on the app's own socket, exactly as a debit asks commerce and
// a decision asks risk. It is the WRITE side of the door ObsErrorPost already
// claims a slice of, and it reaches the SAME write core every HTTP door reaches
// — one admission, one normalizer, one storage projection. A second path into
// event.fact would be a second answer to "what is an event", and the two would
// disagree the first time the scrubber changed.
//
// THE TENANT IS THE CALLER'S. It is minted from the plane principal and there is
// no field on the wire that could name one, so a peer can only ever write into
// its own organisation's partition. That is the HTTP door's rule (analytics.go's
// tenancy paragraph) applied at the other door, and it is the whole of the
// isolation: `org` is stamped into the fact by normalize, and every read binds it
// positionally.
import (
"context"
"strings"
"github.com/hanzoai/cloud"
planeops "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// exposeCapture publishes the door. build calls it.
func exposeCapture() {
zip.Post[planeops.EventIn, planeops.EventCaptured](cloud.Plane(), "/event/capture", planeCapture,
zip.WithOperationID(planeops.EventCapture),
zip.WithSummary("Capture one occurrence onto the calling organisation's own event plane"))
}
// Captures ONE occurrence onto the calling organisation's own event plane — the
// same plane the HTTP door fills and /v1/insights reads back, through the SAME
// write core.
//
// The organisation is the CALLER's, minted from the plane principal and never
// from this body, which carries no field that could name one. A peer that states
// no principal writes nothing: an unidentified caller has no partition, and
// defaulting one would be a shared store with a tenant anybody can reach.
//
// A name is REQUIRED and is refused rather than defaulted. Every other route into
// this core has a server-chosen default name for the signal it carries (a page
// view, an error, a span); a peer's occurrence is a tracked act, and a tracked
// act with no name is unroutable by the plane's own rule — so the refusal is the
// normalizer's, said at the boundary where the caller can still be told.
//
// A named handler, not a closure, so zipdoc can lift this prose into the registry.
func planeCapture(ctx context.Context, in *planeops.EventIn) (*planeops.EventCaptured, error) {
org := strings.TrimSpace(cloud.Who(ctx).Org)
if org == "" {
return nil, zip.ErrForbidden("capture: no org on the call, so the occurrence acts for no tenant")
}
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, zip.ErrBadRequest("capture: 'name' is required — an unnamed act is unroutable")
}
res, err := ingestEvents(ctx, org, sourcePlane, []CaptureEvent{{
Event: name,
Product: strings.TrimSpace(in.Product),
DistinctID: strings.TrimSpace(in.Subject),
Timestamp: strings.TrimSpace(in.At),
Properties: stated(in.Attributes),
}})
if err != nil {
return nil, err
}
return &planeops.EventCaptured{Accepted: res.Accepted, Dropped: res.Dropped}, nil
}
// stated lifts the wire's name/value list into the property bag the write core
// reads. It is the plane's inverse of the map a map could not be: the list is the
// wire because zapenc refuses a map at encode, and this is the one place the two
// shapes meet.
//
// An unnamed pair is DROPPED rather than stored under "": an empty key and an
// absent one read identically out of the store, so keeping it buys nothing and
// costs a key in every row's dictionary — attributesOf makes the same call about
// empty VALUES for the same reason.
func stated(list []planeops.Signal) map[string]any {
if len(list) == 0 {
return nil
}
out := make(map[string]any, len(list))
for _, s := range list {
if name := strings.TrimSpace(s.Name); name != "" {
out[name] = s.Value
}
}
return out
}
+150
View File
@@ -0,0 +1,150 @@
package analytics
// event_rpc_test.go — the peer door, held to the two properties that make it safe
// to let another process write into a shared, per-tenant store.
//
// THE TENANT IS THE CALLER'S. Not a field (there is none), not a default, and
// not absent — a call with no principal writes nothing at all.
// IT REACHES THE ONE WRITE CORE. What a peer states is normalized, scrubbed and
// published exactly like what a browser posts, so there is one answer to "what
// is an event" rather than two that drift.
import (
"context"
"reflect"
"strings"
"testing"
"github.com/hanzoai/cloud"
planeops "github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// asPeer is a plane call: a context with no request behind it, carrying the org
// the caller states. It is the one place zip reads a stated caller.
func asPeer(org string) context.Context { return cloud.For(context.Background(), org) }
// TestPlaneCapture_StampsTheCallersOwnTenant is the whole isolation property,
// read off the COMMITTED fact rather than off the argument: the row's org is the
// caller's, and it got there without the wire carrying one.
//
// Mutation proof: take the org from anywhere but cloud.Who and the committed
// fact carries the wrong tenant (or none, which normalize would land under "").
func TestPlaneCapture_StampsTheCallersOwnTenant(t *testing.T) {
w := fakeWarehouse(t)
out, err := planeCapture(asPeer("acme"), &planeops.EventIn{
Name: "risk_decided",
Product: "risk",
Subject: "8f14e45fceea167a",
Attributes: []planeops.Signal{
{Name: "action", Value: "review"},
{Name: "posture", Value: "shadow"},
},
})
if err != nil {
t.Fatalf("planeCapture: %v", err)
}
if out.Accepted != 1 || out.Dropped != 0 {
t.Fatalf("receipt {accepted:%d dropped:%d}, want exactly one landed", out.Accepted, out.Dropped)
}
if len(w.facts) != 1 {
t.Fatalf("%d facts committed, want 1", len(w.facts))
}
f := w.facts[0]
switch {
case f.org != "acme":
t.Errorf("the fact landed under org %q, want %q — the tenant is the caller's", f.org, "acme")
case f.signal != signalAct:
t.Errorf("signal %q, want %q — a peer's occurrence is an act, in the same partition "+
"as the product's own", f.signal, signalAct)
case f.kind != kindTrack:
t.Errorf("kind %q, want %q", f.kind, kindTrack)
case f.name != "risk_decided":
t.Errorf("name %q, want the stated one", f.name)
case f.product != "risk":
t.Errorf("product %q, want the emitting surface", f.product)
case f.distinct != "8f14e45fceea167a":
t.Errorf("distinct %q, want the stated subject", f.distinct)
}
for k, want := range map[string]string{
"action": "review", "posture": "shadow", "$source": sourcePlane,
} {
if got := f.attributes[k]; got != want {
t.Errorf("attributes[%q] = %q, want %q", k, got, want)
}
}
}
// TestPlaneCapture_FailsClosedWithoutAPrincipal. An unidentified peer has no
// partition, and defaulting one would make a shared store writable by anybody who
// can reach the socket.
func TestPlaneCapture_FailsClosedWithoutAPrincipal(t *testing.T) {
w := fakeWarehouse(t)
_, err := planeCapture(context.Background(), &planeops.EventIn{Name: "risk_decided"})
he, ok := err.(*zip.HTTPError)
if !ok || he.Status != 403 {
t.Fatalf("err %v, want a 403 — a call that acts for no tenant must write nothing", err)
}
if len(w.facts) != 0 {
t.Errorf("%d facts committed by a call with no principal", len(w.facts))
}
}
// TestPlaneCapture_RefusesAnUnnamedAct. Every other route into the write core has
// a server-chosen default name for the signal it carries; a tracked act has none,
// so an unnamed one is unroutable — refused at the boundary where the caller can
// still be told, rather than counted as a silent drop.
func TestPlaneCapture_RefusesAnUnnamedAct(t *testing.T) {
w := fakeWarehouse(t)
for _, name := range []string{"", " "} {
_, err := planeCapture(asPeer("acme"), &planeops.EventIn{Name: name})
he, ok := err.(*zip.HTTPError)
if !ok || he.Status != 400 {
t.Errorf("name %q: err %v, want a 400", name, err)
}
}
if len(w.facts) != 0 {
t.Errorf("%d facts committed by an unnamed act", len(w.facts))
}
}
// TestEventIn_CannotNameAnOrg is the STRUCTURAL half of the isolation: a peer
// cannot spoof a tenant it cannot spell.
//
// Mutation proof: add an Org field to plane.EventIn and this names it.
func TestEventIn_CannotNameAnOrg(t *testing.T) {
rt := reflect.TypeOf(planeops.EventIn{})
for i := 0; i < rt.NumField(); i++ {
name := strings.ToLower(rt.Field(i).Name)
for _, banned := range []string{"org", "tenant", "brand", "owner"} {
if strings.Contains(name, banned) {
t.Errorf("plane.EventIn.%s names the tenant — the organisation a row lands under "+
"rides the caller, never the argument", rt.Field(i).Name)
}
}
}
}
// TestStated_DropsAnUnnamedPair. An empty key and an absent one read identically
// out of the store, so keeping it buys nothing and costs a dictionary entry in
// every row.
func TestStated_DropsAnUnnamedPair(t *testing.T) {
got := stated([]planeops.Signal{
{Name: "action", Value: "allow"},
{Name: " ", Value: "nowhere"},
{Name: "", Value: "nowhere either"},
{Name: " posture ", Value: "live"},
})
if len(got) != 2 {
t.Fatalf("stated kept %d pairs, want 2: %v", len(got), got)
}
if got["action"] != "allow" || got["posture"] != "live" {
t.Errorf("stated lost a named pair or kept its padding: %v", got)
}
if stated(nil) != nil {
t.Error("an empty list produced a non-nil bag — an allocation per empty call")
}
}
+321 -18
View File
@@ -17,8 +17,8 @@
// 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
// hand-offs an accepted batch still owes:
// fact publish IS the commit — and what remains here are the SUBSCRIBER hand-offs an
// accepted batch still owes:
//
// - the ENVELOPE onto the plane (PublishEvents, bus.go) — the webhook-delivery
// contract (apps/webhooks): orgs subscribe to event.<folded name> subjects and
@@ -28,23 +28,43 @@
// - a COPY-taking downstream SINK — the destinations subsystem — which translates
// and forwards each event to the org's connected ad/analytics platforms (GA4, Meta
// CAPI, …).
// - the ERROR sinks: the error-signal slice of the batch, handed to consumers that
// project a failure onto another surface. apps/o11y installs one (errorsink.go)
// that lands each error on the embedded Sentry plane, so a /v1/event error
// surfaces on sentry.hanzo.ai beside the errors a Sentry SDK posts directly.
// - the SPAN sinks: the span-signal slice, on identical terms. apps/o11y installs one
// (spansink.go) that lands each LLM-shaped span on event.span, so a /v1/event span
// surfaces in the LLM views (GET /v1/o11y/llm/observations, /llm/traces, and the
// eval board) beside the gen_ai spans the ai emit path sends over the ZAP wire.
//
// The seam is:
//
// - ONE-WAY. analytics never imports its consumers; a sink (destinations) calls
// AddSink from its own Mount. No sinks means no sink fan-out, so this file changes
// nothing about ingest when they are absent.
// - RAW. The sink receives the event BEFORE the warehouse privacy scrub, because a
// server-side Conversions-API forwarder must hash the match keys (email/phone/
// click ids) the warehouse deliberately drops. The org connected the destination
// and owns that consent; the destination adapters SHA-256 every PII field before
// it leaves the process.
// - FAIL-SOFT. The sink runs detached (a panic-guarded goroutine) so a slow or
// broken destination can never block, fail, or crash an ingest.
// - ONE-WAY. analytics never imports its consumers; a sink (destinations, o11y)
// calls AddSink / AddErrorSink / AddSpanSink from its own Mount. No sinks means no
// sink fan-out, so this file changes nothing about ingest when they are absent.
// - RAW, WHERE THE CONSUMER FORWARDS. The destination sink receives the event BEFORE
// the warehouse privacy scrub, because a server-side Conversions-API forwarder must
// hash the match keys (email/phone/click ids) the warehouse deliberately drops. The
// org connected the destination and owns that consent; the destination adapters
// SHA-256 every PII field before it leaves the process. The exception text an error
// sink reads is already the folded copy foldException scrubbed, and the Sentry
// normalizer scrubs again on its side.
// - SCRUBBED, WHERE THE CONSUMER STORES. The span slice carries the same scrubMap copy
// the write core stores in the fact's attributes, because its consumer writes a ROW:
// scrubText's whole contract is that a token in a property is redacted before
// storage, and a projection that stored more than the plane stores would be a second
// copy of the batch under a weaker rule.
// - FAIL-SOFT. Each sink runs detached (a panic-guarded goroutine) so a slow or
// broken consumer can never block, fail, or crash an ingest.
// - ADDITIVE. A projection failure is invisible to the ingest — the fact publish
// already committed and the honest receipt already returned.
package analytics
import "time"
import (
"strings"
"time"
)
// SinkEvent is one accepted event handed to the downstream fan-out. It carries the
// resolved canonical name plus the commerce + identity fields a conversion needs;
@@ -83,15 +103,100 @@ func AddSink(fn func(org string, evs []SinkEvent)) (remove func()) {
return func() { sinks[i] = nil }
}
// fanOut hands the accepted batch to the sink, detached and fail-soft. org is the
// SERVER-resolved tenant (already an owned copy from principal.Org). It builds
// SinkEvents from the RAW events (skipping unroutable ones, mirroring the write
// core's drop rule) and, if any remain and a sink is installed, dispatches them on a
// panic-guarded goroutine so ingest is never blocked or failed by a destination.
// ErrorEvent is one accepted error occurrence handed to the error fan-out. It is a
// carrier of exactly the fields an error projection needs, so analytics stays
// orthogonal to its consumers — apps/o11y builds the Sentry wire event on its side.
// The exception text is the folded, scrubbed copy (foldException); the tenant is the
// org argument to the sink, never a field here.
type ErrorEvent struct {
MessageID string // client idempotency id / minted; becomes the projection's event id
Time time.Time
ExceptionType string // e.g. "TypeError"; "" ⇒ the consumer groups on the message
Message string // the exception message (the grouping value)
Stack string // raw client stack string, when the wire carried one
Handled *bool // whether the app caught it (nil ⇒ unknown)
Level string // "error" for these events
Platform string // e.g. "javascript" (properties.$platform; descriptive)
Release string // build the error fired in
Environment string // deployment the error fired in
Transaction string // the route the error fired on (path, else url)
URL string
Path string
DistinctID string // the reporting visitor (user id — never PII)
SessionID string
Product string // emitting surface: console|chat|app|site|admin
Site string // deployed property the error came from
Service string // emitting service, when the wire named one
Library string
TraceID string // trace linkage
SpanID string
}
// errorSinks are the error fan-out consumers, on the same terms as sinks: registered
// at Mount, package-global, each dispatch detached and panic-guarded.
var errorSinks []func(org string, errs []ErrorEvent)
// AddErrorSink registers an error fan-out consumer and returns its remover.
func AddErrorSink(fn func(org string, errs []ErrorEvent)) (remove func()) {
i := len(errorSinks)
errorSinks = append(errorSinks, fn)
return func() { errorSinks[i] = nil }
}
// SpanEvent is one accepted span handed to the span fan-out. It is a carrier of exactly
// the fields a span projection needs, so analytics stays orthogonal to its consumers —
// apps/o11y decides on its side which of these spans are LLM calls and what a row of
// event.span looks like. Properties is where a span states its OTel semantic attributes
// (gen_ai.*), carried as the SCRUBBED copy the fact row stores (see the header); the
// tenant is the org argument to the sink, never a field here.
type SpanEvent struct {
MessageID string // client idempotency id / minted; the fallback row identity
Time time.Time
Name string // the span's name, resolved by the plane's own rule
Kind string // client|server|producer|consumer|internal
Status string // how the span ended: ok|error|unset, lowercased
Duration uint64 // elapsed nanoseconds
TraceID string // the trace this span belongs to
SpanID string // this span's own id
Parent string // the enclosing span's id, empty for a root span
Service string // emitting service, when the wire named one
Product string // emitting surface: console|chat|app|site|admin
Site string // deployed property the span came from
Release string // build the span was recorded in
Environment string // deployment the span was recorded in
DistinctID string // the reporting visitor (user id — never PII)
SessionID string
Properties map[string]any // RAW; where gen_ai.* semantic attributes travel
}
// spanSinks are the span fan-out consumers, on the same terms as sinks and errorSinks:
// registered at Mount, package-global, each dispatch detached and panic-guarded.
var spanSinks []func(org string, spans []SpanEvent)
// AddSpanSink registers a span fan-out consumer and returns its remover.
func AddSpanSink(fn func(org string, spans []SpanEvent)) (remove func()) {
i := len(spanSinks)
spanSinks = append(spanSinks, fn)
return func() { spanSinks[i] = nil }
}
// fanOut hands the accepted batch to the plane envelope and to every installed sink,
// each detached and fail-soft. org is the SERVER-resolved tenant (already an owned
// copy from principal.Org). One call site — the ingestEvents tail.
func fanOut(org string, evs []CaptureEvent) {
if len(evs) == 0 {
return
}
fanOutEvents(org, evs)
fanOutErrors(org, evs)
fanOutSpans(org, evs)
}
// fanOutEvents builds SinkEvents from the RAW events (skipping unroutable ones,
// mirroring the write core's drop rule), publishes the envelope onto the plane, and
// dispatches each installed sink on a panic-guarded goroutine so ingest is never
// blocked or failed by a consumer.
func fanOutEvents(org string, evs []CaptureEvent) {
live := make([]func(string, []SinkEvent), 0, len(sinks))
for _, fn := range sinks {
if fn != nil {
@@ -139,3 +244,201 @@ func fanOut(org string, evs []CaptureEvent) {
}()
}
}
// fanOutErrors filters the batch to the events the plane routes as the error signal —
// routeOf is the ONE routing rule, so the projection can never carry a fact the plane
// filed as something else — builds ErrorEvents, and dispatches each installed error
// sink on a panic-guarded goroutine. Non-error events (the overwhelming majority) are
// skipped, so a normal batch never touches this path.
func fanOutErrors(org string, evs []CaptureEvent) {
live := make([]func(string, []ErrorEvent), 0, len(errorSinks))
for _, fn := range errorSinks {
if fn != nil {
live = append(live, fn)
}
}
if len(live) == 0 {
return
}
now := time.Now()
out := make([]ErrorEvent, 0)
for _, e := range evs {
if routeOf(e).signal != signalError {
continue
}
typ, msg, stack, handled := exceptionOf(e)
out = append(out, ErrorEvent{
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
Time: clampTS(e.Timestamp, now),
ExceptionType: trim(typ),
Message: trim(msg),
Stack: stack,
Handled: handled,
Level: "error",
Platform: propStr(e.Properties, "$platform"),
Release: firstNonEmptyStr(trim(e.Release), propStr(e.Properties, "$release")),
Environment: firstNonEmptyStr(trim(e.Environment), propStr(e.Properties, "$environment")),
Transaction: firstNonEmptyStr(trim(e.Path), trim(e.URL)),
URL: trim(e.URL),
Path: trim(e.Path),
DistinctID: trim(e.DistinctID),
SessionID: trim(e.SessionID),
Product: trim(e.Product),
Site: trim(e.Site),
Service: trim(e.Service),
Library: trim(e.Library),
TraceID: firstNonEmptyStr(trim(e.TraceID), propStr(e.Properties, "$trace_id")),
SpanID: firstNonEmptyStr(trim(e.SpanID), propStr(e.Properties, "$span_id")),
})
}
if len(out) == 0 {
return
}
for _, fn := range live {
fn := fn
go func() {
defer func() { _ = recover() }()
fn(org, out)
}()
}
}
// fanOutSpans filters the batch to the events the plane routes as the span signal —
// routeOf is the ONE routing rule, the same pin fanOutErrors holds, so a projection can
// never carry a fact the plane filed as something else — builds SpanEvents, and
// dispatches each installed span sink on a panic-guarded goroutine. Non-span events
// (the overwhelming majority) are skipped, so a normal batch never touches this path.
func fanOutSpans(org string, evs []CaptureEvent) {
live := make([]func(string, []SpanEvent), 0, len(spanSinks))
for _, fn := range spanSinks {
if fn != nil {
live = append(live, fn)
}
}
if len(live) == 0 {
return
}
now := time.Now()
out := make([]SpanEvent, 0)
for _, e := range evs {
r := routeOf(e)
if r.signal != signalSpan {
continue
}
b := spanBodyOf(e)
// The SCRUB the write core applies before it stores a fact's attributes, applied
// once here: the projection reads its property fallbacks out of the very map it
// hands the consumer, so what a row carries and what a fallback saw are the same
// values.
props := scrubMap(e.Properties)
// The plane's OWN precedence, restated (applySpan, fact.go): the route's default
// kind stands unless the envelope names one, and the span BODY refines both —
// a client emitting a span knows its role precisely.
kind := firstNonEmptyStr(trim(e.Kind), r.kind)
if k := trim(b.Kind); k != "" {
kind = k
}
out = append(out, SpanEvent{
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
Time: clampTS(e.Timestamp, now),
Name: resolveName(r, e),
Kind: strings.ToLower(kind),
Status: strings.ToLower(trim(b.Status)),
Duration: b.Duration,
// The first-class envelope ids win over the body's — the SAME order the fact
// row resolves them in (the envelope fills trace/span at build, applySpan
// fills only what is still empty), so the projected span and the fact carry
// one identity rather than two. The legacy $ spellings are the last resort
// for a client that has not moved to the first-class field, exactly as the
// error slice above reads them.
TraceID: firstNonEmptyStr(firstNonEmptyStr(trim(e.TraceID), trim(b.Trace)), propStr(props, "$trace_id")),
SpanID: firstNonEmptyStr(firstNonEmptyStr(trim(e.SpanID), trim(b.ID)), propStr(props, "$span_id")),
Parent: trim(b.Parent),
Service: trim(e.Service),
Product: trim(e.Product),
Site: trim(e.Site),
Release: firstNonEmptyStr(trim(e.Release), propStr(props, "$release")),
Environment: firstNonEmptyStr(trim(e.Environment), propStr(props, "$environment")),
DistinctID: trim(e.DistinctID),
SessionID: trim(e.SessionID),
Properties: props,
})
}
if len(out) == 0 {
return
}
for _, fn := range live {
fn := fn
go func() {
defer func() { _ = recover() }()
fn(org, out)
}()
}
}
// spanBodyOf returns the event's span body, or the ZERO body when the wire carried
// none. A span-typed event without a body is still a span — applySpan stores it and the
// fact lands — and the zero value states exactly the "nothing further known" that row
// records, so the projection reads one shape instead of branching on a pointer.
func spanBodyOf(e CaptureEvent) SpanBody {
if e.Span == nil {
return SpanBody{}
}
return *e.Span
}
// exceptionOf extracts the exception's (type, message, stack, handled) from whichever
// carrier holds it: the typed Error (folded events carry it), or properties.$exception
// as either the typed *Exception (post-fold, same process) or a decoded map (from the
// JSON wire). Returns zero values for an error event that carries no exception — a
// bare error-typed event, which the consumer then groups on its message/transaction.
func exceptionOf(e CaptureEvent) (typ, message, stack string, handled *bool) {
if e.Error != nil {
return e.Error.Type, e.Error.Message, e.Error.Stack, e.Error.Handled
}
raw, ok := e.Properties["$exception"]
if !ok {
return "", "", "", nil
}
switch x := raw.(type) {
case *Exception:
if x == nil {
return "", "", "", nil
}
return x.Type, x.Message, x.Stack, x.Handled
case Exception:
return x.Type, x.Message, x.Stack, x.Handled
case map[string]any:
return mapStr(x, "type"), mapStr(x, "message"), mapStr(x, "stack"), mapBool(x, "handled")
default:
return "", "", "", nil
}
}
// propStr reads a string-valued property, "" when absent or non-string. The ingest
// never trusts these for tenancy — they are descriptive only.
func propStr(p map[string]any, key string) string {
if p == nil {
return ""
}
if v, ok := p[key].(string); ok {
return trim(v)
}
return ""
}
// mapStr reads a string value from a decoded exception map.
func mapStr(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
// mapBool reads a *bool from a decoded exception map (JSON bools decode to bool).
func mapBool(m map[string]any, key string) *bool {
if v, ok := m[key].(bool); ok {
return &v
}
return nil
}
+249
View File
@@ -0,0 +1,249 @@
// 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.
package analytics
import (
"testing"
"time"
)
// collectErrors installs an error sink that forwards the batch for one org onto a
// channel, and returns the channel + the sink's remover. Mirrors forward_test's
// SinkEvent probe.
func collectErrors(t *testing.T, wantOrg string) (<-chan []ErrorEvent, func()) {
t.Helper()
got := make(chan []ErrorEvent, 1)
remove := AddErrorSink(func(org string, errs []ErrorEvent) {
if org == wantOrg {
got <- errs
}
})
return got, remove
}
// TestFanOutErrorsFoldedException verifies the primary /v1/event path: an event whose
// top-level error was folded (foldException — Type defaulted to "error", the scrubbed
// exception copied into properties.$exception) is routed to the error sink with its
// exception fields + identity/context carried.
func TestFanOutErrorsFoldedException(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
handled := false
// foldException runs in ingestDecoded before the write core; replicate it here so
// the fan-out sees exactly what production hands it.
folded := foldException(CaptureEvent{
Error: &Exception{Type: "TypeError", Message: "x is not a function", Stack: "at f (app.js:1:1)", Handled: &handled},
DistinctID: "u1", SessionID: "s1", Path: "/checkout", URL: "https://acme.ai/checkout",
Product: "app", Library: "@hanzo/event",
Properties: map[string]any{"$release": "v2", "$trace_id": "abc", "keep": "me"},
})
fanOut("acme", []CaptureEvent{
folded,
{Type: "pageview"}, // not an error — must be skipped
{Type: "event", Event: "click"}, // not an error — must be skipped
})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1 error event (pageview+click skipped), got %d", len(errs))
}
e := errs[0]
if e.ExceptionType != "TypeError" || e.Message != "x is not a function" {
t.Errorf("exception not carried: %+v", e)
}
if e.Stack != "at f (app.js:1:1)" {
t.Errorf("stack not carried: %q", e.Stack)
}
if e.Handled == nil || *e.Handled != false {
t.Errorf("handled flag not carried: %v", e.Handled)
}
if e.DistinctID != "u1" || e.SessionID != "s1" {
t.Errorf("identity not carried: %+v", e)
}
if e.Transaction != "/checkout" || e.Path != "/checkout" || e.URL != "https://acme.ai/checkout" {
t.Errorf("route not carried: %+v", e)
}
if e.Product != "app" || e.Library != "@hanzo/event" {
t.Errorf("surface not carried: %+v", e)
}
if e.Release != "v2" || e.TraceID != "abc" {
t.Errorf("property fallbacks not carried: release=%q trace=%q", e.Release, e.TraceID)
}
if e.Level != "error" {
t.Errorf("level = %q, want error", e.Level)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsEnvelopeFields verifies the first-class envelope qualifiers win over
// the property spellings: a wire that states release/environment/service/site/trace on
// the envelope hands exactly those to the sink.
func TestFanOutErrorsEnvelopeFields(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "error", Error: &Exception{Message: "boom"},
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
TraceID: "t-1", SpanID: "s-1",
Properties: map[string]any{"$release": "stale", "$trace_id": "stale"},
}})
select {
case errs := <-got:
e := errs[0]
if e.Release != "v3" || e.Environment != "prod" {
t.Errorf("envelope release/environment must win: %+v", e)
}
if e.Service != "gateway" || e.Site != "acme.ai" {
t.Errorf("service/site not carried: %+v", e)
}
if e.TraceID != "t-1" || e.SpanID != "s-1" {
t.Errorf("envelope trace linkage must win: %+v", e)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsNativeExceptionMap verifies the JSON-wire path: an error-typed event
// carrying $exception as a decoded map (not the typed *Exception). exceptionOf must
// read type/message/stack/handled out of the map.
func TestFanOutErrorsNativeExceptionMap(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "error", Event: "$error",
Properties: map[string]any{
"$exception": map[string]any{
"type": "RangeError", "message": "out of range", "stack": "at g()", "handled": true,
},
},
}})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1, got %d", len(errs))
}
e := errs[0]
if e.ExceptionType != "RangeError" || e.Message != "out of range" || e.Stack != "at g()" {
t.Errorf("map exception not read: %+v", e)
}
if e.Handled == nil || *e.Handled != true {
t.Errorf("handled from map not read: %v", e.Handled)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsTypedErrorNoException verifies a bare type:'error' event with NO
// exception is still carried (the consumer groups it on message/transaction).
func TestFanOutErrorsTypedErrorNoException(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom", Path: "/x"}})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1, got %d", len(errs))
}
if errs[0].ExceptionType != "" || errs[0].Message != "" {
t.Errorf("bare error should carry no exception: %+v", errs[0])
}
if errs[0].Transaction != "/x" {
t.Errorf("transaction = %q, want /x", errs[0].Transaction)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called for a bare error-typed event")
}
}
// TestFanOutErrorsIsThePlanesRoute pins the filter to routeOf — the projection carries
// exactly the facts the plane files under the error signal, so sentry.hanzo.ai and
// event.fact can never disagree about what an error is. In particular a $exception
// property on an event the plane routes as an act does NOT reach the sink.
func TestFanOutErrorsIsThePlanesRoute(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "ERROR", Event: "case-folded"}, // routed error: type folds lower
{Error: &Exception{Message: "typeless"}}, // routed error: typeless + exception
{Type: "event", Event: "decorated", Properties: map[string]any{ // routed act: the plane's word wins
"$exception": map[string]any{"message": "not an error fact"},
}},
})
select {
case errs := <-got:
if len(errs) != 2 {
t.Fatalf("want the 2 error-routed events, got %d: %+v", len(errs), errs)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsNoErrorsNoDispatch verifies a batch with zero error events never
// dispatches to the error sink (the common case — a normal pageview/event batch).
func TestFanOutErrorsNoErrorsNoDispatch(t *testing.T) {
fired := make(chan struct{}, 1)
remove := AddErrorSink(func(org string, errs []ErrorEvent) { fired <- struct{}{} })
defer remove()
fanOut("acme", []CaptureEvent{
{Type: "pageview"},
{Type: "event", Event: "order_completed", Revenue: 10},
})
select {
case <-fired:
t.Fatal("error sink fired for a batch with no error events")
case <-time.After(200 * time.Millisecond):
// expected: no dispatch
}
}
// TestFanOutErrorsNoSinksIsNoOp verifies fan-out is inert when no error sink is
// installed — the default when the o11y embed is off. A removed sink counts as absent.
func TestFanOutErrorsNoSinksIsNoOp(t *testing.T) {
remove := AddErrorSink(func(string, []ErrorEvent) { t.Error("removed sink must not fire") })
remove()
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom"}})
time.Sleep(50 * time.Millisecond)
}
// TestExceptionOf covers extraction from each carrier: typed Error, typed post-fold
// $exception, and decoded map.
func TestExceptionOf(t *testing.T) {
h := true
// typed Error field
if typ, msg, stack, handled := exceptionOf(CaptureEvent{Error: &Exception{Type: "E", Message: "m", Stack: "s", Handled: &h}}); typ != "E" || msg != "m" || stack != "s" || handled == nil || !*handled {
t.Errorf("typed Error: %q %q %q %v", typ, msg, stack, handled)
}
// post-fold typed (*Exception in properties)
if typ, msg, _, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": &Exception{Type: "E2", Message: "m2"}}}); typ != "E2" || msg != "m2" {
t.Errorf("post-fold typed: %q %q", typ, msg)
}
// decoded map
if typ, msg, stack, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": map[string]any{"type": "E3", "message": "m3", "stack": "s3"}}}); typ != "E3" || msg != "m3" || stack != "s3" {
t.Errorf("decoded map: %q %q %q", typ, msg, stack)
}
// none
if typ, msg, _, _ := exceptionOf(CaptureEvent{Type: "error"}); typ != "" || msg != "" {
t.Errorf("no exception should be empty: %q %q", typ, msg)
}
}
+215
View File
@@ -0,0 +1,215 @@
// 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.
package analytics
import (
"strings"
"testing"
"time"
)
// collectSpans installs a span sink that forwards the batch for one org onto a channel,
// and returns the channel + the sink's remover. Mirrors collectErrors next door.
func collectSpans(t *testing.T, wantOrg string) (<-chan []SpanEvent, func()) {
t.Helper()
got := make(chan []SpanEvent, 1)
remove := AddSpanSink(func(org string, spans []SpanEvent) {
if org == wantOrg {
got <- spans
}
})
return got, remove
}
// TestFanOutSpansIsThePlanesRoute pins the filter to routeOf — the projection carries
// exactly the facts the plane files under the span signal, so the LLM views and
// event.fact can never disagree about what a span is. In particular an event carrying a
// span BODY that the plane routes as an act does NOT reach the sink: the wire's `type`
// picks the route, and a body cannot promote itself past it.
func TestFanOutSpansIsThePlanesRoute(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "span", Span: &SpanBody{ID: "s-1", Trace: "t-1"}}, // routed span
{Type: "SPAN", Span: &SpanBody{ID: "s-2", Trace: "t-1"}}, // routed span: type folds lower
{Type: "event", Event: "click", Span: &SpanBody{ID: "x"}}, // routed act: the plane's word wins
{Type: "error", Event: "boom"}, // routed error
{Type: "pageview"}, // routed act
})
select {
case spans := <-got:
if len(spans) != 2 {
t.Fatalf("want the 2 span-routed events, got %d: %+v", len(spans), spans)
}
if spans[0].SpanID != "s-1" || spans[1].SpanID != "s-2" {
t.Errorf("wrong events carried: %+v", spans)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansEnvelopeFields verifies the first-class envelope fields win over both
// the span body and the legacy $ property spellings — the same order the fact row
// resolves its identity in.
func TestFanOutSpansEnvelopeFields(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "span", MessageID: "m-1",
TraceID: "t-envelope", SpanID: "s-envelope",
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
Product: "chat", DistinctID: "u1", SessionID: "sess-1",
Span: &SpanBody{ID: "s-body", Trace: "t-body", Parent: "p-1"},
Properties: map[string]any{"$trace_id": "t-stale", "$span_id": "s-stale", "$release": "stale", "$environment": "stale"},
}})
select {
case spans := <-got:
s := spans[0]
if s.TraceID != "t-envelope" || s.SpanID != "s-envelope" {
t.Errorf("envelope ids must win over body and properties: %+v", s)
}
if s.Release != "v3" || s.Environment != "prod" {
t.Errorf("envelope release/environment must win: %+v", s)
}
if s.Service != "gateway" || s.Site != "acme.ai" || s.Product != "chat" {
t.Errorf("origin not carried: %+v", s)
}
if s.Parent != "p-1" {
t.Errorf("parent comes from the body: %q", s.Parent)
}
if s.MessageID != "m-1" || s.DistinctID != "u1" || s.SessionID != "sess-1" {
t.Errorf("identity not carried: %+v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansBodyRefinesTheRoute verifies the body fills what the envelope omits and
// refines the kind — applySpan's own precedence, restated on the projection: the route's
// default kind (internal) stands unless the envelope names one, and the body wins over
// both. The $ spellings are the last resort for a client with no first-class field.
func TestFanOutSpansBodyRefinesTheRoute(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "span", Kind: "SERVER", Span: &SpanBody{ID: "a", Trace: "t", Kind: "Client", Status: "ERROR", Duration: 42}},
{Type: "span", Kind: "server", Span: &SpanBody{ID: "b", Trace: "t"}},
{Type: "span", Span: &SpanBody{ID: "c", Trace: "t"}},
{Type: "span", Properties: map[string]any{"$trace_id": "t-legacy", "$span_id": "s-legacy"}},
})
select {
case spans := <-got:
if len(spans) != 4 {
t.Fatalf("want 4 spans, got %d", len(spans))
}
if spans[0].Kind != "client" || spans[0].Status != "error" || spans[0].Duration != 42 {
t.Errorf("body must refine kind/status/duration, lowercased: %+v", spans[0])
}
if spans[1].Kind != "server" {
t.Errorf("envelope kind stands when the body names none: %q", spans[1].Kind)
}
if spans[2].Kind != kindInternal {
t.Errorf("the route's default kind stands when nobody names one: %q", spans[2].Kind)
}
if spans[3].TraceID != "t-legacy" || spans[3].SpanID != "s-legacy" {
t.Errorf("legacy $ spellings are the last resort: %+v", spans[3])
}
// Every span is named, so the projection can never write an unnamed row.
for i, s := range spans {
if s.Name != nameSpan {
t.Errorf("span %d name = %q, want the route's default %q", i, s.Name, nameSpan)
}
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansStoresTheScrubbedProperties pins the rule that separates this slice from
// the destinations slice: a span sink WRITES A ROW, so it receives the same scrubMap copy
// the write core stores — the credential key is dropped and the token-shaped value is
// redacted before it can reach a stored attribute. The gen_ai marker survives untouched.
func TestFanOutSpansStoresTheScrubbedProperties(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "span", Span: &SpanBody{ID: "s", Trace: "t"},
Properties: map[string]any{
"gen_ai.system": "openai",
"gen_ai.request.model": "zen-1",
"gen_ai.usage.input_tokens": 128,
"authorization": "Bearer abcdefghijklmnop",
"gen_ai.prompt": "mail me at user@acme.ai",
},
}})
select {
case spans := <-got:
p := spans[0].Properties
if p["gen_ai.system"] != "openai" || p["gen_ai.request.model"] != "zen-1" {
t.Errorf("the gen_ai marker and model must survive the scrub: %+v", p)
}
if _, ok := p["authorization"]; ok {
t.Errorf("credential-shaped key must be dropped before storage: %+v", p)
}
if v, _ := p["gen_ai.prompt"].(string); strings.Contains(v, "user@acme.ai") {
t.Errorf("email must be redacted before storage: %q", v)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansNoSpansNoDispatch verifies a batch with zero span events never
// dispatches to the span sink (the common case — a normal pageview/event batch).
func TestFanOutSpansNoSpansNoDispatch(t *testing.T) {
fired := make(chan struct{}, 1)
remove := AddSpanSink(func(org string, spans []SpanEvent) { fired <- struct{}{} })
defer remove()
fanOut("acme", []CaptureEvent{
{Type: "pageview"},
{Type: "event", Event: "order_completed", Revenue: 10},
{Type: "error", Event: "boom"},
})
select {
case <-fired:
t.Fatal("span sink fired for a batch with no span events")
case <-time.After(200 * time.Millisecond):
// expected: no dispatch
}
}
// TestFanOutSpansNoSinksIsNoOp verifies fan-out is inert when no span sink is installed —
// the default when the o11y plane sink is off. A removed sink counts as absent, which is
// what makes ShutdownO11y's detach real.
func TestFanOutSpansNoSinksIsNoOp(t *testing.T) {
remove := AddSpanSink(func(string, []SpanEvent) { t.Error("removed sink must not fire") })
remove()
fanOut("acme", []CaptureEvent{{Type: "span", Span: &SpanBody{ID: "s", Trace: "t"}}})
time.Sleep(50 * time.Millisecond)
}
// TestSpanBodyOf covers both carriers: a span that stated a body, and a span-typed event
// that stated none (the zero body, which reads as "nothing further known").
func TestSpanBodyOf(t *testing.T) {
if b := spanBodyOf(CaptureEvent{Span: &SpanBody{ID: "s", Duration: 7}}); b.ID != "s" || b.Duration != 7 {
t.Errorf("stated body not returned: %+v", b)
}
if b := spanBodyOf(CaptureEvent{Type: "span"}); b != (SpanBody{}) {
t.Errorf("absent body must read as the zero body: %+v", b)
}
}
+446
View File
@@ -0,0 +1,446 @@
// 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.
// replay.go — the session-replay snapshot door.
//
// POST /v1/replay body: {sessionId, windowId, distinctId, events:[…]} -> {accepted, dropped}
//
// A browser recorder posts a batch of rrweb events; this door turns it into ONE
// message on the replay ingest topic and answers the SAME receipt every other
// analytics door answers. It is the whole of cloud's part: the downstream
// ingester consumes the topic, writes the snappy blocks to object storage and
// computes the summary row the player reads. Cloud does not touch object storage
// and does not touch the warehouse on this path.
//
// IT IS NOT A `doors` ENTRY, and cannot be. Every row of that table is a wire that
// decodes to []CaptureEvent and flows through the ONE write core onto the event
// plane (ingestEvents → the JetStream fact plane → the warehouse). A snapshot batch
// decodes to none of that: it is an opaque rrweb recording bound for a different
// consumer on a different transport. Forcing it into the doors table would mean
// either a decoder that returns no events (a door that always drops) or a second
// meaning for CaptureEvent. So it is registered explicitly in routes, exactly as
// the Sentry relay is — the other route on this surface whose body is not the
// canonical wire.
//
// WHAT IS SHARED IS ADMISSION, and that is the half that matters. The credential is
// resolved by eventTenant — the SAME pluggable, fail-closed, strict-trust-order
// resolver every door uses — and refused with the SAME cannotAttribute/cannotWrite
// vocabulary. There is no second resolver here, so a pk- that works on /v1/event
// works here, and a key that is refused there is refused here, forever, by
// construction rather than by two functions being kept in agreement.
//
// THE TENANT IS NEVER READ FROM THE BODY, and it is the ONLY thing this door tells the
// ingester about who is writing. The org is whatever eventTenant resolved from the
// credential; it rides the message as a ROUTING fact, and the ingester files the
// recording under the project that org owns. It is not a credential and it does not
// authenticate anything — the authentication already happened here, once, against the
// IAM-issued pk-, and a message on this topic is only ever produced by this door.
//
// So there is no second token anywhere on this path. An org's recordings cannot be
// steered into another tenant's stream by naming one, because the caller never names
// it: the value on the wire is the server-resolved org and nothing a caller can set.
//
// A PRODUCE FAILURE IS A 503, NEVER A 200. The produce is the commit point, exactly
// as the fact publish is on the event plane (bus.go): the door answers "accepted"
// only after the broker has the message. A fire-and-forget produce would turn every
// receipt into a maybe, which is the failure mode `answer` (event.go) exists to make
// impossible.
package analytics
import (
"context"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/hanzoai/cloud"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/zap-proto/zip"
)
// replayPath is the door. No /api/ prefix and no version but v1 — the house rule
// for every route on api.hanzo.ai.
const replayPath = "/v1/replay"
// sourceReplay is this door's origin tag. It is a METRIC label only, and it lives
// here rather than in capture.go's source block deliberately: every tag in that
// block is stamped into a warehouse row's $source by ingestEvents, and this door
// writes no row. Naming it there would put a value in a table whose comment promises
// one tag per entry in `doors` — a promise this door is precisely the exception to.
const sourceReplay = "replay"
// The snapshot contract, as CONSTANTS rather than literals at the point of use.
// Every one of them is read by a consumer this repo does not own, so each is a
// published fact: changing one is a wire change, and it should take an edit here.
const (
// snapshotTopic is the topic the replay ingester consumes.
snapshotTopic = "session_recording_snapshot_item_events"
// snapshotEvent is the event name, carried BOTH in the envelope and in the
// `event` message header.
snapshotEvent = "$snapshot_items"
// snapshotSource names the recorder's platform.
snapshotSource = "web"
// snapshotLib is the SDK that produced the recording.
snapshotLib = "@hanzo/replay"
// snapshotTime is RFC3339 at millisecond precision — the rendering the `now`
// and `timestamp` envelope fields and the `now` header all carry. Seconds-only
// RFC3339 would lose the ordering of two batches inside one second.
snapshotTime = "2006-01-02T15:04:05.000Z07:00"
)
// maxSessionID is the longest session id the pipeline accepts. It mirrors the
// upstream producer's own check, and it is enforced HERE because this value becomes
// the PARTITION KEY: an id the downstream refuses is a message that is produced,
// partitioned and then dropped at the far end, which the caller would see as a 200.
const maxSessionID = 70
// maxReplayBytes bounds ONE snapshot request, and it is the ONLY bound on one, because
// there is only one thing to bound: how much a single message can carry. A second cap
// on the event COUNT would be a second answer to that question, arbitrary where this
// one is derived, and a recorder that hit it would be told 400 about a batch whose
// only problem was its size.
//
// THE BYTE CAP IS NOT ARBITRARY AND IT IS NOT THE GLOBAL ONE. One batch becomes ONE
// Kafka message, and the transport under the embedded broker is the platform bus,
// whose default maximum payload is 1 MiB. The envelope also carries the recording
// DOUBLE-ENCODED — `data` is a JSON string, so every quote in the rrweb payload is
// escaped, and a quote-dense recording inflates well past its raw size. 512 KiB of
// body therefore still fits a 1 MiB message with room for the escaping, while the
// global 16 MiB BodyLimit (config.go) would not: a body admitted there would be
// produced, refused by the bus, and answered 503 for a reason the caller cannot act
// on. Bounding at the door turns that into an honest 413 the recorder can chunk
// against.
const maxReplayBytes = 512 << 10
// replayProduceTimeout bounds ONE produce so a wedged broker cannot hold an ingest
// request open; the caller gets an honest 503 instead. Same budget, same reason, as
// the event plane's publishTimeout.
const replayProduceTimeout = 5 * time.Second
// replayBody is the PUBLIC wire, and it is deliberately not the pipeline's. The
// message the ingester reads is a PostHog-flavored envelope with $-prefixed
// properties and a double-encoded body; none of that is a fact about Hanzo's API, so
// none of it appears here. A recorder posts the four things it actually knows.
type replayBody struct {
// SessionID groups every batch of one recorded visit and is the message's
// partition key, so all of a session's batches stay ordered behind one another.
// Required: at most 70 characters, ASCII letters, digits and '-'.
SessionID string `json:"sessionId"`
// WindowID distinguishes two tabs inside one session. Optional.
WindowID string `json:"windowId"`
// DistinctID is the person the recording is attributed to. Optional.
DistinctID string `json:"distinctId"`
// Events is the rrweb batch, each element a raw eventWithTime object
// ({type, timestamp, data}). Carried VERBATIM — the ingester derives the
// summary (click, keypress and mouse-activity counts, size) from these bytes,
// so cloud re-encodes nothing and drops no field it does not understand.
Events []json.RawMessage `json:"events"`
}
// validSessionID reports whether an id may be used as the partition key: non-empty,
// at most maxSessionID characters, and ASCII alphanumeric or '-' throughout.
//
// It is BYTE-wise on purpose. Ranging a string in Go yields runes, so a multi-byte
// character would be counted as one against a limit the downstream applies to bytes,
// and this check has to refuse exactly what the pipeline refuses — a length this
// accepts and the far end rejects is a message produced into a partition and then
// dropped, reported to the caller as a success.
func validSessionID(id string) bool {
if id == "" || len(id) > maxSessionID {
return false
}
for i := 0; i < len(id); i++ {
c := id[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// ── the message ─────────────────────────────────────────────────────────────
// snapshotEnvelope is the OUTER message value. `Data` is the inner document as a
// JSON STRING — the encoding is DOUBLE on purpose, because that is what the consumer
// parses; it reads Data as text and unmarshals it a second time.
//
// It carries no tenant field. The org travels in the message HEADER, in ONE copy,
// because the header is where the ingester reads it — a second copy in the envelope
// would be a value that can only ever agree with itself, or disagree.
type snapshotEnvelope struct {
UUID string `json:"uuid"`
DistinctID string `json:"distinct_id"`
IP string `json:"ip"`
Data string `json:"data"`
Now string `json:"now"`
Event string `json:"event"`
Timestamp string `json:"timestamp"`
}
// snapshotData is the INNER document, the one carried as text in the envelope's
// `data`. Its property names are the pipeline's, $-prefixes and all: this is the
// one place the public wire is translated into the consumer's vocabulary, which is
// precisely why replayBody does not have to speak it.
type snapshotData struct {
Event string `json:"event"`
Properties snapshotProperties `json:"properties"`
}
type snapshotProperties struct {
DistinctID string `json:"distinct_id"`
SessionID string `json:"$session_id"`
WindowID string `json:"$window_id"`
SnapshotSource string `json:"$snapshot_source"`
SnapshotItems []json.RawMessage `json:"$snapshot_items"`
Lib string `json:"$lib"`
}
// snapshotRecord renders ONE accepted batch as the message the replay ingester
// consumes: the partition key, the seven headers, and the double-encoded envelope.
//
// It is PURE — every varying input (the id, the clock, the resolved org's token, the
// client address) is a parameter rather than something it reaches for — so the whole
// wire contract is asserted directly in a test, byte for byte, with no broker and no
// clock. That matters more here than anywhere else on this surface: the consumer is
// out of this repo and out of this language, so a drift in a header name or in the
// double encoding cannot be caught by anything in the build. The test IS the
// contract.
//
// THE ORG TRAVELS IN A HEADER, in one copy, and it is the whole of what this message
// says about tenancy. The ingester resolves it to the project the org owns and files
// the recording there; a message whose org resolves to no project is dropped at the
// far end rather than filed anywhere, which is the same fail-closed shape this door
// answers with.
func snapshotRecord(in replayBody, org, ip, id string, now time.Time) (*kgo.Record, error) {
stamp := now.UTC().Format(snapshotTime)
data, err := json.Marshal(snapshotData{
Event: snapshotEvent,
Properties: snapshotProperties{
DistinctID: in.DistinctID,
SessionID: in.SessionID,
WindowID: in.WindowID,
SnapshotSource: snapshotSource,
SnapshotItems: in.Events,
Lib: snapshotLib,
},
})
if err != nil {
return nil, err
}
// The second encode is what makes `data` a STRING in the outer object. Marshal
// of a Go string does exactly that escaping, so the double encoding is a
// property of the type and not of a hand-built literal.
value, err := json.Marshal(snapshotEnvelope{
UUID: id,
DistinctID: in.DistinctID,
IP: ip,
Data: string(data),
Now: stamp,
Event: snapshotEvent,
Timestamp: stamp,
})
if err != nil {
return nil, err
}
return &kgo.Record{
Topic: snapshotTopic,
// The session id, raw. It is the partition key, so every batch of one
// recording lands on one partition and stays in order behind the last.
Key: []byte(in.SessionID),
Value: value,
Headers: []kgo.RecordHeader{
// The tenant, as the ingester's routing key. It is the SERVER-RESOLVED
// org — the same value every other write on this surface is attributed
// to — and it replaces the project token this message used to carry.
{Key: "org", Value: []byte(org)},
{Key: "distinct_id", Value: []byte(in.DistinctID)},
{Key: "session_id", Value: []byte(in.SessionID)},
// Unix MILLIS as a decimal string — the one header that is not the same
// rendering as its envelope twin.
{Key: "timestamp", Value: []byte(strconv.FormatInt(now.UnixMilli(), 10))},
{Key: "event", Value: []byte(snapshotEvent)},
{Key: "uuid", Value: []byte(id)},
{Key: "now", Value: []byte(stamp)},
},
}, nil
}
// ── the producer ────────────────────────────────────────────────────────────
// replayBrokersEnv is where this process reaches the platform's Kafka wire. It
// defaults to the in-cluster Service that fronts the embedded adaptor (apps/kafka,
// which serves the Kafka binary protocol over the same JetStream everything else on
// this plane rides).
const replayBrokersEnv = "CLOUD_REPLAY_BROKERS"
// defaultReplayBrokers is that Service.
const defaultReplayBrokers = "kafka:9092"
func replayBrokers() []string {
raw := strings.TrimSpace(os.Getenv(replayBrokersEnv))
if raw == "" {
raw = defaultReplayBrokers
}
return strings.Split(raw, ",")
}
// producer holds the process's ONE Kafka client, dialed on first use and reused.
// A struct rather than a bare var so the dial is guarded by a single mutex: a burst
// of concurrent first requests dials once, not once per request. Same shape, and the
// same reason, as bus.go's connection to the event plane.
type producer struct {
mu sync.Mutex
client *kgo.Client
}
var snapshots = &producer{}
// connect returns the live client, dialing on first use.
//
// IT GOES OVER THE WIRE, and that is a correctness decision rather than a
// convenience. The broker assigns offsets under a per-partition lock it holds
// privately (hanzoai/kafka protocol.Broker: partitionMu, partitionBounds,
// stampBaseOffsets), so producing is only sound THROUGH it. Reaching around it —
// publishing record bytes straight onto the partition's subject — would skip both
// the lock and the offset stamp and corrupt the partition for every consumer, which
// is why no such shortcut exists here.
func (p *producer) connect() (*kgo.Client, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.client != nil {
return p.client, nil
}
cl, err := kgo.NewClient(
kgo.SeedBrokers(replayBrokers()...),
kgo.ClientID("hanzo-cloud-replay"),
// UNCOMPRESSED, deliberately. A record batch is stored and served verbatim,
// so the codec is a contract with the CONSUMER, not with the broker — and
// the consumer is out of this repo. franz-go would negotiate snappy by
// default; an uncompressed batch is readable by every client that speaks the
// protocol at all, and the traffic is in-cluster where the bandwidth this
// costs does not signify.
kgo.ProducerBatchCompression(kgo.NoCompression()),
)
if err != nil {
return nil, err
}
p.client = cl
return cl, nil
}
// close releases the client on graceful shutdown. Idempotent.
func (p *producer) close() {
p.mu.Lock()
cl := p.client
p.client = nil
p.mu.Unlock()
if cl != nil {
cl.Close()
}
}
// produce is this door's ONE seam onto the broker, and the only way a recording
// leaves this package. It is a package var for exactly the reason `publish` is
// (bus.go): production is always produceSnapshot, and a test substitutes it to read
// back the record a request actually produced — the headers and the double-encoded
// value are the whole contract, and none of it is observable through a status code.
var produce = produceSnapshot
// produceSnapshot produces one record, SYNCHRONOUSLY. The produce is the commit
// point: the door answers a receipt only once the broker has the message, so an
// "accepted" means durable rather than "buffered in this process".
func produceSnapshot(ctx context.Context, rec *kgo.Record) error {
ctx, cancel := context.WithTimeout(ctx, replayProduceTimeout)
defer cancel()
cl, err := snapshots.connect()
if err != nil {
return err
}
return cl.ProduceSync(ctx, rec).FirstErr()
}
// closeProducer releases the Kafka client. Cloud calls it on graceful shutdown,
// beside the event plane's own close.
func closeProducer() { snapshots.close() }
// ── the door ────────────────────────────────────────────────────────────────
// replayIngest is the handler: admission, then bounds, then the one produce.
//
// The ORDER is the wire and is chosen for the same reason public.go's is — a caller
// must be told the FIRST thing that is wrong with its request, and the first thing
// is always whether anybody vouched for it. Credential, then size, then shape, then
// the tenant's configuration, then the produce.
func replayIngest(_ *cloud.Service[state], c *zip.Ctx) error {
a, ok := eventTenant(c)
if !ok {
return cannotAttribute(presented(c))
}
// A REDUCED principal is refused rather than projected. The projection
// (publicIngest) narrows an event to a pageview or an error in a closed
// vocabulary; a screen recording has no such reduction — it is the full-fidelity
// capture of a session, and there is no version of it that is safe for a guest
// to write into a host org it was invited into for one channel. So the answer is
// the capability refusal that already exists, not a weaker recording.
if !a.full {
return cannotWrite(true)
}
body := c.Body()
if len(body) > maxReplayBytes {
return zip.Errorf(http.StatusRequestEntityTooLarge,
"snapshot payload too large: %d bytes, limit %d — send the recording in smaller batches", len(body), maxReplayBytes)
}
var in replayBody
if err := json.Unmarshal(body, &in); err != nil {
return zip.ErrBadRequest("malformed snapshot payload")
}
if !validSessionID(in.SessionID) {
return zip.ErrBadRequest("sessionId must be 1-70 characters of ASCII letters, digits or '-'")
}
if len(in.Events) == 0 {
return zip.ErrBadRequest("no rrweb events in this batch")
}
// The org is the one eventTenant resolved from the credential, and it goes on the
// wire as-is. There is nothing to look up and nothing that can be unconfigured:
// the only way to reach this line is to have authenticated, and the tenant a
// credential resolves to is exactly the tenant its recordings are filed under.
rec, err := snapshotRecord(in, a.org, cloud.ClientIP(c), uuid.NewString(), time.Now())
if err != nil {
return zip.ErrBadRequest("snapshot payload cannot be encoded")
}
if err := produce(c.Context(), rec); err != nil {
// 503 and never a 200 with a count. The batch was admitted and could not be
// made durable, which is the caller's business — it is the one thing a
// recorder can act on, by retrying.
// org is NOT repeated here: the request logger already binds it, and a second
// copy in the same line is a field that can only ever agree with itself.
c.Log().Warn("replay snapshot produce failed", "session", in.SessionID, "err", err)
return zip.Errorf(http.StatusServiceUnavailable, "session replay ingest unavailable: %v", err)
}
// The SAME receipt every other door answers. One batch is one accepted unit:
// the recording is produced whole or not at all, so there is no partial count to
// report and `dropped` is honestly zero.
cloud.ObserveIngest(sourceReplay, 1, 0)
return c.JSON(http.StatusOK, CaptureResult{Accepted: 1})
}
+538
View File
@@ -0,0 +1,538 @@
// 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.
// replay_test.go — the session-replay door's proofs.
//
// The load-bearing one is TestSnapshotRecordIsTheWireContract. Every other consumer
// on this surface is in this repo and in this language, so a drift in what cloud
// writes goes red at compile time somewhere. The replay ingester is NEITHER: it is a
// separate process that reads these bytes, and nothing in this build links against
// it. A renamed header, a `data` that stopped being double-encoded, or a partition
// key that stopped being the session id would all compile, deploy, answer 200, and
// silently produce recordings nobody can play. That test IS the contract.
package analytics
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/twmb/franz-go/pkg/kgo"
)
// ── the harness ─────────────────────────────────────────────────────────────
// fakeProducer substitutes the door's ONE seam onto the broker and records what it
// was handed, so a test reads back the record a request actually produced. Restored
// via t.Cleanup, the same discipline fakeWarehouse keeps.
//
// err is what the substituted produce returns, so the failure path is driven from
// the same helper as the happy one — a produce that fails must never become a 200,
// and that is only assertable by making one fail.
func fakeProducer(t *testing.T, err error) *[]*kgo.Record {
t.Helper()
var got []*kgo.Record
orig := produce
produce = func(_ context.Context, rec *kgo.Record) error {
got = append(got, rec)
return err
}
t.Cleanup(func() { produce = orig })
return &got
}
// replayWire is a minimal well-formed snapshot batch for org "acme".
const replayWire = `{"sessionId":"sess-01","windowId":"win-01","distinctId":"person-7",` +
`"events":[{"type":4,"timestamp":1785951969399,"data":{"href":"https://hanzo.ai/"}}]}`
// header reads one header off a produced record. Absent is "" and the tests
// distinguish that from an empty value by asserting presence separately.
func header(rec *kgo.Record, key string) (string, bool) {
for _, h := range rec.Headers {
if h.Key == key {
return string(h.Value), true
}
}
return "", false
}
// ── 1. the wire contract ────────────────────────────────────────────────────
// TestSnapshotRecordIsTheWireContract pins EVERY byte of the produced message that
// the out-of-repo consumer reads: the topic, the partition key, all seven headers,
// the envelope, and the fact that `data` is a JSON STRING which parses a second time
// into the inner document with the rrweb events verbatim.
//
// It drives the PURE builder, so the id and the clock are inputs rather than
// ambient — the assertion is exact rather than "looks about right".
func TestSnapshotRecordIsTheWireContract(t *testing.T) {
// A quote-dense rrweb event, chosen so a `data` that was NOT double-encoded
// would decode differently rather than coincidentally the same.
item := `{"type":2,"timestamp":1785951969399,"data":{"node":{"tagName":"div","attributes":{"class":"a \"b\""}}}}`
in := replayBody{
SessionID: "sess-01",
WindowID: "win-01",
DistinctID: "person-7",
Events: []json.RawMessage{json.RawMessage(item)},
}
now := time.Date(2026, 8, 5, 17, 46, 9, 399_000_000, time.UTC)
rec, err := snapshotRecord(in, "acme", "203.0.113.9", "0d1a5f4e-7c62-4a5b-9c31-2f0e6b8d4a11", now)
if err != nil {
t.Fatalf("snapshotRecord: %v", err)
}
if rec.Topic != "session_recording_snapshot_item_events" {
t.Errorf("topic = %q, want session_recording_snapshot_item_events", rec.Topic)
}
// The partition key is the session id, RAW — it is what keeps every batch of one
// recording on one partition and in order.
if string(rec.Key) != "sess-01" {
t.Errorf("partition key = %q, want the raw session id %q", rec.Key, "sess-01")
}
// Every header, by name and by value. The `org` one is load-bearing: the
// consumer resolves the project from that HEADER, so a message missing it is
// dropped with no error at either end.
for _, want := range []struct{ key, value string }{
{"org", "acme"},
{"distinct_id", "person-7"},
{"session_id", "sess-01"},
{"timestamp", strconv.FormatInt(now.UnixMilli(), 10)},
{"event", "$snapshot_items"},
{"uuid", "0d1a5f4e-7c62-4a5b-9c31-2f0e6b8d4a11"},
{"now", "2026-08-05T17:46:09.399Z"},
} {
got, present := header(rec, want.key)
if !present {
t.Errorf("header %q is ABSENT — the consumer reads the tenant and the identity off these", want.key)
continue
}
if got != want.value {
t.Errorf("header %q = %q, want %q", want.key, got, want.value)
}
}
if len(rec.Headers) != 7 {
t.Errorf("produced %d headers, want exactly 7", len(rec.Headers))
}
// The OUTER envelope.
var env snapshotEnvelope
if err := json.Unmarshal(rec.Value, &env); err != nil {
t.Fatalf("envelope json: %v (%s)", err, rec.Value)
}
for _, f := range []struct{ name, got, want string }{
{"uuid", env.UUID, "0d1a5f4e-7c62-4a5b-9c31-2f0e6b8d4a11"},
{"distinct_id", env.DistinctID, "person-7"},
{"ip", env.IP, "203.0.113.9"},
{"now", env.Now, "2026-08-05T17:46:09.399Z"},
{"event", env.Event, "$snapshot_items"},
{"timestamp", env.Timestamp, "2026-08-05T17:46:09.399Z"},
} {
if f.got != f.want {
t.Errorf("envelope %s = %q, want %q", f.name, f.got, f.want)
}
}
// THE ENVELOPE CARRIES NO TENANT. The org is in the header, in one copy. A
// `token` field reappearing here would be the second credential this path was
// built to not have, so its ABSENCE is asserted rather than assumed.
var envRaw map[string]json.RawMessage
if err := json.Unmarshal(rec.Value, &envRaw); err != nil {
t.Fatalf("envelope raw: %v", err)
}
for _, gone := range []string{"token", "org"} {
if _, present := envRaw[gone]; present {
t.Errorf("envelope carries %q — the tenant travels in the header, in ONE copy", gone)
}
}
// `data` IS DOUBLE-ENCODED. Proving it takes two steps that cannot both pass
// unless it really is a string carrying JSON: the raw field must be a JSON
// string (not an object), and its contents must parse on their own.
var raw map[string]json.RawMessage
if err := json.Unmarshal(rec.Value, &raw); err != nil {
t.Fatalf("envelope raw json: %v", err)
}
if b := raw["data"]; len(b) == 0 || b[0] != '"' {
t.Fatalf("envelope data is not a JSON STRING (starts %q) — the consumer parses it a second "+
"time, so an inlined object is a message it cannot read", string(b[:min(len(b), 12)]))
}
var doc snapshotData
if err := json.Unmarshal([]byte(env.Data), &doc); err != nil {
t.Fatalf("inner document json: %v (%s)", err, env.Data)
}
if doc.Event != "$snapshot_items" {
t.Errorf("inner event = %q, want $snapshot_items", doc.Event)
}
p := doc.Properties
for _, f := range []struct{ name, got, want string }{
{"distinct_id", p.DistinctID, "person-7"},
{"$session_id", p.SessionID, "sess-01"},
{"$window_id", p.WindowID, "win-01"},
{"$snapshot_source", p.SnapshotSource, "web"},
{"$lib", p.Lib, "@hanzo/replay"},
} {
if f.got != f.want {
t.Errorf("inner properties %s = %q, want %q", f.name, f.got, f.want)
}
}
// The rrweb events survive VERBATIM — the ingester derives the whole summary
// (click/keypress/mouse-activity counts, size) from exactly these bytes.
if len(p.SnapshotItems) != 1 {
t.Fatalf("$snapshot_items has %d entries, want 1", len(p.SnapshotItems))
}
if got := string(p.SnapshotItems[0]); got != item {
t.Errorf("$snapshot_items[0] = %s\nwant (byte-identical) %s", got, item)
}
}
// TestSnapshotItemsIsAFlatArray guards the shape the consumer walks: the events go
// in as a flat array of rrweb objects, never wrapped in a per-batch envelope of
// their own.
func TestSnapshotItemsIsAFlatArray(t *testing.T) {
in := replayBody{SessionID: "s", Events: []json.RawMessage{
json.RawMessage(`{"type":3,"timestamp":1,"data":{}}`),
json.RawMessage(`{"type":4,"timestamp":2,"data":{}}`),
}}
rec, err := snapshotRecord(in, "acme", "", "id", time.Unix(0, 0).UTC())
if err != nil {
t.Fatalf("snapshotRecord: %v", err)
}
var env snapshotEnvelope
if err := json.Unmarshal(rec.Value, &env); err != nil {
t.Fatalf("envelope: %v", err)
}
var probe struct {
Properties struct {
Items []struct {
Type int `json:"type"`
} `json:"$snapshot_items"`
} `json:"properties"`
}
if err := json.Unmarshal([]byte(env.Data), &probe); err != nil {
t.Fatalf("inner: %v", err)
}
if len(probe.Properties.Items) != 2 ||
probe.Properties.Items[0].Type != 3 || probe.Properties.Items[1].Type != 4 {
t.Errorf("$snapshot_items = %v, want a FLAT array of the two rrweb events in order",
probe.Properties.Items)
}
}
// ── 2. sessionId validation ─────────────────────────────────────────────────
// TestValidSessionID is the grammar, exactly as the pipeline applies it. The id is
// the PARTITION KEY, so one this accepts and the far end refuses is a message
// produced into a partition and then dropped — reported to the caller as success.
func TestValidSessionID(t *testing.T) {
for _, tc := range []struct {
name string
id string
want bool
}{
{"simple", "abc123", true},
{"with dashes", "sess-01-abc", true},
{"uuid shaped", "0d1a5f4e-7c62-4a5b-9c31-2f0e6b8d4a11", true},
{"upper case", "ABC-123", true},
{"single char", "a", true},
{"exactly 70", strings.Repeat("a", 70), true},
{"empty", "", false},
{"71 chars", strings.Repeat("a", 71), false},
{"underscore", "sess_01", false},
{"dot", "sess.01", false},
{"slash", "sess/01", false},
{"space", "sess 01", false},
{"newline", "sess\n01", false},
{"null byte", "sess\x0001", false},
{"non-ascii", "sessión", false},
{"emoji", "sess-🎬", false},
{"colon", "sess:01", false},
{"plus", "sess+01", false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := validSessionID(tc.id); got != tc.want {
t.Errorf("validSessionID(%q) = %v, want %v", tc.id, got, tc.want)
}
})
}
}
// TestSessionIDLengthIsBytesNotRunes is the subtle half of the bound. Ranging a Go
// string yields RUNES, so a rune-counted limit would admit an id of 70 multi-byte
// characters — 210 bytes at the far end, which applies the limit to bytes. The
// non-ASCII refusal makes it moot today and the assertion keeps it moot if the
// character rule is ever widened.
func TestSessionIDLengthIsBytesNotRunes(t *testing.T) {
if validSessionID(strings.Repeat("é", 70)) {
t.Error("a 70-rune / 140-byte id was accepted — the downstream bound is in BYTES")
}
}
// TestReplayRefusesABadSessionID drives the same grammar through the DOOR, so the
// refusal is a 400 on the wire and not merely a false from a pure function.
func TestReplayRefusesABadSessionID(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
for _, id := range []string{"", "sess_01", "sess/01", strings.Repeat("a", 71), "sess 01"} {
body := `{"sessionId":` + strconv.Quote(id) + `,"events":[{"type":4,"timestamp":1,"data":{}}]}`
if code, resp := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", body); code != http.StatusBadRequest {
t.Errorf("sessionId %q = %d (%s), want 400", id, code, resp)
}
}
if len(*got) != 0 {
t.Errorf("a refused sessionId still produced %d record(s) — a key the pipeline "+
"rejects must never reach the topic", len(*got))
}
}
// ── 3. the credential gate ──────────────────────────────────────────────────
// TestReplayRefusesTheAnonymousCaller: no credential of any kind, on a recognized
// brand host and on the API host, is 401 ingest_key_required — the SAME refusal
// every ingest door answers, because this door shares their resolver. Nothing is
// produced.
func TestReplayRefusesTheAnonymousCaller(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
for _, host := range []string{"hanzo.ai", "api.hanzo.ai"} {
code, body := doHost(t, app, replayPath, "", "", host, replayWire)
refusedAnon(t, "anonymous "+replayPath+" on host "+host, code, body)
}
if len(*got) != 0 {
t.Errorf("an anonymous caller produced %d record(s) — a recording nobody can attribute "+
"must not reach the topic", len(*got))
}
}
// TestReplayFailsClosedOnUnresolvableCredential: a caller that PRESENTED a
// credential which does not resolve is 403 on every carrier — never downgraded, and
// never produced. This is the same fail-closed rule doors_test.go holds over the
// doors table, asserted here because this route is not in that table.
func TestReplayFailsClosedOnUnresolvableCredential(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "", false })
for _, hdr := range []map[string]string{
{"x-api-key": "sk-nosuch"},
{"Authorization": "Bearer pk-nosuch"},
{"x-hanzo-ingest-key": "pk-nosuch"},
} {
if code := postKeyed(t, app, replayPath, "hanzo.ai", replayWire, hdr); code != http.StatusForbidden {
t.Errorf("unresolvable credential %v = %d, want 403 (fail closed)", hdr, code)
}
}
if len(*got) != 0 {
t.Errorf("an unresolvable credential produced %d record(s)", len(*got))
}
}
// TestReplayAdmitsAResolvedKey is the "the gate is not just a wall" half: a
// resolvable sk- and a resolvable pk- both reach the produce.
func TestReplayAdmitsAResolvedKey(t *testing.T) {
for _, hdr := range []map[string]string{
{"x-api-key": "sk-good"},
{"Authorization": "Bearer pk-good"},
{"x-hanzo-ingest-key": "pk-good"},
} {
got := fakeProducer(t, nil)
app := mountApp(t)
stubResolver(t, func(string) (string, bool) { return "acme", true })
if code := postKeyed(t, app, replayPath, "", replayWire, hdr); code != http.StatusOK {
t.Errorf("resolvable %v = %d, want 200", hdr, code)
}
if len(*got) != 1 {
t.Errorf("resolvable %v produced %d record(s), want 1", hdr, len(*got))
}
}
}
// TestReplayTakesTheKeyOffTheQueryCarrier is the sendBeacon case, and it is the
// reason this door cannot be a typed op: a recorder drains its buffer on page-unload
// through navigator.sendBeacon, which cannot set a header, so the key arrives as
// ?ingest_key= — a carrier a typed In never sees.
func TestReplayTakesTheKeyOffTheQueryCarrier(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
key := stubResolver(t, func(string) (string, bool) { return "acme", true })
if code := postKeyed(t, app, replayPath+"?ingest_key=pk-beacon", "", replayWire, nil); code != http.StatusOK {
t.Fatalf("?ingest_key= = %d, want 200", code)
}
if *key != "pk-beacon" {
t.Errorf("resolver was handed %q, want the key off the query carrier", *key)
}
if len(*got) != 1 {
t.Fatalf("produced %d record(s), want 1", len(*got))
}
}
// TestReplayTenantIsTheCredentialsNeverTheBody: the org that goes on the wire is the
// one the CREDENTIAL resolved to. A body naming another org — or carrying a `token`
// field, the credential this path no longer has — cannot move it.
func TestReplayTenantIsTheCredentialsNeverTheBody(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
body := `{"sessionId":"sess-01","distinctId":"d","org":"victim","token":"hi_victim",` +
`"events":[{"type":4,"timestamp":1,"data":{}}]}`
if code, resp := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", body); code != http.StatusOK {
t.Fatalf("= %d (%s), want 200", code, resp)
}
if len(*got) != 1 {
t.Fatalf("produced %d record(s), want 1", len(*got))
}
org, _ := header((*got)[0], "org")
if org != "acme" {
t.Errorf("org header = %q, want acme — the tenant is the CREDENTIAL's, and a body "+
"naming another org must not reach another tenant's stream", org)
}
// The body's `token` must not survive anywhere on the wire: it is a caller-supplied
// string, and this path has exactly one tenant fact, which the caller does not set.
if _, present := header((*got)[0], "token"); present {
t.Error("a token header was produced — this path carries no credential to the ingester")
}
if strings.Contains(string((*got)[0].Value), "hi_victim") {
t.Error("the body's token reached the message value")
}
}
// ── 4. the tenant on the wire ───────────────────────────────────────────────
// TestProducedOrgIsTheCredentialsOrg: two orgs, two credentials, and each reaches
// only its own — with NOTHING configured for either. That is the point of the
// change this test replaced: there is no mapping to be missing, so an org cannot be
// "not configured for replay" and there is no deployment-side edit to forget when a
// tenant arrives. Authentication is the whole of admission.
func TestProducedOrgIsTheCredentialsOrg(t *testing.T) {
for _, org := range []string{"acme", "globex"} {
got := fakeProducer(t, nil)
app := mountApp(t)
if code, body := doBody(t, app, http.MethodPost, replayPath, "user-dave", org, replayWire); code != http.StatusOK {
t.Fatalf("org %s = %d (%s), want 200", org, code, body)
}
if len(*got) != 1 {
t.Fatalf("org %s produced %d record(s), want 1", org, len(*got))
}
if h, _ := header((*got)[0], "org"); h != org {
t.Errorf("org %s produced org header %q, want %q", org, h, org)
}
}
}
// TestNoCredentialTravelsToTheIngester is the invariant the whole change is for: a
// produced message carries the tenant and no secret. A `hi_` anywhere in the bytes
// would mean a second credential had grown back onto this path.
func TestNoCredentialTravelsToTheIngester(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
if code, body := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", replayWire); code != http.StatusOK {
t.Fatalf("= %d (%s), want 200", code, body)
}
rec := (*got)[0]
if strings.Contains(string(rec.Value), "hi_") {
t.Errorf("the message value carries a project token: %s", rec.Value)
}
for _, h := range rec.Headers {
if h.Key == "token" || strings.HasPrefix(string(h.Value), "hi_") {
t.Errorf("header %q = %q — no credential belongs on this wire", h.Key, h.Value)
}
}
}
// ── 5. bounds, and the produce failure ──────────────────────────────────────
// TestReplayBoundsAreRefusedNotTruncated: over either bound the request is refused
// whole. A truncated recording would be unplayable and the receipt would be a lie.
func TestReplayBoundsAreRefusedNotTruncated(t *testing.T) {
t.Run("oversize body is 413", func(t *testing.T) {
got := fakeProducer(t, nil)
app := mountApp(t)
// One event whose data pushes the body past the cap.
big := `{"sessionId":"sess-01","events":[{"type":2,"timestamp":1,"data":{"blob":"` +
strings.Repeat("x", maxReplayBytes) + `"}}]}`
code, body := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", big)
if code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize body = %d (%s), want 413 — the status is what tells a recorder to chunk", code, body)
}
if len(*got) != 0 {
t.Errorf("an oversize body produced %d record(s)", len(*got))
}
})
t.Run("empty batch is 400", func(t *testing.T) {
app := mountApp(t)
fakeProducer(t, nil)
if code, resp := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme",
`{"sessionId":"sess-01","events":[]}`); code != http.StatusBadRequest {
t.Errorf("empty batch = %d (%s), want 400", code, resp)
}
})
t.Run("malformed body is 400", func(t *testing.T) {
app := mountApp(t)
fakeProducer(t, nil)
if code, resp := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme",
`{"sessionId":`); code != http.StatusBadRequest {
t.Errorf("malformed body = %d (%s), want 400", code, resp)
}
})
}
// TestProduceFailureIsA503NeverA200 is the receipt's honesty, and it is the whole
// reason the produce is synchronous. A batch that was admitted and could not be made
// durable is the caller's business: a 200 over it would be exactly the silent loss
// that `answer` (event.go) exists to prevent, and a recorder would never retry.
func TestProduceFailureIsA503NeverA200(t *testing.T) {
fakeProducer(t, errors.New("broker unreachable"))
app := mountApp(t)
code, body := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", replayWire)
if code != http.StatusServiceUnavailable {
t.Fatalf("failed produce = %d (%s), want 503", code, body)
}
if strings.Contains(string(body), `"accepted"`) {
t.Errorf("a failed produce answered a receipt (%s) — a recording that was not made "+
"durable must never be reported as accepted", body)
}
}
// TestReplayAnswersTheSharedReceipt: the happy path answers the SAME CaptureResult
// every other door on this surface answers, and one batch is one accepted unit.
func TestReplayAnswersTheSharedReceipt(t *testing.T) {
fakeProducer(t, nil)
app := mountApp(t)
code, body := doBody(t, app, http.MethodPost, replayPath, "user-dave", "acme", replayWire)
if code != http.StatusOK {
t.Fatalf("= %d (%s), want 200", code, body)
}
var got CaptureResult
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("receipt json: %v (%s)", err, body)
}
if got.Accepted != 1 || got.Dropped != 0 {
t.Errorf("receipt = %+v, want {Accepted:1 Dropped:0}", got)
}
}
// ── 6. the seam defaults to the real thing ──────────────────────────────────
// TestProduceSeamDefaultsToTheRealThing pins what fakeProducer substitutes, for the
// same reason TestWritePathSeamsDefaultToTheRealThing pins the write path's: a
// `produce` rebound at its DECLARATION to a func returning nil would discard every
// recording while each caller still got its 200, and nothing else in this package
// would notice.
func TestProduceSeamDefaultsToTheRealThing(t *testing.T) {
if !samePtr(produce, produceSnapshot) {
t.Error("produce does not default to produceSnapshot — a substituted produce seam " +
"discards recordings behind a 200 receipt")
}
}
+153
View File
@@ -0,0 +1,153 @@
// 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.
//go:build datastore_live
// EVERY SIGNAL, ONE DOOR — the live proof that each kind of fact is reachable from
// the one ingest contract and lands as exactly ONE row under its own signal.
//
// capture_live_test.go proves the act leg end-to-end (emit → fact → row → read
// lens). This file closes the set: error, log and span driven through the SAME
// door in one batch. The signal is `type` on the wire; routeOf (fact.go) is the
// whole routing rule; the writers table (warehouse.go) is the whole storage rule.
// Nothing in between gets an opinion, which is why one door serves every signal.
//
// It also pins what the per-signal reads depend on: a log is read by
// (org, service, time) and a span assembled by trace, so the row carries service
// and severity for the log and trace/span/parent/kind/status/duration for the
// span — and the two share a trace_id, the correlation the identical envelope
// buys. One fact never lands twice: the batch's four events produce exactly four
// rows, one per signal.
//
// Run:
//
// DATASTORE_ADDR=127.0.0.1:9000 DATASTORE_DB=hanzo \
// go test -tags datastore_live -run TestLiveEverySignal -v ./apps/analytics/
package analytics
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/apps/datastore"
)
// TestLiveEverySignalLandsItsOwnRow drives one fact of EACH landable signal
// through the one door and asserts each landed as one row under its own signal.
func TestLiveEverySignalLandsItsOwnRow(t *testing.T) {
ready, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := datastore.Wait(ready); err != nil {
t.Fatalf("datastore did not connect (set DATASTORE_ADDR=127.0.0.1:9000 with a live Datastore): %v", err)
}
ctx := context.Background()
requirePlane(t, ctx)
landDirect(t)
org := "acme-sig-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
app := liveApp(t)
// One batch, four signals. The wire says which; nothing else does.
body := `{"batch":[
{"type":"page","event":"page_viewed","distinctId":"u-1","product":"console","path":"/pricing"},
{"type":"error","distinctId":"u-1","product":"console","service":"web",
"error":{"type":"RangeError","message":"out of range","stack":"at f (https://app.test/a.js:2:1)"}},
{"type":"log","event":"request.served","distinctId":"u-1","product":"console",
"service":"gateway","traceId":"trace-abc","spanId":"span-1",
"log":{"severity":"info","number":9,"body":"served 200 in 12ms"}},
{"type":"span","event":"GET /v1/event","distinctId":"u-1","product":"console",
"service":"gateway",
"span":{"trace":"trace-abc","id":"span-2","parent":"span-1",
"kind":"server","duration":12000000,"status":"OK"}}
]}`
code, resp := livePost(t, app, canonDoor, "u-1", org, body)
if code != http.StatusOK {
t.Fatalf("POST %s = %d (%s)", canonDoor, code, resp)
}
t.Logf("ingest receipt: %s", strings.TrimSpace(string(resp)))
row := func(signal string) map[string]any {
t.Helper()
rows, err := datastore.Query(ctx,
"SELECT name, kind, message, severity, duration, service, trace_id, span_id, parent, status, class, issue "+
"FROM "+factTable+" WHERE org = ? AND signal = ?", org, signal)
if err != nil {
t.Fatalf("readback %s: %v", signal, err)
}
if len(rows) != 1 {
t.Fatalf("signal %q has %d rows for org %s, want exactly 1 — a fact did not land, or landed twice", signal, len(rows), org)
}
return rows[0]
}
act := row("act")
t.Logf("── act landed ── name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
if aString(act["name"]) != "page_viewed" || aString(act["kind"]) != "page" {
t.Fatalf("act row mismatch: name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
}
er := row("error")
t.Logf("── error landed ── class=%q message=%q issue=%q", aString(er["class"]), aString(er["message"]), aString(er["issue"]))
if aString(er["class"]) != "RangeError" || aString(er["message"]) != "out of range" {
t.Fatalf("error columns did not land: class=%q message=%q", aString(er["class"]), aString(er["message"]))
}
if aString(er["issue"]) == "" {
t.Fatal("no issue fingerprint — the error cannot be grouped, and grouping is the read")
}
lr := row("log")
t.Logf("── log landed ── service=%q severity=%v message=%q trace=%q",
aString(lr["service"]), lr["severity"], aString(lr["message"]), aString(lr["trace_id"]))
if aString(lr["service"]) != "gateway" {
t.Fatalf("service = %q, want gateway — the (org, service, time) log read depends on it", aString(lr["service"]))
}
if aInt64(lr["severity"]) != 9 {
t.Fatalf("severity did not land: %v", lr["severity"])
}
if aString(lr["message"]) != "served 200 in 12ms" {
t.Fatalf("log body did not land: %q", aString(lr["message"]))
}
sr := row("span")
t.Logf("── span landed ── kind=%q trace=%q span=%q parent=%q duration=%v status=%q",
aString(sr["kind"]), aString(sr["trace_id"]), aString(sr["span_id"]),
aString(sr["parent"]), sr["duration"], aString(sr["status"]))
if aString(sr["trace_id"]) == "" {
t.Fatal("no trace_id — assembling the trace is the read")
}
// kind is the discriminator WITHIN a signal: track|page|identify|group on an
// act, the OTel span kind here. One column, not two.
if aString(sr["kind"]) != "server" {
t.Fatalf("kind = %q, want server — the span kind IS the envelope's kind column", aString(sr["kind"]))
}
if aString(sr["span_id"]) != "span-2" || aString(sr["parent"]) != "span-1" {
t.Fatalf("span identity did not land: id=%q parent=%q", aString(sr["span_id"]), aString(sr["parent"]))
}
// status is a small named set, normalized to lower case, so `status = 'error'`
// says what it means where a status_code = 2 needed a comment.
if aInt64(sr["duration"]) != 12000000 || aString(sr["status"]) != "ok" {
t.Fatalf("span outcome did not land: duration=%v status=%q", sr["duration"], aString(sr["status"]))
}
// The log and the span share a trace: the correlation the identical envelope buys.
if aString(lr["trace_id"]) != aString(sr["trace_id"]) {
t.Fatalf("log trace %q != span trace %q", aString(lr["trace_id"]), aString(sr["trace_id"]))
}
// One fact, one row: four signals in, exactly four rows out.
total, err := datastore.Query(ctx, "SELECT count() AS n FROM "+factTable+" WHERE org = ?", org)
if err != nil || len(total) == 0 {
t.Fatalf("count readback: %v", err)
}
if n := aInt64(total[0]["n"]); n != 4 {
t.Fatalf("%s has %d rows for org %s, want exactly 4 — a signal landed twice or not at all", factTable, n, org)
}
t.Logf("LIVE OK: one door, four signals — act/error/log/span each landed exactly one row (org=%s)", org)
}
+64
View File
@@ -181,5 +181,69 @@
window.__hanzoEvent = true
window.hanzo = { track: track, identify: identify, page: page, error: error, flush: flush }
// ── config-driven tag injection (the hosted twin of track.js) ─────────────
// Fetch the SITE's connected browser pixels (/v1/tags, dual-resolved per site by
// this key or the host) and inject them first-party. Every event then also fires
// the native pixel — translated through the SAME taxonomy the server-side CAPI uses
// (destinations/translate.go), stamped with a shared event_id carried on the
// /v1/event row too — so the browser pixel and the server CAPI DEDUPLICATE instead
// of double-counting. Keyless already returned above, so this only runs when keyed.
var STD = {
$pageview: 'page_view', pricing_viewed: 'view_content', signup_viewed: 'view_content',
product_viewed: 'view_content', plan_clicked: 'lead', signup_submitted: 'lead',
waitlist_joined: 'lead', referral_used: 'lead', signup_completed: 'signup',
product_added: 'add_to_cart', add_to_cart: 'add_to_cart', checkout_started: 'start_checkout',
begin_checkout: 'start_checkout', order_completed: 'purchase', purchase: 'purchase'
}
var NATIVE = {
ga: { page_view: 'page_view', view_content: 'view_item', add_to_cart: 'add_to_cart', lead: 'generate_lead', signup: 'sign_up', start_checkout: 'begin_checkout', purchase: 'purchase' },
meta: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'Purchase' },
tiktok: { page_view: 'Pageview', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'SubmitForm', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'CompletePayment' },
x: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'SignUp', start_checkout: 'InitiateCheckout', purchase: 'Purchase' }
}
function nativeName(t, n) { var s = STD[n]; return s ? (NATIVE[t] || {})[s] || null : null }
function loadJS(u) { var e = document.createElement('script'); e.async = true; e.src = u; document.head.appendChild(e) }
function eid() { try { return crypto.randomUUID() } catch (e) { return 'e-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10) } }
var INJECT = {
ga: {
load: function (id) { loadJS('https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id)); window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function () { dataLayer.push(arguments) }; gtag('js', new Date()); gtag('config', id) },
fire: function (n, p, id) { if (!window.gtag) return; var name = nativeName('ga', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.event_id = id; if (STD[n] === 'purchase') o.transaction_id = id; gtag('event', name, o) }
},
meta: {
load: function (id) { if (!window.fbq) { var n = window.fbq = function () { n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments) }; if (!window._fbq) window._fbq = n; n.push = n; n.loaded = true; n.version = '2.0'; n.queue = []; loadJS('https://connect.facebook.net/en_US/fbevents.js') } fbq('init', id); fbq('track', 'PageView') },
fire: function (n, p, id) { if (!window.fbq) return; var name = nativeName('meta', n); if (name) fbq('track', name, p || {}, { eventID: id }); else fbq('trackCustom', n, p || {}) }
},
tiktok: {
load: function (id) { var q = window.ttq = window.ttq || []; if (!q.methods) { q.methods = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']; q.setAndDefer = function (t, e) { t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } }; for (var i = 0; i < q.methods.length; i++) q.setAndDefer(q, q.methods[i]); loadJS('https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=' + encodeURIComponent(id) + '&lib=ttq') } if (q.load) q.load(id); if (q.page) q.page() },
fire: function (n, p, id) { if (!window.ttq || !window.ttq.track) return; var name = nativeName('tiktok', n) || n; window.ttq.track(name, p || {}, { event_id: id }) }
},
x: {
load: function (id) { if (!window.twq) { var s = window.twq = function () { s.exe ? s.exe.apply(s, arguments) : s.queue.push(arguments) }; s.version = '1.1'; s.queue = []; loadJS('https://static.ads-twitter.com/uwt.js') } twq('config', id) },
fire: function (n, p, id) { if (!window.twq) return; var name = nativeName('x', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.conversion_id = id; twq('event', name, o) }
}
}
var active = []
try {
fetch(src.origin + '/v1/tags?key=' + encodeURIComponent(key))
.then(function (r) { return r.ok ? r.json() : { tags: [] } })
.then(function (cfg) {
var tags = (cfg && cfg.tags) || []
for (var i = 0; i < tags.length; i++) {
var t = tags[i]
if (INJECT[t.type]) { try { INJECT[t.type].load(t.id); active.push(t) } catch (e) {} }
}
if (active.length) {
var base = window.hanzo.track
window.hanzo.track = function (n, p) {
var id = eid()
for (var j = 0; j < active.length; j++) { try { INJECT[active[j].type].fire(n, p, id) } catch (e) {} }
var o = {}; if (p) for (var k in p) o[k] = p[k]; o.event_id = id
base(n, o)
}
}
})
.catch(function () {})
} catch (e) {}
page()
})()
+24 -4
View File
@@ -118,12 +118,14 @@ function anonOf(r) {
assert.strictEqual(ev.event, undefined, 'naming is resolveEventName server-side')
}
// 4. Without sendBeacon the fetch path carries the key as a bearer.
// 4. Without sendBeacon the fetch path carries the key as a bearer. (A keyed tag also
// fetches /v1/tags for its browser pixels; the /v1/event POST is the one asserted.)
{
const r = run({ 'data-key': 'pk-live-abc' }, { noBeacon: true })
r.fire('pagehide')
assert.strictEqual(r.sent.fetch.length, 1)
const init = r.sent.fetch[0].init
const posts = r.sent.fetch.filter((f) => f.url.indexOf('/v1/event') !== -1)
assert.strictEqual(posts.length, 1)
const init = posts[0].init
assert.strictEqual(init.headers.authorization, 'Bearer pk-live-abc')
assert.strictEqual(init.keepalive, true)
assert.ok(JSON.parse(init.body).batch.length === 1)
@@ -199,4 +201,22 @@ function anonOf(r) {
assert.strictEqual(err.error.handled, false)
}
console.log('tag.js: 10/10 behavioral checks passed')
// 11. Config-driven injection: a keyed tag fetches its site's browser pixels from
// /v1/tags (dual-resolved per site by this key or the host), so the hosted
// one-liner injects GA/Meta/TikTok/X first-party. The pixels + the deduping
// event_id are unit-tested in track.js; here we prove the hosted tag makes the
// request — and that a keyless tag never does.
{
const r = run({ 'data-key': 'pk-live-abc' })
assert.ok(
r.sent.fetch.some((f) => f.url.indexOf('/v1/tags?key=pk-live-abc') !== -1),
'keyed tag fetches /v1/tags for its browser pixels'
)
const keyless = run({})
assert.ok(
!keyless.sent.fetch.some((f) => f.url.indexOf('/v1/tags') !== -1),
'keyless tag never fetches /v1/tags'
)
}
console.log('tag.js: 11/11 behavioral checks passed')
+25 -22
View File
@@ -5,12 +5,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// typed_wire_test.go — the projection gate. Six of this package's thirteen
// operations are TYPED ops; the other seven are not, and each of those has a WIRE
// FACT that keeps it out. Both halves are MEASURED here rather than asserted in
// prose, because prose cannot go red: a route added untyped goes red without anyone
// remembering to name it, a reason naming a route this package no longer serves goes
// red too, and every refusal's wire is re-proved against the live router.
// typed_wire_test.go — the projection gate. The reads and the health probe are
// TYPED ops; the ingest doors are not, and each of those has a WIRE FACT that
// keeps it out. Both halves are MEASURED here rather than asserted in prose,
// because prose cannot go red: a route added untyped goes red without anyone
// remembering to name it, a reason naming a route this package no longer serves
// goes red too, and every refusal's bytes are re-proved against the live router.
package analytics
import (
@@ -33,13 +33,6 @@ import (
// written the way the DOCUMENT writes them, which is the identity every projection
// keys on.
var untypedByDesign = map[string]string{
"GET /v1/analytics/health": "answers 503 CARRYING the degraded report as its body " +
"(status/datastore/reason), and 200 with the per-lens table availability otherwise. zip stamps " +
"a non-nil Out with cmp.Or(op.Status, 200) and WithStatus refuses a non-2xx, so a typed op " +
"would turn the 503 into a 200; returning an error instead renders zip's flat " +
"{status,code,error} and drops the report. Writing the body from inside the op does not escape " +
"it either — a nil Out is stamped cmp.Or(op.Status, 204) over whatever was written.",
"GET /v1/event.js": "the tag is an ASSET, not an operation: its body is JavaScript and zip renders " +
"a typed Out as JSON, so there is no Out that can carry it. It also answers 304 with an empty " +
"body on a matching If-None-Match — WithStatus refuses a non-2xx and a nil Out is stamped " +
@@ -53,6 +46,16 @@ var untypedByDesign = map[string]string{
"POST /v1/event/{project}/store": "the Sentry error wire on the one event door: the body is a " +
"raw Sentry envelope stream and the credential is a DSN key the o11y consumer verifies itself " +
"(cloud.ObsErrorIngest) — no principal, no struct In, nothing for a typed op to say.",
"POST /v1/replay": "the session-replay snapshot door has a declarable BODY — unlike every other " +
"entry here — and is still not typable, because ADMISSION is what keeps it out. " +
admissionReason + " That is not academic on this door: it takes a publishable pk- on " +
"?ingest_key= (a recorder drains its buffer through navigator.sendBeacon on unload, which " +
"cannot set headers), and a typed op never sees the query string the credential arrived on. " +
"Its 413 has the same ORDER problem as the anonymous lane's: the RAW body length is refused " +
"before any decode, and zip's op.invoke decodes first, so a typed op would answer 400 to an " +
"oversized recording that is answered 413 today — and 413 is the one status that tells a " +
"recorder to chunk.",
}
const (
@@ -194,12 +197,10 @@ var proseless = map[string]bool{
"CaptureResult": true,
// The PostHog wire — now served on /v1/event, sniffed by decodeEvent.
"insightsBody": true, "insightsEvent": true,
// The health probe's report — the one body that is the same at 200 and 503 —
// including the event plane's own availability and the sink's irrecoverable-loss
// counters, which reach the document nested inside it through that same Register
// seam. Every one of these fields carries a doc comment in Go; reflection is what
// cannot see it.
"healthReport": true, "healthLenses": true, "healthLens": true, "healthPlane": true, "loss": true,
// The session-replay snapshot wire (replay.go). Its fields carry doc comments in
// Go like every other type here; it reaches the document through the same
// reflection-based Register seam, which cannot see them.
"replayBody": true,
}
// TestEveryPublishedFieldIsDescribed covers the RESPONSE side the op-level gate
@@ -397,9 +398,11 @@ func TestArrayBodiedDoorsStillAnswer200(t *testing.T) {
}
}
// TestHealthStillCarriesItsReportAt503 is the measurement behind the /v1/analytics/health
// refusal: the status and the body are ONE answer. A typed op can carry the body or
// the status, never both, and this is the pair it would have to break.
// TestHealthStillCarriesItsReportAt503 is the health conversion's parity proof:
// the status and the body are ONE answer. The typed op declares WithStatus(200,
// 503) and the report's own StatusCode picks between them, so the degraded answer
// keeps carrying the report — and this test is what goes red if a change ever
// drops it back to an error envelope.
func TestHealthStillCarriesItsReportAt503(t *testing.T) {
app := mountApp(t)
code, body := do(t, app, http.MethodGet, "/v1/analytics/health", "", "")
+40 -10
View File
@@ -84,6 +84,16 @@ const (
maxDeliver = 8
)
// consumeConcurrency is how many clients pull each durable at once. It bounds how
// many rows can be in flight for the store to coalesce into one async_insert block,
// which is what decides throughput here — not the size of any buffer we keep.
//
// Every puller holds one message and one open insert, so this is also the ceiling on
// concurrent inserts per signal. 16 is chosen against MaxAckPending (1000 by default,
// so nowhere near it) and against the store, which handles this many concurrent
// inserts comfortably while the materialized-view cascades overlap.
const consumeConcurrency = 16
// table is WHERE a signal lands and HOW a row is built for it: one name, one column
// list, one row builder. There are exactly TWO, because there are exactly two grains —
// an occurrence HAPPENED, a sample was MEASURED. Everything that used to differ per
@@ -424,16 +434,36 @@ func (s *drain) consume(ctx context.Context, cl *infra.PubSubClient) error {
}); err != nil {
return fmt.Errorf("create consumer %s: %w", durable, err)
}
// Contained: this runs over bus payloads, so a panic here would kill the
// process and every tenant with it rather than this one consumer.
cloud.Go(s.log, "event.sink", []any{"signal", string(w.signal)}, func() {
var once sync.Once
send := func(err error) { once.Do(func() { errc <- err }) }
defer send(fmt.Errorf("event sink: %s consumer panicked (recovered)", w.signal))
send(cl.ConsumeMessages(ctx, EventStream, durable, func(sm *infra.StreamMessage) error {
return s.land(ctx, w, sm)
}))
})
// One durable, MANY pullers. A JetStream pull consumer load-balances across
// every client bound to it, so this is concurrency without a second cursor:
// each message is still delivered once, and still acked only after ITS OWN
// row has landed. Redelivery, MaxDeliver and the abandoned-fact advisory all
// mean exactly what they meant with one puller.
//
// It is the fix for a throughput floor that had nothing to do with the store.
// The sink commits one row per message, and insertSettings asks the store to
// batch (async_insert) while wait_for_async_insert holds the call open until
// the block is durable. With a single puller those two are in deadlock: the
// one in-flight row IS the whole batch, so every insert pays a full
// materialized-view cascade to land it, and the next message cannot start
// until it finishes. Throughput collapses to 1/insert-latency — measured at
// ~24 rows/min against a ~2.5s cascade — regardless of how much is queued.
//
// Pulling concurrently gives async_insert real blocks to coalesce and lets
// the store overlap the cascades, so the ceiling becomes the store's rather
// than the round-trip's.
var once sync.Once
send := func(err error) { once.Do(func() { errc <- err }) }
for i := 0; i < consumeConcurrency; i++ {
// Contained: this runs over bus payloads, so a panic here would kill the
// process and every tenant with it rather than this one consumer.
cloud.Go(s.log, "event.sink", []any{"signal", string(w.signal), "puller", i}, func() {
defer send(fmt.Errorf("event sink: %s consumer panicked (recovered)", w.signal))
send(cl.ConsumeMessages(ctx, EventStream, durable, func(sm *infra.StreamMessage) error {
return s.land(ctx, w, sm)
}))
})
}
}
select {
case <-ctx.Done():
+35
View File
@@ -9,6 +9,29 @@ import (
)
func init() {
zip.Describe("GET /v1/analytics/health", zip.Doc{
Description: "Health reports whether the event plane can take a write and the warehouse can\nanswer a read.\n\nIt reports the analytics subsystem's own liveness in BOTH directions: plane is\nthe event plane it WRITES (the bus and the JetStream stream every accepted\nevent is published to, both named in the report), and datastore is the\nwarehouse it READS, with each read lens's table reported as it is provisioned\n(the LLM usage ledger and the product-event table).\n\nEITHER ONE DOWN IS A 503, and the report says WHICH — they are probed\nindependently and never collapse into a single bit. This endpoint used to\nreport the read half only, and answered 200/ok while every POST /v1/event\nfailed on a stream that could not bind: a total ingest outage behind a green\nprobe. A readiness gate here now gates on the write path too.\n\nplane.ready IS A REAL PROBE and walks the ingest path itself — the same\nconnection and the same stream a publish uses — so it cannot answer ready while\na publish would 503. plane.reason carries the plane's own error text when it is\nfalse.\n\ndatastore IS NOT PROBED WITH A QUERY. It is the state of the process's own\nshared client — established, and not since closed — so a warehouse accepting\nconnections and failing reads still reports true. Degraded CARRIES the report\n(status, the failing half, reason) as its body rather than an error envelope,\nso a gate reads the cause off the same object it got at 200.\n\nA MISSING LENS TABLE IS NOT A FAILURE and never moves the status: a lens\nreported available:false answers honest-empty rather than erroring, so a fresh\ndeployment whose collector has not emitted yet is legitimately 200 with the\nproduct-event lens unavailable. The lens block is reported whenever the\nwarehouse is REACHABLE — including on a report degraded by the plane, where the\ntables genuinely were probed — and is absent only when the warehouse is not,\nhaving nothing to say about tables it could not reach.\n\nUnauthenticated on purpose — liveness has to be probe-able — and it reads NO\ntenant data: table existence and stream presence only, never a row and never an\nevent.",
Fields: map[string]string{
"healthLens.available": "Available reports whether that table exists in the warehouse right now.",
"healthLens.table": "Table is the fully-qualified warehouse table the lens reads.",
"healthLenses.events": "Events is the web/commerce lens (event.fact, signal='act'), honest-empty until the\ncollector emits.",
"healthLenses.llm": "LLM is the live per-org usage ledger lens (hanzo.cloud_usage).",
"healthPlane.bus": "Bus is the address this process reaches the plane at.",
"healthPlane.ready": "Ready reports whether an ingest would succeed right now. False is a 503.",
"healthPlane.reason": "Reason is the plane's own failure text, present only when Ready is false.",
"healthPlane.stream": "Stream is the JetStream stream every signal lands on.",
"healthReport.datastore": "Datastore reports whether the shared warehouse client has a live connection.\nIt is load-bearing for the READ path: false is one of the two ways this\nanswers 503.",
"healthReport.lenses": "Lenses is per-lens table availability, probed only when connected — so it is\nabsent from a degraded report, which has nothing to say about tables it could\nnot reach.",
"healthReport.lost": "Lost is the count of facts the sink irrecoverably dropped since boot\n(warehouse.go). It is reported on the DEGRADED report too, and deliberately: a\nwarehouse that is unreachable is exactly when facts start failing their\ndeliveries, so suppressing the number here would hide it precisely when it\nmoves. ANY NON-ZERO VALUE IS AN ALARM — it counts data the door already\nanswered 200 for.",
"healthReport.plane": "Plane reports the event plane — the bus and the stream every accepted event is\npublished to BEFORE any of it reaches the warehouse. It is load-bearing for the\nWRITE path, and it is here because its absence was a real outage: this endpoint\nanswered 200/ok on warehouse connectivity alone while every POST /v1/event 503'd\non a stream that could not bind, so 100% ingest loss was invisible to monitoring.\nA probe that cannot see the write path cannot report the write path.",
"healthReport.reason": "Reason is the human-readable cause, present only on a degraded report.",
"healthReport.service": "Service names the subsystem answering, so a probe aggregating several health\nendpoints can attribute a degraded one.",
"healthReport.status": "Status is ok or degraded. Degraded is the 503 and means EITHER load-bearing\ndependency is down — the warehouse this subsystem reads, or the event plane it\nwrites. It is not moved by a missing lens table, which is honest-empty.",
"healthReport.warehouse": "Warehouse names the datastore database every lens reads.",
"loss.exhausted": "Exhausted counts facts the bus abandoned after maxDeliver failed inserts.",
"loss.undecodable": "Undecodable counts messages acked without landing because they did not parse.",
},
})
zip.Describe("GET /v1/analytics/overview", zip.Doc{
Description: "Overview returns the caller org's analytics KPIs for one time window. Three lenses\nover one warehouse: llm is the live per-org LLM usage ledger (requests, tokens,\nspend, models, providers, errors) and is always real; web (pageviews, visitors,\nsessions) and commerce (orders, revenue, AOV) read the product-event table and\nreport available=false rather than fabricating zeros when it holds nothing yet.\n\nThe org is the validated principal's — never a parameter — so a caller can only\never read its own tenant. 403 without a validated bearer, 400 on an unknown range,\n503 when the warehouse is unreachable.",
Fields: map[string]string{
@@ -162,4 +185,16 @@ func init() {
"insightsStatus.surface": "Surface is the path prefix this status covers: /v1/insights.",
},
})
zip.Describe("POST /event/capture", zip.Doc{
Description: "Captures ONE occurrence onto the calling organisation's own event plane — the\nsame plane the HTTP door fills and /v1/insights reads back, through the SAME\nwrite core.\n\nThe organisation is the CALLER's, minted from the plane principal and never\nfrom this body, which carries no field that could name one. A peer that states\nno principal writes nothing: an unidentified caller has no partition, and\ndefaulting one would be a shared store with a tenant anybody can reach.\n\nA name is REQUIRED and is refused rather than defaulted. Every other route into\nthis core has a server-chosen default name for the signal it carries (a page\nview, an error, a span); a peer's occurrence is a tracked act, and a tracked\nact with no name is unroutable by the plane's own rule — so the refusal is the\nnormalizer's, said at the boundary where the caller can still be told.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
Fields: map[string]string{
"EventCaptured.accepted": "Accepted is how many occurrences were admitted and published.",
"EventCaptured.dropped": "Dropped is how many were not, because nothing about them named a landable\nrow.",
"EventIn.at": "At is when it happened, RFC 3339. Empty means now, decided by the app that\nowns the plane (which is also the one that clamps a stated time).",
"EventIn.attributes": "Attributes are the occurrence's own facts, as [Signal] — the plane's ONE\nname/value pair — because a map cannot cross this plane at all (see\n[Header], which learned it the expensive way). They land in the row's\nattributes map, so a new fact is a new value and never a schema change.",
"EventIn.name": "Name is what happened, in the emitter's own verb-object vocabulary\n(risk_decided, secret_rotated). It is the column every lens groups by, so it\nmust come from a CLOSED set the emitter owns — never from a string a caller\nof the emitter chose, which is unbounded cardinality in the one column the\nplane indexes.",
"EventIn.product": "Product is the emitting SURFACE — the app's own name. Empty attributes the\nrow to no surface, which is honest and unhelpful.",
"EventIn.subject": "Subject is whom the occurrence concerned, as the value the emitter is\nwilling to have stored: the row's distinct id. An identifying value belongs\nhere only in an opaque form — the plane is a SHARED store read by every lens\nthe organisation has, not the emitter's private record.",
},
})
}
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := ask
include ../../mk/plugin.mk
+40 -17
View File
@@ -139,23 +139,31 @@ func init() {
"under the caller's own credentials, and only then is the result narrated. So the "+
"figures and their sources are the domain's, resolved before any model call and never "+
"altered by one — a wrong answer is a wrong query, never an invention.\n\n"+
"Domains: books (the org's ledger) and web (search, news, research, deep). A validated "+
"principal is required; the answer is scoped to that principal's org and nothing else.")
"Domains: books (the org's ledger), projects (what is built and what of it is deployed), "+
"git (the org's repositories and what changed in them), and web (search, news, research, "+
"deep). A validated principal is required; the answer is scoped to that principal's org "+
"and nothing else.")
}
// Mount wires POST /v1/ask into cloud, building the contributor registry (books today) over the
// SAME app so a contributor's Gather replays a domain's grounded read in-process. The narration
// model comes from deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the
// ai /v1/* catch-all.
// Mount wires POST /v1/ask into cloud, building the contributor registry from domains() —
// every domain a PEER asked over the internal plane, because this app ships as its own
// process and the domains it grounds in do not run in it. The narration model comes from
// deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the ai /v1/*
// catch-all.
//
// app is not handed to the registry: a contributor reaches its domain by NAME over the
// plane, so there is nothing for it to do with this process's router. Keeping the router
// out of the seam is what makes "which process owns that data" stop being the advisor's
// problem.
func Mount(app cloud.Router, deps cloud.Deps) error {
b := cloud.NewBase(deps, "ask")
svc := &cloud.Service[*state]{Base: b, State: &state{
registry: NewRegistry(newBooksContributor(app)),
registry: NewRegistry(domains()...),
ai: deps.AI,
model: strings.TrimSpace(deps.AIDefaultModel),
model: cloud.DefaultModel,
}}
app.Post("/v1/ask", cloud.Handle(svc, askHandler))
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,web", "web_modes", "search,news,research,deep")
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,projects,git,web", "web_modes", "search,news,research,deep")
return nil
}
@@ -196,10 +204,19 @@ func askHandler(s *cloud.Service[*state], c *zip.Ctx) error {
return askJSON(c, honestFallback())
}
// Gather the REAL figures in-process, under the caller's OWN credentials (so the read is
// scoped to the caller's org and no other). A gather error degrades to the honest fallback —
// never a guessed number.
facts, sources, err := domain.Gather(c.Context(), credential(c))
// Gather the REAL figures from the domain, AS THIS CALLER — so the read is scoped to the
// caller's own org and no other. A gather error degrades to the honest fallback — never a
// guessed number.
//
// cloud.As(c, "") and not c.Context(), and the difference is the whole tenancy story on
// this path. This is an UNTYPED handler: zip attaches the in-flight request to the context
// it hands a TYPED op, not to this one, so c.Context() answers "nobody is calling" and a
// domain that correctly refuses an anonymous read would refuse every question ever asked.
// As carries THIS request's principal — read off the headers the edge already validated,
// which is also what the gate above just checked — onto a context the peer can read it
// from. The empty org argument is "keep the caller's own tenant": there is no widening
// here, and no place for one, because a question is only ever asked about the asker.
facts, sources, err := domain.Gather(cloud.As(c, ""), credential(c))
if err != nil {
s.Log.Warn("ask gather failed", "domain", domain.Name(), "err", err)
return askJSON(c, honestFallback())
@@ -305,22 +322,28 @@ func templateAnswer(facts []Fact) string {
// figure the advisor cannot ground is a figure it must not state.
func honestFallback() askAnswer {
return askAnswer{
Answer: "I can answer questions about your finances today — MRR, revenue, burn, runway, margin, cash, and P&L. Infra and usage advisors are coming.",
Answer: "I can answer questions about your finances (MRR, revenue, burn, runway, margin, cash, P&L), " +
"your projects and what of them is deployed, and your repositories and what changed in them.",
Figures: []Fact{},
Followups: []string{"What's my MRR?", "How long is my runway?", "What is my gross margin?"},
Followups: []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"},
Sources: []string{},
Domain: "",
}
}
// followups returns sharp next questions for a domain — deterministic, so the advisor always
// offers a path forward. Extended per domain as new contributors join.
// offers a path forward. One case per contributor; the default is the cross-domain menu, which
// is also what a caller sees when no domain matched.
func followups(domain string) []string {
switch domain {
case "books":
return []string{"How long is my runway?", "What is my gross margin?", "How much of revenue is recurring?"}
case "projects":
return []string{"Which projects are live?", "What did I deploy most recently?", "How many repositories do I have?"}
case "git":
return []string{"Which repositories changed recently?", "What have I deployed?", "How much code do I have?"}
default:
return []string{"What's my MRR?", "How long is my runway?"}
return []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"}
}
}
+188 -63
View File
@@ -1,17 +1,26 @@
package ask
// ask_test.go — proofs for the unified grounded advisor. The whole point is GROUNDING: every
// figure the advisor states is a REAL value read from a domain endpoint in-process; the model
// only narrates the figures it is handed and can NEVER override one. These tests stand up a fake
// in-process app whose /v1/books/metrics returns known figures scoped to the org the replay
// carries, plus a recording fakeAI, and assert:
// figure the advisor states is a REAL value read from a domain, over the internal plane; the
// model only narrates the figures it is handed and can NEVER override one.
//
// 1. a financial question returns the REAL figure the books read produced, cited in sources;
// 2. the model is fed the EXACT figure (the prompt contains it) and a hallucinated number in the
// model's reply does NOT override the grounded figure the caller receives;
// The domains here are STAND-IN PEERS, not stand-in transports: each test registers a real op
// on the real plane under the domain's real app name and operation id, so the advisor reaches
// them through the same generated client (plane/books, plane/projects, plane/git) it uses in
// production. What the fakes replace is the STORE behind the op, never the path to it — which
// is the distinction the previous version of this file got wrong. It faked the transport too,
// mounting /v1/books/metrics on the advisor's own router, and so it passed for months while
// production answered every question from the fallback: the advisor ships as its own process
// and never had that route.
//
// They assert:
//
// 1. a financial question returns the REAL figure the books peer produced, cited in sources;
// 2. the model is fed the EXACT figure and a hallucinated number does NOT override it;
// 3. a non-groundable question returns the honest fallback with ZERO fabricated figures;
// 4. org isolation — the replay carries the CALLER's org, so a books read can only ever surface
// the caller's own org's data, never another's.
// 4. org isolation — the peer is answered for the CALLER's org, so a domain read can only
// ever surface the caller's own org's data, never another's;
// 5. every wired domain — books, projects, git — actually contributes.
import (
"context"
@@ -19,11 +28,13 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
@@ -46,40 +57,89 @@ func (r *recordingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float
return nil, nil
}
// fakeBooks mounts a stand-in GET /v1/books/metrics that returns figures scoped to the org it
// SEES on the request — the same principal.Org gate the real books read uses. A figure is tagged
// with the org, so a test can prove the advisor only ever surfaces the caller's own org's data.
// This is the grounded read the books contributor replays in-process.
func fakeBooks(app *zip.App, mrrByOrg map[string]string) {
app.Get("/v1/books/metrics", func(c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in")
}
mrr, seen := mrrByOrg[org]
if !seen {
mrr = "$0"
}
return c.JSON(http.StatusOK, map[string]any{
"figures": []Fact{
{Label: "MRR", Value: mrr, Period: "2026-07"},
{Label: "org-echo", Value: org, Period: "2026-07"},
},
})
})
// byOrg is a stand-in domain store: the figures each org holds. A peer built over it answers
// for the org the CALLER was, which is what makes the isolation proof mean something.
type byOrg map[string][]plane.Figure
// peer declares one stand-in domain on the real plane, under the real app name and the real
// operation id — so plane.Ask resolves it exactly as it resolves the live app.
//
// The handler derives the org the SAME way every real figures op does (cloud.Who(ctx).Org,
// anonymous refused). Nothing in the test hands it an org: it reads the one zip carried from
// the advisor's own in-flight request, which is the mechanism under proof.
//
// Declaring and SERVING are separate steps because the plane app freezes the first time it
// listens: every op has to be on it before any socket is bound, which is the same order Serve
// uses in production (mount everything, then bind).
func peer(app, path, opID string, data byOrg) {
zip.Post[plane.FiguresIn, plane.FiguresOut](cloud.Plane(), path,
func(ctx context.Context, _ *plane.FiguresIn) (*plane.FiguresOut, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrForbidden(app + " figures: org required")
}
figs := data[org]
// An org this store has never heard of holds nothing — an empty slice, never
// another org's rows and never an error.
return &plane.FiguresOut{Figures: append([]plane.Figure(nil), figs...)}, nil
},
zip.WithOperationID(opID))
}
// newAskApp stands up an app with the fake books read + the ask advisor over a recording AI.
func newAskApp(t *testing.T, ai types.AIClient, mrrByOrg map[string]string) *zip.App {
// servePeers binds the plane socket for each named domain, after every op is declared.
func servePeers(t *testing.T, names ...string) {
t.Helper()
for _, n := range names {
stop, err := cloud.ServePlane(n, luxlog.New("test"))
if err != nil {
t.Fatalf("serve plane %s: %v", n, err)
}
t.Cleanup(func() { _ = stop() })
}
}
// newAskApp stands up the advisor over a recording AI, with stand-in books/projects/git peers
// on the plane. Each test gets its own runtime dir and its own plane, so no test is ever
// answered by a previous test's handler.
func newAskApp(t *testing.T, ai types.AIClient, books, projects, git byOrg) *zip.App {
t.Helper()
// A short run dir: a unix socket path is capped near 104 bytes and t.TempDir() spends
// most of that on the test's own name.
dir, err := os.MkdirTemp("", "askp")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
// ZIP_RUNTIME_DIR and not CLOUD_RUN_DIR: an operator's own runtime dir wins
// unconditionally, whereas CLOUD_RUN_DIR is consulted only when nothing has bound
// yet — and something always has by the second test, so every test after the first
// would keep the first one's sockets and be answered by its handlers.
t.Setenv("ZIP_RUNTIME_DIR", dir)
plane.Unbind()
cloud.ResetPlane()
t.Cleanup(func() {
cloud.ResetPlane()
plane.Unbind()
_ = os.RemoveAll(dir)
})
peer("books", "/books/figures", plane.BooksFigures, books)
peer("projects", "/projects/figures", plane.ProjectsFigures, projects)
peer("git", "/git/figures", plane.GitFigures, git)
servePeers(t, "books", "projects", "git")
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, mrrByOrg)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai}); err != nil {
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: filepath.Join(dir, "data"), AI: ai}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
// money is the one-line stand-in ledger used where the test is about the ADVISOR rather than
// about a particular domain.
func money(mrr string) []plane.Figure {
return []plane.Figure{{Label: "MRR", Value: mrr, Period: "2026-07"}}
}
// ask POSTs a question as a VALIDATED principal for org (X-User-Id set, exactly as the gateway
// mints it — the test app has no sanitizer). Empty org exercises the anonymous 403 path.
func ask(t *testing.T, app *zip.App, org, question string) (int, askAnswer) {
@@ -124,11 +184,11 @@ func hasSource(r askAnswer, src string) bool {
return false
}
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books read
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books peer
// produced, tagged to the books domain and cited in sources.
func TestGroundedFinancialAnswer(t *testing.T) {
ai := &recordingAI{reply: "Your MRR is $4,200 for July."}
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
code, r := ask(t, app, "acme", "what is my MRR right now?")
if code != http.StatusOK {
@@ -140,19 +200,47 @@ func TestGroundedFinancialAnswer(t *testing.T) {
if v, ok := figure(r, "MRR"); !ok || v != "$4,200" {
t.Fatalf("MRR figure must be the real $4,200 from the books read, got %q (ok=%v)", v, ok)
}
if !hasSource(r, "books/metrics") {
t.Fatalf("answer must cite the books/metrics read, got %v", r.Sources)
if !hasSource(r, "books/figures") {
t.Fatalf("answer must cite the books/figures read, got %v", r.Sources)
}
}
// TestEveryWiredDomainContributes is the anti-regression for the defect this file was rewritten
// over: a domain in the registry that cannot actually be REACHED is worse than an absent one,
// because it degrades to the fallback and looks like "no domain matched". Each wired domain
// must route, return its own figure, and cite its own source.
func TestEveryWiredDomainContributes(t *testing.T) {
app := newAskApp(t, nil,
byOrg{"acme": money("$4,200")},
byOrg{"acme": {{Label: "Projects", Value: "7"}, {Label: "Deployed and serving", Value: "3"}}},
byOrg{"acme": {{Label: "Repositories", Value: "12"}, {Label: "Code stored", Value: "48.0 MB"}}},
)
for _, tc := range []struct{ question, domain, source, label, want string }{
{"what is my MRR?", "books", "books/figures", "MRR", "$4,200"},
{"what have I deployed?", "projects", "projects/figures", "Deployed and serving", "3"},
{"how many repositories do I have?", "git", "git/figures", "Repositories", "12"},
} {
_, r := ask(t, app, "acme", tc.question)
if r.Domain != tc.domain {
t.Fatalf("%q must route to %q, got %q (answer=%q)", tc.question, tc.domain, r.Domain, r.Answer)
}
if v, ok := figure(r, tc.label); !ok || v != tc.want {
t.Fatalf("%q must state the real %s=%s, got %q (ok=%v)", tc.question, tc.label, tc.want, v, ok)
}
if !hasSource(r, tc.source) {
t.Fatalf("%q must cite %s, got %v", tc.question, tc.source, r.Sources)
}
}
}
// TestModelFedExactFigureAndCannotOverride is THE grounding proof: the model is handed the EXACT
// figure in its prompt, and even when it replies with a HALLUCINATED number the grounded figure
// the caller receives is unchanged. The prose may carry the model's words; the figures array is
// the ledger's, never the model's.
// the domain's, never the model's.
func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
// The model hallucinates $9,999 in its narration — a number that is NOT the real figure.
ai := &recordingAI{reply: "Your MRR is a whopping $9,999 this month!"}
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "acme", "how's my recurring revenue?")
@@ -173,7 +261,7 @@ func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
// TestNoModelStillGrounded: with no AI wired the advisor still answers with the REAL figures — the
// deterministic template states them, so the numbers are identical whether the model is up or down.
func TestNoModelStillGrounded(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "acme", "what's my mrr?")
if v, _ := figure(r, "MRR"); v != "$4,200" {
t.Fatalf("figure must be the real $4,200 with no model, got %q", v)
@@ -187,9 +275,9 @@ func TestNoModelStillGrounded(t *testing.T) {
// it names what the advisor CAN answer and carries ZERO figures. It must NEVER invent a number.
func TestHonestFallbackNoFabrication(t *testing.T) {
ai := &recordingAI{reply: "42 widgets shipped."} // the model would happily make something up
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
code, r := ask(t, app, "acme", "how many widgets did we ship to Mars?")
code, r := ask(t, app, "acme", "how many widgets did we sell on Mars?")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
@@ -202,24 +290,27 @@ func TestHonestFallbackNoFabrication(t *testing.T) {
if len(r.Sources) != 0 {
t.Fatalf("the fallback must cite no sources, got %v", r.Sources)
}
if !strings.Contains(strings.ToLower(r.Answer), "finance") {
if !strings.Contains(strings.ToLower(r.Answer), "financ") {
t.Fatalf("the fallback must name what it CAN answer, got %q", r.Answer)
}
}
// TestOrgIsolation proves the in-process replay carries the CALLER's org: acme sees acme's figure,
// beta sees beta's, and neither can ever surface the other's data — tenant isolation is inherited
// from the caller's own creds on the replay, never re-implemented.
// TestOrgIsolation proves the domain read is answered for the CALLER's org: acme sees acme's
// figures, beta sees beta's, and neither can ever surface the other's. Nothing in the advisor
// states a tenant — the identity zip forwards off the caller's own request is the whole of it,
// which is why there is no argument here a caller could have supplied instead.
func TestOrgIsolation(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200", "beta": "$77,000"})
app := newAskApp(t, nil,
byOrg{"acme": money("$4,200"), "beta": money("$77,000")},
byOrg{
"acme": {{Label: "Projects", Value: "7"}},
"beta": {{Label: "Projects", Value: "999"}},
}, nil)
_, a := ask(t, app, "acme", "what's my mrr?")
if v, _ := figure(a, "MRR"); v != "$4,200" {
t.Fatalf("acme must see its own $4,200, got %q", v)
}
if echo, _ := figure(a, "org-echo"); echo != "acme" {
t.Fatalf("the books read must have been scoped to acme, got org-echo=%q", echo)
}
_, b := ask(t, app, "beta", "what's my mrr?")
if v, _ := figure(b, "MRR"); v != "$77,000" {
@@ -228,15 +319,35 @@ func TestOrgIsolation(t *testing.T) {
if v, _ := figure(b, "MRR"); v == "$4,200" {
t.Fatalf("beta must NEVER surface acme's $4,200")
}
if echo, _ := figure(b, "org-echo"); echo != "beta" {
t.Fatalf("the books read must have been scoped to beta, got org-echo=%q", echo)
// The same rule on a second domain, because tenancy is a property of the SEAM and not of
// one contributor that happened to get it right.
_, ap := ask(t, app, "acme", "what have I deployed?")
if v, _ := figure(ap, "Projects"); v != "7" {
t.Fatalf("acme must see its own 7 projects, got %q", v)
}
_, bp := ask(t, app, "beta", "what have I deployed?")
if v, _ := figure(bp, "Projects"); v != "999" {
t.Fatalf("beta must see its own 999 projects, got %q", v)
}
}
// TestUnknownOrgGetsNothingNotSomebodyElses: an org the domain has never heard of is answered
// with no figures — never a default, never the first org in the store.
func TestUnknownOrgGetsNothingNotSomebodyElses(t *testing.T) {
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "stranger", "what's my mrr?")
for _, f := range r.Figures {
if strings.Contains(f.Value, "4,200") {
t.Fatalf("an unknown org must never receive acme's figures, got %+v", r.Figures)
}
}
}
// TestAnonymousRefused: /v1/ask is a data plane — a request with no validated principal is 401,
// so an off-gateway forge can neither probe nor read a ledger through the advisor.
func TestAnonymousRefused(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
// Forged X-Org-Id with NO X-User-Id (no validated principal) — the anonymous forge.
body, _ := json.Marshal(askRequest{Question: "what's my mrr?"})
req := httptest.NewRequest(http.MethodPost, "/v1/ask", strings.NewReader(string(body)))
@@ -252,16 +363,30 @@ func TestAnonymousRefused(t *testing.T) {
}
}
// TestClassifierMatchesFinancialVocab locks the books classifier: the founder-vocabulary that
// grounds against the ledger routes to books, and off-topic questions do not.
func TestClassifierMatchesFinancialVocab(t *testing.T) {
reg := NewRegistry(newBooksContributor(nil))
for _, q := range []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"} {
if reg.Match(q) == nil {
t.Fatalf("financial question %q must match the books contributor", q)
// TestClassifierRoutesEachDomainsVocab locks the classifiers: each domain's vocabulary routes to
// it, and off-topic questions match nothing at all rather than being swept into whichever domain
// happens to be first.
func TestClassifierRoutesEachDomainsVocab(t *testing.T) {
reg := NewRegistry(domains()...)
for _, tc := range []struct {
want string
questions []string
}{
{"books", []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"}},
{"projects", []string{"what have I deployed?", "which projects are live", "what sites have I published", "what is running in production", "what did we ship"}},
{"git", []string{"how many repositories do I have?", "what changed recently", "how much code do we have", "list my repos", "which branches are there"}},
} {
for _, q := range tc.questions {
c := reg.Match(q)
if c == nil {
t.Fatalf("%q must match the %s contributor, matched nothing", q, tc.want)
}
if c.Name() != tc.want {
t.Fatalf("%q must route to %s, routed to %s", q, tc.want, c.Name())
}
}
}
for _, q := range []string{"what's the weather", "how many users signed up", "deploy the app"} {
for _, q := range []string{"what's the weather", "how many users signed up", "who is the CEO of France"} {
if c := reg.Match(q); c != nil {
t.Fatalf("off-topic question %q must NOT match any domain, matched %q", q, c.Name())
}
-92
View File
@@ -1,92 +0,0 @@
package ask
// books.go — the FIRST contributor: BOOKS (financial). It grounds MRR/ARR/revenue/burn/runway/
// margin/cash/deferred-revenue/P&L/balance questions by replaying the books domain's OWN
// grounded read — GET /v1/books/metrics — in-process under the caller's own credentials, then
// surfacing the REAL figures it returns. It NEVER recomputes: books stays the single source of
// the numbers AND their formatting (the endpoint returns formatUSD-formatted figures). Per-tenant
// isolation is inherited from the replay carrying the caller's own X-Org-Id — a books read can
// only ever return the caller's own org's ledger.
import (
"context"
"encoding/json"
"fmt"
fiber "github.com/zap-proto/fiber/v3"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/hanzoai/cloud"
)
// booksMetricsPath is the books domain's grounded read the contributor replays. It is the ONE
// endpoint that returns the deterministic metric snapshot as formatted figures.
const booksMetricsPath = "/v1/books/metrics"
// maxMetricsResponse bounds the in-process read so a broken upstream cannot balloon memory.
const maxMetricsResponse = 1 << 20
// booksContributor grounds financial questions against the books domain. It holds the app it
// replays against (the SAME binary; the read flows the normal middleware chain), exactly as
// agent.go's aiCompleter holds the app to replay /v1/chat/completions.
type booksContributor struct{ app cloud.Router }
func newBooksContributor(app cloud.Router) *booksContributor { return &booksContributor{app: app} }
func (booksContributor) Name() string { return "books" }
// CanAnswer is the books classifier: does the question ask about money the ledger knows? Keyword
// match over the founder-vocabulary the metrics engine grounds. First-match in the registry, so
// a books question routes here deterministically. An LLM classifier can replace this body later
// without touching the router or the seam.
func (booksContributor) CanAnswer(question string) bool {
l := strings.ToLower(question)
for _, kw := range booksKeywords {
if strings.Contains(l, kw) {
return true
}
}
return false
}
// booksKeywords is the financial vocabulary that routes a question to the books ledger. It
// mirrors the books intent router's keywords so /v1/ask grounds exactly what /v1/books/ask does.
var booksKeywords = []string{
"mrr", "arr", "recurring", "subscription", "annualized",
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
"runway", "how long", "cash last", "out of money", "out of cash",
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
"cash", "bank", "in the bank", "balance", "cogs",
"deferred", "wallet", "prepaid", "liabilit", "owe",
"p&l", "pnl", "p and l", "financ",
}
// Gather replays GET /v1/books/metrics in-process under the caller's own credentials and returns
// the REAL figures it read, with the domain read that backed them. The figures are books' own —
// this contributor formats nothing and invents nothing; an empty ledger yields honest zero
// figures. The caller's creds carry the caller's org, so the read is scoped to that org and no
// other — tenant isolation is inherited, never re-implemented here.
func (b booksContributor) Gather(ctx context.Context, cred map[string]string) ([]Fact, []string, error) {
req := httptest.NewRequest(http.MethodGet, booksMetricsPath, nil).WithContext(ctx)
for k, v := range cred {
req.Header.Set(k, v)
}
resp, err := b.app.Fiber().Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
return nil, nil, fmt.Errorf("books metrics replay: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return nil, nil, fmt.Errorf("books metrics status %d", resp.StatusCode)
}
var out struct {
Figures []Fact `json:"figures"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxMetricsResponse)).Decode(&out); err != nil {
return nil, nil, fmt.Errorf("books metrics decode: %w", err)
}
return out.Figures, []string{"books/metrics"}, nil
}
+148
View File
@@ -0,0 +1,148 @@
package ask
// domains.go — the domains the advisor can ground a question in.
//
// There is ONE contributor type here, not one per domain, because the domains
// differ in exactly two things: which questions they recognise, and which peer
// they ask. Everything else — refuse anonymous, read the figures, name the
// source — is the same sentence written once. Adding a domain is a value in the
// registry, never a new type and never a router edit.
//
// EVERY DOMAIN IS A PLANE CALL, and that is the whole correction this file
// carries. /v1/ask ships as its own plugin binary (plugin/ask/main.go mounts
// ask.Mount and nothing else), so the pod runs the advisor and every domain it
// asks as separate pids. The first contributor read its figures by replaying an
// HTTP request against the advisor's OWN router — which in production holds one
// route, /v1/ask — so the read 404'd, the gather failed, and every money
// question in production was answered by the "I can answer questions about your
// finances" fallback with an empty figures array, while books sat healthy one
// socket away. An in-process replay cannot cross a process boundary. A plane
// call is the one thing that can.
//
// TENANCY. The org rides the CALLER and is never an argument: [plane.FiguresIn]
// is an empty struct, and zip forwards the gateway's own assertion off the
// in-flight request to the peer (zip caller.go, forwardIdentity). So a
// contributor cannot name an org, cannot be handed one, and cannot widen the one
// it was called with — the peer answers for whoever the edge said was asking,
// and refuses when that is nobody. Nothing here states a tenant, deliberately:
// cloud.For on a context with a request behind it is a silent no-op, so code
// that appeared to set the org would read as correct and scope nothing.
import (
"context"
"strings"
"github.com/hanzoai/cloud/plane"
booksplane "github.com/hanzoai/cloud/plane/books"
gitplane "github.com/hanzoai/cloud/plane/git"
projectsplane "github.com/hanzoai/cloud/plane/projects"
)
// domain is one grounded peer behind the advisor: the name the answer is tagged
// with, the vocabulary that routes a question to it, the read it names as its
// source, and the typed plane client that performs it.
//
// ask is the GENERATED client function (plane/<app>), never plane.Ask with loose
// strings — the compiler is what checks that an op name belongs to the app it is
// sent to and that In and Out are the pair that op declared.
type domain struct {
name string
source string
keywords []string
ask func(context.Context, *plane.FiguresIn) (*plane.FiguresOut, error)
}
func (d domain) Name() string { return d.name }
// CanAnswer is the classifier: does this domain's vocabulary appear in the
// question? Deterministic keyword match, first-match-wins in registry order. An
// LLM classifier can replace this body without touching the seam or the router.
func (d domain) CanAnswer(question string) bool {
l := strings.ToLower(question)
for _, kw := range d.keywords {
if strings.Contains(l, kw) {
return true
}
}
return false
}
// Gather reads the domain's REAL figures over the internal plane, as the caller.
//
// cred is ignored, and its absence from this body is the point: the identity is
// already on the context zip hands the peer, so a credential copied by hand here
// would be a second, weaker answer to a question the transport has already
// answered. The parameter stays because it is the seam's, not this domain's.
//
// An empty org answers an empty figures slice — the domain read succeeded and
// the org has nothing — which the advisor narrates honestly. A FAILURE returns
// an error and the advisor falls back rather than stating a number it could not
// read.
func (d domain) Gather(ctx context.Context, _ map[string]string) ([]Fact, []string, error) {
out, err := d.ask(ctx, &plane.FiguresIn{})
if err != nil {
return nil, nil, err
}
facts := make([]Fact, 0, len(out.Figures))
for _, f := range out.Figures {
facts = append(facts, Fact{Label: f.Label, Value: f.Value, Period: f.Period})
}
return facts, []string{d.source}, nil
}
// domains is the advisor's registry contents, in classification order. Money
// first: it is the most-asked question and its vocabulary is the most specific,
// so a question that is about money is never taken by a domain that merely
// shares a word with it.
func domains() []Contributor {
return []Contributor{
domain{
name: "books",
source: "books/figures",
keywords: booksKeywords,
ask: booksplane.BooksFigures,
},
domain{
name: "projects",
source: "projects/figures",
keywords: projectKeywords,
ask: projectsplane.ProjectsFigures,
},
domain{
name: "git",
source: "git/figures",
keywords: gitKeywords,
ask: gitplane.GitFigures,
},
}
}
// booksKeywords is the financial vocabulary that routes a question to the
// ledger. It mirrors the books intent router's keywords so /v1/ask grounds
// exactly what /v1/books/ask does.
var booksKeywords = []string{
"mrr", "arr", "recurring", "subscription", "annualized",
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
"runway", "how long", "cash last", "out of money", "out of cash",
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
"cash", "bank", "in the bank", "balance", "cogs",
"deferred", "wallet", "prepaid", "liabilit", "owe",
"p&l", "pnl", "p and l", "financ",
}
// projectKeywords is the vocabulary of what the org has BUILT and what of it is
// serving — the "what have we shipped" question.
var projectKeywords = []string{
"project", "deploy", "deployed", "deployment", "shipped", "ship",
"site", "sites", "website", "web site", "published", "publish",
"live", "serving", "in production", "hosting", "hosted",
}
// gitKeywords is the vocabulary of the org's SOURCE — how much there is and what
// moved lately.
var gitKeywords = []string{
"repo", "repos", "repositor", "git", "codebase", "source code",
"commit", "commits", "branch", "branches", "merge", "pushed",
"how much code", "lines of code", "what changed", "recently updated",
}
+5 -13
View File
@@ -9,26 +9,18 @@ import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/openapi"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// askApp mounts the door with a stubbed books contributor and a stubbed model, on
// a bare app — the same harness the behaviour suite uses.
// askApp mounts the door with a stand-in books peer and a stubbed model — the
// same harness the behaviour suite uses, which is the point: one harness, so a
// wire proof and a behaviour proof are made against the same door.
func askApp(t *testing.T) *zip.App {
t.Helper()
noNetworkSearch(t)
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, map[string]string{"acme": "$4,200"})
if err := Mount(app, cloud.Deps{
Logger: luxlog.New("test"), DataDir: t.TempDir(),
AI: &webAI{answer: "Clojure was created by Rich Hickey."},
}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
return newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
byOrg{"acme": money("$4,200")}, nil, nil)
}
func askRaw(t *testing.T, app *zip.App, body string, hdr map[string]string) *http.Response {
+2 -8
View File
@@ -14,10 +14,7 @@ import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// webAI answers the engine's synthesis prompt with a fixed string; the plan and
@@ -49,11 +46,8 @@ func noNetworkSearch(t *testing.T) {
// UNTOUCHED (a no-mode financial question still routes to books).
func TestAskWebModeDispatch(t *testing.T) {
noNetworkSearch(t)
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, map[string]string{"acme": "$4,200"})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: &webAI{answer: "Clojure was created by Rich Hickey."}}); err != nil {
t.Fatalf("Mount: %v", err)
}
app := newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
byOrg{"acme": money("$4,200")}, nil, nil)
// web mode → the answer engine
body, _ := json.Marshal(askRequest{Q: "who created clojure and why", Mode: "search"})
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := audit
include ../../mk/plugin.mk
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := authors
include ../../mk/plugin.mk
+35
View File
@@ -0,0 +1,35 @@
package authors
import (
"context"
"errors"
"testing"
"github.com/hanzoai/cloud"
)
// The defect, on the money path: AccrueForOrg returned a bare int, so "no author
// was owed anything" and "authors is not in this process" were both 0. authors
// ships as its own binary and the accrual walk runs in affiliates, so the second
// case is the one that happens — the royalty leg of every sweep latched nothing
// and reported a completed accrual of zero, with no error channel to say so.
//
// Absence is ErrNoPeer now. A royalty that silently does not accrue is the one
// outcome this seam must not be able to express.
func TestAccrueAbsentIsAnErrorNotAZero(t *testing.T) {
prev := mounted
mounted = nil
t.Cleanup(func() { mounted = prev })
n, err := AccrueForOrg(context.Background(), "acme", 5000, "2026-08", 1)
if err == nil {
t.Fatal("unmounted AccrueForOrg returned no error — royalties accruing " +
"zero forever is not a successful sweep")
}
if !errors.Is(err, cloud.ErrNoPeer) {
t.Errorf("AccrueForOrg err = %v, want ErrNoPeer", err)
}
if n != 0 {
t.Errorf("AccrueForOrg n = %d, want 0 alongside the error", n)
}
}
+180 -49
View File
@@ -68,7 +68,6 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/commerce/transport"
"github.com/hanzoai/cloud/audit"
"github.com/zap-proto/zip"
)
@@ -166,7 +165,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
b := cloud.NewBase(deps, "authors")
s := &cloud.Service[state]{Base: b, State: state{
store: store,
commerce: newCommerceClient(transport.BaseURL(os.Getenv("CLOUD_COMMERCE_HTTP_URL")), os.Getenv("COMMERCE_SERVICE_TOKEN")),
commerce: newCommerceClient(),
forge: newGitHubClient(os.Getenv("CLOUD_IAM_HTTP_URL"), os.Getenv("IAM_SERVICE_TOKEN")),
badgeBase: badgeBase(deps),
maintainerOrg: maintainerOrgFor(deps),
@@ -177,7 +176,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
routes(app, zapp, s)
b.Log.Info("authors mounted", "brand", deps.Brand, "badgeBase", s.State.badgeBase,
"maintainerOrg", s.State.maintainerOrg, "commerce", s.State.commerce.configured())
"maintainerOrg", s.State.maintainerOrg)
return nil
}
@@ -338,7 +337,7 @@ func sweepAuthor(s *cloud.Service[state], ctx context.Context, a Author) (checke
now := time.Now().Unix()
for _, dorg := range orgs {
checked++
spend, serr := s.State.commerce.spendCents(ctx, dorg, orgSubject(dorg))
spend, serr := s.State.commerce.spendCents(ctx, dorg)
if serr != nil {
s.Log.Warn("authors: spend read failed", "author", a.ID, "deployingOrg", dorg, "err", serr)
continue
@@ -385,19 +384,30 @@ func accrueOne(s *cloud.Service[state], ctx context.Context, a Author, deploying
// AccrueForOrg is the seam the unified affiliate accrual walk calls once per source
// org (with the spend it already read): it accrues royalty to EVERY approved author
// whose verified repo that org deployed (excluding the author's own org), latched
// at-most-once per (author, org, period). It resolves the mounted authors singleton;
// when authors is NOT mounted (a partial deploy, or an affiliates unit test that does
// not wire authors) it is a no-op returning 0 — the same degrade-gracefully contract
// treasury.Reserve uses. Returns the number of NEW royalty accruals latched.
func AccrueForOrg(ctx context.Context, deployingOrg string, spend int64, period string, now int64) int {
// at-most-once per (author, org, period). Returns the number of NEW royalty
// accruals latched.
//
// It returned a bare int and no error, so "nothing was owed" and "authors is not
// in this process" were the same value: 0. authors ships as its own binary and
// the walk runs in affiliates, so the second case is the one that happens — the
// royalty leg of every sweep has been latching nothing, reporting it as a
// completed accrual of zero, with no error anywhere to see. On a money path a
// silent zero is the one outcome that must not be expressible, so absence is now
// ErrNoPeer and the caller decides what to do about it.
//
// A lookup failure is likewise returned rather than logged-and-zeroed: a sweep
// that could not read the authors is not a sweep that found none.
func AccrueForOrg(ctx context.Context, deployingOrg string, spend int64, period string, now int64) (int, error) {
s := mounted
if s == nil || s.State.store == nil || spend <= 0 {
return 0
if s == nil || s.State.store == nil {
return 0, fmt.Errorf("%w: authors (this process does not own the royalty store)", cloud.ErrNoPeer)
}
if spend <= 0 {
return 0, nil // nothing metered this period is a real answer
}
authors, err := s.State.store.AuthorsDeployedBy(ctx, deployingOrg, sweepLimit)
if err != nil {
s.Log.Warn("authors: AccrueForOrg lookup failed", "deployingOrg", deployingOrg, "err", err)
return 0
return 0, fmt.Errorf("authors: deployed-by lookup for %q: %w", deployingOrg, err)
}
created := 0
for _, a := range authors {
@@ -405,7 +415,7 @@ func AccrueForOrg(ctx context.Context, deployingOrg string, spend int64, period
created++
}
}
return created
return created, nil
}
// ── Hanzo-fork attribution → treasury ("pay ourselves") ────────────────────────
@@ -518,20 +528,66 @@ func emitAudit(s *cloud.Service[state], ctx context.Context, action string, a Au
// adminAuthorView is one row in the SuperAdmin directory (org exposed).
type adminAuthorView struct {
ID string `json:"id"`
Org string `json:"org"`
GithubLogin string `json:"githubLogin"`
Status string `json:"status"`
Verified bool `json:"verified"`
ShareBps int64 `json:"shareBps"`
RepoCount int `json:"repoCount"`
DeployCount int `json:"deployCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
CreatedAt int64 `json:"createdAt"`
ApprovedAt int64 `json:"approvedAt"`
SuspendedAt int64 `json:"suspendedAt"`
// ID is the author record's server-minted handle, "aut_"-prefixed. It is the id
// the approve, suspend, payout and admin-basis routes address.
ID string `json:"id"`
// Org is the tenant org that owns this author record — UNIQUE, one author per
// org. It is exposed HERE and nowhere else (Author.Org is json:"-" on the tenant
// surface), and it is the org excluded from this author's own accrual: deploying
// your own repo earns you nothing.
Org string `json:"org"`
// GithubLogin is the linked forge account, lowercased. It comes from IAM's
// linked account when the connect had one — which is also what sets verified —
// and otherwise from the login the caller declared. The treasury author carries
// "<brand>-maintainers".
GithubLogin string `json:"githubLogin"`
// Status is connected, approved or suspended. Only an approved author accrues;
// a connected one may verify repos and collect deploy edges but earns nothing
// until a reviewer admits it.
Status string `json:"status"`
// Verified is IDENTITY proof of the login, NOT proof of any repository: true
// when the connect took the login from IAM's linked forge account (and for the
// seeded treasury author), false when the caller merely declared it. A false
// here still earns — repository ownership is proven separately, per claim.
Verified bool `json:"verified"`
// ShareBps is the royalty rate accrual applies, in basis points of a deploying
// org's metered spend for the period: 2000 (the platform default) is 20%, 10000
// would be the entire spend. The platform keeps 10000 shareBps. Changing it
// never rewrites history — each ledger row keeps the rate it was written with.
ShareBps int64 `json:"shareBps"`
// RepoCount is how many of this author's repository claims are VERIFIED, counted
// for this response in one GROUP BY over the whole table rather than a query per
// row. The single-author replies from approve, suspend and payout report 0: they
// carry the mutated row, not a re-listing.
RepoCount int `json:"repoCount"`
// DeployCount is how many attribution edges point at this author — one per
// (repository, project, deploying org), so re-deploying the same project adds
// none. It includes self-deploys, which are recorded for provenance and excluded
// from accrual, so it measures reach, not the earning set.
DeployCount int `json:"deployCount"`
// AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of
// every latched accrual (spend × shareBps / 10000). It only ever rises — a
// payout is recorded against paidCents and never reduces this.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is what a payout may still draw against — accrued paid, floored
// at zero. It is derived for each response, never stored, and it is the exact
// figure the atomic payout guard refuses to exceed.
PendingCents int64 `json:"pendingCents"`
// PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises
// the moment a payout reserves against pending — recording, not settling; a human
// moves the money out of band — and falls back only when a payout is voided.
PaidCents int64 `json:"paidCents"`
// CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the
// login and leaves this alone, so it dates the enrolment, not the latest link.
CreatedAt int64 `json:"createdAt"`
// ApprovedAt is unix seconds of the first approval, and 0 means never approved —
// which is also "has never been able to accrue". Re-approving to renegotiate the
// share leaves it at the original date.
ApprovedAt int64 `json:"approvedAt"`
// SuspendedAt is unix seconds of the most recent suspension. 0 means the author
// is not suspended: either never was, or was and has since been approved again,
// which clears this back to 0.
SuspendedAt int64 `json:"suspendedAt"`
}
func adminViewOf(a Author, repos, deploys int) adminAuthorView {
@@ -546,12 +602,35 @@ func adminViewOf(a Author, repos, deploys int) adminAuthorView {
// authorRepo is one row of an author's verified/claimed repos, with the ready-to-paste
// Deploy-on-Hanzo markdown snippet.
type authorRepo struct {
RepoURL string `json:"repoUrl"`
Verified bool `json:"verified"`
Method string `json:"method,omitempty"`
// RepoURL is the claim key in canonical form — lowercased "host/owner/name",
// no scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is
// normalized through the same function before attribution, so the two sides can
// never miss on a cosmetic difference. UNIQUE across every author: first proven
// claim wins.
RepoURL string `json:"repoUrl"`
// Verified reports that ownership was proven. Only a proven claim is ever
// written, so it is true on every row this surface returns; the deploy path
// re-reads it regardless, because an unverified claim attributes nothing.
Verified bool `json:"verified"`
// Method is HOW ownership was proven: "oauth" — an IAM-linked forge token showed
// admin or push on the repository; "file" — a hanzo.json on the default branch
// carried this author's verify code; or "maintainer" — the repository sits in a
// first-party namespace, where ownership is intrinsic and the treasury author
// holds it with no proof step. Omitted on a row written before the method was
// recorded.
Method string `json:"method,omitempty"`
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
// from this deployment's badge host and never stored: a "Deploy on Hanzo" image
// linking to the one-click import of this repository. Re-hosting the builder
// changes every badge without touching a row.
BadgeMarkdown string `json:"badgeMarkdown"`
VerifiedAt int64 `json:"verifiedAt"`
CreatedAt int64 `json:"createdAt"`
// VerifiedAt is unix seconds of the most recent successful proof. Re-verifying
// refreshes it, and the method beside it, in place.
VerifiedAt int64 `json:"verifiedAt"`
// CreatedAt is unix seconds when the claim was first recorded. It equals
// verifiedAt on the first proof and then stays put while verifiedAt moves, so the
// pair reads as "claimed since / last proven".
CreatedAt int64 `json:"createdAt"`
}
func authorRepoOf(r AuthorRepo, badgeBase string) authorRepo {
@@ -573,12 +652,32 @@ func authorRepos(rs []AuthorRepo, badgeBase string) []authorRepo {
// orgView is one row of an author's verified OWNER-WIDE claims: the owner url + a
// ready-to-paste badge deep-linking that owner's Hanzo template import.
type orgView struct {
OwnerURL string `json:"ownerUrl"`
Verified bool `json:"verified"`
Method string `json:"method,omitempty"`
// OwnerURL is the claim key in canonical form — lowercased "host/owner" with NO
// repository segment, host ∈ {github.com, gitlab.com}. It covers every repository
// under that owner, so code with no claim of its own still earns; a per-repository
// claim outranks it. UNIQUE across every author: first proven claim wins.
OwnerURL string `json:"ownerUrl"`
// Verified reports that ownership of the WHOLE owner was proven — against that
// owner's ".github" control repository, which is exactly as strong as a
// per-repository claim. Only a proven claim is written, so every row returned
// here is true.
Verified bool `json:"verified"`
// Method is HOW the owner was proven, always against its ".github" control
// repository: "oauth" — an IAM-linked forge token showed admin or push on it; or
// "file" — a hanzo.json on its default branch carried this author's verify code.
// The "maintainer" shortcut is a per-repository attribution and never appears
// here. Omitted on a row written before the method was recorded.
Method string `json:"method,omitempty"`
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
// from this deployment's badge host and never stored — here it deep-links the
// OWNER's template import rather than one repository's.
BadgeMarkdown string `json:"badgeMarkdown"`
VerifiedAt int64 `json:"verifiedAt"`
CreatedAt int64 `json:"createdAt"`
// VerifiedAt is unix seconds of the most recent successful proof of the owner;
// re-verifying refreshes it, and the method beside it, in place.
VerifiedAt int64 `json:"verifiedAt"`
// CreatedAt is unix seconds when the owner claim was first recorded — equal to
// verifiedAt on the first proof, then fixed while verifiedAt moves.
CreatedAt int64 `json:"createdAt"`
}
func orgViewOf(o AuthorOrg, badgeBase string) orgView {
@@ -615,16 +714,34 @@ func deployViews(es []DeployEvent) []deployView {
// payoutView is one row of an author's payout history.
type payoutView struct {
ID string `json:"id"`
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference,omitempty"`
Txn string `json:"txn,omitempty"`
// ID is the payout row's server-minted handle, "apo_"-prefixed. A caller never
// supplies it; it is what an operator quotes when reconciling a settlement.
ID string `json:"id"`
// AmountCents is the amount RESERVED against pending royalty, in integer USD
// cents, always positive. The reservation is atomic and can never exceed
// accrued paid, so this is owed money moved out of pending — not money moved.
AmountCents int64 `json:"amountCents"`
// Method is how the operator says this settles, lowercased as recorded.
// "credits" is the one method that means the author's own wallet; anything else
// — wire, paypal, check — is a cash disbursement a human performs. Recording it
// pays nobody either way.
Method string `json:"method"`
// Reference is the operator's external handle for the settlement: a wire
// confirmation, a PayPal transaction id. Absent when none was given.
Reference string `json:"reference,omitempty"`
// Txn is the commerce ledger transaction id of a SETTLED credits payout, and it
// is absent on every payout this service records. Recording moves no money, and
// authors asks the money plane exactly one question — what has this org spent? —
// with no write to answer it with, so there is no receipt to carry. It fills in
// only when a settlement stamps its transaction back onto the row.
Txn string `json:"txn,omitempty"`
// Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author
// and to the admin mirror alike — the disclosure that keeps a first-party
// settlement legible as internal accounting.
Settlement string `json:"settlement,omitempty"`
CreatedAt int64 `json:"createdAt"`
// CreatedAt is unix seconds when the payout was RECORDED — the moment the amount
// left pending, not the moment a human moved the money.
CreatedAt int64 `json:"createdAt"`
}
func payoutViewOf(p Payout) payoutView {
@@ -645,13 +762,27 @@ func payoutViews(ps []Payout) []payoutView {
// product-qualified because the fleet's schema namespace is FLAT and apps/referrals
// already publishes an "adminSummary" of its own.
type authorProgramSummary struct {
Total int `json:"total"`
Connected int `json:"connected"`
Approved int `json:"approved"`
Suspended int `json:"suspended"`
// Total is how many author records this response actually carried. The roll-up
// is folded over the SAME page as authors — newest first, bounded by limit
// (default 500, ceiling 1000) — so on a program larger than the page it
// summarizes that page, not the fleet.
Total int `json:"total"`
// Connected is how many of those are enrolled but not yet admitted to earning.
Connected int `json:"connected"`
// Approved is how many are admitted and accruing.
Approved int `json:"approved"`
// Suspended is how many have been stopped from accruing further. An author holds
// exactly one status, so the three buckets never overlap and connected +
// approved + suspended = total.
Suspended int `json:"suspended"`
// AccruedCents is the page's lifetime royalty accrued, in integer USD cents.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is what the platform still owes across the page, in integer USD
// cents — the sum of each author's own accrued paid, each floored at zero.
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
// PaidCents is what has been RECORDED as paid across the page, in integer USD
// cents. Recorded, not settled: the money leaves in a human's hands.
PaidCents int64 `json:"paidCents"`
}
func (s *authorProgramSummary) add(a Author) {
+2 -4
View File
@@ -37,13 +37,11 @@ func newFakeCommerce() *fakeCommerce {
return &fakeCommerce{balance: map[string]int64{}, spend: map[string]int64{}}
}
func (f *fakeCommerce) configured() bool { return true }
func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int64, _, _, _, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failDep {
return "", errUnconfigured
return "", errNoLedger
}
f.balance[org] += amountCents
f.deposits++
@@ -51,7 +49,7 @@ func (f *fakeCommerce) deposit(_ context.Context, org, _ string, amountCents int
return "txn_test_" + org + "_" + strconv.Itoa(f.seq), nil
}
func (f *fakeCommerce) spendCents(_ context.Context, org, _ string) (int64, error) {
func (f *fakeCommerce) spendCents(_ context.Context, org string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.spend[org], nil
+8 -9
View File
@@ -18,25 +18,24 @@ import (
// 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
spendCents(ctx context.Context, org, user string) (int64, error)
spendCents(ctx context.Context, org string) (int64, error)
}
// 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
var errNoLedger = payout.ErrNoLedger
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
// 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) spendCents(ctx context.Context, org, user string) (int64, error) {
return s.c.SpendCents(ctx, org, user)
func (s commerceSeam) spendCents(ctx context.Context, org string) (int64, error) {
return s.c.SpendCents(ctx, org)
}
// newCommerceClient builds the production binding, delegating to clients/payout.
func newCommerceClient(base, token string) commerce {
return commerceSeam{payout.NewClient(base, token)}
// newCommerceClient builds the production binding, delegating to apps/payout. It
// takes no address: a peer is reached by NAME.
func newCommerceClient() commerce {
return commerceSeam{payout.NewClient()}
}
+112 -31
View File
@@ -12,12 +12,33 @@ func init() {
zip.Describe("GET /v1/admin/authors", zip.Doc{
Description: "Returns the platform's whole author program — every org's author\nrecord, not the caller's — with each one's repository and deploy counts and a\nfleet roll-up of the money accrued, pending and paid.\n\nIt is a Hanzo platform operation: a caller who is not a SuperAdmin gets 403. It\nexposes the owning org of each author, which no tenant-facing read ever does.",
Fields: map[string]string{
"adminBook.data": "Data is the book.",
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"adminBook.data": "Data is the book.",
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
"authorProgramSummary.accruedCents": "AccruedCents is the page's lifetime royalty accrued, in integer USD cents.",
"authorProgramSummary.approved": "Approved is how many are admitted and accruing.",
"authorProgramSummary.connected": "Connected is how many of those are enrolled but not yet admitted to earning.",
"authorProgramSummary.paidCents": "PaidCents is what has been RECORDED as paid across the page, in integer USD\ncents. Recorded, not settled: the money leaves in a human's hands.",
"authorProgramSummary.pendingCents": "PendingCents is what the platform still owes across the page, in integer USD\ncents — the sum of each author's own accrued paid, each floored at zero.",
"authorProgramSummary.suspended": "Suspended is how many have been stopped from accruing further. An author holds\nexactly one status, so the three buckets never overlap and connected +\napproved + suspended = total.",
"authorProgramSummary.total": "Total is how many author records this response actually carried. The roll-up\nis folded over the SAME page as authors — newest first, bounded by limit\n(default 500, ceiling 1000) — so on a program larger than the page it\nsummarizes that page, not the fleet.",
},
})
zip.Describe("GET /v1/admin/authors/:id/basis", zip.Doc{
@@ -44,39 +65,87 @@ func init() {
zip.Describe("POST /v1/admin/authors/:id/approve", zip.Doc{
Description: "Admits one author to EARNING, optionally on a negotiated royalty\nshare. Until this runs, a connected author accrues nothing however many verified\nrepositories they have.\n\nA share override applies from here forward only — existing ledger rows keep the\nshare that was applied when they were written, because a rate change must never\nrewrite what was already owed.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"approveRequest.id": "ID is the author to approve, from the path.",
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (010000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"approveRequest.id": "ID is the author to approve, from the path.",
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (010000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
},
Example: json.RawMessage(`{"id":"aut_1f…","shareBps":2500}`),
})
zip.Describe("POST /v1/admin/authors/:id/payout", zip.Doc{
Description: "Records a payout of accrued royalty and settles it.\n\nThe amount is RESERVED against the author's pending royalty atomically before\nanything is paid, so a payout can never exceed what is owed even under concurrent\ncalls. An external author's payout is then BACKED against the platform reserve\nfund — a second, independent guard — and refused with 402 if the reserve cannot\ncover it, with the reservation voided. A \"credits\" method issues the actual wallet\ngrant after both guards; a cash method is record-only. A first-party (treasury)\nauthor's royalty is realized into Hanzo's own reserve instead of an external\nwallet, and every payout row discloses which of the three it was.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
"payoutRequest.id": "ID is the author to pay, from the path.",
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
"payoutResult.data": "Data carries the payout and the author.",
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
"payoutResult.status": "Status is \"ok\".",
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
"payoutRequest.id": "ID is the author to pay, from the path.",
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
"payoutResult.data": "Data carries the payout and the author.",
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
"payoutResult.status": "Status is \"ok\".",
"payoutView.amountCents": "AmountCents is the amount RESERVED against pending royalty, in integer USD\ncents, always positive. The reservation is atomic and can never exceed\naccrued paid, so this is owed money moved out of pending — not money moved.",
"payoutView.createdAt": "CreatedAt is unix seconds when the payout was RECORDED — the moment the amount\nleft pending, not the moment a human moved the money.",
"payoutView.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed. A caller never\nsupplies it; it is what an operator quotes when reconciling a settlement.",
"payoutView.method": "Method is how the operator says this settles, lowercased as recorded.\n\"credits\" is the one method that means the author's own wallet; anything else\n— wire, paypal, check — is a cash disbursement a human performs. Recording it\npays nobody either way.",
"payoutView.reference": "Reference is the operator's external handle for the settlement: a wire\nconfirmation, a PayPal transaction id. Absent when none was given.",
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
"payoutView.txn": "Txn is the commerce ledger transaction id of a SETTLED credits payout, and it\nis absent on every payout this service records. Recording moves no money, and\nauthors asks the money plane exactly one question — what has this org spent? —\nwith no write to answer it with, so there is no receipt to carry. It fills in\nonly when a settlement stamps its transaction back onto the row.",
},
Example: json.RawMessage(`{"id":"aut_1f…","amountCents":25000,"method":"credits"}`),
})
zip.Describe("POST /v1/admin/authors/:id/suspend", zip.Doc{
Description: "Stops one author earning. Their record, verified claims and ledger\nare untouched — suspension halts future accrual, it does not erase what was already\nowed, and it does not delete the evidence behind it.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
},
})
zip.Describe("POST /v1/admin/authors/sweep", zip.Doc{
@@ -124,10 +193,22 @@ func init() {
zip.Describe("POST /v1/authors/repos/verify", zip.Doc{
Description: "Proves that the caller owns a repository — or a whole OWNER — and\nrecords the claim, which is what makes deploys of that code earn royalty.\n\nOwnership is proven the SAME two ways in both cases, tried in order: an IAM-linked\nforge token with admin or push permission, or a hanzo.json on the default branch\ncarrying the author's verify code. Claiming an OWNER proves it against that\nowner's \".github\" control repository, and is exactly as strong as a per-repository\nclaim — an owner the caller cannot prove is refused with 422, never assumed.\n\nA per-repository claim wins over an owner-wide one, so a specifically-claimed\nrepository always earns for its own author. A repository another author has\nalready verified is a 409. The org must have connected first.\n\nAnswers 201 when it recorded a new claim and 200 when the claim already existed.",
Fields: map[string]string{
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
"authorRepo.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored: a \"Deploy on Hanzo\" image\nlinking to the one-click import of this repository. Re-hosting the builder\nchanges every badge without touching a row.",
"authorRepo.createdAt": "CreatedAt is unix seconds when the claim was first recorded. It equals\nverifiedAt on the first proof and then stays put while verifiedAt moves, so the\npair reads as \"claimed since / last proven\".",
"authorRepo.method": "Method is HOW ownership was proven: \"oauth\" — an IAM-linked forge token showed\nadmin or push on the repository; \"file\" — a hanzo.json on the default branch\ncarried this author's verify code; or \"maintainer\" — the repository sits in a\nfirst-party namespace, where ownership is intrinsic and the treasury author\nholds it with no proof step. Omitted on a row written before the method was\nrecorded.",
"authorRepo.repoUrl": "RepoURL is the claim key in canonical form — lowercased \"host/owner/name\",\nno scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is\nnormalized through the same function before attribution, so the two sides can\nnever miss on a cosmetic difference. UNIQUE across every author: first proven\nclaim wins.",
"authorRepo.verified": "Verified reports that ownership was proven. Only a proven claim is ever\nwritten, so it is true on every row this surface returns; the deploy path\nre-reads it regardless, because an unverified claim attributes nothing.",
"authorRepo.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof. Re-verifying\nrefreshes it, and the method beside it, in place.",
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
"orgView.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored — here it deep-links the\nOWNER's template import rather than one repository's.",
"orgView.createdAt": "CreatedAt is unix seconds when the owner claim was first recorded — equal to\nverifiedAt on the first proof, then fixed while verifiedAt moves.",
"orgView.method": "Method is HOW the owner was proven, always against its \".github\" control\nrepository: \"oauth\" — an IAM-linked forge token showed admin or push on it; or\n\"file\" — a hanzo.json on its default branch carried this author's verify code.\nThe \"maintainer\" shortcut is a per-repository attribution and never appears\nhere. Omitted on a row written before the method was recorded.",
"orgView.ownerUrl": "OwnerURL is the claim key in canonical form — lowercased \"host/owner\" with NO\nrepository segment, host ∈ {github.com, gitlab.com}. It covers every repository\nunder that owner, so code with no claim of its own still earns; a per-repository\nclaim outranks it. UNIQUE across every author: first proven claim wins.",
"orgView.verified": "Verified reports that ownership of the WHOLE owner was proven — against that\nowner's \".github\" control repository, which is exactly as strong as a\nper-repository claim. Only a proven claim is written, so every row returned\nhere is true.",
"orgView.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof of the owner;\nre-verifying refreshes it, and the method beside it, in place.",
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
},
Example: json.RawMessage(`{"repoUrl":"github.com/octocat/hello-world"}`),
})
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := auto
include ../../mk/plugin.mk
+11 -1
View File
@@ -364,11 +364,21 @@ func (o ops) start(ctx context.Context, in *autoStart) (*autoResult, error) {
if err != nil {
return nil, err
}
// Pay before the compute is scheduled, not after — a run the caller cannot
// afford must never reach the tasks plane (billing.go).
if err := o.gate(ctx); err != nil {
return nil, err
}
body := map[string]any{"flowId": id}
if len(in.Input) > 0 {
body["input"] = json.RawMessage(in.Input)
}
return relay(ctx, http.MethodPost, "/v1/runs", org, body)
res, err := relay(ctx, http.MethodPost, "/v1/runs", org, body)
if err != nil {
return nil, err // the run never started; nothing is owed.
}
o.meter(ctx)
return res, nil
}
// Run reads one run record: status, input, output (each executed node's
+86
View File
@@ -0,0 +1,86 @@
package auto
// billing.go — what a durable run costs, and the gate that refuses one the
// caller cannot pay for.
//
// THE GAP THIS CLOSES. This surface declared cloud.Metered, which means one
// thing and only one: "the charge for this surface is owned by a meter
// DOWNSTREAM of the edge" (cloud/price.go). The edge therefore adds nothing —
// and there was no meter downstream either, so every run this plane started was
// free, in a way no test could see. The price was declared, standing was
// required, and nothing ever debited. A surface that promises a meter and keeps
// none is worse than one that declares Free, because it reads as paid.
//
// WHAT IS BILLED, AND WHY ONLY THAT. A RUN is the unit. Starting one schedules
// durable execution on the tasks plane — real compute, for as long as the flow
// takes, whether or not the caller ever reads the result. Everything else on
// this surface is bookkeeping against rows we already hold: listing flows,
// fetching one, editing a draft, publishing. Those cost a database write, which
// is the platform's own cost of doing business, not a provider's invoice. So the
// meter sits on POST /v1/auto/runs and nowhere else.
//
// ONE KNOB, TWO USES. The gate authorizes exactly the amount the meter debits,
// read from the same ResourceFeeCents call, so the number a caller is refused
// for and the number they are charged cannot drift. A deployment that prices a
// run at 0 is un-gated exactly as it is un-billed — ResourceMeter's own
// costCents<=0 short-circuit — which is the honest reading of "this operator
// gives runs away".
import (
"context"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
)
const (
// feeEnvPrefix prices one durable run per deployment (0 ⇒ free).
// CLOUD_AUTO_FEE_CENTS_RUN overrides CLOUD_AUTO_FEE_CENTS overrides the
// platform default (cloud.DefaultResourceFeeCents).
feeEnvPrefix = "CLOUD_AUTO_FEE_CENTS"
// meterKind is the billed unit inside this product — the ledger's per-item
// attribution, so a run is distinguishable from any later footprint charge.
meterKind = "run"
)
// gate refuses a run the caller's balance cannot cover, BEFORE any upstream byte
// is sent — an unfunded org must not schedule compute it cannot pay for.
//
// Off the HTTP path (a CLI LocalInvoke) there is no request and therefore no
// payer to charge, so there is nothing to gate HERE. That silence is no longer a
// hole in the edge: cloud sets a zip authorizer now (cloud.Toll), it runs at
// op.invoke on every transport, and a priced operation with no payer behind it is
// refused there before this ever runs. An MCP tools/call DOES carry a request —
// it arrives as POST /mcp — so the payer below resolves for it exactly as it does
// over REST.
//
// The refusal is cloud.Denied, not DenyResource: a typed op cannot write a
// response body — zip stamps the op's own status over a hand-written one — so
// the money wire's status and sentence travel as an error instead.
func (o ops) gate(ctx context.Context) error {
c, ok := cloud.Request(ctx)
if !ok {
return nil
}
project, validated := principal.ValidatedProject(c)
fee := cloud.ResourceFeeCents(feeEnvPrefix, meterKind)
if err := o.s.Bill.Gate(c.Context(), principal.Payer(c), project, validated, meterKind, fee); err != nil {
return cloud.Denied(err)
}
return nil
}
// meter debits the one run this call started. It runs only after the upstream
// ACCEPTED the run: work that never started is work nobody owes for.
//
// The debit is fire-and-forget on a background context (ResourceMeter.Meter) —
// the run already exists, so the charge must never block the reply nor be
// cancelled by a client disconnect.
func (o ops) meter(ctx context.Context) {
c, ok := cloud.Request(ctx)
if !ok {
return
}
o.s.Bill.Meter(principal.Payer(c), principal.Project(c), meterKind,
cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
}
+1 -1
View File
@@ -2,7 +2,7 @@
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# backs and includes it. Written from the same manifest.Apps rows that write
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := automations
include ../../mk/plugin.mk
+15
View File
@@ -0,0 +1,15 @@
package automations
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()) }
+8 -8
View File
@@ -22,6 +22,7 @@ import (
func newApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
@@ -30,14 +31,12 @@ func newApp(t *testing.T) *zip.App {
return app
}
// newAppMCP mounts the subsystem the way the SERVER does: one cloud.Bridge at the
// app ROOT. zip's own projections of the typed-op registry — the MCP endpoint at
// /mcp and the call plane at /.well-known/zip/op/ — are ordinary routes on the app
// itself, so they sit OUTSIDE every subsystem's group and the group's own Bridge
// never runs for them. cloud.Listen installs the root one (serve.go), which is what
// gives them a validated org in production; newApp above does not, so a tools/call
// there refuses before it reaches a handler. Use this harness to exercise an op
// through MCP.
// newAppMCP is the entry point for exercising an op through MCP. zip's own
// projections of the typed-op registry — the MCP endpoint at /mcp and the call
// plane at /.well-known/zip/op/ — are ordinary routes on the app itself, so
// nothing scoped to a subsystem's group reaches them; only an enrichment
// installed at the ROOT does, which is what gives them a validated org in
// production and what compose installs here.
func newAppMCP(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
@@ -101,6 +100,7 @@ func newAppWithAudit(t *testing.T) (*zip.App, *audit.Recorder) {
t.Fatalf("audit.Open: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Audit: rec}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)

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