Compare commits

...
Author SHA1 Message Date
zeekay 683ac805bd github: an issue filed with the label starts a run too
GitHub sends `labeled` only for a label applied AFTER creation. Labels chosen in
the new-issue form arrive as part of `opened` and produce no `labeled` event at
all, so watching only `labeled` missed the most natural way to ask — file the
issue with the label on it — and missed it silently.

`opened` therefore reads the issue's label SET, while `labeled` keeps reading the
one label that was just applied. That asymmetry is the point rather than an
inconsistency: reading the set on `labeled` would restart the run every time
somebody added "p1" to an issue already carrying the trigger label.
2026-08-06 20:13:04 -07:00
zeekay b58175567d github: the run's branch becomes a pull request, remembering nothing to do it
The loop's last leg. An issue started a run; the run pushed a branch; this is what
makes a pull request appear for it, so a person reads a diff rather than a branch
name.

THE OBVIOUS BUILD IS A TABLE and the table is wrong. The door would remember
"agent/x is for issue #7" and the completion path would look it up. That is
correct on one replica and silently wrong on two — the webhook lands on one
process, the push on another, the lookup misses, and nothing fails to say so.
cloud runs replicas=1 today with no HPA, which is exactly the condition under
which such a bug ships and waits.

So nothing is remembered, because every fact a pull request needs is already
durable somewhere:

  which branches get one   the forge's ref policy already says a coding run
                           writes refs/heads/agent/<x> and nothing else, ever.
                           So the question is about the NAME.
  what it is called        the head commit's subject — the agent's own words.
  which issue it closes    the commit message, because the run's prompt asked
                           for it. GitHub links a PR to an issue from the PR
                           body, the body IS the commit message, and the commit
                           cites the issue. The link is the agent's work
                           product, not our bookkeeping.
  where it merges to       the repository's real default branch, read from
                           GitHub. "main" is a guess, and a repo whose trunk is
                           named otherwise would get a PR against nothing.

IT HANGS OFF THE MIRROR, NOT OFF THE PUSH. A pull request needs its head to
EXIST on GitHub, and what puts it there is the outbound mirror. Subscribing to
the same native push the mirror subscribes to would race it — EmitLifecycle fans
out concurrently — and a PR opened first is a 422 against a ref GitHub has never
seen. "The branch is on GitHub now" is a fact only the mirror holds, so the call
is made where that fact is.

IDEMPOTENCY IS GITHUB'S. A second call for the same head and base is a 422
saying a pull request already exists, which is the state we were asking for: it
is a success, and it answers with the number a human clicks. We keep no
"already opened" set to discover the same thing more slowly and less reliably.

apps/git already imports apps/integrations (mirror_out reaches the App for the
per-org token), so this is a call along an existing edge and not a new seam.

Measured, not assumed: git, tracker, agents and integrations are all 403 on
api.hanzo.ai, so they are one binary and the call is in-process. The zip
framework's mustBuild data race under -race is pre-existing — it reproduces
identically on the untouched tree, which has zero occurrences of this call.

Proven: the title is the commit subject, the body carries "Fixes #7", the base
is the repo's actual default branch and the head is the run's branch; an
existing pull request answers with its number instead of an error; a
non-agent branch, an empty agent/, a non-GitHub target and an unparseable
remote all create nothing.
2026-08-06 20:08:56 -07:00
zeekay 9c774e2bae github: an issue becomes a run, and the person who asked is checked
An issue labeled for the agent, or a comment that mentions it, starts one coding
run against the repository the issue lives in, and the run answers on that issue.
It is a DOOR, not a second engine: coding.Start over the plane, exactly as the
Slack door reaches it. A door that assembled its own dispatcher would have its
own pool and its own rules, and a run started from GitHub would be invisible to
the app that shares its name.

AUTHENTICATION IS NOT AUTHORIZATION, and this is the defect review found. The
HMAC proves GitHub sent the bytes; the installation id proves which tenant owns
them. NEITHER says the person who typed the comment may write the repository
they named — and an installation access token is write-capable across the WHOLE
owner namespace. Anyone can comment on a public issue. So without a repo-level
check, one comment starts a run holding authority over every repository under
that owner.

Two questions, two authorities, both required:

  SCOPE   is owner/repo among the repositories this org's installation actually
          grants? An App can be installed on selected repositories, so "the
          owner is connected" is strictly weaker than "this repository is
          covered", and it is the covered set that bounds the token.
  PERSON  does the sender hold push on THAT repository? Asked of GitHub, which
          is the authority on its own repository's permissions — never inferred
          from org membership, which is a different fact.

Scope is settled first, so an ungranted repository is refused without our even
asking who the person is: which repositories an installation covers is itself a
fact about the tenant.

THE CREDENTIAL IS NEVER IN THE BOX. The installation token is minted here, used
here for two calls cloud makes itself — read a permission, write a comment — and
dropped. Nothing hands it onward. apps/sandbox/push.go carries the same property
for the objects: cloud reads the sandbox's commits and pushes them, so there is
nothing in the pod to steal rather than a small window in which to steal it.
That file arrives here too, adapted to this tree's core-API shape (Land beside
Run/Read/Write, a thin handler over it), and with it the fix that made `Secret`
true only when a body actually carries a credential — the cleartext guard was
refusing every run that had no token to protect.

The trigger is spawned BEFORE the tracker mirror and does not depend on it, for
the same reason the push path fires its build trigger before mirroring: a sync
outage must not silently stop work. It is detached, because authorizing a sender
and answering on an issue are several GitHub round trips and GitHub allows ten
seconds; it reads everything off the live request first, because a fiber buffer
is reused the instant the handler returns.

A test caught one real bug in the writing: "me@hanzo.ai" read as a mention. The
@ needs a whole-word boundary on BOTH sides — the right one is a different
account (@hanzo-ci), the left one is an email address.

Proven: the reader case gets no run and no comment while the permission call is
demonstrably made; the ungranted repository is refused before the person is
asked about; a writer reaches the link invitation, which is the sentence only
reachable past both checks. Unsigned is 401 and mints nothing; a spoofed
X-Org-Id beside a valid signature moves no tenant. go test -race, green.
2026-08-06 19:56:46 -07:00
zeekay d2d06d18ea 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 19:46:44 -07:00
hanzo-devandzeekay 41a7115c14 sandbox: say 'nothing to exclude' instead of spelling it as forty zeros
A branch the forge does not have advertises the zero hash. Passing that to
packOut worked only because git cat-file happens to fail on it — an accident
standing in for an intention. Live proof re-run after the change: b48c4db6.
2026-08-06 19:39:20 -07:00
hanzo-devandzeekay b54301f8fc sandbox: cloud pushes the sandbox's commits, so no credential is ever in the pod
A sandbox could commit but not push, because it holds no credential. The
deleted daemon solved that by writing a token to /tmp at 0600 — readable by
the very code it was sandboxing. Every variant of putting the credential in
the pod has that shape, and a credential helper only swaps the secret for the
capability.

So the push does not happen in the sandbox. Cloud reads the commits (a read,
needing no credential) and pushes them itself. There is nothing in the pod to
steal, which makes the property structural rather than a small window.

POST /v1/sandboxes/:id/push. The sandbox builds a packfile with git's own
plumbing; it leaves through the existing exec channel, base64'd and chunked
under the 1 MiB stdout cap, checksummed end to end because that buffer
truncates quietly. Cloud streams those exact bytes to the forge over
receive-pack, so the commits that land are the sandbox's own — author, message
and parents intact — rather than one re-synthesized from a file list.

The credential comes from KMS at orgs/<org>/forge/push and fails closed: no
shared-token fallback, nothing minted, https only. The fast-forward check is
ours to make at this level of the protocol — receive-pack does not enforce it
and git's client does — so without it every push would silently be a force.

Live: sandbox-live-push-1786022970 committed 35116874 and cloud landed it on
git.hanzo.ai/hanzo/sandbox-push-proof, branch sandbox-proof-1786022997, with
the pod verified to hold no copy of the token.
2026-08-06 19:39:20 -07:00
zeekay 666f249646 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:24 -07:00
hanzo-dev d7e8c32569 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 16:59:53 -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
zeekayandhanzo-dev 21652f34be a rail that can be switched off says so
CI/CD / image (push) Successful in 18m11s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m20s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / rollout (push) Failing after 6s
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Failing after 2s
commerce v1.50.7 added PUT /_/commerce/providers/{name} — the verb behind "a payment
rail can be turned on and off" — and the dep bump landed without prose for it, so
`commerce describe` refused and the app could not project its own document. The
subset is fail-closed, so nothing was written and the drift stayed invisible until
the next regeneration asked.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 00:42:42 -07:00
zooqueenandhanzo-dev 74b743065b catalog: ask projects too — the site half was reaching across the same gap
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The corpus has two sources and both were in-process globals the catalog
process cannot see. The index write was one (previous commit). This is the
other, and it failed more quietly still.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ONE fact, stamped ONCE, read through ONE function:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

An app already declares everything a caller needs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things the merge had to earn rather than assume:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1. Plugin had TWO mount fields with TWO signatures

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

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

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

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

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

2. Router restated zip.Router instead of embedding it

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Alongside, four things the port made necessary:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:21:02 -07:00
antje 8ea5a0abab zip v1.24.4: the framework reports, so the app stops repeating it
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Every program now emits its request log natively — method, path, status,
duration, trace and span, the caller when the environment parked one — through
its own logger, so the per-app Logger install is deleted along with the three
test copies of it. The framework also propagates trace context and serves
/metrics from the one registry.

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

Also carries luxfi/metric v1.9.1 transitively pinned by zip — the registry
that records by default.
2026-08-04 16:07:15 -07:00
hanzo-dev 3186567bbc integrations: record why an OAuth callback was rejected
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
failRedirect is the one place a failed OAuth return lands, and it logged
nothing, so every failure was indistinguishable from a request that never
arrived. It now logs the opaque public reason together with a precise
internal cause; the browser still gets exactly one reason, so no oracle is
offered to a caller probing states.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What the regeneration is, measured rather than assumed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:49:29 -07:00
hanzo-dev f345ac70ae pin the two cloud.Request uses that were leaving main red
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The escape-hatch ratchet has been failing on origin/main, naming
apps/risk/policy_wire.go and then apps/commerce/invoices.go. Both are
legitimate; neither was recorded, so the guard could not tell them from a new
one and main stayed red.

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

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

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

The root package is green again.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:34:11 -07:00
hanzo-dev 6ca28c2e5e merge main
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:38 -07:00
hanzo-dev 282f13b2cd reference: the device aggregate names its signal
event.fact is not a rename of event.event, it is a merge — five signals in one
table. Moving the source without naming the signal counted errors and spans as
device observations, and this is the one cross-tenant reader, so a phantom
identity inflates both k-anonymity floors: 425 identities unfiltered against
423 real ones on the live plane.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:25:27 -07:00
hanzo-dev 024e9bacc2 one name for the CORS allowlist
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
config.go read CLOUD_CORS_ORIGINS and fell back to GATEWAY_CORS_ORIGINS, the
pre-rename gateway spelling, "shared with the gateway so both trust boundaries
agree on one list". Measured across every chart in universe, GATEWAY_CORS_ORIGINS
is set ZERO times — by cloud's chart, by the gateway's, by anyone — so the shared
name agreed on nothing. cloud's own values file sets CLOUD_CORS_ORIGINS.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

and the second term is not a narrower platform predicate, it is a self-service one
wearing a registry map. `isAdmin` is org-scoped and an org's OWN admin sets it on a
member of THEIR org, so every `hanzo` admin could enrol any `hanzo` member into the
gate — and `hanzo` is the deployment's brand org in every deployment, and the one
orgRegistryNamespaces gives `hanzoai`. So the far side of the gate chose its own
callers, and what it chose them for was the tag the whole fleet rolls onto at the
next reconcile: iam, kms, gateway and every customer app, in every tenant.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Removes the two attribution paths that were not that:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:04:51 -07:00
hanzo-dev 71ef141068 Merge remote-tracking branch 'origin/main' into HEAD
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 2m54s
CI/CD / image (push) Failing after 20s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:02:25 -07:00
hanzo-dev 91da40a5c3 apps: a seam that wraps nothing is a seam that never runs
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 12s
CI/CD / containment (push) Successful in 1m42s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
zip refuses a program whose middleware has no routes beneath it, and the refusal
was right about three surfaces here. Nothing in this repo called Build() from a
test, so the refusal could only ever surface as a startup panic — or, for a
subsystem no test drove, as silence.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:00:27 -07:00
hanzo-dev 512938e6cf openapi: the duplicate-route fact zip v1.24 replaced, and the golden it left stale
Hanzo CI/CD / cicd (push) Successful in 15s
CI/CD / gate (push) Successful in 16s
CI/CD / containment (push) Successful in 1m44s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
TestMergedAndChainedAreIndistinguishable... pinned a fact so that a zip bump
which changed it would fail here. A zip bump changed it, so it failed — this is
the test working, not breaking.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TestForwardedHostNeverOverridesOurOwnHost and TestSelfDomainsAreAFloorNotADefault
both fail on the parent:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    mount /v1/o11y: no instance running

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:56:07 -07:00
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
1325 changed files with 131388 additions and 26885 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"
+367 -39
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.
@@ -66,14 +66,46 @@ concurrency:
# 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.
#
# cancel-in-progress is a LITERAL false, and the evidence for that is a run
# whose gate had already PASSED. Run 895 (41a0e5a3, event push, branch main):
# gate, cicd and containment all completed `success` at 09:46:45Z after 39
# minutes, and image, rollout, reach, fanout and receipt were then `cancelled`
# at 10:25:26Z having never started. The run's own conclusion is `cancelled`.
# On a push this expression is supposed to be false, so nothing should have
# cancelled anything — the forge does not evaluate it to a boolean, and a
# non-empty string is truthy.
#
# This was landed once as 2f9ffc2d and reverted by 0d7011e2, which argued the
# cancelled runs "were superseded while still QUEUED, not killed mid-gate, so
# cancel-in-progress was never the thing acting on them". That is true of the
# runs it measured (889-892, which never received a runner) and false of run
# 895: a run that never started cannot contain a job 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 did.
#
# Measured over runs 891-909, every one of them a push to main: 16 cancelled,
# 3 failure, 0 success. The one run that earned a release lost it 39 minutes
# after the gate went green. That is the whole of "the gate is green and
# nothing ships".
#
# This does not queue without bound. One run holds the group and the rest
# collapse into a single pending run, so pushes arriving during a release are
# coalesced rather than accumulated — which is what the paragraph above always
# meant by "queued, not cancelled".
#
# The cost, stated plainly: a stale PR run is no longer cancelled by its own
# next push, because the setting can no longer ask what event it is. A PR that
# is pushed to repeatedly holds more than one run. That is cheap next to a
# release lane that cannot finish.
group: cicd-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: false
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 +132,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
@@ -196,6 +256,7 @@ jobs:
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
# GOPRIVATE names exactly the namespace that is private. github.com/hanzoai/*
# is: ai, account, commerce, orm, xorm, beego, csqlite and ~30 more are
@@ -203,7 +264,30 @@ jobs:
# that cannot see them. Everything else stays on the public proxy + checksum
# db, which is what makes a module hash immutable: zap-proto (all 55 repos)
# and luxfi (all 37 deps here) are public and proxy-served.
#
# OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. The module PATH stays
# github.com/hanzoai/* — that is the package's name, not its address — but
# the address git dials is git.hanzo.ai, which is canonical anyway.
#
# This is not a preference. Every release for nine consecutive commits was
# blocked because hanzoai/zen's GitHub collaborator list drifted from its
# sibling modules': the token could read ai, commerce, orm and account, and
# answered `Repository not found` for zen alone. A private repo denies and
# a missing repo denies with the same 404, so the build could not even say
# which had happened. Nothing about zen changed; an ACL beside it did, and
# it stopped the fleet.
#
# A checksum makes the substitution safe rather than merely convenient: the
# forge mirrors the same objects, so the fetched zip hashes to the h1: line
# already committed in go.sum. A forge that served different bytes would
# fail the build, loudly, instead of shipping them.
#
# GH_PAT remains the fallback for a module the forge has not mirrored.
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -264,14 +348,22 @@ 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'
# github.ref arrives NULL on an API rerun (act_runner drops it), which
# skipped the image on the first fully green gate pass this pipeline ever
# had. A rerun keeps event_name and the workflow only triggers from main
# pushes/tags/dispatch, so refuse PRs and accept ref==main OR the rerun's
# null-ref shape.
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == '' || github.ref == null)
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 }
@@ -282,6 +374,47 @@ 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
- name: Claim a version — atomically, before anything is built
id: ver
env:
@@ -434,18 +567,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
@@ -456,22 +602,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.
@@ -492,6 +653,7 @@ jobs:
org.opencontainers.image.source=https://github.com/hanzoai/cloud
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
FORGE_TOKEN=${{ secrets.FORGE_TOKEN }}
# build-push-action can exit 0 before the manifest resolves, so a green run
# could still mean a future ImagePullBackOff. Prove it pulls BEFORE the pin
@@ -506,10 +668,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 }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
@@ -528,6 +698,50 @@ 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 curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/${RECEIPT}" 2>/dev/null; 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
@@ -536,11 +750,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
@@ -574,6 +800,46 @@ 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:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
CODE=$(curl -s -o /tmp/receipt.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 "$(jq -nc --arg r "refs/${{ steps.img.outputs.receipt }}" --arg s "${{ github.sha }}" '{ref:$r, sha:$s}')")
case "$CODE" in
201) echo "recorded: ${{ steps.img.outputs.digest }} booted, built from ${{ github.sha }}" ;;
422) echo "already recorded by another run — same digest, same statement" ;;
*) echo "::error::could not record the smoke receipt for ${{ steps.img.outputs.digest }} (HTTP $CODE). 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.json; exit 1 ;;
esac
# 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:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/${{ steps.img.outputs.receipt }}" \
|| { 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
@@ -621,7 +887,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
# pin.sh probes the registry before it moves anything; these let it read
# a private manifest instead of falling back to anonymous.
@@ -632,16 +897,21 @@ jobs:
VERSION="${{ needs.image.outputs.version }}"
# Secrets come from KMS, never from a file or a repo variable.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
PIN_TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV})"; exit 1; }
# THERE IS NO ORG IN A KMS PATH. The store root comes from the validated
# claim, so the org is the credential's, not the URL's — that is what
# makes another tenant's secret unnameable rather than merely refused.
# This asked for /v1/kms/orgs/<org>/secrets/... which is not a route the
# broker has, so it 404'd on every release since the car was written.
PIN_TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.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}"
git clone --quiet --depth 1 \
@@ -654,8 +924,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"
@@ -683,7 +969,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
@@ -706,13 +992,19 @@ jobs:
with:
go-version-file: go.mod
# Same private-module contract as the containment job: github.com/hanzoai/*
# is private, so it must resolve direct+authenticated and skip a sumdb that
# cannot see it, while everything else stays on the public proxy.
# Same private-module contract as the containment job, including the forge
# substitution: our own modules resolve from git.hanzo.ai (the canonical
# address) with GitHub as the fallback, so one drifted GitHub ACL cannot
# stop a release. go.sum still decides whether the bytes were right.
- name: go env for private modules
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
if [ -n "${FORGE_TOKEN:-}" ]; then
git config --global url."https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"
fi
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/hanzoai/*"
@@ -775,7 +1067,6 @@ jobs:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
run: |
set -euo pipefail
@@ -798,17 +1089,18 @@ jobs:
# naming the exact KMS path to create, instead of quietly shipping a
# cloud nobody's client knows about. That silent version is what the
# fleet has been living in.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
KMS_TOKEN=$(curl -sS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
| jq -r '.accessToken // empty' || true)
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
TOKEN=$(curl -fsS \
"${KMS_ENDPOINT}/v1/kms/orgs/${KMS_ORG}/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.secret.value // empty')
# Same route correction as the pin above: no org in a KMS path.
TOKEN=$(curl -sS \
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/FLEET_DISPATCH_TOKEN?env=${KMS_SECRET_ENV}" \
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // .secret.value // empty' || true)
if [ -z "$TOKEN" ]; then
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at ${KMS_ORG}/deploy (env ${KMS_SECRET_ENV}). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
echo "::error::FLEET_DISPATCH_TOKEN missing in KMS at deploy/ (env ${KMS_SECRET_ENV}, org from the KMS credential). Create it with contents:write + metadata:read on hanzoai/{python-sdk,js-sdk,java-sdk,cli} hanzo-go/sdk hanzo-rs/sdk hanzo-kotlin/sdk hanzo-cpp/sdk hanzo-docs/docs — six owners, so a fine-grained PAT cannot carry it; use a classic PAT with repo scope or a GitHub App installed on all six. Until it exists, every cloud release ships a document no client is regenerated from — which is the failure this car was built to end, so it fails rather than skipping."
exit 1
fi
echo "::add-mask::${TOKEN}"
@@ -933,6 +1225,10 @@ 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 }}
@@ -943,12 +1239,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
@@ -981,3 +1278,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
+195 -65
View File
@@ -7,44 +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.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:sha-a0a4899-amd64
# 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.
#
# 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.
#
# 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
@@ -58,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
@@ -131,16 +141,27 @@ COPY go.mod go.sum ./
# and resolves fine from a clean cache. That is exactly what wedged the release
# on otel-collector v0.144.10. BUMP THE SUFFIX (-v4 -> -v5) to force a cold
# module cache the next time a phantom pin poisons it.
# FORGE_TOKEN, when supplied, points our OWN modules at git.hanzo.ai. The module
# path stays github.com/hanzoai/* — a name, not an address — and git dials the
# canonical forge instead. The longer prefix wins in git, so only hanzoai/* is
# redirected and every other github.com module still goes to GitHub. go.sum is
# unchanged and still authoritative: the forge mirrors the same objects, so the
# zip hashes to the committed h1: line, and a forge serving different bytes fails
# the build rather than shipping them. Both secrets are optional; absent either,
# this falls back to exactly the previous behaviour.
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=secret,id=FORGE_TOKEN \
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
if [ -s /run/secrets/FORGE_TOKEN ]; then \
git config --global url."https://x:$(cat /run/secrets/FORGE_TOKEN)@git.hanzo.ai/hanzoai/".insteadOf "https://github.com/hanzoai/"; \
fi && \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
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.
#
@@ -152,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
@@ -188,19 +213,62 @@ 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).
#
# `ARG REVISION` already existed — but ONLY in that final stage, and an ARG is
# per-stage, so it was never in scope where `go build` runs and no binary in this
# image could name its commit. The wire was connected at one end.
#
# Do not "fix" it by trusting the label. A label is read by whoever thinks to open
# the registry; the PROCESS is read by whoever is holding the outage — and this
# fleet's revision label has itself read `unknown` on natively-built images
# without anyone noticing, which is what a label is worth.
#
# DECLARED HERE, AS LATE AS POSSIBLE, and deliberately not beside ARG VERSION at
# the top of the stage: everything below `COPY . .` is already re-keyed by any
# source change, so a per-commit value costs nothing from this line down. The same
# value in scope ABOVE would re-key `go mod download` and turn every build into a
# full one.
ARG REVISION=unknown
# ONE flag string for EVERY binary in this image. This is a build-stage variable —
# the final stage does not inherit it and nothing reads it at run time; it exists
# so the stamp cannot reach some binaries and miss others.
#
# It has to reach the PLUGINS. cmd/cloud is a router that links zip and the
# manifest, not the package these symbols live in, so `-X github.com/hanzoai/
# cloud.Version=` on /cloud has always been silently dropped — measured: the flag
# shows up in the binary's `go version -m` build record and the value is nowhere
# in the linked bytes. The plugins are what serve /v1/health, and they carried no
# -X whatsoever, so stamping only the entrypoint would have left the process that
# answers the question mute.
ENV GO_LDFLAGS="-s -w -X github.com/hanzoai/cloud.Version=${VERSION} -X github.com/hanzoai/cloud.revision=${REVISION}"
# THE LIGHT HOST (cmd/cloud) — ~400 packages, pure Go, no codec and no subsystem
# (it links zip + the manifest + the light webui console embed, and nothing else).
# It is the ENTRYPOINT. It knows only where each app lives and what path it
@@ -209,14 +277,31 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
# together, so no build in this image is the mega link that once dominated it.
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build \
-ldflags="-s -w -X github.com/hanzoai/cloud.Version=${VERSION}" -o /cloud ./cmd/cloud
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /cloud ./cmd/cloud
# The functional smoke prober (plugin/smoke) — a stdlib-only static binary shipped
# alongside the host so the release gate can `docker exec` it against the freshly-
# built image (and any deployment can be smoked via `docker run --entrypoint /smoke`).
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /smoke ./plugin/smoke
CGO_ENABLED=0 go build -ldflags="$GO_LDFLAGS" -o /smoke ./plugin/smoke
# 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:
@@ -228,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.
@@ -259,9 +358,38 @@ 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="-s -w" -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
# dropped, the build succeeds, and every binary then reports the entirely
# legitimate-looking "unknown" forever. A renamed package or variable would fail
# in exactly the one way nobody looks at, which is how this started.
#
# `go version -m` is NOT a witness — it echoes the -ldflags string that was
# REQUESTED, and that string is present even when the symbol was never set
# (measured on /cloud, whose Version stamp has been dropped all along). Only the
# linked bytes answer.
#
# strings|grep rather than a bare grep: grep treats binary input as non-text and
# its exit status there is not portable across implementations, so a plain
# `grep -qF` can report no match on a binary that demonstrably contains the sha.
# strings normalises to text lines first; binutils is already installed above.
#
# An image built with no REVISION is not a failure — it is a build that cannot
# name its commit, and it says so here and on every health response it serves.
RUN set -eu; \
if [ "$REVISION" = "unknown" ]; then \
echo ">> no REVISION build-arg: this image cannot name its commit, and every health response it serves will report revision=unknown"; \
else \
strings -a /plugins/base | grep -qF "$REVISION" || { echo "FATAL: -X did not reach /plugins/base — github.com/hanzoai/cloud.revision was not resolved, so it was dropped and every health response would report 'unknown'"; exit 1; }; \
echo ">> revision $REVISION linked into the plugins"; \
fi
# Prove a SHIPPED sqlite-backed plugin binds sqlite3_* to libsqlcipher, not a
# plaintext libsqlite3. /plugins/base opens per-org stores under the SAME CGO=1 +
# libsqlite3 build every plugin above got, so it is a real witness for the set.
@@ -304,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
+1079 -59
View File
File diff suppressed because one or more lines are too long
+143 -44
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,
@@ -28,8 +33,22 @@ LDFLAGS ?= -s -w
# .git has nothing to describe. Empty stamps nothing, and cmd/hanzo's
# resolveVersion then answers from the metadata the toolchain embeds by itself.
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
# Path to a hanzoai/console checkout used to build the embedded console bundle.
CONSOLE_DIR ?= ../console
# The commit those same bytes were built FROM — the other half of the question,
# from the same command, in the same -X idiom. `--abbrev=40 --match=''` makes
# describe report the full object name and nothing else.
#
# `--dirty` is load-bearing rather than decorative: on an uncommitted tree it
# appends `-dirty`, which is not a 40-hex name, so cloud.Revision reports
# "unknown" instead of naming a commit whose source is NOT what was built. That
# lie is the one this whole change exists to remove, so the local build must not
# tell it either. Honest by construction, with no second rule to keep in step.
REVISION ?= $(shell git describe --always --abbrev=40 --match='' --dirty 2>/dev/null)
# What a build says about itself, written ONCE: the tag it was published under
# and the commit it came from. Appended per-target for the reason above — `make
# LDFLAGS=...` keeps overriding exactly what it always did — and shared by the
# host and the plugins, because three copies of a stamp is three chances to
# stamp one binary and forget the one that answers /v1/health.
STAMP = -X github.com/hanzoai/cloud.Version=$(VERSION) -X github.com/hanzoai/cloud.revision=$(REVISION)
# Path to a hanzoai/openapi checkout — the SOT the agent-skills catalog is generated from.
OPENAPI_DIR ?= ../openapi
@@ -64,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 smoke test test-fast test-cgo test-codec vet tidy docker docker-push 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-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
webui: ## Build the real console static bundle into webui/dist (go:embed source). CONSOLE_DIR=<path to console>.
@command -v npm >/dev/null 2>&1 || { echo "npm is required to build the console bundle"; exit 1; }
@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; }
@@ -121,7 +149,7 @@ build: cloud ## FAST PATH (default): build the light host into ./bin/cloud. Then
# named cloud — it IS the one real binary, and its ENTRYPOINT the image ships.
cloud: ## Build the light host into ./bin/cloud (links zip + the manifest, none of the apps).
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) -X github.com/hanzoai/cloud.Version=$(VERSION)" -o bin/$@ ./cmd/$@
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o bin/$@ ./cmd/$@
@echo ">> bin/cloud — $$(CGO_ENABLED=$(CGO_ENABLED) $(GO) list -deps ./cmd/cloud | wc -l) packages, $$(du -h bin/cloud | cut -f1)"
# THE RELEASE LAYOUT: the light host plus one dedicated binary per app, all in
@@ -147,7 +175,7 @@ apps: $(APP_BINS) ## Build every app binary into ./bin. Parallelise: make -j app
$(APP_BINS): bin/%:
@test -d plugin/$* || { echo "no plugin/$* — run 'make generate', or check the name against 'make plugin' with no APP"; exit 1; }
@mkdir -p bin
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o $@ ./plugin/$*
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS) $(STAMP)" -o $@ ./plugin/$*
plugin: ## Build ONE app into ./bin: make plugin APP=wallets.
@test -n "$(APP)" || { echo "usage: make plugin APP=<name>"; echo "apps: $(APPS)"; exit 1; }
@@ -191,6 +219,11 @@ run: cloud ## Run the host, building the plugins in RUN_PLUGINS (iam,base,kms,ga
@for a in $$(echo $(RUN_PLUGINS) | tr ',' ' '); do $(MAKE) --no-print-directory plugin APP=$$a; done
./bin/cloud
# dev and lint are the names every repo in the fleet answers to. They are ALIASES
# of the two targets that already do the work, never copies of them, so each of
# those two things still has exactly one recipe.
dev: run ## Alias for run.
smoke: ## Build and run the smoke prober (mount-time integration check).
$(GO) run ./plugin/smoke
@@ -204,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
@@ -229,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.
@@ -254,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:
@@ -278,7 +333,7 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# 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
@@ -288,16 +343,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)" ./...
@@ -320,6 +369,8 @@ test-codec: ## Run the suite against the engine the image ships (cgo + a real li
vet: ## go vet across the module.
CGO_ENABLED=$(CGO_ENABLED) $(GO) vet ./...
lint: vet ## Alias for vet.
# Not part of `test`: it rewrites source, so it runs deliberately, alone. It is how a
# new assertion earns its place — break the property, watch the test go RED. An anchor
# that no longer matches is a hard FAILURE here, never a skip, so a refactor that
@@ -337,5 +388,53 @@ docker: ## Build the Docker image (uses repo Dockerfile, scratch final stage).
docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
# COMPOSE is the check the v1.801.425/.426 outage needed and nobody had. zip
# refuses to compose a program whose middleware could never run, and it refuses at
# BOOT — so fifteen plugins built, linked, passed vet and unit tests, and then
# crash-looped in production. `go build` cannot see it; only running the binary can.
#
# SURVIVAL is the signal, and it is the only honest one. A compose panic is fatal,
# so a process still alive when the timeout kills it (rc 124) composed. Grepping
# the log for a success line does NOT work: `"message":"zip new"` is printed
# BEFORE composition, and reading it as a pass is exactly how a broken build was
# twice reported shipped.
#
# Each app gets a writable data dir and PORT ZERO on all four listeners. Without a
# data dir it dies on `mkdir /var/lib/cloud/orgs`, and without free ports it dies on
# binding :8080/:9653/:9090/:8081 — either way long before it reaches the router, and
# an early death looks like silence, which reads as a pass.
#
# :0 RATHER THAN A COMPUTED PORT BLOCK. This handed out 41000+index*10 and it was
# accidental complexity: the question is "does this binary compose", and answering it
# does not require owning a port namespace. Worse, it answered WRONG — a second run
# inside sixty seconds collided with the first run's sockets in TIME_WAIT, which
# `ss -lnt` does not show, and reported up to 16 healthy apps as DIED. A check that
# invents failures gets ignored exactly as fast as one that misses them. The kernel
# already allocates ports correctly; asking it removes the bookkeeping, the stride,
# the TIME_WAIT window and the cap on concurrency in one move.
#
# CONCURRENT, because the timeout is the cost and it is paid per app: one at a time,
# $(words $(APPS)) apps take most of an hour, and a check nobody runs is how all of
# this reached production. Failures go to files rather than racing onto stdout.
COMPOSE_DIR ?= .compose
COMPOSE_JOBS ?= 8
compose: apps ## Prove every app binary BOOTS — the compose check `go build` cannot do.
@rm -rf $(COMPOSE_DIR) && mkdir -p $(COMPOSE_DIR)
@printf '%s\n' $(APPS) | xargs -P$(COMPOSE_JOBS) -n1 sh -c '\
a=$$0; d=$(COMPOSE_DIR)/$$0; mkdir -p $$d/rt; \
out=$$(CLOUD_DATA_DIR=$$d ZIP_RUNTIME_DIR=$$d/rt \
CLOUD_LISTEN=:0 CLOUD_ZAP_LISTEN=:0 \
CLOUD_HEALTH_LISTEN=:0 CLOUD_ADMIN_LISTEN=:0 \
timeout 25 ./bin/$$a 2>&1); rc=$$?; \
if printf "%s" "$$out" | grep -q "does not compose"; then \
{ echo "PANIC $$a"; printf "%s\n" "$$out" | grep -E "zip: (the group|GET|POST|PUT|PATCH|DELETE)" | sed "s/^/ /" | head -4; } > $$d.fail; \
elif [ $$rc -ne 124 ]; then \
echo "DIED $$a (rc=$$rc): $$(printf "%s" "$$out" | tail -1 | cut -c1-140)" > $$d.fail; \
fi'
@set -- $(COMPOSE_DIR)/*.fail; \
if [ -e "$$1" ]; then cat $(COMPOSE_DIR)/*.fail; n=$$(ls $(COMPOSE_DIR)/*.fail | wc -l); \
rm -rf $(COMPOSE_DIR); echo ">> compose FAILED: $$n of $(words $(APPS)) apps"; exit 1; \
else rm -rf $(COMPOSE_DIR); echo ">> compose: $(words $(APPS)) apps boot"; fi
clean: ## Remove built artifacts.
rm -rf bin
rm -rf bin $(COMPOSE_DIR)
+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)
}
}
+254
View File
@@ -0,0 +1,254 @@
package cloud
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/apps/sites"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// App returns an app carrying everything a Hanzo program must carry, in the one
// order those parts are correct in. It is the only way to obtain one: a program
// mounts its subsystem on what it gets back and never builds a zip.App itself.
//
// The point is what a caller no longer has the opportunity to forget. Identity is
// not an option a program passes, it is a property of the value it receives, so a
// program is either holding an app that identifies its callers or it is holding
// nothing. Every other member here was equally forgettable and was equally
// forgotten: the o11y binary assembled its own app and reached production with no
// panic recovery, no request id, no response-header posture, no tracing, no
// request log and no typed-op enrichment — a shape nobody chose and nobody could
// see, because there was nothing to compare it against.
//
// WHERE THE EDGE IS. Production runs ingress → gateway → the front door
// (cmd/cloud) → this program. The gateway is the public edge and owns rate
// limiting for the internet. The front door installs no middleware of its own —
// it routes, serves the console, threads operator flags and scopes credentials —
// so a program built here is its OWN edge and defends itself. That is why the
// browser and flood defenses are here rather than borrowed from a parent. A
// program reached over the plane socket instead trusts what its host asserted:
// the kernel answers which process is calling, and the boundary's findings travel
// with the request.
//
// name is what the program calls itself in a diagnostic. tools is the 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: 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.
ErrorHandler: ErrorHandler,
// Static Server fallback for responses the ProductionHeaders middleware
// cannot reach — the transport's own pre-routing errors (431/400) and any
// fiber path that bypasses the chain. Set to this deployment's brand so
// those bytes read Server: <brand>, never the framework default "zip" or
// "fasthttp" (zip>=v1.8.1 propagates this onto the fasthttp transport).
// Handled responses are still branded per-Host by ProductionHeaders.
ServerHeader: cfg.Brand,
})
// Canonical middleware pipeline. Order matters:
// 1. Recover — panic → JSON 500
// 2. RequestID — generate / propagate X-Request-Id
// 3. Tracing — one OTel SERVER span per /v1/* request, over ZAP
// 4. Logger — request-line log
// 5. SanitizeIdentity — establish a VALIDATED principal (see Identify)
app.Use(middleware.Recover())
app.Use(middleware.RequestID())
// Production response-header posture — the Stripe/Cloudflare/GitHub-grade
// signals plus a security floor, from ONE home in the framework so every
// service inherits the same wire posture. Registered right after RequestID
// (before the site edge and the business chain) so its headers ride out on
// every response: success, error, 404, AND the public-site static bytes.
// - Server: the white-label brand of the request Host (BrandForHostOK) — a
// lux/zoo caller is never served "hanzo" and no response leaks the
// framework name; an unmatched Host falls back to this deployment's own
// brand (cfg.Brand), never a framework/single-brand default.
// - X-Api-Version: the build version (brand-neutral key) for support correlation.
// - HSTS + nosniff: the always-safe security floor (no X-Frame-Options/CSP
// here — the console SPA owns its own framing rules).
// X-Request-Id stays owned by RequestID above; the two compose.
app.Use(middleware.ProductionHeaders(middleware.ProductionHeadersConfig{
Brand: func(host string) string { b, _ := BrandForHostOK(host); return b },
Neutral: cfg.Brand,
Version: cfg.Version,
HSTS: true,
}))
// Markdown content negotiation. Registered here — outermost of the business
// chain, just inside Recover/RequestID — so its post-Continue transform sees
// the FINAL response body and re-serializes it via zap-proto/md when the
// caller asked for markdown (Accept: text/markdown or ?format=md). JSON stays
// the default for machines; cfg.MarkdownDefaultPrefixes lets designated
// agent endpoints (/v1/code/, /v1/agents/…) default to markdown. Touches NO
// handler and fails safe (a render error leaves the JSON intact). See
// middleware_markdown.go.
app.Use(MarkdownNegotiation(cfg.MarkdownDefaultPrefixes))
// Request tracing. Sits right after RequestID (so the span carries the
// request_id) and BEFORE identity/audit/billing/handlers, so the whole
// authenticated pipeline nests under one span and the span CONTEXT it writes
// via SetContext parents every downstream span (agent.run → agent.step →
// chat) into a single trace. Spans ship over the SAME global provider installed
// by InstallTelemetry, landing in hanzoai/datastore.
// Health/readiness/metrics + non-/v1 paths are skipped (see traceable). See
// middleware_tracing.go.
app.Use(TracingMiddleware())
// No request logger is installed here: zip reports every request natively —
// method, path, status, duration, trace and span, and the caller when the
// environment parked one — through the app's own logger. A second line per
// request would say less and cost the same.
// Public site edge (clients/sites). Installed FIRST — after Recover/RequestID/
// Logger, BEFORE SanitizeIdentity + BillingGate — so a request whose Host is a
// published-site host (`<slug>.hanzo.app`) is served the site's static bytes
// from OUR S3 and returns HERE, never entering the authenticated/billed API
// pipeline. A published site is a PUBLIC artifact: no IAM JWT, no balance gate.
// For every other Host this middleware calls Continue() and the pipeline below
// runs unchanged. The slug→{org,bucket,prefix} resolver is the projects store,
// injected at its Mount via sites.SetResolver; until then a site host 404s
// honestly. Org isolation (org+prefix come only from the store keyed by the
// validated slug; object keys are rooted-clean) lives in clients/sites.
// The edge asks the app that owns the store when it is not in this process,
// which in production is always: the pod boots ~25 single-app processes, so
// the registry projects.Mount writes is nil here. Co-resident still wins with
// no hop — currentResolver prefers the in-process one.
sites.SetFallbackResolver(planeSites{})
app.Use(sites.New(sites.ConfigFromEnv(cfg.Domain), deps.Logger).Middleware())
// Edge policy — the role this program absorbs because nothing in front of it
// installs middleware. Runs BEFORE identity by design:
// - EdgeCORS answers the browser OPTIONS preflight (which carries no
// credentials) and short-circuits it, so a preflight never reaches auth.
// No-op unless CLOUD_CORS_ORIGINS is set (the shared ingress owns CORS on
// the recommended rollout — enabling both would double the ACAO header).
// - EdgeRateLimit caps an ANONYMOUS per-IP flood before the JWKS/validate/
// downstream work it would trigger — the one gap ScopeRateLimit (which keys
// on the validated org, below) structurally can't see. Keyed on the
// public client IP; in-cluster direct callers (no X-Forwarded-For) are
// exempt, matching the standalone gateway's public-only scope. See
// middleware_edge.go.
//
// 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.
//
// The two halves are one function because each is wrong without the other, and
// wrong in a way nothing reports. IdentityMiddleware deletes the authority
// headers a client sent and re-mints them from a verified IAM token, so it runs
// first: what it produces is the only principal in the process anyone may trust.
// The enrichment then parks that principal on the request context, which is the
// only path by which a typed op reaches it — a zip.Get[In, Out] handler receives a
// context and its decoded In and nothing else. Reversed, it parks whatever the
// caller claimed for itself. Installed alone, the boundary validates a caller and
// then every typed op reads an empty org and refuses that same caller, which
// reaches the wire as a 403 from a service behaving exactly as built.
//
// That last failure is the reason this is a function rather than two lines of
// advice. It is what the o11y binary did while assembling its own app, and its
// subsystem then compensated from inside its own Mount, on a group node that
// owned no routes — a program zip refuses to compose, which is the outage.
func Identify(app *zip.App, cfg *Config) {
// The identity trust boundary. HIP-0519 says identity is verified once, at the
// edge, and that is the shape to reach. It rests on ONE assumption: the gateway
// is the only ingress. That assumption does not hold here yet, and the estate's
// own red-team probe says so — with this middleware removed, a request carrying
// a forged X-Org-Id, X-User-Id and X-User-IsAdmin reads another org's secret
// VALUE from the in-cluster KMS listener:
//
// PROBE (b) forged org + forged X-User-Id + IsAdmin → 200 {"value":"…"}
//
// So this stays until service listeners are unreachable except through the
// gateway. Removing it is a network-policy change first and a code change
// second, and doing the code half alone is a cross-tenant secret read.
// red_orgscope_isolation_test.go and TestAudit_AnonRequestNotAttributedToForgedOrg
// fail the moment it is dropped; they are the gate on that work, not obstacles
// to it.
app.Use(IdentityMiddleware(cfg))
// Besides the validated org, this carries the request a proxying subsystem
// forwards identity from and the slot a creator writes 201 or 202 into. It must
// precede every typed route, because fiber runs middleware in registration
// order and one installed after its leaves never runs. A subsystem whose routes
// are spread across several top-level nouns owns no single prefix to hang it
// on, which is the other reason it belongs to whoever composes the app. See
// typed.go.
app.Use(Bridge())
}
+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
+82 -23
View File
@@ -150,19 +150,9 @@ func MountAccount(app cloud.Router, deps cloud.Deps) error {
// routesAccount wires the specific self-service routes (order 48).
func routesAccount(s *cloud.Service[state], app cloud.Router) error {
// Bridge FIRST: a typed op receives only a context, so the request facts its
// signature drops — here the VALIDATED principal every route resolves its caller
// from — reach it by being parked there. fiber runs middleware in registration
// order, so this must precede the leaves below. Serve installs one app-wide too
// and nesting is harmless (the inner one is what the handler sees); this one is
// what makes the subsystem self-sufficient when it is mounted on a bare app,
// which is exactly what its own tests do.
//
// It goes through Use, not Group(prefix, mw): account's routes are spread across
// six top-level nouns, so it owns no single prefix to hang a group on — and
// Router.Use is the door that fans middleware out over the prefixes the
// composition root declared for this subsystem, which is precisely that set.
app.Use(cloud.Bridge())
// The composer owns cloud.Bridge: the fused host installs it once at its root
// and the plugin constructor does the same for a plugin program, so no
// subsystem installs it.
// The typed registrars take the App behind the Router: a typed op is a route
// PLUS a registry entry, and the registry lives on the App (scope.go). A
@@ -599,13 +589,68 @@ type onboardResp struct {
// Additional is true when the caller already had an organization and this one
// was created WITHOUT moving them into it — they reach it via the org switcher.
Additional bool `json:"additional"`
// AccessKey is the identifier of the org-scoped credential provisioning minted
// with the organization. Present on a first run that actually minted one.
AccessKey string `json:"accessKey,omitempty"`
// AccessSecret is that credential's confidential half, returned ONCE — on the
// response that mints it and never again. IAM keeps only its argon2id digest
// and blanks the plaintext, so this is the single moment it exists in a form
// its owner can read; a replay of the same provision re-reveals nothing.
AccessSecret string `json:"accessSecret,omitempty"`
}
// hasHomeOrg reports whether the caller already OWNS an organization — the fact
// that separates a FIRST-RUN onboarding from an ADDITIONAL one.
//
// Carrying an X-Org-Id is NOT that fact, and reading it as one is what left a
// fresh sign-up unable to get a workspace. Federated sign-up files a brand-new
// user under the sign-up APPLICATION's own organization (iam
// internal/oidc/federation.go: `org := app.Organization`, which for hanzo-console
// is the brand org — the same value hanzoai/account publishes as SignupOrg), so
// the very first request a new customer ever makes already carries an owner.
// Taken for a home it sent them down the ADDITIONAL branch, which creates an org
// and leaves them OUTSIDE it, and answered `personal: true` with a 409 that was
// true of the landing org and useless to the person who had just signed up.
//
// The orgs a sign-up can land in are exactly the ones this package already
// refuses to hand to a customer — onboarding.go's reservedOrgs, the brand/staff
// and IAM system orgs. One list, one fact, asked twice: an org no customer may
// CREATE is likewise an org no customer can be said to OWN. Naming the set rather
// than the single brand constant is also what keeps a white-labelled deployment
// correct, where the landing org is that brand's own.
//
// STANDING BEATS THE LANDING, and that is not a nicety. A SuperAdmin IS a member
// of the reserved `admin` org — that membership is the whole definition — so
// treating it as a landing and moving them out would strip the privilege. An org
// ADMIN therefore always counts as owning their org. Only IAM may attest to that,
// so it is read from the authoritative row; a header would let a caller elect
// their own move.
//
// A caller already in a real tenant is spared the read entirely: that org is
// theirs whatever standing they hold in it, so an invited member creating a
// second org is never yanked out of the team that invited them.
func hasHomeOrg(ctx context.Context, iam *iamClient, cr caller) (bool, error) {
if cr.owner == "" {
return false, nil // no org at all — unambiguously a first run
}
if !isReservedOrg(cr.owner) {
return true, nil // a real tenant: theirs, and never to be moved out of
}
row, err := iam.getUserRow(ctx, cr.id)
if err != nil {
// Fail closed: unresolved standing must never be read as "no standing",
// because that answer is the one that MOVES the user.
return false, zip.Errorf(http.StatusBadGateway, "could not resolve your account: %v", err)
}
return row.IsAdmin, nil
}
// Onboard creates the caller's organization. Two flows, keyed on whether the caller
// already has a home org (mirrors app/onboard/route.ts):
//
// - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT
// carries the new owner and the cloud scopes everything to it.
// - FIRST-RUN (no home org): create + MOVE the user in as admin, so their next
// JWT carries the new owner and the cloud scopes everything to it. This is the
// path a fresh OAuth sign-up takes, from the sign-up application's org.
// - ADDITIONAL (owner set): create the org but do NOT move the user — a move
// changes their IAM owner (stripping a SuperAdmin's status + orphaning their
// current org). They reach the new org via the OrgSwitcher, which re-scopes
@@ -625,7 +670,10 @@ func (o ops) onboard(ctx context.Context, in *onboardReq) (*onboardResp, error)
body := *in
rctx := c.Context()
additional := cr.owner != ""
additional, herr := hasHomeOrg(rctx, s.State.iam, cr)
if herr != nil {
return nil, herr
}
if additional && body.Personal {
return nil, zip.ErrConflict("you already have an organization; name the new one explicitly")
}
@@ -678,12 +726,20 @@ func (o ops) onboard(ctx context.Context, in *onboardReq) (*onboardResp, error)
return &onboardResp{Org: slug, DisplayName: displayName, Additional: false}, nil
}
// onboardFirstRun drives the ONE atomic IAM provision for a zero-org caller (create
// org + move them in as admin + mint the hashed org-scoped credential), replacing
// the create-org + move-user pair so a mid-flight retry converges on the founder's
// own org instead of orphaning it. The org starts at a ZERO balance — usage is
// pre-paid, so there is no signup grant. Split out so the provisioning glue is
// unit-tested against mock IAM without the CSRF/routing/principal shell.
// onboardFirstRun drives the ONE atomic IAM provision for a caller with no home
// org (create org + move them in as admin + mint the hashed org-scoped
// credential), replacing the create-org + move-user pair so a mid-flight retry
// converges on the founder's own org instead of orphaning it. The org starts at a
// ZERO balance — usage is pre-paid, so there is no signup grant. Split out so the
// provisioning glue is unit-tested against mock IAM without the CSRF/routing/
// principal shell.
//
// The minted credential travels back on THIS response because this is the only
// moment it can: IAM stores the argon2id digest and blanks the plaintext, so the
// secret exists in readable form exactly once, in the answer to the call that
// minted it. Dropping it here left a customer holding an account whose credential
// had been issued and could never be obtained. It is revealed, never persisted in
// the clear, and a replay (which mints nothing) carries no secret at all.
func onboardFirstRun(ctx context.Context, iam *iamClient, callerID, slug, displayName string, personal bool) (onboardResp, error) {
row, err := iam.getUserRow(ctx, callerID)
if err != nil {
@@ -693,7 +749,10 @@ func onboardFirstRun(ctx context.Context, iam *iamClient, callerID, slug, displa
if err != nil {
return onboardResp{}, zip.Errorf(http.StatusBadGateway, "could not provision the organization: %v", err)
}
return onboardResp{Org: res.Org, DisplayName: displayName, Additional: false}, nil
return onboardResp{
Org: res.Org, DisplayName: displayName, Additional: false,
AccessKey: res.AccessKey, AccessSecret: res.AccessSecret,
}, nil
}
// resolveOnboardName derives the base slug + display name from the request, or a
+9
View File
@@ -255,12 +255,20 @@ func mountApp(t *testing.T, base, clientID, clientSecret string) *zip.App {
return mount(t, "hanzo")
}
// compose installs what a host installs. A subsystem never installs cloud.Bridge:
// the program's composer owns it — serve.go at the root of the fused host, the
// plugin constructor for a plugin program. In a test the test is the composer, so
// it owes the same install; skipping it drives a program where every org-scoped op
// answers 403 for a reason production callers never see.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mount mounts the account subsystem on a bare app — exactly what production
// registers (account@48). The caller sets the IAM env (IAM_URL / IAM_MINT_CLIENT_*)
// before calling.
func mount(t *testing.T, brand string) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
deps := cloud.Deps{Logger: luxlog.New("test"), Brand: brand}
if err := MountAccount(app, deps); err != nil {
t.Fatalf("MountAccount: %v", err)
@@ -742,6 +750,7 @@ func TestAccountClaimsNothingUnderIAM(t *testing.T) {
t.Setenv("IAM_MINT_CLIENT_SECRET", "s3cr3t")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %v", err)
}
+1
View File
@@ -120,6 +120,7 @@ func mountAvatar(t *testing.T) (*zip.App, *memVFS, *fakeIAM) {
// sources could not enqueue: a framework cap below the app's, surfacing as an
// opaque error nobody could act on.
app := zip.New(zip.Config{Logger: luxlog.New("test"), BodyLimit: edgeBodyLimit})
compose(app)
deps := cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo", Domain: "api.hanzo.ai", VFS: vfs}
if err := MountAccount(app, deps); err != nil {
t.Fatalf("MountAccount: %v", err)
+18 -3
View File
@@ -62,10 +62,25 @@ func PinBillingSubject() zip.Handler {
// Not a validated customer — admit ONLY a trusted in-proc S2S caller that
// names its own org (same admission billingData makes), leaving its query
// untouched. Everything else is refused before the read runs.
if s2sBillingCall(c) && c.Org() != "" {
return c.Next()
//
// The two refusals are DIFFERENT answers and must not share a status. A
// service token is a credential: presenting one and omitting X-Org-Id is
// an authenticated request that names no scope, which is 403. Presenting
// nothing is not signed in, which is 401 — and the difference is load
// bearing on the customer path, because a browser re-authenticates on 401
// and merely reports 403. These routes moved here from cloud's billing
// app, which answered 401 deliberately ("a customer's own billing action,
// so no identity is 401 sign in, never the wildcard's admin 403"); serving
// them in-process silently made every one of them 403, so an expired
// session on the saved-cards screen showed a permission error instead of
// sending the customer to sign in.
if s2sBillingCall(c) {
if c.Org() != "" {
return c.Next()
}
return zip.ErrForbidden("X-Org-Id is required to scope a service-token billing read")
}
return zip.ErrForbidden("sign in to view billing")
return zip.ErrUnauthorized("sign in to view billing")
}
subject := account.Payer(account.Credential{
+8 -4
View File
@@ -49,8 +49,10 @@ var alice = map[string]string{"X-User-Id": "alice", "X-Org-Id": "acme"}
func pinApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// MountAccount installs the identity middleware PinBillingSubject relies on; mounting
// it keeps the probe on the same trust plane as the real co-resident registration.
// compose installs the identity middleware PinBillingSubject relies on; mounting
// the real subsystem keeps the probe on the same trust plane as the co-resident
// registration.
compose(app)
if err := MountAccount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo"}); err != nil {
t.Fatalf("MountAccount: %v", err)
}
@@ -120,8 +122,10 @@ func TestPinBillingSubject_RefusesUnvalidated(t *testing.T) {
app := pinApp(t)
code, _ := callH(t, app, http.MethodGet, "/probe?userId=victim",
map[string]string{"X-Org-Id": "victim"}, "")
if code != http.StatusForbidden {
t.Fatalf("unvalidated caller: want 403, got %d", code)
// 401, not 403: no credential was presented at all, and a browser only
// re-authenticates on 401. A forged X-Org-Id is not a credential.
if code != http.StatusUnauthorized {
t.Fatalf("unvalidated caller: want 401, got %d", code)
}
}
+7 -1
View File
@@ -122,10 +122,16 @@ func (c *iamClient) provision(ctx context.Context, owner, name, orgSlug string,
// userRow is the subset of an IAM user the onboarding path reads to resolve the
// caller's authoritative (owner, name) — a zero-org caller's owner is not on its
// token, so provision needs it from the row.
// token, so provision needs it from the row — and whether they ADMIN the org they
// are in, which is what tells a home org from a place they merely landed.
type userRow struct {
Owner string `json:"owner"`
Name string `json:"name"`
// IsAdmin is IAM's org-admin bit: standing in Owner, as opposed to mere
// membership of it. It is read from the ROW and never from a header — the
// decision it feeds moves a user between organizations, so a caller must not
// be able to elect their own move.
IsAdmin bool `json:"isAdmin"`
}
// getUserRow resolves the user by the caller's id (the same read the move did) into
+74
View File
@@ -0,0 +1,74 @@
package account
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// TestOnboardFirstRun_RevealsTheCredentialItMinted — provisioning mints the org's
// credential and the secret half is shown ONCE, on the response that mints it
// (IAM stores only its argon2id digest and blanks the plaintext, so there is no
// second chance to read it). Dropping it on the floor left a customer holding an
// account whose credential had been issued and could never be obtained.
func TestOnboardFirstRun_RevealsTheCredentialItMinted(t *testing.T) {
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/iam/users/get":
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"owner": "hanzo", "name": "dave"},
})
case "/v1/iam/admin/provision":
_, _ = io.ReadAll(r.Body)
_ = json.NewEncoder(w).Encode(map[string]any{
"org": "dave", "accessKey": "pk-live-abc", "accessSecret": "sk-live-xyz",
})
default:
http.NotFound(w, r)
}
}))
defer iamSrv.Close()
iam := &iamClient{base: iamSrv.URL, clientID: "c", clientSecret: "s", serviceToken: "svc", http: &http.Client{}}
resp, err := onboardFirstRun(t.Context(), iam, "hanzo/dave", "dave", "Dave", true)
if err != nil {
t.Fatalf("onboardFirstRun: %v", err)
}
if resp.AccessKey != "pk-live-abc" {
t.Fatalf("accessKey = %q, want the minted pk- (the caller has no other way to learn it)", resp.AccessKey)
}
if resp.AccessSecret != "sk-live-xyz" {
t.Fatalf("accessSecret = %q, want the one-time reveal of the minted sk-", resp.AccessSecret)
}
}
// TestOnboardFirstRun_RevealsNothingItDidNotMint — on a replay IAM returns the
// access key but no secret (it holds only the digest). The response must then
// carry no secret rather than an empty field a client could mistake for one.
func TestOnboardFirstRun_RevealsNothingItDidNotMint(t *testing.T) {
iamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/iam/users/get":
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "data": map[string]any{"owner": "hanzo", "name": "dave"},
})
case "/v1/iam/admin/provision":
_ = json.NewEncoder(w).Encode(map[string]any{"org": "dave", "accessKey": "pk-live-abc"})
default:
http.NotFound(w, r)
}
}))
defer iamSrv.Close()
iam := &iamClient{base: iamSrv.URL, clientID: "c", clientSecret: "s", serviceToken: "svc", http: &http.Client{}}
resp, err := onboardFirstRun(t.Context(), iam, "hanzo/dave", "dave", "Dave", true)
if err != nil {
t.Fatalf("onboardFirstRun: %v", err)
}
if resp.AccessSecret != "" {
t.Fatalf("accessSecret = %q, want empty — a replay re-reveals nothing", resp.AccessSecret)
}
}
+130
View File
@@ -0,0 +1,130 @@
package account
import (
"net/http"
"testing"
)
// The day-one path: a brand-new user signs up through OAuth and asks for their
// own workspace.
//
// Federated sign-up files the new user under the sign-up APPLICATION's own
// organization (iam internal/oidc/federation.go: `org := app.Organization`), so
// the very first request they ever make already carries an X-Org-Id — the brand
// org, e.g. "hanzo". That org is one this package already refuses to hand to a
// customer (onboarding.go's reservedOrgs), so landing in it is not owning it.
//
// Read as "already has an org" it sent them down the ADDITIONAL branch, which
// creates an org and leaves the user OUTSIDE it, and answered `personal: true`
// with 409 "you already have an organization" — thirty seconds after signing up,
// about an org that was never theirs.
// TestOnboard_OAuthSignup_GetsItsOwnOrg drives a real OAuth-shaped signup end to
// end through the mounted route: a validated principal whose org is the sign-up
// application's, asking for a personal workspace. It must end OWNING its own org.
func TestOnboard_OAuthSignup_GetsItsOwnOrg(t *testing.T) {
f := newFakeIAM()
// The row federated sign-up wrote: owner is the sign-up application's org, and
// the user admins nothing there — they were deposited, not enrolled.
f.user["hanzo/dave"] = map[string]any{
"owner": "hanzo", "name": "dave", "type": "normal-user", "isAdmin": false,
}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "dave", "hanzo", `{"personal":true}`)
if code != http.StatusOK {
t.Fatalf("OAuth signup asking for its own workspace: want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if resp.Additional {
t.Fatalf("a fresh signup's FIRST org must not be an additional one: %+v", resp)
}
if resp.Org != "dave" {
t.Fatalf("personal org slug = %q, want %q", resp.Org, "dave")
}
// The whole point: they must end up IN it. An org they do not own is the bug.
if f.movedTo["hanzo/dave"] != "dave" {
t.Fatalf("signup must be moved into the org it just created, movedTo=%v", f.movedTo)
}
if owner, _ := f.createdOrgs[0]["owner"].(string); owner != adminOrg {
t.Fatalf("created org must be owned by %q, got %q", adminOrg, owner)
}
}
// TestOnboard_OAuthSignup_NamedOrgAlsoMoves is the same first run through the
// other door — a named org rather than a personal one. It took the ADDITIONAL
// branch silently: 200, an org created, and the founder left outside it.
func TestOnboard_OAuthSignup_NamedOrgAlsoMoves(t *testing.T) {
f := newFakeIAM()
f.user["hanzo/dave"] = map[string]any{"owner": "hanzo", "name": "dave", "isAdmin": false}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "dave", "hanzo", `{"name":"Acme Rockets"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if resp.Additional {
t.Fatalf("a fresh signup's first named org must not be additional: %+v", resp)
}
if f.movedTo["hanzo/dave"] != "acme-rockets" {
t.Fatalf("founder must be moved into their own org, movedTo=%v", f.movedTo)
}
}
// TestOnboard_SuperAdminKeepsTheirOrg holds the line the landing-org rule must not
// cross. A SuperAdmin's privilege IS their membership of the reserved `admin` org
// (owner == "admin"), so treating that as a landing and moving them out would
// strip the very thing that makes them one. Standing beats the landing, and only
// IAM may attest to it — a header would let a caller elect their own move.
func TestOnboard_SuperAdminKeepsTheirOrg(t *testing.T) {
f := newFakeIAM()
f.user["admin/root"] = map[string]any{"owner": "admin", "name": "root", "isAdmin": true}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
// A named additional org: created, but the SuperAdmin is NOT moved.
code, body := call(t, app, http.MethodPost, "/v1/orgs", "root", "admin", `{"name":"Side Project"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if !resp.Additional {
t.Fatalf("a SuperAdmin's new org is an ADDITIONAL one: %+v", resp)
}
if len(f.movedTo) != 0 {
t.Fatalf("a SuperAdmin must never be moved out of the admin org, movedTo=%v", f.movedTo)
}
// And the 409 stays correct where it was always correct: asking for a personal
// workspace when you already hold one is still a conflict.
code, _ = call(t, app, http.MethodPost, "/v1/orgs", "root", "admin", `{"personal":true}`)
if code != http.StatusConflict {
t.Fatalf("personal-while-orged: want 409, got %d", code)
}
}
// TestOnboard_MemberOfATenantIsNotFirstRun keeps an invited teammate where they
// are. Their org is a real tenant, not a landing, so their new org is additional
// however little standing they hold in it — a move would yank them out of the
// team that invited them.
func TestOnboard_MemberOfATenantIsNotFirstRun(t *testing.T) {
f := newFakeIAM()
f.user["acme/bob"] = map[string]any{"owner": "acme", "name": "bob", "isAdmin": false}
app := mountApp(t, f.server(t).URL, "hanzo-console", "s3cr3t")
code, body := call(t, app, http.MethodPost, "/v1/orgs", "bob", "acme", `{"name":"Side Project"}`)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
var resp onboardResp
mustJSON(t, body, &resp)
if !resp.Additional {
t.Fatalf("a tenant member's new org is additional: %+v", resp)
}
if len(f.movedTo) != 0 {
t.Fatalf("a tenant member must never be moved, movedTo=%v", f.movedTo)
}
}
+8 -6
View File
@@ -62,13 +62,15 @@ func init() {
Example: json.RawMessage(`{"type":"publishable"}`),
})
zip.Describe("POST /v1/orgs", zip.Doc{
Description: "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT\n carries the new owner and the cloud scopes everything to it.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
Description: "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no home org): create + MOVE the user in as admin, so their next\n JWT carries the new owner and the cloud scopes everything to it. This is the\n path a fresh OAuth sign-up takes, from the sign-up application's org.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
Fields: map[string]string{
"onboardReq.name": "Name is the organization's display name. Ignored when personal is true, which\nderives the name from the caller's own username instead.",
"onboardReq.personal": "Personal asks for the caller's own workspace: the name is derived from their\nusername and the slug auto-suffixes to stay unique. Meaningless — and refused\n— for a caller who already has an organization.",
"onboardResp.additional": "Additional is true when the caller already had an organization and this one\nwas created WITHOUT moving them into it — they reach it via the org switcher.",
"onboardResp.displayName": "DisplayName is the organization's human name.",
"onboardResp.org": "Org is the created organization's slug, which is what X-Org-Id carries.",
"onboardReq.name": "Name is the organization's display name. Ignored when personal is true, which\nderives the name from the caller's own username instead.",
"onboardReq.personal": "Personal asks for the caller's own workspace: the name is derived from their\nusername and the slug auto-suffixes to stay unique. Meaningless — and refused\n— for a caller who already has an organization.",
"onboardResp.accessKey": "AccessKey is the identifier of the org-scoped credential provisioning minted\nwith the organization. Present on a first run that actually minted one.",
"onboardResp.accessSecret": "AccessSecret is that credential's confidential half, returned ONCE — on the\nresponse that mints it and never again. IAM keeps only its argon2id digest\nand blanks the plaintext, so this is the single moment it exists in a form\nits owner can read; a replay of the same provision re-reveals nothing.",
"onboardResp.additional": "Additional is true when the caller already had an organization and this one\nwas created WITHOUT moving them into it — they reach it via the org switcher.",
"onboardResp.displayName": "DisplayName is the organization's human name.",
"onboardResp.org": "Org is the created organization's slug, which is what X-Org-Id carries.",
},
Example: json.RawMessage(`{"name":"Acme"}`),
})
+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
+6 -5
View File
@@ -115,11 +115,12 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
func routes(app cloud.Router, s *cloud.Service[core.State]) {
o := ops{s: s}
z := cloud.ZipApp(app)
// The bridge FIRST: fiber runs middleware in registration order, so one installed
// after these leaves would never run — and every op below takes the request off the
// context it parks. Bounded to admin's own subtree. Serve installs one app-wide too;
// nesting is harmless, and this is what makes the surface testable on a bare app.
app.Group("/v1/admin").Use(cloud.Bridge())
// Every op below takes the request off the context, and whoever composes the app
// parks it there — at the root, ahead of these leaves, since fiber runs
// middleware in registration order. This surface installs none of its own: one
// it installed for itself could only hang on a /v1/admin node, and every op
// below registers through the root, so that node would carry middleware over an
// empty subtree and zip refuses to compose it.
// Org-scoped panels — AdmitScoped. Cross-tenant reads are impossible for a
// non-super caller.
+3
View File
@@ -39,6 +39,7 @@ func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, pat
func mountService(t *testing.T, iamURL, commerceURL, healthURL string) (func(method, path string, hdr map[string]string) (*http.Response, []byte), *cloud.Service[core.State], *fiber.App) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
s := &cloud.Service[core.State]{State: core.State{
IAM: iam.New(iamURL),
Commerce: commerce.New(commerceURL, "test-token"),
@@ -731,6 +732,7 @@ func TestMount_NilGuards(t *testing.T) {
t.Error("Mount(nil app) must error")
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{}); err == nil {
t.Error("Mount(nil logger) must error")
}
@@ -747,6 +749,7 @@ func servePlatformEmpty(t *testing.T) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
app := zip.New(zip.Config{AppName: "platform"})
compose(app)
zip.Post[struct{}, plane.Fleet](app, "/platform/fleet",
func(context.Context, *struct{}) (*plane.Fleet, error) {
return &plane.Fleet{}, nil
+7 -5
View File
@@ -34,10 +34,12 @@ func mountWithStore(t *testing.T) (*auditstore.Recorder, func(method, path strin
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin", AuditStore: rec}}
// Mirror the real mount: the request bridge, then the typed ops. A typed op sees
// the caller only through the bridge, so registering routes without it would test
// a wiring that cannot exist.
app.Group("/v1/admin").Use(cloud.Bridge())
// Stand in for the composer: the principal enrichment at the root, then the
// typed ops — the order cloud.App gives every production program. A typed op
// sees the caller only through what the enrichment parks, and a group at
// /v1/admin would be a node of its own with no routes beneath it, which zip
// refuses to compose.
app.Use(cloud.Bridge())
Routes(app, s)
fa := app.Fiber()
@@ -206,7 +208,7 @@ func TestAdminAudit_DeniedWithoutSuperAdmin(t *testing.T) {
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
s := &cloud.Service[core.State]{State: core.State{AdminOrg: "admin"}} // no auditStore
app.Group("/v1/admin").Use(cloud.Bridge())
app.Use(cloud.Bridge())
Routes(app, s)
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
for k, v := range superAdmin {
+15
View File
@@ -0,0 +1,15 @@
package admin
import (
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// compose stands in for the composer. Production programs are built by
// cloud.App, which installs the principal enrichment once at the root before
// any route; a test that mounts this subsystem on a bare app owns that duty
// itself, exactly once, here. A test that sends no identity is unaffected —
// with nothing validated there is nothing to park — so anonymous cases still
// refuse, and principal-carrying cases reach the handler as they do in
// production.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
+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
View File
@@ -29,6 +29,7 @@ func spec(t *testing.T) (map[string]any, []string) {
Logger: luxlog.New("test"),
OpenAPI: zip.OpenAPIConfig{Title: "cloud", Version: "v1.0.0"},
})
compose(app)
routes(app, &cloud.Service[core.State]{State: core.State{AdminOrg: "admin"}})
var live []string
+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
+217 -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"
)
@@ -19,6 +26,7 @@ import (
func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Get("/probe", func(c *zip.Ctx) error {
fn(c)
return c.NoContent(204)
@@ -36,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)
@@ -48,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)
@@ -66,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)
@@ -88,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) {
@@ -100,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) {
@@ -111,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)
@@ -122,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
}
+15
View File
@@ -0,0 +1,15 @@
package admission
import (
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// compose stands in for the composer. Production programs are built by
// cloud.App, which installs the principal enrichment once at the root before
// any route; a test that mounts this subsystem on a bare app owns that duty
// itself, exactly once, here. A test that sends no identity is unaffected —
// with nothing validated there is nothing to park — so anonymous cases still
// refuse, and principal-carrying cases reach the handler as they do in
// production.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
+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 {
+4 -2
View File
@@ -33,11 +33,12 @@ 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)
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai", Approvals: approvals, Gate: testGate}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
return app
@@ -214,8 +215,9 @@ func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
// host, so Enforce never gates pre-boot.
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai",
Approvals: newApprovalsWithLookup(func(context.Context, string, string) (string, bool) { return "pending", true }, time.Minute)}))
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 {
+5 -3
View File
@@ -348,9 +348,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// gated user can still resolve mode.
//
// A typed op receives only a context, so the request the ?host= default falls
// back to has to be parked there. Installed BEFORE the leaf — fiber runs
// middleware in registration order, so one installed after it never runs.
app.Group("/v1/flags/waitlist").Use(cloud.Bridge())
// back to reaches it from that context. Whoever composes the app parks it there,
// at the root, ahead of every leaf; this surface installs no middleware of its
// own. One that it installed for itself could only hang on a /v1/flags/waitlist
// node, and the leaf below registers through the root, so that node would carry
// middleware over an empty subtree and zip refuses to compose it.
zip.Get(cloud.ZipApp(app), "/v1/flags/waitlist", waitlistOps{}.mode)
log.Info("admission gate ready", "services", n)
return nil
+1
View File
@@ -24,6 +24,7 @@ import (
func mountGate(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Brand: "hanzo"}); err != nil {
t.Fatalf("Mount: %v", err)
}
+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
+5 -8
View File
@@ -119,14 +119,11 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// method are projected from. The exception is named at its registration below.
func routes(app cloud.Router, s *cloud.Service[state]) {
g := app.Group("/v1/ads")
// The Bridge FIRST, on the subtree ads owns: a typed op receives only a
// context, so the validated org has to be parked there, and fiber runs
// middleware in registration order — one installed after these leaves would
// never run. cloud.Listen installs one app-wide too; nesting is harmless (the
// inner one is what the handler sees), and having it here is what makes this
// package's own tests — which mount on a bare app — exercise the same
// tenancy the binary does.
g.Use(cloud.Bridge())
// A typed op receives only a context, so the validated org it reads is parked
// there by cloud.Bridge. This subsystem does not install it: the program's
// composer does, once at the root, after the identity check that mints the org
// and before any subsystem registers a route — an order only the composer can
// hold.
// Ops are declared ON THE GROUP: every zip.Router is an OpTarget, and the op's
// path is the group's prefix composed with the leaf — the identity every
+5 -7
View File
@@ -6,11 +6,10 @@ import (
"errors"
"fmt"
// cek is the ONE opener: the database is born encrypted under the key cek
// derives from the process master and this namespace.
"github.com/hanzoai/cek"
// sqlpool.Open is the ONE opener: the database is born encrypted under the
// key cek derives from the process master and the system namespace, and comes
// back with the single-connection cap already applied.
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
// The ONE "sqlite" driver.
_ "github.com/hanzoai/sqlite"
@@ -33,11 +32,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "ads", dir)
db, err := sqlpool.Open("ads", dir)
if err != nil {
return nil, fmt.Errorf("open ads store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+10 -3
View File
@@ -35,13 +35,20 @@ var untypedByDesign = map[string]string{
"zip can declare a body-tolerant op.",
}
// compose installs what a HOST installs. A subsystem never installs cloud.Bridge
// (routes() says why): the program's composer does, once at the root. In a test
// the test IS the composer, so it owes the same install — skipping it does not
// test a stricter program, it tests one where every org-scoped op answers 403
// for a reason production could never produce.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mountApp mounts the ads surface on a fresh in-memory app with a temp store,
// exactly as the unified binary does — and, deliberately, with NO app-wide
// cloud.Bridge, so the bridge these ops read their tenant through has to be the
// one routes() installs itself.
// composed exactly as the unified binary is: cloud.Bridge at the root, the
// subsystem's routes beneath it.
func mountApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
t.Fatalf("Mount: %v", err)
}
+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 ─────────────────────────────────────────────────────────────────────
+5 -6
View File
@@ -9,11 +9,11 @@ import (
"fmt"
"strings"
// cek is the ONE opener; the ONE Hanzo SQLite driver registers "sqlite".
// sqlpool.Open is the ONE opener (cek + the single-connection cap); the ONE
// Hanzo SQLite driver registers "sqlite".
// Mirrors clients/referrals / clients/crm — one storage pattern.
"github.com/hanzoai/cek"
"github.com/hanzoai/cloud/sqlpool"
"github.com/hanzoai/namespace"
_ "github.com/hanzoai/sqlite"
)
@@ -182,11 +182,10 @@ type Store struct {
}
func openStore(dir string) (*Store, error) {
db, err := cek.Open(namespace.System(), "affiliates", dir)
db, err := sqlpool.Open("affiliates", dir)
if err != nil {
return nil, fmt.Errorf("open affiliates store: %w", err)
return nil, err
}
sqlpool.Single(db)
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
+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
+12 -3
View File
@@ -118,15 +118,24 @@ func init() {
// Mount wires POST /v1/agent (+ reads) into cloud, injecting the ai completion and
// the tool plane. The caller identity comes from cloud's validated principal.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("agent.Mount: nil app")
}
_, err := hz.Mount(app, hz.Deps{
// hanzoai/agent registers TYPED ops, and the op registry lives on the concrete
// App — so this is the named hole (cloud.ZipApp), not a widened parameter. The
// signature stays the fleet's one MountFunc, and agent installs no app-wide
// middleware (hanzoai/agent calls Use nowhere), so it mounts SCOPED: taking the
// concrete type used to cost it the whole binary's middleware grant.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("agent.Mount: router is not a zip app — the typed op registry is unreachable")
}
_, err := hz.Mount(zapp, hz.Deps{
Logger: deps.Logger,
DataDir: deps.DataDir,
Brand: deps.Brand,
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
+226 -50
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,21 +345,33 @@ 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
// typed op receives only a context, so the validated org reaches it by being
// parked there — never as an In field, which is caller-supplied and would be a
// cross-tenant read the caller asserted for itself.
//
// IT IS INSTALLED ON THE ROUTER, NOT ON THE /v1/agents GROUP, because this
// surface is not composed under that group. A group's middleware wraps the
// routes in its OWN subtree, and three quarters of this surface is registered
// somewhere else: the collection root and the two sub-planes go on the Router by
// absolute path (zip.Get(zapp, "/v1/agents"), mountSessions(s, app),
// mountTargets(s, app)) and only /metrics, /activity and the :ref leaves are
// composed beneath g. So a Bridge on g parked no org for /v1/agents/targets or
// /v1/agents/sessions, and every op there answered 403 "X-Org-Id required" to a
// request that carried one. Serve installs one app-wide, which is why serving
// was unaffected and only the tests — which Mount onto a bare app — could see
// it; a gate whose absence just one door down is invisible in production is the
g := app.Group("/v1/agents")
// Bridge FIRST, and at the TOP of the whole surface: a typed op receives only
// a context, so the validated org reaches it by being parked there — never as
// an In field, which is caller-supplied and would be a cross-tenant read the
// caller asserted for itself. fiber runs middleware in registration order, so
// one installed further down never runs for the leaves above it: this used to
// sit inside mountTargets, below, which left every leaf registered before that
// call — this file's, mountSessions' — with no org on the context the moment
// they became typed ops. Serve installs one app-wide too; nesting is harmless
// (the inner one is what the handler sees) and the tests mount this subsystem
// on a bare app with no Serve, so the subsystem's own install is what makes
// them pass.
g.Use(cloud.Bridge())
// cloud.Bridge parks the validated org on the context a typed op receives; it
// is the composer's install — once at the root of every program — so this
// package does not install its own.
//
// The root of the surface. Declared on the App with its WHOLE path, not on the
// group with an empty leaf: joining "/v1/agents" with "" yields "/v1/agents/",
// a different path from the one these two have always served.
@@ -360,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
@@ -437,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.
@@ -498,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
}
@@ -870,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.
@@ -886,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)
@@ -944,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 != "" {
@@ -966,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)
@@ -982,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
}
@@ -1017,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
}
@@ -1090,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)
}
+25 -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
@@ -88,10 +72,12 @@ func mountBilled(t *testing.T, commerceURL string, ai types.AIClient) *zip.App {
t.Fatalf("metering.New: %v", err)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
// 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"}
compose(app)
// 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")
}
}
+457
View File
@@ -0,0 +1,457 @@
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 — hanzo_<subsystem>
// carrying an `op` enum, plus hanzo_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 hanzo_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.
//
// The name check is not paranoia about the door: it is what makes "the model is
// offered exactly what it will call" true at the seam, since a descriptor under
// another name would put a schema in front of the model for a tool it cannot
// reach.
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 != op {
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
}
return types.ToolDef{Name: d.Name, 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)
}
+245
View File
@@ -0,0 +1,245 @@
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/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.
func fleetDoor(t *testing.T, at 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)
}
d := fleet.Mount(host, manifest.MCPPath, apps, func(app string) (string, error) {
sock, ok := at[app]
if !ok {
return "", &net.AddrError{Err: "no instance running", Addr: app}
}
return sock, nil
})
door := zip.New(zip.Config{AppName: "plane", DisableStartupMessage: true})
d.Serve(door, manifest.MCPPath)
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"),
})
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")})
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"),
})
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")})
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)
}
}
}
// 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")})
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")})
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")
}
}
+35 -14
View File
@@ -15,32 +15,40 @@ import (
"github.com/zap-proto/zip"
)
// compose installs what the program's composer installs — cloud.Bridge, once at
// the app root. A subsystem never installs its own, so a test app owes the same
// root install; without it every org-scoped op answers a 403 no production
// program would produce.
func compose(app *zip.App) { app.Use(cloud.Bridge()) }
// mountApp mounts the agents surface with a deterministic fake AI so run() is
// exercised end-to-end over HTTP without a real gateway. Pass a nil interface
// to exercise the no-inference fail-closed path.
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")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir, AI: ai, AIDefaultModel: defaultModel}); err != nil {
compose(app)
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
@@ -90,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
}
+1 -2
View File
@@ -203,8 +203,7 @@ func toEventView(e Event) eventView {
//
// The typed ops are declared on the GROUP, so each op's path is the group's
// prefix composed with its leaf — the same composition the router does, and the
// identity every projection keys on. cloud.Bridge is installed once, at the top
// of Mount, ahead of this call.
// identity every projection keys on.
func mountSessions(s *cloud.Service[state], app cloud.Router) {
o := sessionOps{s: s}
g := app.Group("/v1/agents")
+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)
}
-6
View File
@@ -624,12 +624,6 @@ type patchTargetIn struct {
// captured as a ref. The static /v1/agents/targets precedes /v1/agents/targets/:id.
func mountTargets(s *cloud.Service[state], app cloud.Router) {
g := app.Group("/v1/agents")
// cloud.Bridge is installed ONCE, at the top of Mount, ahead of every leaf on
// this prefix. It used to be installed here, which was too late for the leaves
// registered before this call: fiber runs middleware in registration order, so
// the sessions and agent-CRUD routes above would have had no org on the context
// the moment they became typed ops.
//
// TYPED ops, declared on the group itself: zip.Get and friends take any
// Router since v1.18.0, so the prefix is part of each op's path and every
// projection — the document, the MCP tool, the CLI command, the call plane —
+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
+174 -31
View File
@@ -21,14 +21,16 @@ import (
"fmt"
aimod "github.com/hanzoai/ai"
webtools "github.com/hanzoai/ai/agent/builtin_tool/web"
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
aiweb "github.com/hanzoai/ai/web"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/websearch"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// The MODEL API IS THE DOOR'S REGISTRY, and it is asked rather than described.
@@ -90,10 +92,84 @@ 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.
func Mount(app *zip.App, deps cloud.Deps) error {
func Mount(app cloud.Router, deps cloud.Deps) error {
// The typed MCP op and hanzoai/ai's own mount both register on the concrete
// App, which cloud.ZipApp is the named hole for. ai's app-wide reach is
// DECLARED as Plugin.Global at its composition root — it is a policy fact, not
// something a parameter type should be able to grant on its own.
zapp := cloud.ZipApp(app)
if zapp == nil {
return fmt.Errorf("ai.Mount: router is not a zip app — the typed op registry is unreachable")
}
// One provider, one wire. cloud.Listen installed the process-global tracer
// provider before MountAll; DECLARE it to ai here so ai emits every gen_ai span
// through THAT provider instead of forking its own. Without this ai's
@@ -111,6 +187,47 @@ func Mount(app *zip.App, deps cloud.Deps) error {
if cloud.TracerProviderInstalled() {
aiobject.AdoptHostTracerProvider()
}
// THE WEB, FOR EVERY RESPONSES-API AGENT.
//
// ai's builtin registry declares web_search / fetch_url / deep_research but holds
// no backend for the two this host serves — agent/builtin_tool/web must stay a
// leaf package (object imports agent, agent imports the registry), and websearch
// lives here in any case. This is where that seam is closed, beside the balance
// and tier readers, for the same reason they are here: this package links both
// sides and the host does not.
//
// In-process, never over api.hanzo.ai. The edge validates a CUSTOMER credential
// and answers 401 to a service; routing our own calls back through it is what
// once fail-closed every completion at 503 on a healthy pod.
//
// deep_research is deliberately NOT installed, and the reason is MONEY rather
// than plumbing.
//
// Research carries an explicit per-answer FEE — 25 cents, apps/answer/mode.go —
// charged through Bill.Gate on the request path, where a payer has been
// resolved and can be refused. A tool call has no payer. Installing this seam
// with a direct call to the engine would therefore be an unbilled 25-cent
// operation an agent may invoke in a loop: free inference, arrived at by the
// exact route this codebase keeps closing.
//
// That apps/answer makes it awkward is not an accident to route around:
// Params is built from request-scoped billing context and Sink's methods are
// unexported, so the money gate is structurally hard to bypass. Wiring this
// properly means giving the package an entry that takes a payer and charges
// it — a billing decision, not an adapter.
//
// The two tools above are different in kind, not merely cheaper: their HTTP
// routes gate on AUTHENTICATION (a validated principal or the service key),
// and the agent request that reaches this tool was already authenticated and
// metered at /v1/responses. Using them in-process is consistent with how they
// are reached over HTTP; deep_research is not.
//
// Until then the tool reports that it is unavailable in this deployment — the
// honest answer, and specifically NOT an empty result: an agent told "no
// results" concludes the web holds nothing on the subject and answers from
// memory in a confident voice.
installWebSearch(websearch.Search)
// THE PREPAID GATE'S COMPLETION CEILING, PER MODEL, FROM THE CATALOG.
//
// cloud's meter must bound a completion BEFORE it runs, and that bound is a
@@ -194,48 +311,28 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// can only ever refuse slightly early, which is the safe direction for a
// fail-closed gate. Nothing is billed from this number; it decides
// admission only.
a, err := bal.Amount.Parse()
cents, err := bal.Amount.FloorMinor()
if err != nil {
return 0, fmt.Errorf("plane balance read: %w", err)
}
minor := a.Minor() // big.Int of cents, truncated toward zero by Rescale
if !minor.IsInt64() {
return 0, fmt.Errorf("plane balance read: %s %s exceeds int64 cents",
bal.Amount.Decimal, bal.Amount.Currency)
}
return minor.Int64(), nil
return cents, nil
})
}
// The DEBIT crosses the same way, for the same reason — and it must key on the SAME
// 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)
@@ -251,10 +348,56 @@ func Mount(app *zip.App, 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).
mountMCP(app)
mountMCP(zapp)
// The door: ONE `app.All("/v1/*")` (hanzoai/ai mount.go) adapting the legacy
// beego ControllerRegister through zip.AdaptNetHTTP, so ai's ~200 real routes —
// /v1/chat/completions, /v1/models, /v1/messages and the rest — reach the wire
@@ -267,5 +410,5 @@ func Mount(app *zip.App, deps cloud.Deps) error {
// projects routers.App's own table through it, so the published surface is ai's
// 192 paths rather than one wildcard. Typed request and response schemas for them
// are still work in github.com/hanzoai/ai, where those handlers live.
return aimod.Mount(app, deps)
return aimod.Mount(zapp, deps)
}
+12 -11
View File
@@ -20,25 +20,26 @@ import (
// DOWN, never up: rounding up would admit a request the balance cannot cover, and the
// debit that follows is exact — so the difference lands as a negative balance nobody
// authorized. Rounding down can only refuse slightly early.
func TestBalanceIsRoundedDownExplicitly(t *testing.T) {
//
// This used to be asserted by GREPPING ai.go for `a.Minor()` and a comment claiming it
// "truncates toward zero". It does not — money.Amount.Minor() is Rescale, which rounds
// HALF-AWAY-FROM-ZERO — so the test passed while the property it named was false, and
// 4.995 was admitted against a 5.00 charge. A test that reads the source can only
// confirm the code still says what it said; it cannot notice that the sentence is
// wrong. The arithmetic is asserted where the rounding now lives, plane/money_test.go.
// What is left here is the one thing only this package can say: that THIS gate still
// asks for the floored figure, and has not drifted back to the helper that refuses.
func TestBalanceGateDoesNotCallRefusingMinor(t *testing.T) {
src, err := os.ReadFile("ai.go")
if err != nil {
t.Fatalf("read ai.go: %v", err)
}
body := string(src)
// It must not call the refusing helper and hope.
if strings.Contains(body, "bal.Amount.Minor()") {
t.Error("Money.Minor() refuses sub-cent amounts — the gate must round explicitly")
}
// It must parse and take minor units itself, which truncates toward zero.
for _, want := range []string{"bal.Amount.Parse()", "a.Minor()", "minor.IsInt64()"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q — the rounding choice must be visible at the call site", want)
}
}
// And it must not silently widen: an out-of-range balance is an error, not a clamp.
if !strings.Contains(body, "exceeds int64 cents") {
t.Error("an amount too large for int64 must error, never wrap into a wrong balance")
if !strings.Contains(body, "bal.Amount.FloorMinor()") {
t.Error("the balance gate must floor: rounding up admits spend the balance cannot cover")
}
}
+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
+94 -92
View File
@@ -92,7 +92,6 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/datastore"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/apps/sites"
planeops "github.com/hanzoai/cloud/plane"
"github.com/hanzoai/types"
luxlog "github.com/luxfi/log"
@@ -123,12 +122,17 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
// build carries no per-subsystem state — analytics reads the shared warehouse. It
// records the informative mount line, installs the site-host ingest carve, and brings
// up the event sink.
// records the informative mount line and brings up the event sink.
func build(b cloud.Base) (state, error) {
b.Log.Info("analytics surface", "warehouse", "hanzo", "brand", b.Brand)
installHostCarve(b)
// The key→project resolver this door refuses without. The FALLBACK only:
// projects.Mount installs the in-process one when it shares this process, and
// currentKeyResolver prefers it.
SetFallbackKeyResolver(planeKeys{})
startSink(b.Log)
// 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
}
@@ -172,54 +176,13 @@ 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
}
// installHostCarve wires the published-site-host beacon ingest (the twin of base's
// sites.SetBaseHostHandler): a page served on a site host can POST its OWN analytics
// beacon to an ingest door and have it ingested onto the event plane under the site's
// resolved Org — the server-supplied, host-derived tenant, never a body/header claim.
//
// It goes STRAIGHT to the ANONYMOUS lane (publicIngest), and this is the honest
// description of the door rather than a policy applied to it: sites.Middleware runs
// BEFORE the identity boundary (serve.go — sites at 241, IdentityMiddleware at 267),
// so on a site host c.User()/c.Org() are still RAW client headers and NOTHING here can
// be vouched for. A published site is a public artifact and its beacons are anonymous
// by construction, so they get the anonymous capability: the pageview/error allowlist
// and the field projection (no revenue, no personId, no groupId, no property bag), the
// 50-event / 64 KiB bounds, the per-IP and per-peer rate caps, and the DNT gate.
//
// The Site's org is the anonymous TENANT, so a customer's own site analytics keep
// landing in the customer's org — the same host-derived tenant this host is already
// trusted for when the file plane serves its bytes and the Base carve serves its data.
// A caller wanting FULL capability presents a credential to api.hanzo.ai/v1/event,
// which sits behind the identity boundary where a credential can actually be checked.
//
// Gated by the SAME already-existing flag the anonymous ingest path uses —
// CLOUD_ANALYTICS_PUBLIC_CAPTURE (publicCaptureEnabled, default ON) — so a site
// host accepts its own beacons out of the box, and turning public capture off also
// removes this carve (a site host then 405s a beacon POST, unchanged). sites.Middleware
// gates the carve on method POST and on the exact path set handed to it here, so the
// authenticated GET read lenses are never hijacked.
//
// That set is doors (event.go) — the SAME list routes registers — so a site host
// carves exactly the doors an API host routes. sites is handed each path already
// bound to its handler, which is why it holds no path literal of its own: the map it
// looks a beacon up in IS the dispatch, so membership and wire are one decision and
// a door added or deleted tomorrow moves both surfaces at once.
func installHostCarve(b cloud.Base) {
if !publicCaptureEnabled() {
b.Log.Info("analytics public-host ingest carve disabled", "flag", publicCaptureEnv)
return
}
carve := make(map[string]func(string, *zip.Ctx) error, len(doors))
for _, d := range doors {
carve[d.path] = d.anon
}
sites.SetAnalyticsHost(carve)
b.Log.Info("analytics public-host ingest carve enabled", "flag", publicCaptureEnv, "doors", len(carve))
}
// zipdoc lifts the doc comment off each typed op and off each field of its In and
// Out into zipdoc_gen.go, which is the ONLY way that prose reaches the published
// document and the MCP tool list — Go drops comments at compile time. Run by
@@ -227,8 +190,8 @@ func installHostCarve(b cloud.Base) {
//
//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.
@@ -243,18 +206,10 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// A typed op receives ONLY a context, so the validated org has to be PARKED
// there — never carried as an In field, which is caller-supplied and would make
// a cross-tenant read something the caller asserts for itself. cloud.Bridge
// parks it, and it is installed FIRST because fiber runs middleware in
// registration order: one installed below a leaf never runs for that leaf.
//
// On a scoped mount Use installs it once per prefix the subsystem DECLARES
// (scope.go), which is why plugin/analytics/main.go now declares all six of
// this app's prefixes: with only the /v1/<name> default, the typed reads at
// /v1/errors and /v1/insights/* would sit outside every prefix this subsystem
// could gate. Serve installs one app-wide too; nesting is harmless, and the
// tests mount this subsystem on a bare app with no Serve, so the subsystem's
// own install is what makes them pass.
app.Use(cloud.Bridge())
// parks it, and the COMPOSER installs it, not this subsystem: the fused host
// once at its root (serve.go), and a plugin program's constructor likewise. An
// install here would hang middleware on prefixes with no routes beneath them,
// a program zip refuses to compose.
o := readOps{s: s}
// The read lenses, declared on the GROUP: each op's path is the prefix composed
// with its leaf — the same composition the router does, and the identity every
@@ -265,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
@@ -295,6 +248,11 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
app.Post(d.path, cloud.Handle(s, d.ingest))
}
// The tag that feeds the canonical door, on the same origin as the door
// (tag.go). GET, static, unauthenticated: it is the install path for a
// surface with no bundler, and the page supplies the key.
app.Get(tagPath, zip.AdaptNetHTTP(http.HandlerFunc(serveTag)))
// The Sentry error wire, on the SAME door: POST /v1/event/{project}/envelope|store.
// The project segment is variable, so the door's owner carries the route and
// relays to the o11y PROCESS over the plane socket (plane.ObsErrorPost). It
@@ -332,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
@@ -739,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.
@@ -818,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 {
@@ -853,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
+199
View File
@@ -0,0 +1,199 @@
/*! anon.js THE anonymous-identity chain. ONE implementation, three distributions.
*
* One browser is ONE person on every Hanzo surface, whichever client a page
* happens to have loaded. There were three implementations writing TWO keys
* `hz_anon_id` (the npm client, the hosted tag) and `hz_id` (hz.js) so the same
* visitor was several people depending on which snippet the surface shipped.
*
* The three call sites:
* 1. src/storage.ts the bundled npm client; IMPORTS this file.
* 2. hz.js the no-build script tag; INLINES the marked region.
* 3. hanzoai/cloud apps/analytics/tag.js the tag the door hosts at
* /v1/event.js; vendors this file and its tag.go serves the marked region
* with the tag as one asset, so the door holds no second copy either.
*
* (2) and (3) have no bundler and cannot import anything, which is why the chain
* lives in a file that is plain ES5 rather than in a .ts: the region between the
* BEGIN and END markers is COPIED VERBATIM, and src/anon.test.ts fails if hz.js's
* copy is so much as a byte different. Keep the region ES5, dependency-free,
* `hz`-prefixed (it is spliced into other people's scopes) and unformatted a
* reformat of one copy is a diff against the other.
*/
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
/** The ONE anonymous-id key, on every surface and in every distribution. */
var HZ_ANON_KEY = 'hz_anon_id'
/** hz.js used to write `hz_id` a SECOND identity space, so the one-paste tag
* and the npm client were two different people on one page. It is READ and never
* written: an id already in the wild is ADOPTED into the shared identity, because
* minting over one detaches a returning visitor from their own history. */
var HZ_ANON_LEGACY_KEY = 'hz_id'
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
var HZ_ANON_DOMAIN = 'hanzo.ai'
/** Two years, rewritten on every read, so the cookie rolls forward with the
* visitor instead of expiring two years after first touch. Safari caps a
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
/** Last resort for a browser that refuses cookies AND localStorage: without it
* every event in a page load would mint an id of its own. */
var hzAnonMemo
/**
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
*
* It has to be v7, and this is the only minter any distribution may use. The
* session rollups on the event plane derive a session's start instant FROM THE ID
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
* is not merely unordered there it is DISCARDED, silently, and the rollup stays
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
*/
function hzUuidv7(now) {
var b = new Uint8Array(16)
var i
var c = typeof crypto !== 'undefined' ? crypto : undefined
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
var t = Math.floor(now === undefined ? Date.now() : now)
for (i = 5; i >= 0; i--) {
b[i] = t % 256
t = Math.floor(t / 256)
}
b[6] = 0x70 | (b[6] & 0x0f) // version 7
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
var h = ''
for (i = 0; i < 16; i++) {
h += (b[i] + 0x100).toString(16).slice(1)
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
}
return h
}
/** The cookie jar, or null wherever there is no document to read one from. */
function hzAnonJar() {
try {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
return document
} catch (e) {
return null // sandboxed frame with an opaque origin
}
}
/** localStorage, or null when the browser refuses it (Safari private mode). */
function hzAnonStore() {
try {
if (typeof window === 'undefined' || !window.localStorage) return null
return window.localStorage
} catch (e) {
return null
}
}
/** One stored value, or '' — a jar can read as well as refuse to. */
function hzAnonItem(store, name) {
try {
return (store && store.getItem(name)) || ''
} catch (e) {
return ''
}
}
/** The value of cookie `name`, or ''. */
function hzAnonCookie(name) {
var d = hzAnonJar()
if (!d) return ''
var parts = d.cookie.split(';')
for (var i = 0; i < parts.length; i++) {
var eq = parts[i].indexOf('=')
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
var v = parts[i].slice(eq + 1).trim()
if (!v) continue
try {
return decodeURIComponent(v)
} catch (e) {
return v // not percent-encoded — take it as written
}
}
return ''
}
/** Writes `name` on the registrable domain, for as long as the browser allows. */
function hzAnonWrite(name, value) {
var d = hzAnonJar()
if (!d) return
var host = ''
var secure = false
try {
if (typeof window !== 'undefined' && window.location) {
host = window.location.hostname || ''
// A Secure cookie is refused outright by a non-secure origin, which would
// strand http://localhost dev on the localStorage path.
secure = window.location.protocol === 'https:'
}
} catch (e) {
/* location unreachable — write a host-only, non-secure cookie */
}
// encodeURIComponent leaves a UUID byte-identical while making any value that is
// not one unable to forge a `;` and inject an attribute.
var c = name + '=' + encodeURIComponent(value)
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
// would be rejected and the whole cookie dropped, so it stays host-only there.
// Prefixing both sides with '.' matches the domain itself and its subdomains
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
c += '; Domain=' + HZ_ANON_DOMAIN
}
if (secure) c += '; Secure'
try {
d.cookie = c
} catch (e) {
/* cookies refused — localStorage still carries the id */
}
}
/**
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
*
* Resolution is strictly ADDITIVE every id that already exists is ADOPTED, and
* only a browser holding none of them is given a new one:
*
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
*
* Minting over an id resets a returning visitor and detaches them from their own
* history, so the order is the migration: the cookie is the shared home, the two
* localStorage keys are what the three implementations wrote before it existed,
* and each is read until nothing is left to adopt.
*
* localStorage keeps being written, so a rollback finds everyone where it left
* them, and a browser that refuses cookies still holds one id per origin.
*/
function hzAnonId() {
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
var s = hzAnonStore()
var id =
hzAnonCookie(HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
hzAnonMemo ||
hzUuidv7()
hzAnonMemo = id
hzAnonWrite(HZ_ANON_KEY, id)
try {
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
} catch (e) {
/* quota exhausted, or a private-mode jar that reads but refuses writes */
}
return id
}
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
export { hzAnonId, hzUuidv7 }
+168 -59
View File
@@ -9,6 +9,7 @@ package analytics
import (
"fmt"
"math"
"net/http"
"strings"
"testing"
@@ -49,7 +50,7 @@ const anonClick = `{"batch":[{"type":"event","event":"$click","distinctId":"anon
func TestAnonAutocapture_ClickAdmittedThroughThePublicDoor(t *testing.T) {
roomyRate(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "", "", "hanzo.ai", anonClick)
code, body := postAnon(t, app, "/v1/event", anonClick, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("anonymous $click = %d (%s), want 503 ADMITTED — a logged-out interaction is "+
"the bulk of what a heatmap is drawn from, and it was being dropped behind a 200",
@@ -66,7 +67,7 @@ func TestAnonAutocapture_ClickAdmittedOnThePostHogWire(t *testing.T) {
app := mountApp(t)
body := `{"event":"$click","distinct_id":"anon-1",` +
`"properties":{"$current_url":"https://hanzo.ai/pricing","$pathname":"/pricing","$el":"nav/button[cta]"}}`
if code, got := doHost(t, app, "/v1/event", "", "", "insights.hanzo.ai", body); code != http.StatusServiceUnavailable {
if code, got := postAnon(t, app, "/v1/event", body, nil); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous $click on the PostHog wire = %d (%s), want 503 ADMITTED", code, got)
}
}
@@ -84,12 +85,12 @@ func TestAnonAutocapture_StoresTheRealURL(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("admitPublic = %d admitted / %d dropped, want 1/0", len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("the admitted click must be routable — an unnamed track is dropped by the write core")
}
if f.org != publicTenant {
t.Fatalf("fact org = %q, want %q", f.org, publicTenant)
if f.org != "acme" {
t.Fatalf("fact org = %q, want %q", f.org, "acme")
}
if f.name != "$click" {
t.Fatalf("stored name = %q, want $click", f.name)
@@ -170,7 +171,7 @@ func TestAnonAutocapture_NameIsTheServersNotTheCallers(t *testing.T) {
t.Fatalf("spelling %q stored as %q — the stored name must be the table's value, "+
"never the caller's bytes", wire, out[0].Event)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != "$click" {
t.Fatalf("spelling %q normalized to %q (routable=%v), want $click", wire, f.name, ok)
}
@@ -209,7 +210,7 @@ func TestAnonAutocapture_VocabularyIsClosed(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("%q: admitted %d dropped %d, want 1/0", wire, len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != stored {
t.Fatalf("%q normalized to %q (routable=%v), want %q", wire, f.name, ok, stored)
}
@@ -245,7 +246,7 @@ func TestAnonAutocapture_OnlyTheAnnotationCrosses(t *testing.T) {
}
// Through the real normalizer nothing the caller chose reaches the attributes map —
// the dictionary an unbounded anonymous bag would attack.
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
@@ -271,7 +272,7 @@ func TestAnonPageview_StillCarriesNoCallerName(t *testing.T) {
t.Fatalf("projected pageview carries name %q — the kind family must drop the caller's name "+
"and let resolveName supply it", out[0].Event)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok || f.name != "page_viewed" {
t.Fatalf("stored pageview name = %q (routable=%v), want the route's own page_viewed", f.name, ok)
}
@@ -304,7 +305,7 @@ func TestAnonError_NameIsNeverTheCallersExceptionClass(t *testing.T) {
t.Fatalf("class %.20q: admitted %d dropped %d, want 1/0 — an anonymous error still lands",
class, len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("class %.20q: admitted error must stay routable", class)
}
@@ -324,7 +325,7 @@ func TestAnonError_ClassStillGroupsTheIssue(t *testing.T) {
fact := func(class, msg string) fact {
e := foldException(CaptureEvent{Type: "error", Error: &Exception{Type: class, Message: msg}})
out, _ := admitPublic([]CaptureEvent{e})
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("class %q must stay routable", class)
}
@@ -357,7 +358,7 @@ func TestAnonError_OversizeClassIsDropped(t *testing.T) {
if len(out) != 1 || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want the error to still land", len(out), dropped)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
@@ -411,7 +412,7 @@ func TestAnonAnnotation_OversizeIsDropped(t *testing.T) {
t.Errorf("%s: reached the projection as %+v — an out-of-bounds annotation is not carried",
tc.what, out[0].Properties)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s: want routable", tc.what)
}
@@ -448,7 +449,7 @@ func TestAnonAnnotation_RealClientOutputFits(t *testing.T) {
Type: "event", Event: "$click",
Properties: map[string]any{"$el": label, "$path": trail, "$role": "button"},
}})
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
@@ -478,42 +479,41 @@ func TestAnonAnnotation_BoundsAreDerivedNotInvented(t *testing.T) {
}
}
// TestAnonError_RealOrgNeverTakesACallerChosenName is Red's probe, kept: the attack was
// not theoretical and its worst form went through the published-site host, where the
// projection's tenant is a REAL org rather than $public. Fifty distinct caller-chosen
// classes in ONE request — the batch ceiling, and at the documented rate caps
// 15 000 names/min from a single IP — must produce fifty rows all named `error`.
// ownerOrg is a REAL org — the projected lane files into one (a team guest writes
// into the org that invited it), which is what makes these rules load-bearing.
const ownerOrg = "hanzo"
// TestAnonError_RealOrgNeverTakesACallerChosenName is Red's probe, kept: the projected
// lane files into a REAL org (a team guest writes into the org that invited it), so a
// caller-chosen error class would mint cardinality in that org's ORDER BY key. Fifty
// distinct classes in ONE request — the batch ceiling — must produce fifty rows all
// named `error`.
//
// It asserts on the FACTS the write path emitted, not on the status code, because the
// door returned 200 both before and after: the whole bug lived past the receipt.
// Driven at the projection, which is where the rule lives: admitPublic decides what is
// admitted, normalize stamps the tenant and the name.
func TestAnonError_RealOrgNeverTakesACallerChosenName(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
w := fakeWarehouse(t)
app := firstPartyApp(t)
var b strings.Builder
b.WriteString(`{"batch":[`)
evs := make([]CaptureEvent, 0, maxPublicBatch)
for i := 0; i < maxPublicBatch; i++ {
if i > 0 {
b.WriteByte(',')
}
// Each one distinct, and long enough that a survivor is unmistakable.
fmt.Fprintf(&b, `{"type":"error","path":"/pricing","error":{"type":"RED-%d-%s","message":"boom"}}`,
i, strings.Repeat("N", 200))
evs = append(evs, CaptureEvent{
Type: "error", Path: "/pricing",
Error: &Exception{Type: fmt.Sprintf("RED-%d-%s", i, strings.Repeat("N", 200)), Message: "boom"},
})
}
b.WriteString(`]}`)
if code := postHost(t, app, "yadota.hanzo.ai", "/v1/event", b.String(), nil); code != http.StatusOK {
t.Fatalf("batch = %d, want 200 — the errors are admitted, they are just not caller-named", code)
}
if len(w.facts) != maxPublicBatch {
t.Fatalf("stored %d facts, want %d — the errors must still land", len(w.facts), maxPublicBatch)
admitted, dropped := admitPublic(evs)
if len(admitted) != maxPublicBatch || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want %d/0 — the errors must still land",
len(admitted), dropped, maxPublicBatch)
}
names := map[string]int{}
for _, f := range w.facts {
for _, e := range admitted {
f, ok := normalize(ownerOrg, time.Now(), foldException(e))
if !ok {
t.Fatal("want routable")
}
names[f.name]++
if f.org != ownerOrg {
t.Fatalf("fact landed in %q, want the site's real org %q", f.org, ownerOrg)
t.Fatalf("fact landed in %q, want the real org %q", f.org, ownerOrg)
}
if strings.Contains(f.name, "RED-") || len(f.name) > 64 {
t.Fatalf("caller bytes reached `name`: %.60q (len %d)", f.name, len(f.name))
@@ -560,7 +560,7 @@ func TestAnonAutocapture_CarriesNoException(t *testing.T) {
t.Errorf("%s/%s: the projection carried an exception onto a row that is not a fault", tc.kind, tc.event)
}
// The fold runs AFTER the projection, exactly as ingestDecoded runs it.
f, ok := normalize(publicTenant, time.Now(), foldException(out[0]))
f, ok := normalize("acme", time.Now(), foldException(out[0]))
if !ok {
t.Fatalf("%s/%s: want routable", tc.kind, tc.event)
}
@@ -592,7 +592,7 @@ func TestAnonError_StillCarriesItsException(t *testing.T) {
t.Fatal("the projection dropped the exception from an ERROR — the fix over-reached and the " +
"anonymous error stream is now empty")
}
f, ok := normalize(publicTenant, time.Now(), foldException(out[0]))
f, ok := normalize("acme", time.Now(), foldException(out[0]))
if !ok {
t.Fatal("want routable")
}
@@ -620,22 +620,25 @@ func TestAnonError_StillCarriesItsException(t *testing.T) {
// the door answered 200 before the fix and answers 200 after: the whole bug lived past
// the receipt.
func TestAnonAutocapture_NoExceptionReachesARealOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
w := fakeWarehouse(t)
app := firstPartyApp(t)
body := fmt.Sprintf(
`{"batch":[{"type":"event","event":"$click","url":"https://yadota.hanzo.ai/pricing","path":"/pricing",`+
`"properties":{"$el":"nav/button[cta]"},"error":{"type":"TypeError","message":"%s","stack":"%s"}}]}`,
strings.Repeat("M", 22000), strings.Repeat("S", 10000))
if code := postHost(t, app, "yadota.hanzo.ai", "/v1/event", body, nil); code != http.StatusOK {
t.Fatalf("POST = %d, want 200 — the click is admitted, it just carries no fault", code)
admitted, dropped := admitPublic([]CaptureEvent{{
Type: "event", Event: "$click",
URL: "https://yadota.hanzo.ai/pricing", Path: "/pricing",
Properties: map[string]any{"$el": "nav/button[cta]"},
Error: &Exception{
Type: "TypeError",
Message: strings.Repeat("M", 22000),
Stack: strings.Repeat("S", 10000),
},
}})
if len(admitted) != 1 || dropped != 0 {
t.Fatalf("admitted %d dropped %d, want 1/0 — the click is admitted, it just carries no fault",
len(admitted), dropped)
}
if len(w.facts) != 1 {
t.Fatalf("stored %d facts, want 1", len(w.facts))
// The REAL pipeline order: the projection runs first, foldException second.
f, ok := normalize(ownerOrg, time.Now(), foldException(admitted[0]))
if !ok {
t.Fatal("want routable")
}
f := w.facts[0]
if f.org != ownerOrg {
t.Fatalf("fact landed in %q, want the site's real org %q", f.org, ownerOrg)
}
@@ -696,7 +699,7 @@ func TestAnonLane_CannotMintALensName(t *testing.T) {
} {
out, _ := admitPublic([]CaptureEvent{tc.ev})
for _, adm := range out {
f, ok := normalize(publicTenant, time.Now(), foldException(adm))
f, ok := normalize("acme", time.Now(), foldException(adm))
if !ok {
continue // unroutable is a drop, which is a pass
}
@@ -723,7 +726,7 @@ func TestAnonAutocapture_IsNotTheAdLensClick(t *testing.T) {
if len(out) != 1 {
t.Fatalf("%s must be admitted — it is the heatmap", n)
}
f, ok := normalize(publicTenant, time.Now(), out[0])
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s must be routable", n)
}
@@ -751,3 +754,109 @@ func TestAnonAutocapture_IsNotTheAdLensClick(t *testing.T) {
func faulted(f fact) bool {
return f.signal == signalError || f.class != "" || f.issue != "" || len(f.frames) > 0
}
// ── the position ────────────────────────────────────────────────────────────
// TestAnonAutocapture_ThePositionCrosses: element identity says WHICH thing was clicked
// and never where on the page it sat, so a heat map cannot be drawn from the annotation
// alone. The bulk of what a heat map is made of is logged-out traffic, so the position
// has to survive THIS lane or it survives for a minority of clicks.
func TestAnonAutocapture_ThePositionCrosses(t *testing.T) {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{
"$el": "main/button[save]",
"$role": "button",
"$x": float64(640),
"$y": float64(1200),
"$target_fixed": false,
"$viewport_width": float64(1440),
"$viewport_height": float64(900),
},
}})
if len(out) != 1 {
t.Fatal("want 1 admitted event")
}
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
// The warehouse reads these off the attributes map (insights heatmap_mv), so the
// assertion is on the STORED strings, not on the projected bag.
for k, want := range map[string]string{
"$x": "640", "$y": "1200", "$target_fixed": "false",
"$viewport_width": "1440", "$viewport_height": "900",
} {
if got := f.attributes[k]; got != want {
t.Errorf("attributes[%q] = %q, want %q — a click with no position is a count, not a heatmap", k, got, want)
}
}
if f.el.label != "main/button[save]" {
t.Fatalf("the annotation stopped crossing: %+v", f.el)
}
}
// TestAnonAutocapture_PositionIsAClosedSet: admitting a second family must not open the
// bag. A caller's own key still cannot reach the dictionary, and a coordinate that is not
// a number is not a coordinate.
func TestAnonAutocapture_PositionIsAClosedSet(t *testing.T) {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{
"$el": "main/button",
"$x": float64(10),
"$viewport_width": "1440", // a string is not a coordinate
"$viewport_height": map[string]any{"nope": true},
"$scroll_depth": float64(99), // plausible, unnamed, therefore refused
"tenant_id": "maxpower",
},
}})
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatal("want routable")
}
if f.attributes["$x"] != "10" {
t.Fatalf("the named coordinate did not cross: %v", f.attributes)
}
for _, k := range []string{"$viewport_width", "$viewport_height", "$scroll_depth", "tenant_id"} {
if _, bad := f.attributes[k]; bad {
t.Errorf("key %q reached the attributes dictionary: %v", k, f.attributes)
}
}
}
// TestAnonAutocapture_PositionIsFilteredNotClamped: a clamped coordinate is a click
// somewhere the visitor did not click, and a heat map is a picture of exactly that. Over
// the bound the key is dropped and the interaction still lands.
func TestAnonAutocapture_PositionIsFilteredNotClamped(t *testing.T) {
for _, tc := range []struct {
what string
x any
}{
{"absurdly deep", float64(1 << 24)},
{"absurdly negative", float64(-(1 << 24))},
{"not a number", math.NaN()},
{"infinite", math.Inf(1)},
} {
out, _ := admitPublic([]CaptureEvent{{
Type: "event",
Event: "$click",
Properties: map[string]any{"$el": "main/button", "$x": tc.x, "$y": float64(10)},
}})
if len(out) != 1 {
t.Fatalf("%s: the interaction itself must still land", tc.what)
}
f, ok := normalize("acme", time.Now(), out[0])
if !ok {
t.Fatalf("%s: want routable", tc.what)
}
if v, bad := f.attributes["$x"]; bad {
t.Errorf("%s: out-of-bounds coordinate was stored as %q — the bound is a filter", tc.what, v)
}
if f.attributes["$y"] != "10" {
t.Errorf("%s: a sound coordinate beside a refused one was lost", tc.what)
}
}
}
+15 -60
View File
@@ -8,13 +8,9 @@
package analytics
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// anon_capability_test.go — the TRUST-LEVEL invariant, proven at EVERY door.
@@ -62,22 +58,6 @@ const commercePostHog = `{"event":"order_completed","distinct_id":"attacker",` +
// working on every door, so the fix is a capability drop and not a feature deletion.
const pageviewWire = `{"batch":[{"type":"pageview","distinctId":"anon-1","path":"/pricing"}]}`
// postHostBody is postHost (hostcarve_test.go) with the response body returned, so a
// site-host case can assert the honest {accepted,dropped} receipt and not just a status.
func postHostBody(t *testing.T, app *zip.App, host, path, body string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "http://"+host+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Host = host
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test POST %s%s: %v", host, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// roomyRate installs anonymous counters big enough that no capability test can be
// masked by a 429 from a bucket another test in this package already spent. The rate
// cap itself is pinned by TestPublic_RateLimited / TestPublic_PeerCeiling.
@@ -98,38 +78,9 @@ func TestAnonCommerce_RefusedOnEveryBrandHost(t *testing.T) {
}
}
// TestAnonCommerce_RefusedAtSiteHostDoor: the published-site carve is the second door
// that reached full capability with no credential. It ran BEFORE the identity boundary
// (serve.go mounts sites at 241, IdentityMiddleware at 267), so nothing there could
// vouch for a caller — yet it wrote into the site's REAL org whatever the body said.
// Anyone could aim it at any customer's org with a Host header.
//
// Before the fix all three paths answered 503 (admitted at full capability).
func TestAnonCommerce_RefusedAtSiteHostDoor(t *testing.T) {
roomyRate(t)
app := carveApp(t, "yadota")
for _, door := range doors {
code, body := postHostBody(t, app, "yadota.hanzo.app", door.path, commerceFor(t, door))
if code == http.StatusServiceUnavailable {
t.Errorf("site-host POST %s: reached the write core at FULL capability — "+
"a Host header alone let a stranger write revenue/groupId/personId into the site's org", door.path)
continue
}
refusedAnon(t, "site-host POST "+door.path, code, body)
}
}
// TestAnonCommerce_RefusedOnBoundCustomDomain: the carve fires for a bound custom
// domain too, so that door needed the same drop.
func TestAnonCommerce_RefusedOnBoundCustomDomain(t *testing.T) {
roomyRate(t)
app := carveApp(t, "yadota")
code, body := postHostBody(t, app, "yadota.tech", "/v1/event", commerceWire)
if code == http.StatusServiceUnavailable {
t.Fatalf("custom-domain beacon reached the write core at FULL capability")
}
refusedAnon(t, "custom-domain anonymous commerce", code, body)
}
// The site-host carve is deleted, so "a Host header may not reach the write core" is
// no longer a rule this door enforces — there is no site-host door. apps/sites'
// TestSiteHostNeverIngests pins that a site host serves bytes and is terminal.
// TestAnonIdentity_RefusedAtEveryDoor: `identify` and `group` are the two kinds that
// bind an event to a named person and a named group. A caller nobody vouched for may
@@ -155,17 +106,21 @@ func TestAnonIdentity_RefusedAtEveryDoor(t *testing.T) {
}
}
// TestPublicCaptureOff_RefusesEveryAnonymousDoor: CLOUD_ANALYTICS_PUBLIC_CAPTURE is the
// ONE anonymous-capture switch and it still governs every door — including the two that
// used to route around the anonymous lane entirely (and therefore around this flag's
// only enforcement point).
func TestPublicCaptureOff_RefusesEveryAnonymousDoor(t *testing.T) {
t.Setenv(publicCaptureEnv, "off")
// TestAnonymousRefusedOnEveryDoor: a keyless beacon is refused on every door, with
// no switch to turn it back on. It used to be ACCEPTED into a reserved tenant and
// answered 200 — the switch that governed it defaulted ON, so the silent-accept was
// the shipped behaviour and only an operator who knew the flag existed could stop it.
// Attribution is the key now, so there is nothing left to gate.
func TestAnonymousRefusedOnEveryDoor(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, d := range doors {
if code, body := doHost(t, app, d.path, "", "", "hanzo.ai", pageviewFor(t, d)); code != http.StatusForbidden {
t.Errorf("public-capture-off anonymous %s = %d (%s), want 403", d.path, code, body)
code, body := doHost(t, app, d.path, "", "", "hanzo.ai", pageviewFor(t, d))
if code != http.StatusUnauthorized {
t.Errorf("anonymous %s = %d (%s), want 401", d.path, code, body)
}
if !strings.Contains(string(body), "ingest_key_required") {
t.Errorf("anonymous %s body = %s, want the ingest_key_required code", d.path, body)
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ func TestAnonCanonicalWireCarriesItsKind(t *testing.T) {
{"batch envelope", `{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", tc.body)
code, body := postAnon(t, app, "/v1/event", tc.body, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview on the %s shape = %d (%s), want 503 ADMITTED — "+
"all three published shapes of one wire must mean the same thing, or the "+
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// attribution.go — what a publishable key names, and the seam that answers
// which one.
//
// A key is minted with a project (apps/projects) and this is where a beacon
// carrying it is turned back into (org, project). The projects app owns the row,
// the ingest door reads it, and they are not the same process in production — the
// pod boots one process per app — so this is the same two-resolver seam
// sites.SetResolver already uses: in-process when the store is here, over the
// plane when it is not.
package analytics
import (
"context"
"sync"
)
// Attribution is what a publishable key resolves to: the org that owns the rows, and
// the project that emitted them.
//
// Project is the SERVER's answer to a question the wire also asks — an event
// carries a `product` field naming its emitting surface, and that field is the
// caller's to set. When a key names a project the server's answer wins (see
// attributeProject), which is the difference between a label and an attribution.
type Attribution struct {
Org string
Project string
}
// KeyResolver maps a publishable ingest key to the scope it names.
//
// found=false ⇒ no project holds this key: the honest refusal, and the whole of
// "if the site is missing it stops recording". err ⇒ a real store or transport
// failure, which is NOT a refusal and must not be collapsed into one — a
// transient failure of the owning app would otherwise read exactly like every
// customer's site being deleted at once.
type KeyResolver interface {
Resolve(ctx context.Context, key string) (Attribution, bool, error)
}
var (
keyMu sync.RWMutex
keyResolver KeyResolver
keyFallback KeyResolver
)
// SetKeyResolver installs the in-process resolver. projects.Mount calls it with
// its store — the no-hop answer when ingest and the project store share a process.
func SetKeyResolver(r KeyResolver) {
keyMu.Lock()
keyResolver = r
keyMu.Unlock()
}
// SetFallbackKeyResolver installs the cross-process resolver. The composition
// root calls it with a plane client, for every process that does NOT own the
// project store — which in production is the one serving this door.
func SetFallbackKeyResolver(r KeyResolver) {
keyMu.Lock()
keyFallback = r
keyMu.Unlock()
}
// HasFallbackKeyResolver reports whether a cross-process resolver is installed,
// so the host can prove it wired the door. An unwired seam refuses every beacon
// on the fleet and no test inside this package can see it, because the package
// is correct either way.
func HasFallbackKeyResolver() bool {
keyMu.RLock()
defer keyMu.RUnlock()
return keyFallback != nil
}
func currentKeyResolver() KeyResolver {
keyMu.RLock()
r, fb := keyResolver, keyFallback
keyMu.RUnlock()
if r != nil {
return r
}
return fb
}
// resolveAttribution answers which project a key names. It reports only found/not —
// a store failure is logged by the resolver and read here as "not resolved",
// because this door's caller is a browser that can do nothing with the
// difference. What it must never do is answer with an org and no project: that
// is the silent misfiling this whole change removes.
func resolveAttribution(ctx context.Context, key string) (Attribution, bool) {
r := currentKeyResolver()
if r == nil || key == "" {
return Attribution{}, false
}
at, ok, err := r.Resolve(ctx, key)
if err != nil || !ok || at.Org == "" {
return Attribution{}, false
}
return at, true
}
// attributeProject stamps the resolved project onto every event, replacing
// whatever the caller put in `product`. The key is the evidence and the body is
// not: a page that ships one project's key cannot file its rows under another's
// name. The pure twin of attribute (public.go), which does the same for identity
// on the reduced lane.
func attributeProject(evs []CaptureEvent, project string) []CaptureEvent {
if project == "" {
return evs
}
for i := range evs {
evs[i].Product = project
}
return evs
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"context"
"errors"
"net/http"
"strings"
"testing"
)
// stubKeys installs an in-process key resolver over a fixed table, and clears BOTH
// resolver slots so a leaked fallback cannot answer instead.
func stubKeys(t *testing.T, table map[string]Attribution) {
t.Helper()
keyMu.Lock()
origR, origF := keyResolver, keyFallback
keyMu.Unlock()
SetKeyResolver(fixedKeys(table))
SetFallbackKeyResolver(nil)
t.Cleanup(func() {
SetKeyResolver(origR)
SetFallbackKeyResolver(origF)
})
}
type fixedKeys map[string]Attribution
func (f fixedKeys) Resolve(_ context.Context, key string) (Attribution, bool, error) {
at, ok := f[key]
return at, ok, nil
}
// failingKeys is the owning app being unreachable — an error, never a miss.
type failingKeys struct{}
func (failingKeys) Resolve(context.Context, string) (Attribution, bool, error) {
return Attribution{}, false, errors.New("projects unreachable")
}
const siteKey = "pk-sitekeysitekeysitekeysitekeysitekey00"
// TestProjectKeyAttributesToItsSite is the design: the key names org AND site, so a
// beacon lands in the project's org tagged with the project — an attribution the
// server states rather than accepts.
func TestProjectKeyAttributesToItsSite(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "", `{"type":"pageview","event":"$pageview"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusOK {
t.Fatalf("keyed beacon = %d, want 200", code)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "acme" {
t.Fatalf("tenant = %v, want [acme]", got)
}
if len(w.facts) != 1 || w.facts[0].product != "shop" {
t.Fatalf("product = %q, want shop — the key must name the site", w.facts[0].product)
}
}
// TestProjectKeyOverridesTheBodysProduct: `product` is client-supplied and therefore
// not evidence. When the key names a project the server's answer wins, so a page
// shipping one project's key cannot file its rows under another's name.
func TestProjectKeyOverridesTheBodysProduct(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "",
`{"type":"pageview","event":"$pageview","product":"someone-elses-site"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusOK {
t.Fatalf("keyed beacon = %d, want 200", code)
}
if len(w.facts) != 1 || w.facts[0].product != "shop" {
t.Fatalf("product = %q, want shop — a body claim reached the fact", w.facts[0].product)
}
}
// TestKeyRidesEveryCarrier: the project key travels on all three ingest carriers, so
// a page can use whichever its transport allows. The query carrier is load-bearing:
// navigator.sendBeacon cannot set headers, and that is the transport a real page
// uses on unload.
func TestKeyRidesEveryCarrier(t *testing.T) {
roomyRate(t)
for _, tc := range []struct {
name, path string
hdr map[string]string
}{
{"bearer", "/v1/event", map[string]string{"Authorization": "Bearer " + siteKey}},
{"ingest header", "/v1/event", map[string]string{"x-hanzo-ingest-key": siteKey}},
{"beacon query", "/v1/event?ingest_key=" + siteKey, nil},
} {
t.Run(tc.name, func(t *testing.T) {
stubKeys(t, map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}})
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, tc.path, "", `{"type":"pageview","event":"$pageview"}`, tc.hdr)
if code != http.StatusOK {
t.Fatalf("%s = %d, want 200", tc.name, code)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "acme" {
t.Fatalf("%s tenant = %v, want [acme]", tc.name, got)
}
})
}
}
// TestUnknownKeyRefusedAndWritesNothing: a key that names no project is 403, never a
// downgrade. Filing it anywhere would hide the rows in a partition its owner cannot
// read — the silent failure this change exists to end.
func TestUnknownKeyRefusedAndWritesNothing(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
stubResolver(t, func(string) (string, bool) { return "", false })
w := fakeWarehouse(t)
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "", `{"type":"pageview","event":"$pageview"}`,
map[string]string{"Authorization": "Bearer " + siteKey})
if code != http.StatusForbidden {
t.Fatalf("unknown key = %d, want 403", code)
}
if got := w.tenants(t); len(got) != 0 {
t.Fatalf("an unresolvable key wrote %v", got)
}
}
// TestDeletedSiteStopsRecordingAtTheDoor is the CTO's rule end to end: the same key
// that was landing rows stops landing them the moment its project is gone.
func TestDeletedSiteStopsRecordingAtTheDoor(t *testing.T) {
roomyRate(t)
live := map[string]Attribution{siteKey: {Org: "acme", Project: "shop"}}
stubKeys(t, live)
stubResolver(t, func(string) (string, bool) { return "", false })
w := fakeWarehouse(t)
app := mountApp(t)
body := `{"type":"pageview","event":"$pageview"}`
hdr := map[string]string{"Authorization": "Bearer " + siteKey}
if code := postKeyed(t, app, "/v1/event", "", body, hdr); code != http.StatusOK {
t.Fatalf("precondition: keyed beacon = %d, want 200", code)
}
before := len(w.facts)
delete(live, siteKey) // the project is deleted; the key now names nothing
if code := postKeyed(t, app, "/v1/event", "", body, hdr); code != http.StatusForbidden {
t.Fatalf("after delete = %d, want 403", code)
}
if len(w.facts) != before {
t.Fatalf("a deleted site still wrote %d fact(s)", len(w.facts)-before)
}
}
// TestKeylessBeaconRefusedAndWritesNothing: the defect this change removes. A keyless
// beacon used to be accepted into a reserved tenant and answered {"accepted":1} — its
// owner could not read the partition, so it lost everything behind a 200.
func TestKeylessBeaconRefusedAndWritesNothing(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
w := fakeWarehouse(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "", "", "cloud.hanzo.ai",
`{"type":"pageview","event":"$pageview"}`)
if code != http.StatusUnauthorized {
t.Fatalf("keyless beacon = %d (%s), want 401", code, body)
}
if !strings.Contains(string(body), "ingest_key_required") {
t.Fatalf("body = %s, want the ingest_key_required code", body)
}
if got := w.tenants(t); len(got) != 0 {
t.Fatalf("a keyless beacon wrote %v", got)
}
}
// TestBearerStillAttributes: console.hanzo.ai deliberately carries NO key — it is one
// brand-agnostic image, so a baked-in key would pin lux/zoo white-labels onto hanzo —
// and attributes through its IAM bearer instead. That path must keep working.
//
// A bearer names an ORG and no site, so the fact carries no product. That is the
// honest answer: `product` on the canonical wire is not a caller field at all, and the
// only thing that can state one is a key minted with a project.
func TestBearerStillAttributes(t *testing.T) {
roomyRate(t)
stubKeys(t, map[string]Attribution{})
w := fakeWarehouse(t)
app := mountApp(t)
code, body := doHost(t, app, "/v1/event", "u_console", "hanzo", "console.hanzo.ai",
`{"type":"pageview","event":"$pageview"}`)
if code != http.StatusOK {
t.Fatalf("bearer beacon = %d (%s), want 200", code, body)
}
if got := w.tenants(t); len(got) != 1 || got[0] != "hanzo" {
t.Fatalf("tenant = %v, want [hanzo]", got)
}
if w.facts[0].product != "" {
t.Fatalf("product = %q — a bearer names no site", w.facts[0].product)
}
}
// TestResolverFailureIsNotAMiss: the owning app being unreachable must not read as
// "this site does not exist". Both refuse, but only one is the caller's to fix, and a
// transient failure must never be reported as a deleted project.
func TestResolverFailureIsNotAMiss(t *testing.T) {
at, ok := resolveAttribution(context.Background(), siteKey)
_ = at
if ok {
t.Fatal("precondition")
}
SetKeyResolver(failingKeys{})
SetFallbackKeyResolver(nil)
t.Cleanup(func() { SetKeyResolver(nil); SetFallbackKeyResolver(nil) })
if _, ok := resolveAttribution(context.Background(), siteKey); ok {
t.Fatal("a failing resolver must not attribute")
}
}
// TestAttributionRequiresAnOrg: a resolver that answers found with no org is refused.
// An empty org would be a write with no tenant at all.
func TestAttributionRequiresAnOrg(t *testing.T) {
stubKeys(t, map[string]Attribution{siteKey: {Org: "", Project: "shop"}})
if _, ok := resolveAttribution(context.Background(), siteKey); ok {
t.Fatal("an attribution with no org must be refused")
}
}
// TestAttributeProjectIsPureAndTotal: the stamp reaches every event in a batch, and
// an empty project leaves the caller's value alone (a bearer names no site).
func TestAttributeProjectIsPureAndTotal(t *testing.T) {
evs := []CaptureEvent{{Product: "a"}, {Product: "b"}, {}}
out := attributeProject(evs, "shop")
for i, e := range out {
if e.Product != "shop" {
t.Fatalf("event %d product = %q, want shop", i, e.Product)
}
}
back := attributeProject([]CaptureEvent{{Product: "console"}}, "")
if back[0].Product != "console" {
t.Fatalf("empty project overwrote %q", back[0].Product)
}
}
// TestMountWiresTheKeyDoor: an unwired seam refuses every beacon on the fleet, and no
// behavioural test inside this package can see it because the package is correct
// either way. So the wiring itself is asserted.
func TestMountWiresTheKeyDoor(t *testing.T) {
keyMu.Lock()
origR, origF := keyResolver, keyFallback
keyMu.Unlock()
SetKeyResolver(nil)
SetFallbackKeyResolver(nil)
t.Cleanup(func() { SetKeyResolver(origR); SetFallbackKeyResolver(origF) })
_ = mountApp(t)
if !HasFallbackKeyResolver() {
t.Fatal("Mount left the key resolver unwired; every beacon on the fleet would refuse")
}
}
+8 -19
View File
@@ -45,7 +45,6 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"regexp"
"strings"
"time"
@@ -58,12 +57,6 @@ import (
// Larger batches are rejected (400) rather than silently truncated.
const maxBatch = 500
// publicCaptureEnv gates anonymous (no-principal) capture. Default ON: the
// marketing sites emit anonymous pageviews, and cloud is REPLACING the already-
// public insights-capture ingest, so refusing anonymous events would drop that
// traffic. Set to a falsey value to require a validated principal on every event.
const publicCaptureEnv = "CLOUD_ANALYTICS_PUBLIC_CAPTURE"
// maxClockSkew and maxBackdate are the TWO bounds on the one caller-chosen value
// that reaches a key column, and they exist for different reasons.
//
@@ -539,16 +532,6 @@ func strconv64(n int64) string {
// ── handler ──────────────────────────────────────────────────────────────────
// publicCaptureEnabled reports whether anonymous capture is allowed (default ON).
func publicCaptureEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(publicCaptureEnv))) {
case "0", "false", "no", "off":
return false
default:
return true
}
}
// resolveKeyOrg maps a presented project/API key to its org through the ONE IAM
// key seam (cloud.OrgForKey). It is a package var ONLY so a test can substitute a
// resolver without standing up IAM; production is always cloud.OrgForKey.
@@ -611,8 +594,8 @@ func projectKey(c *zip.Ctx) string {
//
// That was a per-DOOR copy of a decision that belongs to the TRUST LEVEL. Both alias
// handlers now call handle (event.go) like every other door: a credential resolves to
// its own org at full capability, and a credential-less caller gets the anonymous
// projection under publicTenant. A Host header no longer names a tenant anywhere.
// its own org, at full capability or through the projection, and a credential-less
// caller is refused. A Host header no longer names a tenant anywhere.
// ── ONE write core ───────────────────────────────────────────────────────────
@@ -630,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
+21 -18
View File
@@ -44,6 +44,7 @@ import (
func liveApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("live")})
compose(app)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("live")}); err != nil {
t.Fatalf("Mount: %v", err)
}
@@ -242,10 +243,10 @@ func livePost(t *testing.T, app *zip.App, path, user, org, body string) (int, []
return resp.StatusCode, b
}
// TestLiveAnonymousCapture proves the marketing-site path: an ANONYMOUS pageview
// (no principal) posted with no credential lands under the reserved public tenant,
// resolved server-side — never from a client field.
func TestLiveAnonymousCapture(t *testing.T) {
// TestLiveAnonymousCaptureIsRefused proves it against the real warehouse: a pageview
// posted with no credential is refused 401 and writes NO row. A brand Host buys
// nothing — attribution is the key, and there is no tenant to fall back to.
func TestLiveAnonymousCaptureIsRefused(t *testing.T) {
ready, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := datastore.Wait(ready); err != nil {
@@ -256,35 +257,37 @@ func TestLiveAnonymousCapture(t *testing.T) {
landDirect(t)
app := liveApp(t)
// A unique session id lets us find exactly this run's row (an anonymous row
// carries no caller properties, so the marker rides a projected column).
// A unique session id lets us look for exactly this run's row. Nothing must
// carry it.
marker := "anon-" + time.Now().UTC().Format("150405.000")
body := `{"batch":[{"type":"pageview","distinctId":"visitor-x","sessionId":"` + marker + `","product":"site","path":"/"}]}`
req := httptest.NewRequest(http.MethodPost, canonDoor, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Host = "hanzo.ai" // brand host buys NOTHING; the row lands under $public
req.Host = "hanzo.ai" // a brand host names no tenant
resp, err := app.Test(req)
if err != nil {
t.Fatalf("anon POST: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("anon capture = %d, want 200", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon capture = %d (%s), want 401", resp.StatusCode, raw)
}
var e struct {
Code string `json:"code"`
}
if err := json.Unmarshal(raw, &e); err != nil || e.Code != "ingest_key_required" {
t.Fatalf("anon capture code = %q (%s), want ingest_key_required", e.Code, raw)
}
rows, err := datastore.Query(ctx,
"SELECT org, kind, product FROM "+factTable+" WHERE session_id = ?", marker)
if err != nil {
t.Fatalf("readback: %v", err)
}
if len(rows) != 1 {
t.Fatalf("anon rows = %d, want 1", len(rows))
}
tenant := aString(rows[0]["org"])
t.Logf("anonymous pageview landed: org=%q kind=%q product=%q",
tenant, aString(rows[0]["kind"]), aString(rows[0]["product"]))
if tenant != publicTenant {
t.Fatalf("anon tenant = %q, want %s (no Host names a tenant)", tenant, publicTenant)
if len(rows) != 0 {
t.Fatalf("anon rows = %d, want 0 — a refused beacon must reach no partition, got org=%q",
len(rows), aString(rows[0]["org"]))
}
}
+18 -29
View File
@@ -405,20 +405,21 @@ func doBody(t *testing.T, app *zip.App, method, path, user, org, body string) (i
}
// TestCapture_NoPrincipalGetsAnonymousLane: a credential-less POST is not refused
// outright — it takes the anonymous lane, because admission is decided by trust level
// rather than per door. A pageview is admitted (503, datastore down) under the
// reserved public tenant, and everything beyond the allowlist is dropped, which is
// what the retired alias routes used to get WRONG in the other direction: they
// resolved a REAL brand org from the Host and admitted the lot.
func TestCapture_NoPrincipalGetsAnonymousLane(t *testing.T) {
// outright at the door and admitted nowhere: admission is decided by trust level
// rather than per door, and with no credential there is no trust level to decide on.
// The retired alias routes got this WRONG in the other direction — they resolved a
// REAL brand org from the Host and admitted the lot.
func TestCapture_NoPrincipalIsRefused(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
p := canonDoor
if code, body := doBody(t, app, http.MethodPost, p, "", "", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
t.Fatalf("no-principal POST %s want 503 (anonymous lane, admitted), got %d (%s)", p, code, body)
for _, body := range []string{
`{"batch":[{"type":"pageview"}]}`,
`{"batch":[{"type":"event","event":"order_completed","revenue":99}]}`,
} {
code, got := doBody(t, app, http.MethodPost, p, "", "", body)
refusedAnon(t, "no-principal POST "+p+" "+body, code, got)
}
code, body := doBody(t, app, http.MethodPost, p, "", "", `{"batch":[{"type":"event","event":"order_completed","revenue":99}]}`)
refusedAnon(t, "no-principal commerce POST "+p, code, body)
}
// TestCapture_ForgedOrgWithoutBearerBuysNothing: a raw X-Org-Id with no validated
@@ -498,12 +499,11 @@ func doHost(t *testing.T, app *zip.App, path, user, org, host, body string) (int
return resp.StatusCode, b
}
// TestCapture_HostIsNotATenant: marketing traffic on a recognized brand Host is still
// ACCEPTED (503 — admitted, datastore down), so nothing external breaks; what changed is
// that the Host no longer picks the TENANT. Anonymous traffic lands under the reserved
// public tenant whatever the Host says, and an UNRECOGNIZED Host now behaves exactly
// like a recognized one — the two used to differ (403 vs. a real brand org), which is
// precisely how a caller-settable header ended up selecting a real partition.
// TestCapture_HostIsNotATenant: a Host never picks a tenant, and now never admits one
// either. A recognized brand Host, an unrecognized one and a customer's own all answer
// the SAME 401 — the Host is not evidence of anything. It used to differ (403 vs. a
// real brand org), which is precisely how a caller-settable header ended up selecting
// a real partition.
func TestCapture_HostIsNotATenant(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
@@ -518,19 +518,8 @@ func TestCapture_HostIsNotATenant(t *testing.T) {
{"/v1/event", "hanzo.ai", posthogPage},
{"/v1/event", "evil.example.com", posthogPage},
} {
if code, body := doHost(t, app, tc.path, "", "", tc.host, tc.body); code != http.StatusServiceUnavailable {
t.Fatalf("anonymous pageview %s on host %q want 503 (admitted), got %d (%s)", tc.path, tc.host, code, body)
if code, body := doHost(t, app, tc.path, "", "", tc.host, tc.body); code != http.StatusUnauthorized {
t.Fatalf("anonymous pageview %s on host %q want 401 (no key), got %d (%s)", tc.path, tc.host, code, body)
}
}
}
func TestCapture_PublicCaptureDisabled(t *testing.T) {
t.Setenv(publicCaptureEnv, "off")
app := mountApp(t)
// With public capture disabled, even a recognized brand host is refused
// without a validated principal.
code, _ := doHost(t, app, canonDoor, "", "", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`)
if code != http.StatusForbidden {
t.Fatalf("public-capture-off anonymous want 403, got %d", code)
}
}
+4 -4
View File
@@ -102,7 +102,7 @@ func TestAcceptedPathIsUnchanged(t *testing.T) {
{"bare array", `[{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"}]`},
{"batch envelope", `{"batch":[{"type":"pageview","event":"$pageview","distinctId":"anon-1"}]}`},
} {
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", tc.body)
code, body := postAnon(t, app, "/v1/event", tc.body, nil)
if code != http.StatusServiceUnavailable {
t.Errorf("anonymous pageview (%s) = %d (%s), want 503 ADMITTED — the fix must not "+
"narrow what the door accepts", tc.name, code, body)
@@ -122,7 +122,7 @@ func TestPartialBatchStillSucceeds(t *testing.T) {
const mixed = `{"batch":[` +
`{"type":"pageview","event":"$pageview","distinctId":"anon-1","path":"/pricing"},` +
`{"type":"event","event":"order_completed","revenue":99}]}`
code, body := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", mixed)
code, body := postAnon(t, app, "/v1/event", mixed, nil)
if code != http.StatusOK {
t.Fatalf("partial batch = %d (%s), want 200 — some events landing is a success", code, body)
}
@@ -138,7 +138,7 @@ func TestEmptyBodyStillSucceeds(t *testing.T) {
roomyRate(t)
app := mountApp(t)
for _, body := range []string{``, ` `, `{"batch":[]}`, `[]`} {
code, got := doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", body)
code, got := postAnon(t, app, "/v1/event", body, nil)
if code != http.StatusOK {
t.Errorf("empty body %q = %d (%s), want 200 — dropping nothing is not losing anything",
body, code, got)
@@ -220,7 +220,7 @@ func TestDropIsVisibleToAnAlert(t *testing.T) {
roomyRate(t)
app := mountApp(t)
doHost(t, app, "/v1/event", "", "", "api.hanzo.ai", `{"event":"app.log","distinctId":"d1"}`)
postAnon(t, app, "/v1/event", `{"event":"app.log","distinctId":"d1"}`, nil)
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
+70 -210
View File
@@ -22,12 +22,8 @@ import (
// doors_test.go — the ingest SURFACE is one set, and these are its proofs.
//
// Three things used to answer "what is an ingest door" independently: the route
// table, sites' analyticsPaths literal, and a path switch inside the carve. They
// disagreed — /v1/tracker and /v1/ingest were routed doors sites did not name, so the
// same beacon was admitted on an API host and 405'd on a site host. doors (event.go)
// is now the only answer and both surfaces derive from it; the tests below hold that
// shut from both ends.
// doors (event.go) is the only answer to "what is an ingest door"; the router derives
// from it, and the tests below hold that shut.
//
// Every gate assertion here is QUANTIFIED OVER doors rather than written against a
// path list, so a door added tomorrow inherits the whole contract instead of needing
@@ -84,8 +80,7 @@ func sameWire(a, b decode) bool { return samePtr(a, b) }
// named them, has no importer left in the fleet.
//
// /v1/tracker is retired FROM THIS PACKAGE only, and this list is scoped to this
// package's two surfaces (its own router and the carve it hands sites). The path
// itself belongs to the tracker product, which owns the prefix in the app manifest
// package's own router. The path itself belongs to the tracker product, which owns the prefix in the app manifest
// and keeps serving /v1/tracker/projects/… — analytics squatting the bare path is
// precisely what ends here. mountApp mounts analytics alone, so a 404 in this
// harness is the honest statement that ANALYTICS no longer answers there.
@@ -98,28 +93,6 @@ var retiredDoors = []string{
"/v1/analytics", "/v1/analytics/batch", "/v1/tracker",
}
// notDoors are paths that must never ingest: the read lenses, near-miss spellings, and
// the neighbouring subsystem's route. They are the paired negative for every positive
// below — widen the door lookup to a prefix, or give it a default case, and these go
// red.
//
// The last row is the deliberate strictness. c.Path() is the RAW request target —
// zip returns Fiber's path verbatim and nothing upstream unescapes or normalizes it
// (see resolveKey in clients/sites) — and the carve matches it BYTE-EXACTLY. So an
// encoded or denormalized spelling of a real door misses the carve and is served as
// static, even where Fiber's own router would still reach the door (POST /v1/event/
// routes on an API host and does not carve on a site host). That asymmetry is chosen,
// not overlooked: the carve hands a request a tenant derived from a Host, so it admits
// only the exact strings it was given, and every near-miss fails to the static serve.
// Normalizing here to match the router would widen a security-relevant exact set to
// chase a routing convenience — the same mistake as the prefix match this set replaced.
var notDoors = []string{
"/v1/analytics/overview", "/v1/analytics/timeseries", "/v1/analytics/top",
"/v1/analytics/health", "/v1/analytics/anything", "/v1/analytics/batch/extra",
"/v1/eventx", "/v1/insights/e/extra", "/v1/insights/events", "/v1/tracker/projects",
"/v1/%65vent", "/v1/event/", "//v1/event", "/v1/./event", "/v1/x/../event",
}
func doorPaths() []string {
p := make([]string, len(doors))
for i, d := range doors {
@@ -216,10 +189,9 @@ func TestWritePathSeamsDefaultToTheRealThing(t *testing.T) {
}
}
// tenants returns the org of every fact committed — the fact the site-host lane
// and the anonymous lane must disagree about, and the only place that disagreement
// is visible. The wide row died with hanzo.events, so the fact's own envelope is
// where the tenant stamp is read now.
// tenants returns the org of every fact committed — the only place the tenant a lane
// actually wrote is visible. The wide row died with hanzo.events, so the fact's own
// envelope is where the tenant stamp is read now.
func (w *warehouse) tenants(t *testing.T) []string {
t.Helper()
out := make([]string, 0, len(w.facts))
@@ -261,7 +233,7 @@ func sameSet(a, b []string) bool {
}
// admittedWire returns the body, from cands, that THIS door's own wire decodes into
// exactly one event the anonymous lane ADMITS. Picking the body through the door's
// exactly one event the PROJECTION admits. Picking the body through the door's
// real decoder + the real projection is what lets every test below quantify over
// doors without a per-wire lookup table beside it — the thing whose duplication
// caused the drift in the first place.
@@ -276,12 +248,12 @@ func admittedWire(t *testing.T, d door, cands ...string) string {
return b
}
}
t.Fatalf("no candidate body is admitted by the anonymous lane on door %s", d.path)
t.Fatalf("no candidate body is admitted by the projection on door %s", d.path)
return ""
}
// droppedWire is the twin: exactly one decoded event that the anonymous lane REFUSES
// (a commerce/custom kind), which is what proves capability rather than reachability.
// droppedWire is the twin: exactly one decoded event the PROJECTION refuses (a
// commerce/custom kind), which is what proves capability rather than reachability.
func droppedWire(t *testing.T, d door, cands ...string) string {
t.Helper()
for _, b := range cands {
@@ -293,7 +265,7 @@ func droppedWire(t *testing.T, d door, cands ...string) string {
return b
}
}
t.Fatalf("no candidate body is dropped by the anonymous lane on door %s", d.path)
t.Fatalf("no candidate body is dropped by the projection on door %s", d.path)
return ""
}
@@ -383,15 +355,9 @@ func TestIngestSurfaceIsExactlyTheContract(t *testing.T) {
// that reaches the ROW. Without it, source could be pinned in the table and dropped on
// the way to the warehouse and both halves would still look right.
//
// It quantifies over doors × HANDLERS, because a door has two of them and they stamp
// $source independently: ingest (the API host, via handle) and anon (the site host,
// which calls publicIngest directly). Driving only the ingest half left the anon half
// free to stamp a CONSTANT, and $source is precisely the signal the alias sunset is
// decided on — the documented rule is that a door may be retired when its $source
// volume reaches zero, so an anon lane that stamped 'event' for every door would read
// as "/v1/tracker is dead" while site-host callers were still beaconing it. The
// sunset is a delete-the-route decision made on this column; it has to be true on
// EVERY lane that writes it, not just the one a test happened to drive.
// $source is the signal the alias sunset is decided on — a door may be retired when
// its volume reaches zero — so the value declared in the table has to be the value
// that reaches the column.
func TestEveryDoorStampsItsOwnSource(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
@@ -401,106 +367,28 @@ func TestEveryDoorStampsItsOwnSource(t *testing.T) {
t.Fatalf("door %s = %d (%s), want 200 (written to the fake warehouse)", d.path, code, body)
}
if got := w.sources(t); len(got) != 1 || got[0] != d.source {
t.Errorf("door %s ingest lane wrote $source %v, want [%s]", d.path, got, d.source)
}
w = fakeWarehouse(t)
site := carveApp(t, "hanzo")
if code := postHost(t, site, "yadota.hanzo.app", d.path, pageviewFor(t, d), nil); code != http.StatusOK {
t.Fatalf("site-host door %s = %d, want 200 (admitted and written)", d.path, code)
}
if got := w.sources(t); len(got) != 1 || got[0] != d.source {
t.Errorf("door %s anon lane wrote $source %v, want [%s] — the sunset metric must name "+
"the door the beacon actually arrived through, on this lane too", d.path, got, d.source)
t.Errorf("door %s wrote $source %v, want [%s]", d.path, got, d.source)
}
}
}
// ── the site-host lane, which is the one that derives a tenant from a Host ───
// ── the tenant is the credential's, and a beacon without one writes nothing ──
// TestSiteHostLaneWritesTheResolvedSiteOrg is the tenant proof for the carve, and the
// reason the warehouse seam exists. Every declared door, POSTed to a LIVE site host,
// must write rows under the RESOLVED Site.Org — not the reserved public tenant, and
// not the org the request claims in a header or body.
//
// Paired failures, all of which used to pass unnoticed because the pipeline stopped at
// the readiness gate and every case answered 503: pass publicTenant instead of org and
// a customer's own site analytics land in a partition they cannot read; honour the
// caller's X-Org-Id and a stranger writes into any org they can name.
func TestSiteHostLaneWritesTheResolvedSiteOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
w := fakeWarehouse(t)
app := carveApp(t, "hanzo")
if code := postHost(t, app, "yadota.hanzo.app", d.path, pageviewFor(t, d),
map[string]string{"X-Org-Id": "attacker", "X-User-Id": "attacker-user"}); code != http.StatusOK {
t.Fatalf("site-host door %s = %d, want 200 (admitted and written)", d.path, code)
}
got := w.tenants(t)
if len(got) != 1 || got[0] != "hanzo" {
t.Errorf("site-host door %s wrote tenants %v, want [hanzo] — the carve must file a "+
"beacon under the RESOLVED Site.Org", d.path, got)
}
for _, g := range got {
if g == publicTenant {
t.Errorf("site-host door %s filed the site's own beacon under %q, where its owner "+
"cannot read it", d.path, publicTenant)
}
if g == "attacker" {
t.Errorf("site-host door %s took the tenant from the caller's header", d.path)
}
}
}
}
// TestSiteHostLaneNeverConsultsHandle: on a site host the anonymous lane is reached
// DIRECTLY, and it has to be. sites.Middleware runs before the identity boundary, so
// X-User-Id / X-Org-Id there are still raw client headers that nothing has validated —
// exactly the shape SanitizeIdentity would have minted for a real bearer.
//
// So a request carrying them must still be PROJECTED. If door.anon consulted handle,
// those headers would resolve a principal and buy full capability, and the commerce
// payload would become a row under whatever org the caller named. The assertion is on
// the ROW, not the status: with a warehouse in place "admitted" is a 200 too, so a
// status check alone cannot tell the two lanes apart.
func TestSiteHostLaneNeverConsultsHandle(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
w := fakeWarehouse(t)
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", d.path, commerceFor(t, d),
map[string]string{"X-User-Id": "user-dave", "X-Org-Id": "acme"})
// 401: the projection refused the whole payload, which is the point — had the
// carve consulted handle, those raw headers would have bought full capability
// and the batch would have reached the write core (503) and been STORED.
if code != http.StatusUnauthorized {
t.Fatalf("site-host door %s with raw identity headers = %d, want 401", d.path, code)
}
if got := w.tenants(t); len(got) != 0 {
t.Errorf("site-host door %s STORED a commerce payload under %v — the site-host lane "+
"consulted handle, so unvalidated headers bought full capability", d.path, got)
}
}
}
// TestApiHostAnonymousLaneWritesThePublicTenant is the other half of the tenant pair:
// on an API host a credential-less caller is the RESERVED public tenant, whatever Host
// it used. Together with the site-host test above, this is what makes each lane's
// tenant a checked fact rather than a comment — one must be $public and the other must
// not, so a change that collapses them fails on one side or the other.
func TestApiHostAnonymousLaneWritesThePublicTenant(t *testing.T) {
// TestApiHostAnonymousWritesNothing: a credential-less caller is REFUSED on every
// door, whatever Host it used, and reaches the warehouse not at all. There is no
// reserved tenant to fall back to — a row lands in the org a credential named or it
// does not land.
func TestApiHostAnonymousWritesNothing(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
for _, host := range []string{"api.hanzo.ai", "hanzo.ai"} {
w := fakeWarehouse(t)
app := mountApp(t)
if code, body := doHost(t, app, d.path, "", "", host, pageviewFor(t, d)); code != http.StatusOK {
t.Fatalf("anonymous door %s on %q = %d (%s), want 200", d.path, host, code, body)
}
got := w.tenants(t)
if len(got) != 1 || got[0] != publicTenant {
t.Errorf("anonymous door %s on host %q wrote tenants %v, want [%s] — no Host names a tenant",
d.path, host, got, publicTenant)
code, body := doHost(t, app, d.path, "", "", host, pageviewFor(t, d))
refusedAnon(t, "anonymous door "+d.path+" on host "+host, code, body)
if got := w.tenants(t); len(got) != 0 {
t.Errorf("anonymous door %s on host %q wrote tenants %v, want none — a beacon "+
"nobody can attribute must not reach the warehouse", d.path, host, got)
}
}
}
@@ -515,7 +403,7 @@ func TestRoutedPostSetIsExactlyTheDoors(t *testing.T) {
var posts []string
// GetRoutes(true) drops the `use` entries — middleware, which fiber keeps in the
// same stack as routes and reports under every method at the prefix it gates.
// cloud.Bridge is one of those (routes installs it so a typed op can read the
// cloud.Bridge is one of those (compose installs it so a typed op can read the
// validated org), and so is every middleware Serve installs app-wide, so an
// unfiltered read has never been "the POST surface" in the real binary either. A
// middleware is a passthrough, not a door: it dispatches nothing.
@@ -524,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)
@@ -536,13 +436,13 @@ func TestRoutedPostSetIsExactlyTheDoors(t *testing.T) {
}
// TestEveryDoorIsRoutedAndAdmits is the positive half on the API host: each declared
// door actually exists (never 404) and reaches the write core for an admissible
// anonymous event (503, no datastore in the harness).
// door actually exists (never 404) and, for a credential that resolves, reaches the
// write core (503, no datastore in the harness).
func TestEveryDoorIsRoutedAndAdmits(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
for _, d := range doors {
code, body := doHost(t, app, d.path, "", "", "api.hanzo.ai", pageviewFor(t, d))
code, body := doBody(t, app, http.MethodPost, d.path, "user-dave", "acme", pageviewFor(t, d))
if code == http.StatusNotFound {
t.Errorf("door %s is declared but not routed (404)", d.path)
continue
@@ -553,20 +453,14 @@ func TestEveryDoorIsRoutedAndAdmits(t *testing.T) {
}
}
// TestRetiredDoorIsGoneFromBothSurfaces is the deletion proof, and it checks BOTH
// surfaces because deleting a route while leaving the carve entry (or the reverse) is
// the exact failure mode this whole change removes. A retired door must 404 on the API
// host and fall to the static serve (405) on a site host.
func TestRetiredDoorIsGoneFromBothSurfaces(t *testing.T) {
// TestRetiredDoorIsGone is the deletion proof: a retired door must 404 on the API host
// and be absent from the door table.
func TestRetiredDoorIsGone(t *testing.T) {
api := mountApp(t)
site := carveApp(t, "hanzo")
for _, p := range retiredDoors {
if code, body := doHost(t, api, p, "", "", "api.hanzo.ai", canonPageview); code != http.StatusNotFound {
t.Errorf("retired door %s is still routed on the API host: %d (%s)", p, code, body)
}
if code := postHost(t, site, "yadota.hanzo.app", p, canonPageview, nil); code != http.StatusMethodNotAllowed {
t.Errorf("retired door %s is still carved on a site host: %d (want 405, static serve)", p, code)
}
for _, d := range doors {
if d.path == p {
t.Errorf("retired door %s is still declared in doors", p)
@@ -575,54 +469,12 @@ func TestRetiredDoorIsGoneFromBothSurfaces(t *testing.T) {
}
}
// ── the carve set IS the door set ───────────────────────────────────────────
// TestSiteHostCarvesExactlyTheDoors is the reconciliation proof. On a live site host
// every declared door is carved to the anonymous lane under the SITE's org, and no
// non-door is — so the routed set (pinned exactly above) and the carved set are the
// same set. Before, they were not: /v1/tracker routed here and 405'd there.
//
// The negative half is the paired failure: hand sites anything other than the doors,
// or let its lookup fall back to a default, and a notDoors path starts carving.
func TestSiteHostCarvesExactlyTheDoors(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
for _, d := range doors {
app := carveApp(t, "hanzo")
// A forged org on the wire must not win — the tenant is the resolved Site's.
if code := postHost(t, app, "yadota.hanzo.app", d.path, pageviewFor(t, d),
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusServiceUnavailable {
t.Errorf("door %s on a site host = %d, want 503 (carved, ingested for the site org)", d.path, code)
}
}
app := carveApp(t, "hanzo")
for _, p := range notDoors {
if code := postHost(t, app, "yadota.hanzo.app", p, canonPageview, nil); code != http.StatusMethodNotAllowed {
t.Errorf("non-door %s carved on a site host: %d (want 405, static serve)", p, code)
}
}
}
// TestSiteHostCarveNeedsAResolvedSite: the carve is gated on a Site actually
// resolving, not merely on the host looking like one. An unresolvable slug host falls
// to the static serve on EVERY door — no door turns an unbacked Host into a tenant.
func TestSiteHostCarveNeedsAResolvedSite(t *testing.T) {
app := carveApp(t, "hanzo") // the resolver knows only "yadota"
for _, d := range doors {
if code := postHost(t, app, "nosuchsite.hanzo.app", d.path, pageviewFor(t, d), nil); code == http.StatusServiceUnavailable {
t.Errorf("door %s ingested on an UNRESOLVED site host — the carve must require a resolved Site", d.path)
}
}
}
// ── the gate, quantified over every door ────────────────────────────────────
// TestEveryDoorFailsClosedOnUnresolvableCredential is THE admission gate. A caller that
// PRESENTED a credential which does not resolve is refused on every door — never
// silently downgraded into the anonymous lane, where its events would land in a
// partition its owner cannot read.
//
// Paired failure: delete handle's `if presented(c)` branch and every door answers 200
// or 503 instead of 403, and this fails on all of them at once.
// PRESENTED a credential which does not resolve is refused 403 on every door — never
// downgraded, because a downgrade files a misconfigured key's events where its owner
// cannot read them.
func TestEveryDoorFailsClosedOnUnresolvableCredential(t *testing.T) {
for _, d := range doors {
app := mountApp(t)
@@ -639,14 +491,13 @@ func TestEveryDoorFailsClosedOnUnresolvableCredential(t *testing.T) {
}
}
// TestEveryDoorProjectsTheAnonymousCaller is the capability gate. With no credential
// of any kind, on a RECOGNIZED BRAND HOST, a commerce payload must be dropped — never
// stored, and never at full capability into a real org.
// TestEveryDoorRefusesTheAnonymousCaller is the capability gate. With no credential of
// any kind, on a RECOGNIZED BRAND HOST, a commerce payload is refused — never stored,
// and never at full capability into a real org.
//
// 503 is the failure signal here, not the success one: it would mean the request
// reached the write core unprojected. Paired failure: give handle a host fallback, or
// let admitPublic see the org, and these turn 503.
func TestEveryDoorProjectsTheAnonymousCaller(t *testing.T) {
// reached the write core. Paired failure: give handle a host fallback and these turn 503.
func TestEveryDoorRefusesTheAnonymousCaller(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := mountApp(t)
for _, d := range doors {
@@ -663,8 +514,8 @@ func TestEveryDoorProjectsTheAnonymousCaller(t *testing.T) {
}
// TestEveryDoorAdmitsAValidatedPrincipal is the "the gate is not just a wall" half: a
// validated bearer keeps FULL capability on every door, so the commerce payload the
// anonymous lane drops is admitted here (503 = reached the write core).
// validated bearer keeps FULL capability on every door, so the commerce payload a
// credential-less caller is refused for is admitted here (503 = reached the write core).
func TestEveryDoorAdmitsAValidatedPrincipal(t *testing.T) {
app := mountApp(t)
for _, d := range doors {
@@ -722,9 +573,18 @@ func TestEveryUntypedRouteDeclaresItsBodies(t *testing.T) {
}
continue
}
// Any declared media type counts: an asset route answers JavaScript, not
// JSON (openapi.Bytes), and requiring application/json here would force a
// document that lies about what the handler sets.
resp, ok := op.Responses["2XX"]
if !ok || len(resp.Content["application/json"].Schema) == 0 {
if !ok || len(resp.Content) == 0 {
t.Errorf("%s publishes no 2XX body schema", key)
continue
}
for media, m := range resp.Content {
if len(m.Schema) == 0 {
t.Errorf("%s publishes a 2XX %s with no schema", key, media)
}
}
_ = path
}
+138 -116
View File
@@ -136,6 +136,13 @@ func (e Event) toCapture() CaptureEvent {
type admission struct {
org string
full bool
// project is the site the credential named, when it named one. Only a project
// key can: it is minted with a project and resolves to nothing else, so this is
// the one attribution the server can state rather than accept. It REPLACES the
// caller's `product` on every admitted row (attributeProject). Empty for the
// org-level credentials — a bearer and an IAM key name an org and no site, and
// an empty project honestly says "this write names no site".
project string
// subject is the credential's OWN signed identity. It is only consulted on the
// reduced lane, where it REPLACES the caller-supplied distinctId — see handle. It
// is empty for the full-capability credentials, which are trusted to attribute
@@ -147,12 +154,10 @@ type admission struct {
// in strict trust order:
//
// 1. a validated IAM bearer principal wins (its owner org), at FULL capability;
// 2. else a presented write-only publishable key (pk_…) is HMAC-verified to its org
// with no IAM/DB hop (the SAME verifier publishable.go's /v1/ingest used — folded
// in here so a pk_ caller uses /v1/event directly), at FULL capability;
// 3. else a presented out-of-band IAM access key (sk-…) is resolved to its org
// through the ONE key seam (resolveKeyOrg → cloud.OrgForKey), at FULL capability;
// 4. else a verified Hanzo Team workspace token — at FULL capability for a member,
// 2. else a presented key on either carrier resolves through keyAdmission — the
// project that minted it (org AND site), else the org IAM issued it to — at FULL
// capability;
// 3. else a verified Hanzo Team workspace token — at FULL capability for a member,
// and at REDUCED capability for a guest (teamTenant, team.go).
//
// None matches ⇒ (admission{}, false), which handle answers by refusing a presented-
@@ -173,13 +178,13 @@ func eventTenant(c *zip.Ctx) (admission, bool) {
// Safe only because a pk- no longer authenticates: IdentityFromRequest
// refuses it, so it attributes a write and never mints a reading principal.
if key := ingestKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
if a, ok := keyAdmission(c, key); ok {
return a, true
}
}
if key := projectKey(c); key != "" {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
if a, ok := keyAdmission(c, key); ok {
return a, true
}
}
// A Hanzo Team workspace token (HS256 over SERVER_SECRET, org and role in the
@@ -199,6 +204,29 @@ func eventTenant(c *zip.Ctx) (admission, bool) {
return admission{}, false
}
// keyAdmission resolves ONE presented key, on either carrier, to what it names.
// Both carriers call it so they cannot drift into meaning different things by the
// same string.
//
// Two issuers, and they are DISJOINT rather than a fallback chain: a project key
// exists only in the project store and an IAM key only in IAM, so a lookup in one
// can never shadow the other and the order costs nothing but a miss. Projects are
// asked first because they answer a strictly narrower question — org AND site,
// where IAM can only ever say org, having no project to scope to.
//
// A project key is the credential a site's own beacon carries, so it also carries
// the property the whole change is for: it stops resolving the moment the project
// stops existing.
func keyAdmission(c *zip.Ctx, key string) (admission, bool) {
if sc, ok := resolveAttribution(c.Context(), key); ok {
return admission{org: sc.Org, project: sc.Project, full: true}, true
}
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return admission{org: org, full: true}, true
}
return admission{}, false
}
// firstNonWS returns the index of the first non-JSON-whitespace byte, or len(body)
// when the body is empty or all whitespace. The four bytes are JSON's insignificant
// whitespace (RFC 8259 §2). The ONE place the ingest decoders skip leading space.
@@ -388,6 +416,28 @@ func cannotWrite(signed bool) *zip.HTTPError {
}
}
// cannotAttribute names why ADMISSION refused — the wall before cannotWrite's. Same
// two-answer shape and the same reason: the caller's next move differs.
//
// nothing presented ⇒ 401 ingest_key_required. The one code every client already
// branches on, so a beacon that lost its key reads the same
// whether it never had one or the projection dropped it.
// presented, unresolved ⇒ 403. It HAS a key; the key names no project. Minting
// another would hit the identical wall, so the fix named is the
// project, not the key.
func cannotAttribute(presented bool) *zip.HTTPError {
if presented {
return &zip.HTTPError{
Status: http.StatusForbidden, Code: "ingest_key_unknown",
Msg: "this ingest key names no project: create one (POST /v1/projects) and send the key it mints",
}
}
return &zip.HTTPError{
Status: http.StatusUnauthorized, Code: "ingest_key_required",
Msg: "no event could be attributed: create a project (POST /v1/projects) and send its key as ?ingest_key= or Authorization: Bearer",
}
}
// ingestDecoded is the TAIL of the ingest pipeline, and the ONE place it lives: fold
// type:'error' events (foldException) → the ONE write core (ingestEvents) → the honest
// receipt. Every lane ends here, so "what happens to an admitted event" is written
@@ -514,42 +564,18 @@ func observeDropped(c *zip.Ctx, org, source string, unattributable, unroutable i
}
}
// presented reports whether the request PRESENTED an IDENTIFIABLE credential at all,
// independent of whether it resolved. It is the discriminator between "misconfigured"
// (refuse) and "anonymous" (project), and it names exactly the carriers eventTenant
// consults, so the two can never disagree about what "presented" means. When
// eventTenant learned about team tokens and this did not, they DID disagree, and the
// result was the precise failure the team door exists to prevent: an expired team
// token answered 200 with its rows filed under $public, a partition its org cannot
// read.
// presented reports whether the request PRESENTED an IDENTIFIABLE credential at
// all, independent of whether it resolved. It picks which refusal handle answers:
// 403 (you sent one and it is broken) or 401 (you sent none — here is what to get).
// It names exactly the carriers eventTenant consults, so the two cannot disagree
// about what "presented" means.
//
// WHY A KEY AND A TEAM TOKEN REFUSE, AND A STALE IAM BEARER DOES NOT. The asymmetry is
// a fact about what is DECIDABLE, not a preference:
//
// - an ingest key is self-identifying by PREFIX (pk-/sk-), and a team token is
// self-identifying by STRUCTURE (it carries an `account` claim, which an IAM token
// does not). For both, "the caller presented THIS kind of credential" is answerable
// without trusting anything, so a failure to resolve is unambiguously a
// misconfiguration and 403 is the honest answer.
// - an arbitrary `Authorization: Bearer <jwt>` is not distinguishable from a bearer
// minted for some other audience entirely. IdentityMiddleware already declines to
// 401 it (validatedPrincipal returns nil rather than refusing), so treating its
// mere presence as "presented" here would turn every stale or foreign bearer that
// reaches an ingest door into a 403 — a refusal on evidence we do not have.
//
// So: identifiable credential that fails ⇒ 403. Unidentifiable bearer ⇒ the anonymous
// lane, exactly as before this file learned about team tokens.
// WHY bearerAPIKey IS HERE AND ingestKey IS NOT WIDENED. ingestKey returns only a
// pk- so this door never SHADOWS the identity path: an sk- bearer is IAM's to
// validate, and it arrives here already resolved (tenant ⇒ full capability) or not
// at all. That is right, and it is not the question presented() asks. presented()
// asks whether the caller PRESENTED an identifiable credential, and an sk-
// bearer is identifiable by the SAME prefix authority every other carrier is judged
// by — so a FAILED one is a misconfiguration and must refuse, exactly as the same
// key refuses today on x-api-key. Without this it took the anonymous lane instead:
// 200, with the caller's rows filed under $public, a partition its owner cannot
// read. That is the precise silent-misfiling failure this function exists to
// prevent, reached through the one carrier every Hanzo caller reaches for first.
// A key is identifiable by PREFIX (pk-/sk-) and a team token by STRUCTURE (an
// `account` claim an IAM token lacks), so a failure to resolve is decidably a
// misconfiguration. An arbitrary Bearer JWT is not distinguishable from one minted
// for another audience — IdentityMiddleware itself declines to 401 it — so it
// reads as "presented nothing", and its caller is told to get a key rather than
// that its key is broken.
func presented(c *zip.Ctx) bool {
return ingestKey(c) != "" || projectKey(c) != "" || bearerAPIKey(c) || teamPresented(c)
}
@@ -577,14 +603,23 @@ func bearerAPIKey(c *zip.Ctx) bool {
// itself full capability, and a door added tomorrow inherits this decision by
// construction rather than by remembering to copy it.
//
// credential resolves ⇒ FULL capability into THAT credential's org.
// credential resolves ⇒ FULL capability into THAT credential's org, and into
// the site it named when it named one.
// credential presented,
// does not resolve ⇒ 403. Never downgraded: filing a misconfigured key's
// events under the public tenant would hide them in a
// events under a reserved tenant would hide them in a
// partition its owner cannot read — a silent failure worse
// than the refusal.
// nothing presented ⇒ the ANONYMOUS lane (publicIngest): the projection, the
// kind allowlist, the size/rate bounds, the DNT gate.
// nothing presented ⇒ 401, naming the key to get and where to put it.
//
// THERE IS NO ANONYMOUS LANE. A keyless beacon used to be ACCEPTED into a reserved
// `$public` tenant and answered {"accepted":1} — an org could not read those rows,
// so every such caller lost everything it sent while every status check it had
// stayed green. Three first-party properties shipped keyless without one failed
// build, and a fleet-wide outage answered 200 for two days. A 200 that discards
// data is worse than a 4xx, so the lane is gone rather than gated: attribution is
// the key, a project mints one at create, and a write nobody can attribute is
// refused in the one field every client already reads.
//
// The first branch below is the ONLY unprojected write in this package. It is reached
// only from here, and only with an org eventTenant resolved from a credential — which
@@ -635,12 +670,9 @@ func handle(c *zip.Ctx, dec decode, source string) error {
if err != nil {
return zip.ErrBadRequest("malformed event payload")
}
return ingestDecoded(c, a.org, source, evs, refusal{})
return ingestDecoded(c, a.org, source, attributeProject(evs, a.project), refusal{})
}
if presented(c) {
return zip.ErrForbidden("valid bearer or a resolvable ingest key required")
}
return publicIngest(c, dec, publicTenant, source)
return cannotAttribute(presented(c))
}
// door is one ingest door: a PATH bound to the WIRE it speaks. Capability is not a
@@ -820,30 +852,27 @@ var doors = []door{
"back always takes a real bearer. A Hanzo Team workspace token resolves its org at " +
"REDUCED capability: the signed " +
"account names the person, so a `distinctId` in the body cannot pin events on a colleague.\n\n" +
"NO CREDENTIAL IS ALSO ADMITTED, and that is the point — a logged-out visitor has none. " +
"Such a write is PROJECTED: filed under the reserved `$public` tenant, narrowed to what the " +
"SERVER can name — pageviews and errors, plus the closed autocapture vocabulary ($click, " +
"$input, $change, $submit, $view) — where EVERY one of those names is resolved through a " +
"server-owned table and stored as that table's value, so the name on the wire is never the " +
"name in the row. Stripped, too, to the fields the projection names, so revenue, personId, " +
"groupId and every property but the element annotation " +
"cannot reach a row — and an exception is carried only on an error, never on an " +
"interaction, so a click cannot ship a stack trace into a row's attributes. " +
"ITS IDENTITY IS NAMESPACED for the same reason the name is: nobody signed for it, so a " +
"`distinctId` off the wire is stored under a reserved `$anon:` prefix that no identified " +
"subject carries — an anonymous visitor still counts as one visitor, and still cannot be " +
"joined to a person the org knows. Everything refused is counted in `dropped`. On a " +
"published-site host " +
"the same projection applies with that site's org as the tenant. But a credential that IS " +
"presented and does NOT resolve is 403, never quietly downgraded: filing a misconfigured " +
"key's events under $public would hide them in a partition their owner cannot read.\n\n" +
"The anonymous lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the " +
"NO CREDENTIAL IS REFUSED: a write the server cannot attribute to a project is 401 " +
"`ingest_key_required`, and a credential that IS presented but resolves to no project is " +
"403 `ingest_key_unknown`. Nothing is filed under a shared tenant — events nobody can " +
"read are worse than events nobody sent, because the caller is told it succeeded. A " +
"browser bundle therefore always ships a pk-, which is what /v1/event.js takes.\n\n" +
"A REDUCED principal — a Hanzo Team workspace token — writes through the PROJECTION into " +
"its own org: narrowed to what the SERVER can name (pageviews and errors, plus the closed " +
"autocapture vocabulary $click, $input, $change, $submit, $view), where every one of those " +
"names is resolved through a server-owned table and stored as that table's value, so the " +
"name on the wire is never the name in the row. Stripped, too, to the fields the projection " +
"names, so revenue, personId, groupId and every property but the element annotation cannot " +
"reach a row — and an exception is carried only on an error, never on an interaction, so a " +
"click cannot ship a stack trace into a row's attributes. It does NOT name the person: the " +
"signed account is the identity, so a `distinctId` in the body cannot pin events on a " +
"colleague. Everything refused is counted in `dropped`.\n\n" +
"The projected lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the " +
"per-client-IP and per-peer caps, and a DNT:1 or Sec-GPC:1 request stores nothing and says " +
"so in the receipt. Two stored values carry their own bounds on top, because a request cap " +
"does not bound one value: an element annotation over 2 KiB (or a trail over 32 steps) and " +
"an exception class over 256 bytes are dropped from the row, which still lands. Where a " +
"deployment switches anonymous capture off, a credential-less " +
"write is 403 instead. Authenticated bodies are offered to the observability plane first, " +
"an exception class over 256 bytes are dropped from the row, which still lands. " +
"Authenticated bodies are offered to the observability plane first, " +
"which claims LLM-observability ingestion batches and declines everything else.",
},
}
@@ -879,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
@@ -939,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
@@ -968,11 +998,3 @@ const sentryWire = "\n\nCLOUD ROUTES IT AND READS NONE OF IT. The body is relaye
func (d door) ingest(_ *cloud.Service[state], c *zip.Ctx) error {
return handle(c, d.decode, d.source)
}
// anon is the door's SITE-HOST handler: the anonymous lane directly, with the
// resolved Site's org as the tenant. It does not consult handle because there is
// nothing to consult — sites.Middleware runs before the identity boundary, so no
// credential on a site host has been validated by anything (installHostCarve).
func (d door) anon(org string, c *zip.Ctx) error {
return publicIngest(c, d.decode, org, d.source)
}
-103
View File
@@ -1,103 +0,0 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
package analytics
import (
"net/http"
"testing"
)
// appBeaconBody is the EXACT payload the published-site page beacon posts
// (app/lib/publishing/wired-injection.ts:68-69): a {batch:[CaptureEvent]} envelope.
// The only change the app makes is repointing ANALYTICS_ENDPOINT from /v1/analytics
// to /v1/event — the body is unchanged and MUST land via the canonical door's
// site-host carve.
const appBeaconBody = `{"batch":[{"messageId":"m-abc123","type":"pageview","event":"$pageview",` +
`"timestamp":"2026-07-22T12:00:00.000Z","distinctId":"anon-9","anonymousId":"anon-9",` +
`"sessionId":"sess-1","url":"https://yadota.hanzo.app/pricing","path":"/pricing",` +
`"referrer":"https://news.ycombinator.com/","properties":{"space":"yadota","title":"Pricing"},` +
`"library":"@hanzo/capture-wired","libraryVersion":"0.1.1"}]}`
// TestMount_HostCarve_EventDoorIngestsForSiteOrg is the /v1/event twin of
// TestMount_HostCarve_IngestsForSiteOrg: a beacon POST to the CANONICAL door on a LIVE
// site host is ingested for the site's Org in EVERY wire shape the tolerant decoder
// accepts, even though the request carries a forged org (body + X-Org-Id) and NO
// validated principal. 503 is the discriminator: it passed the door and stopped only at
// datastore-down, so the org came from the host.
//
// The kind is pageview because a site host is anonymous by construction (it runs before
// the identity boundary) and the anonymous lane admits pageview and error. A custom
// event on the same door is dropped — TestMount_HostCarve_AnonymousCapabilityOnly.
func TestMount_HostCarve_EventDoorIngestsForSiteOrg(t *testing.T) {
tightenPublicRate(t, 1_000_000, 1_000_000)
app := carveApp(t, "hanzo")
for _, body := range []string{
`{"batch":[{"type":"pageview"}],"org":"evil"}`, // {batch} envelope
`{"events":[{"type":"pageview"}],"tenant_id":"evil"}`, // {events} alias
`{"batch":[{"type":"error","error":{"message":"x"}}]}`, // the other admitted kind
} {
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", body,
map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusServiceUnavailable {
t.Fatalf("/v1/event beacon %q want 503 (ingested for the site org), got %d", body, code)
}
}
// The BARE canonical Event wire ({event,distinctId,time,properties}) carries no
// `type` field at all, so canonicalType folds it to "event" — a kind the anonymous
// allowlist does not admit. On a site host, where nothing can be vouched for, the
// bare wire is therefore always dropped; a beacon that wants to record a pageview
// sends the {batch:[…]} envelope, which is exactly what the app's wired injection
// emits (appBeaconBody below).
for _, body := range []string{
`{"event":"signup_completed","distinctId":"d","org":"attacker"}`,
`[{"event":"signup_completed","distinctId":"d"}]`,
} {
if code := postHost(t, app, "yadota.hanzo.app", "/v1/event", body,
map[string]string{"X-Org-Id": "attacker"}); code != http.StatusUnauthorized {
t.Fatalf("/v1/event bare-Event beacon %q want 401 (kind not anonymously admitted, so "+
"nothing was stored and the door has to say so), got %d", body, code)
}
}
}
// TestMount_HostCarve_AppBeaconExactBody confirms the CANONICAL door accepts the
// APP beacon's EXACT {batch:[ev]} body via the site-host carve — the acceptance test
// for repointing ANALYTICS_ENDPOINT to /v1/event. Admitted (503, datastore down),
// tenant forced to the site's Org regardless of the beacon's properties.space claim.
func TestMount_HostCarve_AppBeaconExactBody(t *testing.T) {
app := carveApp(t, "hanzo")
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", appBeaconBody, nil)
if code != http.StatusServiceUnavailable {
t.Fatalf("exact app beacon on /v1/event want 503 (admitted via carve), got %d", code)
}
}
// TestMount_HostCarve_EventEmptyBatchOK: an empty beacon batch on the canonical door
// is an honest 200 (zero counts) BEFORE the datastore is consulted — proving the
// carve decodes and funnels through the ONE write core with the host-forced org.
func TestMount_HostCarve_EventEmptyBatchOK(t *testing.T) {
app := carveApp(t, "hanzo")
if code := postHost(t, app, "yadota.hanzo.app", "/v1/event", `{"batch":[]}`, nil); code != http.StatusOK {
t.Fatalf("empty beacon batch on /v1/event want 200, got %d", code)
}
}
// TestMount_HostCarve_EventDirectNoHostGetsNoOrg pins that the forced-org carve is
// HOST-scoped: the SAME body on a NON-site host does not get a site org. The carve did
// not fire, so the request runs the normal canonical gate — no principal and no key, so
// it takes the ANONYMOUS lane, where the forged X-Org-Id and the custom event kind both
// buy nothing: 401, nothing stored, no row under `attacker`.
func TestMount_HostCarve_EventDirectNoHostGetsNoOrg(t *testing.T) {
app := carveApp(t, "hanzo")
code := postHost(t, app, "evil.example.com", "/v1/event",
`{"event":"signup_completed","distinctId":"d"}`, map[string]string{"X-Org-Id": "attacker"})
if code != http.StatusUnauthorized {
t.Fatalf("anonymous /v1/event on a non-site host want 401 (anonymous lane, kind dropped), got %d", code)
}
}

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