Compare commits

...
235 Commits
Author SHA1 Message Date
hanzo-devandzeekay 1ca535bae5 size the build's disk request to a node that can hold it
CI/CD / containment (push) Successful in 1m30s
Hanzo CI/CD / cicd (push) Failing after 9m59s
CI/CD / gate (push) Failing after 10m0s
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 native build has been Pending since 2026-08-02 -- six of them, five days,
across three repos -- and nothing anywhere failed to say so. A runner-pool-32g
node allocates ~88Gi of ephemeral storage and the buildkitd DaemonSet reserves
48Gi on each, leaving 40Gi. The build asked for 50Gi, so the scheduler could not
place it on any node in the pool ("0/34 nodes are available ... 3 Insufficient
ephemeral-storage") and the autoscaler declined to help because no larger node
matched the selector either. hanzoai/iam published its last image at v1.34.26
and six tags after it produced nothing.

The request itself is right and stays: a best-effort pod is placed on a full
node and evicted first, which is the failure it was added for. It just has to be
a number the pool can satisfy. 32Gi leaves headroom inside the 40Gi a node has
free beside buildkitd, and is close to what cloud's own builds already request
and schedule with today. The 80Gi limit is untouched, so a large build still
bursts; only the reservation changes.
2026-08-07 09:21:57 -07:00
zeekay 5bd6be0221 coding: a run carries the credential it needs to finish
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
Everything a coding task needs was built and none of it could finish one. The
box leased, the repo cloned, `dev` started, hanzo-mcp answered — and the model
call returned `Missing environment variable: HANZO_API_KEY`. Nothing put a
credential in a sandbox because the pod has no env block, deliberately: a
sandbox runs somebody else's code and a secret in a pod spec is a secret handed
to it.

The run mints one instead, per run, from the machine identity this deployment
already holds — client_credentials against IAM through the same oauth2 config
clients/aihttp.go uses for inference. There is no second notion of who a sandbox
is: it authenticates as the deployment that started it, the identity the gateway
already prices and meters. A long-lived hk- key would have to be stored, rotated
and handed to a box about to execute a model's output; this one expires on its
own and nothing has to remember to revoke it.

IT ARRIVES ON STDIN AND NEVER IN ARGV. /proc makes one process's command line
readable to every other in the pod, and this argv is echoed into the run's
session narration and its audit line, so a credential there is published three
ways at once. `IFS= read -r` takes the first line and leaves the rest of stdin
for the agent; `exec "$@"` replaces the shell so signals and exit codes still
belong to the agent.

Measured in a live sandbox: the agent cloned pallets/click, branched, and
completed a task end to end against zen5-coder — and `ps -eo args=` across the
pod found the token in ZERO command lines while the process's own environment
carried it.

A deployment with no machine identity gets no credential and is not an error:
the lease, the clone and the branch still happened, and the harness's own
message is a truer report of what stopped than a refusal here would be.
2026-08-07 09:19:38 -07:00
zeekay e50ec9140a merge: github main
CI/CD / containment (push) Successful in 1m41s
Hanzo CI/CD / cicd (push) Failing after 11m23s
CI/CD / gate (push) Failing after 11m23s
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-07 09:13:20 -07:00
hanzo-dev f3fa834af7 revert the run inference grant — it made a sandbox a principal everywhere
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 found the run key was taught to TWO resolvers, not one.
authResolveProvider is the inference door and was the intent. credentialUser
is the ai binary's general identity function, and ai owns the bare /v1
catch-all — so widening it widened roughly two hundred routes.

Two consequences, both reproduced against v1.832.41:

GET /v1/ai/account self-heals a session from any non-anonymous principal,
so a sandbox trades its run key for a signed-in cookie and RequireSignedInUser
passes thereafter. IsSuperAdmin is a bare string compare on Owner, so a grant
minted for the reserved admin org satisfies it, and GetOrg then honours
X-Org-Id verbatim — cross-tenant, from a credential that is supposed to
authenticate nobody. A tenant grant stays confined; the reserved org does not,
and the feature's own test only ever built Run{Org: "acme"}, so it passed.

The design is right and the billing need is real: a box must hold no key, and
the org must still be debited. What is wrong is paying for it by making the
token a general principal. It returns when the org rides authResolveProvider
on the eleven routes that need it, an admin-org grant is refused at mint and
at resolve, and the rolling cap sees the run path.

ai stays at v1.832.40, which carries the catalog work without .41's resolver.
2026-08-07 09:13:03 -07:00
zooqueenandhanzo-dev 963cc69fd6 sites: a published asset is readable cross-origin
The builder previews a project in a frame sandboxed WITHOUT
allow-same-origin — deliberately, so generated HTML cannot reach the IAM
tokens — which makes that frame an opaque origin. A Vite build's entry is
<script type="module" crossorigin>, and a module always fetches in CORS
mode, so with no Access-Control-Allow-Origin the bundle was refused,
nothing mounted into <div id="root">, and a healthy deployed site
previewed as a blank white page.

Measured from a real sandboxed frame against megashop.hanzo.app: the same
bundle LOADS as an absolute classic script and is BLOCKED as a module.

This grants nothing that was not already public — these bytes are served
on the unauthenticated edge with no credentials, so the header decides
who may READ a response anyone can already fetch. Documents get nothing,
and a site that opted into cross-origin isolation keeps its same-origin
subresources.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 09:08:25 -07:00
zeekay 7f58c1a8a3 merge: github main
CI/CD / containment (push) Successful in 1m42s
Hanzo CI/CD / cicd (push) Failing after 7m54s
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
2026-08-07 09:03:13 -07:00
hanzo-dev 3d2224fa0d merge: forge main — same fix found twice, kept the shorter words 2026-08-07 09:03:13 -07:00
hanzo-dev 518dfb6abd one prefix, one owner — the host could not route /v1/tags and would not boot
/v1/tags was given to projects, which owns the store behind it, while
destinations still named it. Two apps claiming one prefix is not a
preference the host resolves: it is a program with no routing table, and
zip says so by panicking the moment Start reaches for the plugin —

    panic: zip: this program does not compose, so it has no projection

That is the right answer. What was wrong is that nothing said it until a
pod was crash-looping: unit tests passed, the image built green, and the
failure arrived as a startup probe timeout on a live rollout, four
restarts in two minutes, and a rollback.

So the manifest now states the rule it depends on, and destinations stops
publishing a path it no longer serves — its code moved the route and its
artifact did not, which is what made the reachability gate the second
half of the same bug.

The existing compose test cannot catch this. It composes the host, and a
duplicated prefix only bites when Start reaches for the owner — so a
program that could not boot passed the test that exists to prove it can.
2026-08-07 09:02:30 -07:00
zeekayandClaude Opus 4.8 1d8fa80903 fix(tags): claim /v1/tags once — the duplicate prefix panicked the host
CI/CD / containment (push) Successful in 3m10s
Hanzo CI/CD / cicd (push) Failing after 10m40s
CI/CD / gate (push) Failing after 10m55s
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 entries claimed /v1/tags (projects and destinations), which panics
zip's host build (mustBuild) and crash-looped the cloud pod — a full API
outage. The handler and the project store both live in the projects app,
so projects owns the prefix; the destinations claim referenced a handler
that had already moved and is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 08:59:37 -07:00
antje 6041b0ef2a commerce: credit finance from async settlements; sandbox charges hit sandbox books
apps/commerce/ledger.go honors CreditInput.Test on the finance deposit AND the
balance read-back (both were hardcoded false — the read reported the wrong book).
apps/commerce/settle.go carries the settlement's test-ness through. Pairs with
commerce v1.50.31 (the settlement-credit + seam Test-field change). Reviewed
blue->red, SHIP.
2026-08-07 08:59:15 -07:00
hanzo-dev 4dfd6e3b35 coding: a run may buy inference, and holds no key to do it
CI/CD / containment (push) Successful in 2m2s
Hanzo CI/CD / cicd (push) Failing after 8m54s
CI/CD / gate (push) Failing after 8m59s
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 sandbox had no way to reach a model, and the obvious repair — a model key on
the box's environment — is the mistake the git token already cost us once. A key
IAM can resolve is a USER: cloud mints X-Org-Id and X-User-Id from it, so the one
process executing untrusted model output would hold a credential that opens
/v1/kms/secrets and every other org-scoped door. It would also miss the per-org
meter, so an org would never be billed for its own agent's tokens.

So the run gets a grant instead, shaped exactly like the push grant beside it. It
names an act rather than a person: this run may spend on THIS org's ledger, until
it expires. The table lives in the ai process, and apps are separate binaries
routed by prefix — so /v1/kms/* is served by a process that has never heard of
the token and has no code to read it. It is refused there by default rather than
by a check. The org is in the grant and never in the request, so a run cannot be
aimed at another tenant's balance, and every call it makes goes through the
ordinary balance gate, budget reservation and usage debit: a cheaper credential,
never a cheaper call.

It reaches the harness on the harness's own invocation. Commands enter the pod
through the apiserver's exec channel, so the credential is an argument to one
command rather than an environment the pod was built with — never in the pod
spec, never in an image layer, and not lying around for the step that executes
model output. Cloud states the secret; the image states which model and which
base URL, because that is the box's configuration and not the run's.

ai owns the inference door, so ai decides who may spend at it — the sentence
apps/git/grant.go makes about refs. The orchestrator asks at dispatch and is no
longer a credential custodian, because there is no standing credential to custody.
2026-08-07 08:46:24 -07:00
hanzo-devandzeekay 95880de2ec sandbox: a run is not a project, so it leaves no disk behind
A project names a per-(org, project) 20Gi disk that apps/sandbox deliberately
KEEPS when a lease ends, so a checkout survives between sessions. A run passed
its SESSION id there — a name opened once per dispatch and never reopened — so
every run minted a disk that could never be addressed again, and keep-by-default
then made it immortal. Measured: 15 disks, 300GiB, none attached to anything.

The fix is not to purge harder. A purge on the way out still strands the disk of
any run killed, OOM'd or evicted before it says goodbye. It is to stop asking for
the wrong primitive: a run wants scratch space that dies with its pod, which is
what an emptyDir already is and what runtime.go mounts whenever there is no
volume. Its lifetime is the pod's by construction, so there is nothing left to
remember to delete. The work leaves by being committed and pushed, as before.

The other half is pinned where it already was: a genuine project disk still
outlives its lease and is still reused rather than remade.
2026-08-07 08:36:31 -07:00
zeekay bf52370a6b sandbox docs: say which parts of the design did not ship
CI/CD / containment (push) Successful in 2m34s
Hanzo CI/CD / cicd (push) Failing after 14m24s
CI/CD / gate (push) Failing after 14m30s
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 file still opens "Status: design, agreed. No code yet." It shipped, and it
shipped differently in three places — so every reader since has been handed a
list of gaps to close that are not gaps: a boxd daemon that was deliberately
deleted, an "@hanzo/dev image gap" that was closed by dropping the npm package
for native binaries, and three classes where there are now four.

An audit was written off this file last week and every one of those turned into
a work item. The correction is at the top, where a reader hits it before the
stale part, and it names hanzoai/bot's Dockerfile.box as the source of truth for
what is in a box rather than restating it here to go stale a second time.
2026-08-07 08:23:38 -07:00
zeekay 1f1a7c9463 integrations: claim the GitHub installations the app already holds
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 3m17s
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 installation IS the grant — GitHub recorded consent when the app was
installed, and our connection row is bookkeeping that only ever got written
on our own callback. Twenty-three accounts granted straight from GitHub
therefore read as nothing, and the only cure was clicking Connect once per
account for consent GitHub had already taken.

claim writes those rows from the app's own view. The org comes from the
validated principal and never from the body: an installation carries a
login, a type and a repository selection, nothing that names a Hanzo org, so
the binding cannot be derived, only asserted. Inferring one from the account
name would be a guess the store cannot catch — its key is (org,provider,
owner), so a wrong org is a valid row, and a valid row is a mirror pointed
at the wrong tenant. Platform sudo only, for the same reason: a tenant's
proof that an account is its own is GitHub's consent screen.

Idempotent by that key, with connected_at preserved, and a re-claim
refreshes the installation id so a reinstalled account self-heals rather
than minting tokens against a dead install.
2026-08-07 08:19:17 -07:00
zeekay 53bc2fb48c sandbox: a desktop starts its screen, and three images stop being one
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
Two declarations that named three different things and produced one.

The pod spec stated `command: sleep infinity` for every class, which replaces
the image's CMD. The desktop image's CMD is the only thing that starts Xvfb, the
window manager and the VNC/noVNC pair, so the class that exists to have a
display came up with no X server — byte-identical to `dev` but for a label, and
silent, because a sleeping pod answers every probe. The command is now stated
for the classes that are a place to run commands and omitted for the one whose
screen IS its process; work still arrives only through the exec subresource for
all three.

hanzo.yml's `images:` entries carried `args:` that nothing read. hanzoai/bot
declares its three sandbox classes as three entries off ONE Dockerfile differing
only by `args: {STAGE: exec|dev|desktop}`, so all three tags were built as
whatever stage the Dockerfile defaults to: an `exec` tag — the minimal,
volumeless interpreter box — carrying a whole desktop. The args are parsed,
validated at the k8s choke point, and emitted sorted, so one commit is one argv
and one cache key. VERSION and REVISION are filtered out of the declared set:
they are receipts derived from the tag and the commit, and an image that can
name a commit it was not built from defeats the point of pinning it.

oci.hanzo.ai joins the owned registry hosts. It and registry.hanzo.ai are one
store behind one router, so naming the canonical host grants no reach the
deprecated alias did not already have — it only stops refusing the name the
fleet is told to write.
2026-08-07 08:17:10 -07:00
zeekay 8472a65b88 websearch: brave, the one paid engine — opt-in, cached, priced at $5/1000
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 free engines scrape a public page and are rate limited for it. Brave sells
an API with no such wall and answers better. Measured, same query:

  bing         firecracker microvm kvm  ->  captainboom.com  github  wikipedia
  brave        firecracker microvm kvm  ->  firecracker-microvm.github.io
  bing+brave   n=20  by={bing:8 brave:12}  304ms first, 0ms cached
               top: github.com/firecracker-microvm/firecracker

OPT-IN, because an engine that spends money the moment it compiles in is a bill
nobody agreed to: not in the default set, and a missing key contributes nothing
rather than erroring — the rule a challenged engine already follows.

`fetch` is a seam, not an adapter. Brave is a JSON API rather than a page, and
reshaping JSON into an *html.Node so it could reach parse() is the shim this
package keeps deleting. It reports the SAME outcomes as every other engine, so a
quota refusal reads as `failed` and an empty answer as `blind` — the distinction
a3de3352 just added exists precisely so a paid engine going quiet is visible.

PRICE, and why not at the edge: price.go's Consumes() makes GET free by
construction — "a read spends nothing, so a read costs nothing" — and a search
IS a read. price.go names the answer for this case: such a surface meters its
own units downstream and declares Metered.

The customer pays for the ANSWER, not for our upstream call: one debit per
search served with this engine enabled, INCLUDING when the cache answered and no
Brave request was made. A pricing decision, not cost recovery — what we save by
not re-asking is margin. Said plainly in the file so a reader assuming
cost-recovery does not "fix" the cache path into a discount.

STATED GAP: the debit is NOT yet taken. searchNative is a bare http.HandlerFunc
with no service and no Bill, so it cannot reach the seam apps/answer already
uses (answer.go:370). The price is declared here (bravePriceMillicents = 500,
WEBSEARCH_BRAVE_PRICE overrides); the surface must declare Metered and take the
debit once the handler holds a service. Brave calls are UNBILLED until then,
which is why this says so rather than reading as finished.

Key is WEBSEARCH_BRAVE_KEY, KMS-sourced. Never a literal, never in a manifest.
2026-08-07 08:16:14 -07:00
antje 790612ac98 sandbox: the terminal prefers zsh, and one identity gets the operator's image
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 CHANGES, ONE SUBJECT: what a person lands in when they open a terminal.

THE SHELL IS A PREFERENCE CHAIN, one link longer. zsh, then bash, then sh —
each asked for and none required, which is the property the old chain already
had and the reason it stays a chain: the sandbox images are not one image, and
a shell that must exist is a shell that will one day not. An image with no zsh
gets bash; stock node still gets a prompt.

IMAGE SELECTION LEARNS WHO IS ASKING, for exactly one class. A SuperAdmin's
`dev` sandbox runs the `admin` image — dev plus zsh, kubectl and doctl
(hanzoai/bot Dockerfile.box) — and every other caller's `dev` sandbox runs the
same bytes it ran before.

It is a substitution and NOT a fourth class. `classes` is the closed set a
caller may ask for and it stays three: which bytes a caller is handed is a fact
about the caller, answered where the caller is known and never offered as a
field. The row still says class `dev` and the pod still carries the `dev`
label; only Image differs, which is the honest record of what happened.

Only `dev`, because `admin` is BUILT from dev and a substitute has to be a
superset of what it replaces. `exec` stays the throwaway a tool call spends
fifteen minutes in — swapping it would put a bigger image behind every function
invocation this identity makes — and `desktop` keeps its screen.

principal.IsSuperAdmin is THE predicate, read once on the HTTP door and once on
the plane, each in that door's own vocabulary (zip.Caller.Admin is the same
attestation). The rule itself lives in one place, imageFor.

`super` is a PARAMETER and not a field on Spec nor a read off the context. On
Spec it would sit beside Class and Project — things the caller asks for — one
refactor from being bound off a request body, which is the hazard trust_test.go
exists to catch. Read from the context it would make this core read identity,
and the reason every function in api.go takes `org` as an argument is that none
of them may.

THE ADMIN IMAGE CARRIES NO CREDENTIAL, which is what keeps this one line rather
than a gate: kubectl with no kubeconfig and doctl with no token reach nothing,
so there is nothing to defend against a caller naming the image by hand — which
checkImage already permits for every platform image, for this reason. The day a
kubeconfig is wired in it arrives at the POD from the identity, never in a
layer.

Tests: the substitution per class, the admin image's own digest (reading dev's
would be the neighbour's-digest bug in a new costume), an invariant that no
ordinary caller is ever handed it whatever the env pins, and the shell chain
checked by ORDER rather than by matching a shell fragment verbatim.
2026-08-07 08:10:27 -07:00
zeekay 5ea1e3e34a coding: the harness is asked to run the task, not to print its own version
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
`dev -p "<task>"` was the argv every coding run has ever dispatched. `-p` is
--profile, so dev answered `config profile "<task>" not found` and exited. No
model was ever asked anything. Measured in a dev-class pod on the published
image (1.0.1, dev 0.6.91): `dev exec` is the non-interactive verb, and it needs
--full-auto because nobody is at a terminal to approve a command, and
--skip-git-repo-check because CloneURL is optional and a pod is not the laptop
that check protects.

`--` before the prompt, and that one is not tidiness. The prompt is caller text
in an argv position, so a prompt beginning with a dash is a flag. All three
agent harnesses printed their own version and exited when handed "--version" as
the task; dev's `-c key=value` reaches its own sandbox policy, so the text a
caller sends could choose how much of the box the model may touch.

Tool and Desktop reach the runner. They have been on RunRequest since the wire
learned what kind of computer a run is, with nothing assigning them, so
classFor always said dev and argvFor always said dev — the claude, codex,
python, node and desktop branches were unreachable. They now travel
CodingStartIn -> Req -> RunRequest, and an unknown harness is refused at the
door rather than read as dev by a `default:` that answers as though the
asked-for harness had run.
2026-08-07 08:08:16 -07:00
zeekay 9eeb1f5a18 integrations: platform sudo sees the GitHub installs no org has claimed
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 App granted straight from GitHub runs no connect flow, so no connection
row exists. Every GitHub surface here projects over those rows, so an App
installed across 23 accounts read as nothing at all: the console card says
"not connected" and an operator asked which orgs we see could only answer
for accounts already bound — precisely the ones that were never the question.

A super admin now reads the App's own install list, each account carrying
whether the caller's org has bound it and how far the grant reaches. That
list is the platform's inventory, not any tenant's data, and platform sudo
is the one cross-tenant scope here; a tenant's view is unchanged and still
shows only what it bound. For that caller the App call IS the answer, so an
upstream failure is a 502 rather than an empty list that reads as success.
2026-08-07 08:06:18 -07:00
zeekay a3de3352dd websearch: an engine that says nothing is either empty or blind
CI/CD / containment (push) Successful in 3m15s
Hanzo CI/CD / cicd (push) Failing after 11m31s
CI/CD / gate (push) Failing after 12m23s
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
Zero results was not a value. fetchEngine returned ([]webResult, error) and a
bot-challenge page came back (nil, nil) — the same value as a query the web has
no answer for. An engine could stop working entirely and the only symptom was a
slightly shorter page. That is how Brave was dropped rather than fixed, and how
DuckDuckGo sat in the default set contributing nothing.

An engine's turn now has three states: answered, blind (the fetch succeeded and
the parser read NOTHING), failed (never reached, so it says nothing about the
parser). Zero is blind per engine, always, because no engine can be trusted to
report its own emptiness — asked three distinct nonsense strings Bing returned
ten results each time (Edmonton property tax, Bastille Day, Microsoft support),
and for a fourth, pornography. It has no zero state at all.

The genuine-empty case is recovered where its evidence lives, ACROSS engines:
blind while a sibling answered the same query is proof the query HAS results and
that engine cannot see them. Two instruments, different widths — the counter
records every turn and an operator reads the ratio; the log line fires only on
that confirmed fault, so it stays worth reading. `browsed` rides along because it
decides who is woken: false is configuration, true means a real browser drew the
page and our parser still read nothing.

DDG'S CHALLENGE IS AN HTTP 202, and that one fact cost us the engine. The fetch
accepted only 200, so the challenge became a transport error, the transport error
short-circuited past the escalation, and the engine the browser was deployed to
rescue was the one that could never reach it. Any 2xx is now parsed, and there is
ONE remedy for "nothing readable came back" whichever way it happened: render it.
Live from cluster egress that turned

  [bing=answered(10) ddg=failed(0) mojeek=answered(20)]

into all three answering, and pushed post.ca.gov/Training off the top of "post
quantum cryptography lattice" — now redhat's lattice-based-cryptography, then
Wikipedia. A render costs ~1.13s against 400ms-1.4s for a whole static blend, so
escalation stays worth paying on zero and not before.

GOOGLE IS NOT VIABLE FROM THIS NETWORK and is deliberately not added. Every path
returns the /sorry/ interstitial — ~6KB, 19 captcha markers, "unusual traffic",
zero results: headless Crawl with stealth, &udm=14, &gbv=1, and a HEADFUL Chrome
150 on a real X display from a second egress IP. The control rules out the
technique: that same headful browser reads DDG's ten results and loads
google.com's homepage normally. Only /search is refused, from two node IPs,
headless and headful alike — reputation attached to datacenter addresses, which
no browser flag reaches.

The browser service is real, and the code said it was not: apps/crawl claimed
crawl.hanzo.svc was NXDOMAIN and that escalation shipped "while the browser is
not deployed". It is Running at 10.124.54.223:11235 and requires the KMS-sourced
crawl-secrets/CRAWL_API_TOKEN — unauthenticated it answers "Authentication
required", so a missing token is no render at all. Those comments are gone.
2026-08-07 07:59:43 -07:00
zeekay 9a8b66c89a sandbox: an org is a claim, a runtime is a request
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 shape rule barring wire fields covered org, owner, tenant AND runtime,
on the reading that any of them "would let a caller state its own". Three of
those are identity and one is not.

An org on the body IS the escalation: there is no second source of truth to
check it against, so accepting the field is accepting the claim and nothing
downstream can undo it. A runtime is checked against everything —
runtimeFor reads `kernel` off m.Org, which came from a validated principal
and never from a field, and `shares` off the volume, and it refuses what it
cannot honour instead of substituting. Asking is not getting.

Barring the field bought nothing and cost the comparison. Nothing, because
`want` still arrives from the deployment, so no branch was removed — only
made unreachable by real callers, which is another way of spelling untested.
The cost: two boundaries could not be run against one task without a
rollout, so nobody ran them.

So the invariant narrows to identity, and the claim it used to imply is now
asserted where it belongs — at the door, with controls. A stranger POSTing
{"runtime":"runc"} gets 400 and the reason; the same org with no runtime,
and with gvisor, reaches the cluster. Which makes the refusal a statement
about the runtime rather than about the route.
2026-08-07 07:58:21 -07:00
zeekay aa4aaef792 sandbox: a caller may ask for a runtime, and is told which one it got
The isolation boundary was already per sandbox and already refused a
combination that would lose data — but no client could name one. `runtime`
never appeared on the create body, so the only way to compare gVisor against
a microVM was a redeploy, and the answer a run got was reported nowhere at
all.

Three edits, one fact:

  - The door binds `runtime`, and the plane's LeaseIn carries it. Asking is
    all it is: runtimeFor still decides, and still refuses rather than
    substituting, so a crafted body cannot obtain a boundary the policy turns
    away. Proved at the route, not only at the derivation.

  - The row records what was GRANTED. That was left out on the reasoning that
    the pod is the source of truth and a copy could go stale — but a pod's
    runtimeClassName is immutable, a sandbox's pod is created once and never
    recreated, and its name is never reused, so the copy cannot drift while
    the pod exists. Without it, a caller that asked for one runtime and could
    only have another had no way to tell, and a measurement labelled itself.

  - podSpec reads m.Runtime instead of taking it beside the sandbox, and the
    `rc == "" then use r.runtimeClass` fallback under it is gone. That line is
    exactly how the row and the pod come to disagree: the row would say "the
    node default" while the pod ran gvisor. One derivation, one field, and the
    value the row reports is the value the pod is built from.

Spec.RuntimeClass is Spec.Runtime — it holds a runtime, and RuntimeClass is
the Kubernetes spelling, which stays in runtime.go where Kubernetes is spoken.
2026-08-07 07:58:21 -07:00
zeekay 34d915f877 integrations: say which GitHub accounts an org has connected, and where to add one
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The GitHub App is installed across the orgs, and cloud could not name one of
them. Every call was installation-scoped — a token minted from an id somebody
had already pasted at connect time — so an org with a dozen grants looked
identical to an org with none until it named an installation it had no way to
discover. An agent asked "which of my GitHub orgs can you see" had nothing true
to answer with, and a console offering "connect GitHub" had nothing to list.

GET /v1/integrations/github/installations answers it. appInstallations reads the
App's OWN view — signed with the App JWT, the same transport
githubInstallationAccount already uses, one page size up — which is the only
call that can see accounts no installation token covers.

It is ORG-SCOPED on the way out, deliberately. The App is installed across every
customer, so the raw list is the customer list; the route returns only accounts
the CALLER's org has bound, each confirmed against the App's view. That
confirmation is the second half of the value: a connection whose installation
was since removed on GitHub mints nothing, and every list and import against it
fails with a token error that reads as "our git integration is broken" rather
than "that install is gone". connected=false says which it is.

A failed App call leaves every row connected=false rather than emptying the
list — an unreachable GitHub must not read as "you have no integrations".
installUrl (GITHUB_APP_SLUG) gives a UI with an empty list somewhere to send the
reader instead of a dead end; absent, the list still renders and simply offers
no add link.

The surface ledger in ops_projection_test.go gains the op, because that gate
counts the surface exactly and a route nobody declared is a route nobody
decided on.
2026-08-07 07:54:41 -07:00
antje 9fc843dff3 ci: trigger release build on un-starved runners
CI/CD / containment (push) Successful in 2m55s
Hanzo CI/CD / cicd (push) Failing after 17m24s
CI/CD / gate (push) Failing after 17m39s
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
Runner fleet was CPU-oversubscribed so release gates starved and left imageless
version tags (v1.801.491-493). Runners now request 2 cores, spread 3/node across
a grown pool. Empty commit exercises the repaired cicd.yml image job end-to-end
to mint a real semver image + pin, replacing the hand-edited sha pin.
2026-08-07 07:45:47 -07:00
antje f8ac61dd67 ci: trigger release build on un-starved runners
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
Runner fleet was CPU-oversubscribed so release gates starved and left imageless
version tags (v1.801.491-493). Runners now request 2 cores, spread 3/node across
a grown pool. Empty commit exercises the repaired cicd.yml image job end-to-end
to mint a real semver image + pin, replacing the hand-edited sha pin.
2026-08-07 07:45:38 -07:00
antje af927d7711 ci: trigger release build on un-starved runners
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
Runner fleet was CPU-oversubscribed so release gates starved and left imageless
version tags (v1.801.491-493). Runners now request 2 cores, spread 3/node across
a grown pool. Empty commit exercises the repaired cicd.yml image job end-to-end
to mint a real semver image + pin, replacing the hand-edited sha pin.
2026-08-07 07:45:29 -07:00
zeekayandClaude Opus 4.8 e5d89f0d92 fix(tags): route /v1/tags to the projects process in the app manifest
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 tag door moved to the projects app, but /v1/tags was in no app's manifest
prefixes, so the front door had no child to dispatch it to and answered 404.
(It previously resolved in-process off the eager destinations mount, which
could not see the lazy projects store — the empty-tags symptom.) Route it to
the process that owns both the handler and the store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:38:35 -07:00
zeekay 35afb39d95 sandbox: a project disk says whose it is and when it was last wanted
CI/CD / containment (push) Successful in 3m2s
Hanzo CI/CD / cicd (push) Failing after 14m25s
CI/CD / gate (push) Failing after 14m45s
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 disk outlives everything that could account for it — the pod by an hour, the
row by the lease — so it was the only object in the namespace whose owner was
unrecoverable. The name ends in sha256(org, project) and the object carried one
org label, so "which project is this, and is anyone still using it?" could be
answered only by guessing project strings and hashing them.

That is what makes these disks permanent. Not the absence of a retention policy
— the absence of the facts a policy would have to read. Measured on the live
namespace: 15 disks, 300GiB, every one in exactly that state, and the projects
recovered only by brute-forcing the hash.

So every disk is born carrying its project, and every lease stamps the day it
was wanted. A date and not a timestamp, so a disk leased forty times before
lunch is patched once. Nothing here reclaims anything; this writes down what a
reclaim would need in order to be safe.

The lifetime itself is unchanged and the test pins it: ending a lease keeps the
disk, a second lease on the same project finds the first disk rather than
minting another, and purge remains the only thing that may take it.
2026-08-07 07:27:18 -07:00
zeekay b79ed8b784 sandbox: the boundary follows who owns the code, not only what it keeps
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 sandbox states two facts about itself and one table answers both: whose code
it runs decides how much isolation is needed, and whether it keeps anything
decides which boundaries can serve it. Neither fact can be handed in — the org
is the caller's validated identity and the volume came from the project — so a
caller that cannot name its own org cannot name its own kernel.

runc is in the table and unreachable until the cluster keeps it to nodes of its
own, read from the RuntimeClass rather than taken from a setting. Our own agent
is not our own binary: its commands are written by a model that just read a
repository, so the node's kernel is only defensible when the blast radius is
drawn by topology.

Three paths asked that question and two of them consulted the topology, so the
predicate is now one method all three call. SANDBOX_RUNTIME_CLASS is checked
against the table at startup, where it used to reach a pod spec unread whenever
the sandbox had no volume.
2026-08-07 07:24:27 -07:00
zeekay 90386e0296 coding: present the grant so the forge actually sees it
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The credential rode the clone URL as userinfo, and git does not SEND a
URL-embedded credential until it is challenged: it makes an anonymous request
and waits for 401 WWW-Authenticate. resolvePackRepo answers a caller it cannot
place with 403, never 401 -- so the grant was never presented at all, and a
clone of any private repo could only 403. Verified against git 2.43 with a
server that answers the way the forge does: with the credential in the URL the
server sees an empty Authorization on every request.

It is now a url-SCOPED Authorization header, which is the form the rest of the
forge already uses (mirror_out.go) and which arrives on the first request:

  git -c http://<clone-url>.extraHeader=Authorization: Basic <base64> clone …
  git -c http://<clone-url>.extraHeader=Authorization: Basic <base64> push  …

Scoped rather than bare because a plain http.extraHeader is attached to
WHATEVER the command reaches -- a redirect, another host -- and what a
repository makes git reach is chosen by whoever wrote the repository. Measured:
the same config carries nothing to a second host; the unscoped form carries the
grant to it.

Still not on disk: a top-level `git -c` is not written into the new repository's
config, unlike `git clone -c`. Both spellings of the credential -- the token as
issued and the base64 it travels as -- are scrubbed out of every error and tail
that leaves the sandbox.

apps/git pins the whole thing end to end against a live forge: the exact argv
the runner builds clones, leaves .git/config clean, pushes agent/<session>, and
is refused on refs/heads/main by the ref policy with git's own rejection line.
2026-08-07 07:17:34 -07:00
zeekay e37be93cb8 coding: a finished run says where to read it
CI/CD / containment (push) Successful in 3m17s
Hanzo CI/CD / cicd (push) Failing after 17m1s
CI/CD / gate (push) Failing after 17m19s
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 PR seam was called Tracker and filed a board row with no address, so a run
that pushed a branch ended in a Slack line naming an identifier nobody can
click -- and for a repository whose reviewers are on GitHub, no pull request
existed at all.

It is now one seam, PR.Open, with two backends chosen by WHERE THE CODE LIVES
and never by who is asking:

  github.com  the repository mirrors into a GitHub account, so the proposal is a
              real pull request there, opened against the base with the run's
              body. The head is pushed to the mirror first, because mirror_out
              is a best-effort lifecycle subscriber on a one-slot semaphore and
              GitHub refuses a pull request whose head it cannot see. A force
              push of an already-identical ref is a no-op, so doing it here
              costs nothing and removes the race.
  here        the repository lives only in the forge, which has no pull request
              of its own; the branch's own page is where the work is read.

A GitHub repository never quietly gets a forge link instead: the credential is
minted before the head is pushed, and a proposal that cannot be opened is an
error rather than a link to the wrong host. The installation token rides an
Authorization header, is minted per call and never stored -- the same discipline
the outbound mirror already holds itself to.

The address travels the whole way out: PRRef.URL, the done event's `url`, and a
BARE url in the chat line, because `<url|text>` is the one mrkdwn element that
carries an arbitrary destination and nothing a run emits may construct one.

completeChanged also keeps the ref it got even beside an error. Opening a PR is
two acts in two places; a run that filed its row but could not reach GitHub has
a real handle to report, and discarding it hid the half that worked.
2026-08-07 07:10:29 -07:00
zeekay 2daf7ac6b6 cloud: pin that a request joins the trace it arrived 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
The propagator and the inbound extraction shipped without the tests that say
what they are for. Both assert on the outcome an operator depends on rather
than on the mechanism.

A request carrying trace context is recorded IN that trace: the framework
stamps a traceparent at the edge and forwards it across every process hop, so
the webhook, the plane hop and the run all arrive with one id, and extraction
is what makes OTel record them under it. Without extraction the assertion
reads a freshly minted id — each process rooting its own trace is precisely
the defect, and every one of those traces was individually well-formed, which
is why nothing looked broken.

The second names the failure mode of accepting anything: an all-zero trace id
is what every broken sender emits, so honouring one would merge unrelated
requests from unrelated tenants into a single trace. Absent, malformed and
all-zero must each yield a real sampled trace instead.
2026-08-07 07:00:30 -07:00
zeekay 7861ada4d3 coding: a run's edits leave the sandbox
CI/CD / containment (push) Failing after 1m31s
Hanzo CI/CD / cicd (push) Failing after 16m40s
CI/CD / gate (push) Failing after 16m45s
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 Runner did lease -> clone -> run -> end and threw the work away. It never
set Changed, CommitSha or Diffstat, so every run reported "no changes" and filed
no PR; the sandbox was reaped and the model's edits died with it. The whole
agentic coding product was a no-op that read as configured.

A run now commits what the tool left behind, pushes it to the one ref its grant
names, and reports what actually happened:

  the branch   cloud's, created before the first edit, so a tool that commits
               for itself commits onto the run's branch and never onto the base.
  the change   the difference between two object ids -- what we checked out and
               what is there now. Not a status parse: that is the only reading
               true both when a tool leaves the tree dirty and when it commits.
               "No changes" is now a measurement instead of a silence.
  the push     one ref, named in full, never forced, refused outright when the
               branch is outside agent/*.

The credential stops touching disk. It rode the clone URL on the claim that
"nothing survives the process that used it", which was false: git writes the URL
it is given into .git/config verbatim, userinfo and all, so the grant sat
readable in the checkout for every later step of the run -- including the one
executing untrusted model output. It is now a per-invocation `git -c` URL
rewrite (not persisted, unlike `git clone -c`), applied identically by clone and
push, and scrubbed out of every error, tail and narration that leaves the
sandbox.

apps/git states the trunk rule head-on now that a run actually pushes: a grant
confined to agent/<session> cannot write refs/heads/main, master, a release
branch or a tag, and still writes its own.
2026-08-07 06:57:41 -07:00
zeekay d3d3680738 o11y: pin the seam a run's tenant crosses
planeOrg reads one key per span and falls back to the platform's org when it
is absent; the agent spans name their tenant with that key. Both halves were
correct in isolation while disagreeing about the spelling, which is how every
agent run came to be stored as platform telemetry.

Renders the exact attribute set a run emits and asserts the rows land on the
tenant — plus the negative, a span naming its tenant under an unread key,
which is what the defect looked like and what a rename would restore. Also
asserts the three spans fold to ONE trace summary: the summary is keyed
(org, trace_id), so spans that disagree about their tenant split a trace in
two and each half reports a fraction of the span count.
2026-08-07 06:57:06 -07:00
zeekay 378c0a0054 o11y: an agent run's trace reaches the tenant that ran 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
Three defects, each of which alone made a run's trace unreadable, and each
of which reported success while dropping everything.

THE TENANT. apps/o11y planesink.go files every span row under one attribute,
hanzo.org, read PER SPAN — OTel children inherit no attributes — and falls
back to the platform's own org when it is absent. The agent spans stamped
hanzo.agent.org, a name nothing reads, and agent.step stamped none at all.
So every run/step/tool span was stored as the PLATFORM's telemetry: absent
from the org-scoped read the console issues, and sitting in the platform's
bucket with that tenant's tool names, run ids and user subjects in it. One
trace was also split into two event.trace summaries, keyed (org, trace_id),
so the trace list reported a fraction of its spans and the wrong duration.

THE PROPAGATOR. otel.SetTextMapPropagator was never called, so the global
stayed OTel's EMPTY composite: every Extract a no-op, every Inject a no-op.
The framework already stamps a W3C traceparent on every request and forwards
it across process hops; OTel could not read it, so it rooted a fresh trace in
each process and the fleet carried two unrelated id spaces — the framework's,
printed in every log line, and OTel's. The middleware now extracts, which
collapses them: a span's trace id equals the `trace` field logged beside it.
The propagator is a value in the package rather than a read of the global,
so extraction cannot be defeated by initialization order.

WHAT THE TOOL DID. A dispatch recorded which tool ran and nothing about what
it ran with, so a trace said an agent called post_v1_exec_run six times and
could not say what it executed. Arguments and results now ride the span under
the convention's own names, through audit.RedactText and a 4 KiB bound.

Redaction gained the one credential a key denylist cannot see: userinfo in a
URL. A clone URL arrives under an innocent key ("cloneUrl") with a live token
in it, and every character would have been recorded. Userinfo is a credential
by construction, so this is a structural rule, not value sniffing — the
user half is kept, the secret half replaced.

Also: retries and failovers are recorded as events (an attribute would be
overwritten by each later round of a tool loop); finish reason and the
answering model reach the streaming path's span; and a chat turn emits a span
naming its provider, channel and thread plus the run id it caused — the join
from a Slack conversation to a run that holds whether the fleet runs fused or
as separate processes, since a detached turn cannot carry trace context.

Tests: cross-tenant isolation of a run's spans, credential-free capture of
arguments and results, the stated bound, trace joining and the refusal of an
all-zero trace id, and the turn span's conversation identity.
2026-08-07 06:52:40 -07:00
hanzo-dev a8968c90f5 Merge remote-tracking branch 'hanzogit/main' into HEAD
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 06:51:02 -07:00
zeekay 38eba9579c ask: the researched answer, at an address an agent can speak
The deep-research loop was already here and already complete — plan,
search, read, rank, synthesize, cite, follow up, bounded and metered, in
apps/answer behind POST /v1/ask with a mode. What it never had was a door
a MODEL could open. /v1/ask is untyped for three wire reasons that are all
still true, and an untyped route reaches REST and nothing else: no MCP
tool, no CLI command, no SDK method. So the fleet's research capability
was correct, live, and invisible to every agent in it. `ask` did not
appear in the door's subsystem list at all, because it had no typed op.

research_web is that door. It is the same engine — Engine.Answer runs the
same Run() the stream runs, returning one value instead of a stream, so
there is no second implementation to drift. Serve keeps streaming.

Beside it, two names that were lying:

  - crawl's op derived as `create_crawl`, which reads as "make a crawl" —
    a job it cannot start. It reads ONE page that is already addressed.
    It is `read_page` now. A model picks from an `op` enum by name before
    it reads any description.
  - the `x` source hint was appended as a bare word, so asking for X
    turned "openai" into "openai x" — the same open-web query plus a
    token matching nothing. Hints that name a place are `site:` scopes
    now, several joined as alternatives rather than an AND no page can
    satisfy. That is what makes sources:["x"] reach X posts.

fleet/reachable_test drives all four over one door, as a client meets
them: search_web, read_page, research_web, create_exec.
2026-08-07 06:44:57 -07:00
zeekay fd3341dd87 websearch: a second index, and the one that carries site:
Production ran with WEBSEARCH_ENGINES unset, so the default WAS the whole
engine set — and it was Bing alone. Ten results from one index, and no way
to scope a search at all.

Mojeek joins it as a default. Measured from cluster egress rather than
assumed:

  bing   site:x.com  ->  0 results
  mojeek site:x.com  -> 10 results
  mojeek t=20        -> 20 results (it honours the count exactly)

So this is not a second opinion on the same ten pages. Mojeek crawls its
own index, and it is the only engine here that honours `site:`, which
makes it the one that carries every scoped search — X, GitHub, Reddit.
Its result classes are semantic (title, s) rather than build-hashed, so
the parser survives a redeploy; Brave, whose classes are Svelte hashes
like svelte-1rq4ngz, was measured working and left out for that reason.

DuckDuckGo stays coded and out of the default. From the cluster it is
served the anomaly page on BOTH endpoints — /lite/ and the html POST —
67 challenge markers and zero results. Enabling it would have added an
engine that contributes nothing while looking configured, which is the
failure the soft-fail path makes invisible.

The merged cap moves 20 -> 30, which is what two engines can now reach.
2026-08-07 06:44:57 -07:00
zeekay f0f4bc41e3 sandbox: the runtime follows the volume, so a sandbox cannot lose one
kata-fc has no shared filesystem. `configuration-fc.toml` on the node carries no
`shared_fs` key at all, where `configuration-clh.toml:130` sets
`shared_fs = "virtio-fs"` — Firecracker has no virtio-fs device, so a guest
cannot mount a directory from the host.

Kubernetes does not fail that mount. With the same Bound PVC the sandbox gets a
~599M tmpfs standing exactly where the volume should be, every write SUCCEEDS
into it, and the VM takes the bytes when it exits. A `dev` sandbox would look
perfect and lose the checkout, and nothing anywhere would report it.

So the runtime stops being one string the deployment sets for everything and
becomes a derivation from a property the sandbox already declares:

  a sandbox that needs a PERSISTENT VOLUME needs a runtime that can SHARE A
  FILESYSTEM; one that does not, does not.

It keys on m.Volume and NOT on m.Class, which is the whole point. Class does not
decide this — project does. `dev` and `desktop` are refused without a project so
they always carry a volume, but `exec` is optional: an exec sandbox naming a
project gets one too. A class table would have read `exec -> the fast one` and
silently thrown that org's checkout away.

One place decides. runtimeFor sits beside imageFor and answers both callers,
differently on purpose: the DEPLOYMENT states a preference so it is derived down
(a fleet set to kata-fc still has to run dev sandboxes), while a CALLER states a
request so a contradiction is refused with a 400 rather than corrected — handing
back a runtime nobody asked for is the same silence this exists to end, one
level up. The refusal happens before the row is written, so nothing is left
behind. checkRuntime is folded in; being in the closed set was only half the
question, and splitting the two is how a valid name lost a volume.

Rollback is unchanged and still one string: SANDBOX_RUNTIME_CLASS=gvisor puts
everything back, because gvisor shares a filesystem and nothing derives away
from it. Empty still means the node's default runtime.

PROVEN ON THE CLUSTER through the plane routes an agent actually calls, under
each runtime, in volume_live_test.go:

  SANDBOX_RUNTIME_CLASS=kata-fc — dev/<project> was derived to gvisor, /work
  came back 9p, and a file written before the lease ended read back identical
  from a NEW sandbox after it. exec with no project landed on kata-fc with guest
  kernel 6.18.35 against a 6.12.73 host, and no PVC existed at all.

The measurement that matters is NOT the one that motivated this. On the real
lease path, kata-fc is faster once running and slower to start: git status
21-53ms against gVisor's 156-195ms and run 0.46-0.72s against 1.14-1.28s, but
lease 4.2-8.2s against 2.8-2.9s warm — and 112s the first time an image reaches
a node's devmapper pool, which does not share the overlayfs cache. A one-shot
exec therefore costs MORE on Firecracker; it only wins on a session reused
across several filesystem-heavy commands. No flip is included here.

No toleration or nodeSelector is added to the pod spec, deliberately.
RuntimeClass.scheduling is merged in at admission — measured: a pod given only
`runtimeClassName: kata-fc` came back carrying both `hanzo.ai/kata=true` and the
`dedicated=code-exec:NoSchedule` toleration it never asked for. Stating either
again here would be a second copy that goes stale the day the pool moves.
2026-08-07 06:44:36 -07:00
zeekay f983a530a5 sandbox: the runtime follows the volume, so a sandbox cannot lose one
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
kata-fc has no shared filesystem. `configuration-fc.toml` on the node carries no
`shared_fs` key at all, where `configuration-clh.toml:130` sets
`shared_fs = "virtio-fs"` — Firecracker has no virtio-fs device, so a guest
cannot mount a directory from the host.

Kubernetes does not fail that mount. With the same Bound PVC the sandbox gets a
~599M tmpfs standing exactly where the volume should be, every write SUCCEEDS
into it, and the VM takes the bytes when it exits. A `dev` sandbox would look
perfect and lose the checkout, and nothing anywhere would report it.

So the runtime stops being one string the deployment sets for everything and
becomes a derivation from a property the sandbox already declares:

  a sandbox that needs a PERSISTENT VOLUME needs a runtime that can SHARE A
  FILESYSTEM; one that does not, does not.

It keys on m.Volume and NOT on m.Class, which is the whole point. Class does not
decide this — project does. `dev` and `desktop` are refused without a project so
they always carry a volume, but `exec` is optional: an exec sandbox naming a
project gets one too. A class table would have read `exec -> the fast one` and
silently thrown that org's checkout away.

One place decides. runtimeFor sits beside imageFor and answers both callers,
differently on purpose: the DEPLOYMENT states a preference so it is derived down
(a fleet set to kata-fc still has to run dev sandboxes), while a CALLER states a
request so a contradiction is refused with a 400 rather than corrected — handing
back a runtime nobody asked for is the same silence this exists to end, one
level up. The refusal happens before the row is written, so nothing is left
behind. checkRuntime is folded in; being in the closed set was only half the
question, and splitting the two is how a valid name lost a volume.

Rollback is unchanged and still one string: SANDBOX_RUNTIME_CLASS=gvisor puts
everything back, because gvisor shares a filesystem and nothing derives away
from it. Empty still means the node's default runtime.

PROVEN ON THE CLUSTER through the plane routes an agent actually calls, under
each runtime, in volume_live_test.go:

  SANDBOX_RUNTIME_CLASS=kata-fc — dev/<project> was derived to gvisor, /work
  came back 9p, and a file written before the lease ended read back identical
  from a NEW sandbox after it. exec with no project landed on kata-fc with guest
  kernel 6.18.35 against a 6.12.73 host, and no PVC existed at all.

The measurement that matters is NOT the one that motivated this. On the real
lease path, kata-fc is faster once running and slower to start: git status
21-53ms against gVisor's 156-195ms and run 0.46-0.72s against 1.14-1.28s, but
lease 4.2-8.2s against 2.8-2.9s warm — and 112s the first time an image reaches
a node's devmapper pool, which does not share the overlayfs cache. A one-shot
exec therefore costs MORE on Firecracker; it only wins on a session reused
across several filesystem-heavy commands. No flip is included here.

No toleration or nodeSelector is added to the pod spec, deliberately.
RuntimeClass.scheduling is merged in at admission — measured: a pod given only
`runtimeClassName: kata-fc` came back carrying both `hanzo.ai/kata=true` and the
`dedicated=code-exec:NoSchedule` toleration it never asked for. Stating either
again here would be a second copy that goes stale the day the pool moves.
2026-08-07 06:43:33 -07:00
zeekay 9471b9a1f1 clients: refuse a completion that carries an unparsed tool call
A model emits its family's tool-call tokens so the stack serving it can turn
them into structured tool_calls. When that stack does not, the tokens arrive
here as prose — and this client returned them verbatim, so a Slack turn printed
a model's own wire format at the person who asked a question.

unparsed() recognises the markup of the families this gateway routes to and
both exits of the boundary refuse it, tagged ErrUpstreamBusy so the runner
retries or fails over onto a model that answers. The marker rides the span for
an operator; the error quotes none of it, since the error text is itself
something a person may read.

It recognises and does not read: lifting the call out of the text would be a
second tool-call parser, divergent by construction from every real one and
stale the first time a family changed its syntax. The parse belongs to the
stack that owns the model. This is the net under it.

The span also gains the response model and finish reason on the streaming path,
which the buffered path already recorded.
2026-08-07 06:42:44 -07:00
zeekay 29685cb7ec clients: refuse a completion that carries an unparsed tool call
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 model emits its family's tool-call tokens so the stack serving it can turn
them into structured tool_calls. When that stack does not, the tokens arrive
here as prose — and this client returned them verbatim, so a Slack turn printed
a model's own wire format at the person who asked a question.

unparsed() recognises the markup of the families this gateway routes to and
both exits of the boundary refuse it, tagged ErrUpstreamBusy so the runner
retries or fails over onto a model that answers. The marker rides the span for
an operator; the error quotes none of it, since the error text is itself
something a person may read.

It recognises and does not read: lifting the call out of the text would be a
second tool-call parser, divergent by construction from every real one and
stale the first time a family changed its syntax. The parse belongs to the
stack that owns the model. This is the net under it.

The span also gains the response model and finish reason on the streaming path,
which the buffered path already recorded.
2026-08-07 06:41:51 -07:00
zeekay 30ed726abc ask: the researched answer, at an address an agent can speak
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 deep-research loop was already here and already complete — plan,
search, read, rank, synthesize, cite, follow up, bounded and metered, in
apps/answer behind POST /v1/ask with a mode. What it never had was a door
a MODEL could open. /v1/ask is untyped for three wire reasons that are all
still true, and an untyped route reaches REST and nothing else: no MCP
tool, no CLI command, no SDK method. So the fleet's research capability
was correct, live, and invisible to every agent in it. `ask` did not
appear in the door's subsystem list at all, because it had no typed op.

research_web is that door. It is the same engine — Engine.Answer runs the
same Run() the stream runs, returning one value instead of a stream, so
there is no second implementation to drift. Serve keeps streaming.

Beside it, two names that were lying:

  - crawl's op derived as `create_crawl`, which reads as "make a crawl" —
    a job it cannot start. It reads ONE page that is already addressed.
    It is `read_page` now. A model picks from an `op` enum by name before
    it reads any description.
  - the `x` source hint was appended as a bare word, so asking for X
    turned "openai" into "openai x" — the same open-web query plus a
    token matching nothing. Hints that name a place are `site:` scopes
    now, several joined as alternatives rather than an AND no page can
    satisfy. That is what makes sources:["x"] reach X posts.

fleet/reachable_test drives all four over one door, as a client meets
them: search_web, read_page, research_web, create_exec.
2026-08-07 06:41:42 -07:00
zeekay 7870c6e44a websearch: a second index, and the one that carries site:
Production ran with WEBSEARCH_ENGINES unset, so the default WAS the whole
engine set — and it was Bing alone. Ten results from one index, and no way
to scope a search at all.

Mojeek joins it as a default. Measured from cluster egress rather than
assumed:

  bing   site:x.com  ->  0 results
  mojeek site:x.com  -> 10 results
  mojeek t=20        -> 20 results (it honours the count exactly)

So this is not a second opinion on the same ten pages. Mojeek crawls its
own index, and it is the only engine here that honours `site:`, which
makes it the one that carries every scoped search — X, GitHub, Reddit.
Its result classes are semantic (title, s) rather than build-hashed, so
the parser survives a redeploy; Brave, whose classes are Svelte hashes
like svelte-1rq4ngz, was measured working and left out for that reason.

DuckDuckGo stays coded and out of the default. From the cluster it is
served the anomaly page on BOTH endpoints — /lite/ and the html POST —
67 challenge markers and zero results. Enabling it would have added an
engine that contributes nothing while looking configured, which is the
failure the soft-fail path makes invisible.

The merged cap moves 20 -> 30, which is what two engines can now reach.
2026-08-07 06:41:42 -07:00
zeekay 4c0f7d9387 websearch: cache what was asked, rank what engines agree on, escalate zero to a browser
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 defects, measured against the real engines before any of this was written.
Four queries, back to back:

  post quantum cryptography lattice   bing 10  ddg 10
  firecracker microvm kvm setup       bing 10  ddg  0
  gvisor runsc syscall interception   bing 10  ddg  0
  rust tokio select cancellation      bing 10  ddg 10

  bing's top three for the first query: post.ca.gov/Training,
  post.ca.gov/post-profile, usps.com — it matched the word "post".

1. DDG ANSWERED ONCE AND THEN STOPPED. Not broken: asked four times in three
   seconds. A challenge page is a 200 whose markup holds no results, so
   fetchEngine returns zero WITHOUT an error — deliberately, so one unhappy
   engine cannot fail a request another can answer. The cost is that a
   rate-limited engine and a disabled one look identical. cache.go removes the
   repeat ask, which is the only fix that does not consist of asking someone
   else's server more nicely. An EMPTY answer is never stored: caching a
   challenge would pin the failure for the whole TTL.

2. THE MERGE RANKED BY CONFIGURATION ORDER. First engine's hits led, so bing's
   three irrelevant results opened the page and ddg's correct ones sat below
   them — the order of a comma in an env var deciding relevance. rank.go ranks
   by AGREEMENT (a URL more than one engine returned) then mean position. That
   is the whole reason to run several engines rather than the best one, and the
   old merge threw it away by deduping agreement into a first-seen hit.

3. NOTHING EVER ASKED THE BROWSER. We run Hanzo Crawl in-cluster and apps/crawl
   already escalates to it for client-rendered pages; search never did, though a
   challenge is exactly what a browser gets past. render.go is that same one-way
   best-effort escalation, attempted only when the static fetch produced NOTHING,
   so it can add results and never remove them.

Measured after, same four queries: 19-20 results each (was 10 on the two DDG was
challenged on), firecracker-microvm.github.io leads the firecracker query where
captainboom.com did, and the repeat run of all four returns in 0ms.

TWO THINGS THIS GOT WRONG FIRST, both caught by tests rather than by taste:

  The cache was keyed on the engine LABEL. Every endpoint here is an env
  override, so "bing" is not one address — it is whichever address is configured
  now. Three existing tests pointed at stub servers and were served the previous
  caller's REAL results: one reported the engine was never reached, one got rows
  from a failing engine, and answer's empty-sources test found sources it had
  gone to trouble to remove. The key is the request URL, which is the honest
  identity of a question in production exactly as in a test.

  Escalation defaulted ON. apps/answer reads pages through the SAME crawl
  service, so one process-wide endpoint silently changed a second subsystem's
  answers. It is named now (WEBSEARCH_RENDER=on), beside WEBSEARCH_ENGINES.
2026-08-07 06:39:50 -07:00
zeekay 719c77fc2d a sandbox run is watchable while it runs, and can be stopped
A command in a sandbox was a box with two ends: post an argv and, up to
twenty-five minutes later, receive everything the program had said. A coding run
narrated four lines into its session — lease, clone, running the task, finished —
and between the third and the fourth there was nothing at all. A working agent
and a wedged one looked identical, and there was no way to end either.

Both halves are the same fact: A COMMAND IN FLIGHT IS ADDRESSABLE.

  tell   name a session on a run and the command's output is appended to that
         session's live log AS IT IS PRODUCED
  work   its cancel is held under the sandbox's id, so stop_run can end it

The narration goes where every other run's narration already goes. agents owns
the fleet's live run feed — one durable ordered event log per session, fanned out
to GET /v1/agents/sessions/stream, scoped to a single run with ?root= — and that
is the ONE place a surface watches a run. Nothing here invents a second feed, and
nothing here touches POST /v1/event, which warehouses product events for the
webhooks engine and has no live tail to read at all.

The bytes are tapped ON THEIR WAY to the result buffers, so the tap is a
pass-through and never a second read; a tap that re-ran the command to observe it
would be observing a different command. Appends are coalesced at one a second,
because a session's log is a durable ordered record and not a byte pipe — one npm
install would otherwise write a row per line — and a burst keeps its TAIL, since
what a program said most recently is what says why it is stuck. Failing to
narrate retires the tell and the command runs on: the work is real and the
commentary is not.

STOP ENDS THE WORK; END ENDS THE RESOURCE. They are two verbs because a run that
went wrong is one somebody still wants to look at, and an agent told to "stop"
that deleted the pod would take the checkout, the logs and the half-written file
with it. stop_run answers how many commands it interrupted; zero is an answer, a
command that finished a moment ago being one there was nothing left to stop.

TENANCY. The org is never an argument. A stop resolves the sandbox through the
same org lookup every other operation here walks through — a neighbour naming a
real id gets 404, not 403, because whether a sandbox exists is itself a
cross-tenant fact — and it does that BEFORE the in-flight set is consulted, so an
id is never an authorization. A tell is built from the org the caller PROVED and
a session the caller named; Cmd carries no org field, so a request cannot supply
one, and a session belonging to somebody else is simply absent from the org the
call acts for and refused on the far side.

The runner also says what only it knows: the sandbox's id when the lease lands
(the handle for both stop_run and end_sandbox — a run that never named its
sandbox could be watched and not touched) and the release when it is gone.

stop_run is a typed op on the agent door and on the plane, so it reaches REST,
OpenAPI, MCP and the CLI at once rather than being a route only a browser could
find.

apps/coding/task.go goes with it. It is the bot-gateway runner sandboxrunner.go
replaced, and its own header says why that path could not run at all —
bot-gateway has neither the docker binary nor a socket, so every dispatch 503'd.
Nothing but its own tests had referenced it since.

Tests: output has to be readable from outside the call while the call has not
returned (it fails, in two seconds, with the tap removed); a stop has to reach the
exec channel and leave the sandbox (it reports 0 with the registry removed); a
neighbour's stop has to change nothing and answer as if the id did not exist.
2026-08-07 06:38:43 -07:00
zeekay 33e075b595 a sandbox run is watchable while it runs, and can be stopped
CI/CD / containment (push) Successful in 2m7s
Hanzo CI/CD / cicd (push) Failing after 19m52s
CI/CD / gate (push) Failing after 19m53s
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 command in a sandbox was a box with two ends: post an argv and, up to
twenty-five minutes later, receive everything the program had said. A coding run
narrated four lines into its session — lease, clone, running the task, finished —
and between the third and the fourth there was nothing at all. A working agent
and a wedged one looked identical, and there was no way to end either.

Both halves are the same fact: A COMMAND IN FLIGHT IS ADDRESSABLE.

  tell   name a session on a run and the command's output is appended to that
         session's live log AS IT IS PRODUCED
  work   its cancel is held under the sandbox's id, so stop_run can end it

The narration goes where every other run's narration already goes. agents owns
the fleet's live run feed — one durable ordered event log per session, fanned out
to GET /v1/agents/sessions/stream, scoped to a single run with ?root= — and that
is the ONE place a surface watches a run. Nothing here invents a second feed, and
nothing here touches POST /v1/event, which warehouses product events for the
webhooks engine and has no live tail to read at all.

The bytes are tapped ON THEIR WAY to the result buffers, so the tap is a
pass-through and never a second read; a tap that re-ran the command to observe it
would be observing a different command. Appends are coalesced at one a second,
because a session's log is a durable ordered record and not a byte pipe — one npm
install would otherwise write a row per line — and a burst keeps its TAIL, since
what a program said most recently is what says why it is stuck. Failing to
narrate retires the tell and the command runs on: the work is real and the
commentary is not.

STOP ENDS THE WORK; END ENDS THE RESOURCE. They are two verbs because a run that
went wrong is one somebody still wants to look at, and an agent told to "stop"
that deleted the pod would take the checkout, the logs and the half-written file
with it. stop_run answers how many commands it interrupted; zero is an answer, a
command that finished a moment ago being one there was nothing left to stop.

TENANCY. The org is never an argument. A stop resolves the sandbox through the
same org lookup every other operation here walks through — a neighbour naming a
real id gets 404, not 403, because whether a sandbox exists is itself a
cross-tenant fact — and it does that BEFORE the in-flight set is consulted, so an
id is never an authorization. A tell is built from the org the caller PROVED and
a session the caller named; Cmd carries no org field, so a request cannot supply
one, and a session belonging to somebody else is simply absent from the org the
call acts for and refused on the far side.

The runner also says what only it knows: the sandbox's id when the lease lands
(the handle for both stop_run and end_sandbox — a run that never named its
sandbox could be watched and not touched) and the release when it is gone.

stop_run is a typed op on the agent door and on the plane, so it reaches REST,
OpenAPI, MCP and the CLI at once rather than being a route only a browser could
find.

apps/coding/task.go goes with it. It is the bot-gateway runner sandboxrunner.go
replaced, and its own header says why that path could not run at all —
bot-gateway has neither the docker binary nor a socket, so every dispatch 503'd.
Nothing but its own tests had referenced it since.

Tests: output has to be readable from outside the call while the call has not
returned (it fails, in two seconds, with the tap removed); a stop has to reach the
exec channel and leave the sandbox (it reports 0 with the registry removed); a
neighbour's stop has to change nothing and answer as if the id did not exist.
2026-08-07 06:36:09 -07:00
hanzo-dev 69f5639162 deps(ai): v1.832.39 — the transcription form is parsed before its fields are read
/v1/audio/transcriptions answered `requires a "model" field` to every request
that carried one: the field was read through beego's r.Form one line above the
file read that was what parsed the multipart body. Only the undocumented
?model= query spelling got through, so no standard OpenAI client could reach
the endpoint.

Carries v1.832.38 with it (whisper/kokoro routes + the `speech` provider row +
the OpenAI TTS branch), so the endpoint and the service it was written for meet
for the first time.

No route surface changes: plugin/ai/openapi.json regenerates byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 06:33:10 -07:00
zeekayandhanzo-dev 126ed2590d commerce v1.50.29 — the crypto rail's gate is the invariant now
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
cryptoDepositsCanBeCredited is gone. An address is handed out only for an asset
the deposit watcher is configured for, asked per request against the same set the
picker projects from — so a buyer cannot be offered something the mint path would
refuse, and an unconfigured chain can no longer mint a real custody address that
nothing will ever look at.

Arming an asset is one act: name it in CRYPTO_DEPOSIT_* (done for Base + USDC in
universe b25a6d1a2). Nothing is watched by default, so this deploys closed for
every asset except the one deliberately configured.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 06:26:18 -07:00
hanzo-dev e10f0576a9 deps(ai): v1.832.38 — whisper and kokoro reach the in-cluster speech service
CI/CD / containment (push) Successful in 1m37s
Hanzo CI/CD / cicd (push) Failing after 16m2s
CI/CD / gate (push) Failing after 16m3s
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/audio/transcriptions has been mounted since v1.832.35 and the speech
service (ghcr.io/hanzoai/speech) has been READY, with nothing routing between
them: no audio model was in the catalogue, so every call died at "model is not
available" and the pod's log held only /healthz.

v1.832.38 registers them on the mechanism that already registers every other
model — whisper/whisper-small/kokoro routes, the `speech` provider row they
name (http://speech.hanzo.svc/v1), and the OpenAI branch the TTS factory was
missing. Both audio paths reach it through the object layer, so the HTTP and
ZAP surfaces are served by one wiring and meter through the one funnel.

No route surface changes: plugin/ai/openapi.json regenerates byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-07 06:13:54 -07:00
zeekayandClaude Opus 4.8 1f38fcc6ff fix(tags): serve /v1/tags from the projects app (owns the store)
Hanzo CI/CD / cicd (push) Successful in 29s
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
Production runs ~25 single-app processes, so the destinations process that
served /v1/tags could not reach the project store (its in-process handle was
nil) and answered empty for a site that had tags. Move the tag door to the
projects app — the process that owns the store — reading ResolveKey/ResolveHost
in-process, the same rule the key/site/scope resolvers already follow. Removes
the cross-process copy from destinations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 06:09:41 -07:00
zeekayandClaude Opus 4.8 6f82f001c4 feat(analytics): hosted /v1/event.js injects the site's browser pixels
CI/CD / containment (push) Successful in 3m58s
Hanzo CI/CD / cicd (push) Successful in 30m20s
CI/CD / gate (push) Successful in 30m21s
CI/CD / image (push) Failing after 2m27s
CI/CD / rollout (push) 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 hosted tag is now the per-site twin of track.js: on load it fetches
/v1/tags (dual-resolved per site by its key or host) and injects the
connected browser pixels (GA/Meta/TikTok/X) first-party, firing each event
to the native pixel — translated through the same taxonomy the server CAPI
uses and stamped with a shared event_id carried on the /v1/event row — so
the browser pixel and the server-side CAPI deduplicate. 11/11 behavioral
checks. The one-liner now does the whole loop.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

apps/tracker/ui and apps/meet/ui embed_test.go pin the base; the drift and
dist-integrity gates pass on the new bytes.
2026-08-06 22:11:13 -07:00
hanzo-dev c663f04e6e forge: size the inventory window by what a refresh costs, not by the clock
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Re-reading the repository list for the 250-repo org is fifty requests to
the endpoint measured degrading from 13s to 22s under load. A one-minute
window put that on the forge every minute, per actor, to learn that nothing
had changed — a cache whose refresh is heavier than the thing it spares.

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:58:35 -07:00
hanzo-dev bacbe30e52 tracker: read the org that holds the work, and never wait forever for it
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The board sat on loading skeletons with an empty browser console. Two
independent defects produced that one symptom, and each hid the other.

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:56:19 -07:00
hanzo-dev a5ec8df325 merge: inc/main into the brand-registry fix
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
2026-08-06 21:42:13 -07:00
hanzo-dev 99216affcc brand: one registry, and a gate that reads what we actually ship
brand.go said zoo's issuer is https://zoolabs.id. Four other places said
https://zoo.id — a host with NO DNS RECORD, unregistered, that the live Zoo
IAM has never stamped:

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:25:43 -07:00
antje 8b704def20 cicd: name the credential when the forge refuses a claim
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The CAS moved to the forge, which answers 409 where github answered 422, and
the catch-all error still said "expected 201 or 422" — it would have printed
the wrong contract for the one status it exists to explain.

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:06:20 -07:00
antje 1d692c7467 meet: lift the prose for the two routes that had none
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
apps/meet/zipdoc_gen.go was stale: /v1/meet/session and /v1/meet/getToken
were registered without their lift being regenerated, so zipdoc-check went
red and took the whole gate with it — and the gate is what the image job
needs, so no release could publish while this sat there.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Both work already; this is the test that says so, because it is a property of the
router's configuration rather than of anything in this package, and a property
nothing measures is a property that changes without anyone noticing.
2026-08-06 19:10:53 -07:00
antje d721be09e4 sandbox: serve the terminal, so an iframe is already a terminal
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
A socket is not a product. Every host that wants to show a shell — the console's
dock, tabs' panes, whatever is next — would otherwise carry its own emulator, its
own resize arithmetic and its own reconnect affordance, and the day one of them
is wrong is the day one product's terminal is quietly worse than another's. So
the terminal is SERVED, and a host embeds it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured, same box, same commit:

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

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

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

The stale prose above the variable is corrected too — it claimed the image builds
with two tags while the image builds with three.
2026-08-06 18:51:55 -07:00
antje ded824b648 sandbox: measure the terminal's two doors as ROUTES
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
This package has already shipped handlers nothing could reach — Create, List,
Get and Delete existed as exported functions with no routes registered, and
nothing said so. The terminal adds two more doors, so the mounting is a fact with
a test rather than a line somebody remembered to write.

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

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

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

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

Two routes and one mechanism:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured in a live sandbox rather than inferred:

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

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

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

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

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

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

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

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

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

	get_v1_projects_by_slug_deployments              list_project_deployments
	post_v1_projects_by_slug_domains_by_host_verify  verify_project_domain
	post_v1_projects_by_slug_deploy                  deploy_project
	delete_v1_projects_by_slug                       delete_project

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 15:01:56 -07:00
zeekay f908ac9213 Merge remote-tracking branch 'inc2/main' into sync-lines
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
2026-08-06 14:58:47 -07:00
zeekay 181969ac0f Merge remote-tracking branch 'forge/main' into feat/plugin-solo-builds
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
2026-08-06 14:57:56 -07:00
zeekay 155e9503ec dockerignore: the context stops carrying a copy of ourselves
`COPY . .` was shipping 1087 MB. /bin/ was already excluded, but nothing that
mattered lived there:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also closes two tenancy defects found beside this work:

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 13:55:06 -07:00
zeekayandzeekay dbcb9040a0 sandbox: resolve the image the publisher actually wrote, and let a digest pin it
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
TWO defects, both silent, both found by trying to start a real sandbox.

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

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

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

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

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

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

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

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

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

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

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

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

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

What is different in the bytes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 12:48:01 -07:00
hanzo-devandzeekay ac2e1d7422 sandbox: the live proof ends the pod it started
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
The cleanup this file promises — "a leaked pod on a shared cluster is somebody
else's problem tomorrow" — was deferring purge, and purge deletes the VOLUME:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also, from the same review:

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

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

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

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

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

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

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

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

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

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

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

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

go build ./... and go vet clean. Full suite: 3 red (base, code, commerce),
identical to both parents — this box's SQLite lacks acos and fts5.
2026-08-06 11:06:47 -07:00
zeekay 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 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 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
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
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
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
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
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 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
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
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
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
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
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 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
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
395 changed files with 50667 additions and 5521 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
+170 -125
View File
@@ -63,43 +63,41 @@ on:
concurrency:
# ONE release at a time, queued not cancelled: the image is pushed early and
# the tag and pin come last, so killing a run midway leaves an orphan — ten of
# them between v1.801.335 and v1.801.350. Pull requests are a different ref, so
# they neither queue behind a release nor hold one up, and a new push to a PR
# cancels its own stale run.
# them between v1.801.335 and v1.801.350. A pull request gets a slot of its
# own, so it neither queues behind a release nor holds one up, and a new push
# to it cancels its own stale run.
#
# 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.
# Two earlier readings of this are worth not repeating, because both blamed the
# setting and the setting was never what acted. One argued cancel-in-progress
# was "a literal false the forge cannot evaluate, and a non-empty string is
# truthy"; the other argued the cancelled runs "were superseded while still
# QUEUED, so cancel-in-progress was never the thing acting on them". The second
# is right about the mechanism and neither found the cause, which is one line
# lower and is the GROUP, not the flag.
# ── THE GROUP MUST NOT BE KEYED ON A VALUE THIS FORGE DOES NOT SET ──────────
#
# 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.
# It was `cicd-${{ github.ref }}`, and act_runner leaves the ref EMPTY: the API
# returns head_branch null and no ref field on ordinary pushes to main. So the
# group rendered as the constant `cicd-` and EVERY run in the repository — main
# pushes, pull requests, tag pushes — shared ONE slot.
#
# 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".
# That is the mechanism behind the cancellations above, and it is not the
# cancel-in-progress setting the paragraphs above blame. Gitea coalesces a
# concurrency queue to depth one: an in-progress run is left alone (which is
# what cancel-in-progress: false buys), but a QUEUED run is cancelled when a
# newer one arrives for the same group. That is correct behaviour — nobody
# wants an image per intermediate commit — and it only became destructive
# because one constant group meant every pull request coalesced away the
# release queued behind it, and vice versa. The tell is in the API: those runs
# carry started_at 1970-01-01, so they were never given a runner at all.
#
# 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: false
# Keyed properly, the original intent holds without depending on the ref.
# Releases share one slot and queue; each pull request gets its own slot and a
# new push to it cancels only its own stale run. cancel-in-progress is now an
# expression that evaluates to a real boolean rather than a literal whose
# truthiness was being argued about.
group: cicd-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'release' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ══ CAR 0 ══ THE DOCUMENT EQUALS THE CODE. Everything below `needs:` this.
@@ -143,6 +141,23 @@ jobs:
# `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
@@ -331,12 +346,32 @@ jobs:
# what makes a tag a receipt for an image that booted.
image:
needs: [gate, containment]
# 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)
# NAME WHAT IS REFUSED, NOT WHAT IS ALLOWED — because this job's ref is not
# reliably knowable and its absence must not read as "no".
#
# act_runner does not populate the ref on these runs: the API returns
# head_branch NULL and no ref field at all, on ordinary pushes to main and
# not merely on the API reruns the previous note blamed. The old condition
# allowed ref=='refs/heads/main' OR '' OR null, which looks like it covers
# exactly that — and does not, because an absent value satisfies none of the
# three, so the whole predicate is false and the job is SKIPPED.
#
# Skipped, not failed, is the entire reason this was invisible. Run 38052
# (7fbafa54a) is the proof: gate success, containment success, and then
# image/rollout/reach/fanout/receipt all `skipped`. A green tick on a run
# that shipped nothing, with no error anywhere to read. That is "the gate is
# green and nothing ships", and it had outlasted several fixes aimed at the
# gate, which was never the thing failing.
#
# Inverting it removes the dependence. `on:` already decides what reaches
# this workflow — pushes to main, v* tags, PRs, dispatch — so the job does
# not need to re-derive that, and re-deriving it is what broke. Two things
# must be refused: pull requests, and TAG pushes (the tag is minted BY the
# claim below, so building on a tag would take the next number and publish a
# second image for a commit that already has one). Everything else — main,
# dispatch, and the ref-less shape — is a release. startsWith() on an absent
# ref is false, so the missing value now means "not a tag", which is true.
if: github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/')
runs-on: [hanzo-build-linux-amd64]
# cloud builds ~28 subsystems and has run 15m, 17m and 20m42s.
timeout-minutes: 60
@@ -357,53 +392,26 @@ jobs:
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: The commit must exist on github before a tag can name it
env:
GH_PAT: ${{ secrets.GH_PAT }}
SHA: ${{ github.sha }}
# The claim below reserves a version by creating refs/tags/v<N> AT THIS
# COMMIT on github.com. A ref can only point at an object that is there,
# so the claim answers 404 — "Object does not exist" — for a commit github
# has never seen, and refuses to build.
#
# It routinely has not seen it. CI runs on git.hanzo.ai, which is canonical
# and where the push lands; github is fed by a PUSH MIRROR on an 8-HOUR
# interval, and the claim runs seconds later. So the object the tag must
# name is normally hours away, and every release in that window fails on a
# 404 that reads like a permissions problem and is really a race. It cost
# the fleet four days of releases stacked behind one.
#
# Publishing the commit here closes the race at its cause: after this step
# github HAS the object, whatever the mirror's schedule. It goes to a ref
# of its own rather than to main, because main is the mirror's to move and
# the two lineages do diverge — this step's job is to make the object
# exist, not to decide what main is.
run: |
set -euo pipefail
api="https://api.github.com/repos/hanzoai/cloud"
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "commit ${SHA} is already on github — nothing to publish"
exit 0
fi
echo "commit ${SHA} is not on github yet (push mirror runs every 8h); publishing it now"
git push --force "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
"${SHA}:refs/heads/forge-head"
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS -o /dev/null -H "Authorization: Bearer ${GH_PAT}" "${api}/commits/${SHA}" 2>/dev/null; then
echo "github now resolves ${SHA}"
exit 0
fi
sleep 3
done
echo "::error::pushed ${SHA} to github but it still does not resolve — the claim below would 404 on an object that is not there"
exit 1
# THE CLAIM IS MADE WHERE THE CODE IS. There used to be a step here that
# published the commit to github first, because the claim was made against
# github and a ref can only point at an object the server has. That step is
# gone, and so is the race it existed to close: CI runs on git.hanzo.ai, so
# the commit is already there — being there is what started this run.
#
# It was also the last thing forcing the private tree onto a github repo on
# every release, and the thing that broke the lane twice: once when the repo
# it pushed to was public, and once when the credential could not see the
# private one it was moved to ("Repository not found", run 38319). A step
# that cannot fail is better than a step with two known ways to fail.
- name: Claim a version — atomically, before anything is built
id: ver
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
# The claim is written to git.hanzo.ai, so it is the forge credential
# that has to be able to write it. GH_PAT is gone from this step: it
# authenticated a register that no longer lives on github.
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
# The commit being released, named explicitly rather than read from
# GITHUB_SHA: that variable is the runner's to set, this claim is the
# workflow's to make, and a claim that silently reads an empty string
@@ -455,10 +463,20 @@ jobs:
done
# What has been TAGGED. ls-remote rather than /repos/../tags because the
# REST list paginates at 100 and this repo carries ~1700 refs. github.com,
# not the forge: that is where cloud's tags actually are; the forge copy
# trails and reading it would collide with fifty published images.
GIT=$(git ls-remote --tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/cloud" \
# REST list paginates at 100 and this repo carries ~1700 refs. The
# private github repo, not the forge: the claim is a compare-and-swap
# and only one server can arbitrate it, so the register of names has to
# be the one the claim writes to. Reading a different git than we claim
# against is how two lanes come to believe they both own a number.
#
# The register moved here from the public repo, so it starts from
# v1.801.491 — the reconciliation release — rather than from the
# public repo's v1.801.490. 487 through 490 are holes there: versions
# claimed by runs that then died in the build, which is the correct
# direction to fail (a hole is inert; a reused number serves wrong
# bytes). They are not carried over, because a name nothing published
# is not a release.
GIT=$(git ls-remote --tags "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
| awk '{print $2}' | sed 's|refs/tags/||' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' || true)
@@ -508,11 +526,11 @@ jobs:
# THE COMPARE-AND-SWAP.
CODE=$(curl -s -o /tmp/claim.json -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H 'Accept: application/vnd.github+json' \
"https://api.github.com/repos/hanzoai/cloud/git/refs" \
-d "{\"ref\":\"refs/tags/v${CAND}\",\"sha\":\"${SHA}\"}")
if [ "$CODE" = "422" ]; then
-H "Authorization: token $FORGE_TOKEN" \
-H 'Content-Type: application/json' \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags" \
-d "{\"tag_name\":\"v${CAND}\",\"target\":\"${SHA}\"}")
if [ "$CODE" = "409" ]; then
# TWO DIFFERENT FAILURES SHARE THIS STATUS, and treating them alike
# would turn one of them into a ten-attempt loop that ends in the
# wrong diagnosis. "Reference already exists" is the collision this
@@ -523,14 +541,14 @@ jobs:
# a name to skip past; it is a repo that has not been published, and
# the next number would fail identically.
WHY=$(jq -r '.message // empty' /tmp/claim.json)
if [ "$WHY" != "Reference already exists" ]; then
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}. If this is 'Object does not exist', commit ${SHA} is on the forge but not on github.com — the release lane claims versions against GitHub, so the mirror must carry the commit first."
exit 1
fi
case "$WHY" in *"already exists"*) : ;; *)
echo "::error::claiming v${CAND} was refused with: ${WHY:-unknown}"
exit 1 ;;
esac
# Taken. By us, or by somebody else? The distinction is the whole
# difference between a resume and a collision, and it is one GET.
HAVE=$(curl -fsS -H "Authorization: Bearer $GH_PAT" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/v${CAND}" | jq -r '.object.sha // empty')
HAVE=$(curl -fsS -H "Authorization: token $FORGE_TOKEN" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/v${CAND}" | jq -r '.commit.sha // empty')
if [ "$HAVE" = "${SHA}" ]; then
echo "v${CAND} is already claimed at our sha — this release, resumed"
CLAIMED="$CAND"; break
@@ -538,8 +556,17 @@ jobs:
echo "v${CAND} is held by ${HAVE:-another ref} — trying the next number"
continue
fi
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
# Say which credential and which scope, because the symptom
# otherwise reads as "the claim is broken" and the cause is one
# secret. FORGE_TOKEN has only ever been READ with in this
# workflow — go module fetches and ls-remote — so the first
# release to write with it is the first test of its scope.
echo "::error::the forge refused the claim for v${CAND} with $CODE. FORGE_TOKEN must carry write:repository on hanzoai/cloud — it creates refs/tags/v<N> and the refs/smoked/<digest> receipt. Re-issue it with that scope and set it as the hanzoai org actions secret; nothing else here needs changing."
cat /tmp/claim.json; exit 1
fi
if [ "$CODE" != "201" ]; then
echo "::error::claiming v${CAND} returned $CODE (expected 201 or 422) — refusing to build a version this run cannot prove it owns"
echo "::error::claiming v${CAND} returned $CODE (expected 201 created or 409 taken) — refusing to build a version this run cannot prove it owns"
cat /tmp/claim.json; exit 1
fi
echo "claimed v${CAND} at ${SHA}"
@@ -598,9 +625,24 @@ jobs:
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.
@@ -647,7 +689,7 @@ jobs:
env:
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
img="ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}"
@@ -703,8 +745,7 @@ jobs:
# 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
if git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" "refs/${RECEIPT}" >/dev/null 2>&1; then
echo "$DIGEST already has a smoke receipt — these exact bytes have booted"
SMOKED=1
fi
@@ -779,19 +820,23 @@ jobs:
- name: Record that these bytes booted
if: steps.img.outputs.smoked == '0'
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
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
# A push IS the compare-and-swap: the server refuses to move a ref that
# already names something else, and re-pushing the identical value is a
# no-op. Gitea has no POST for a ref outside refs/tags, and it does not
# need one — this is the operation that endpoint would have wrapped.
if git push "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"${{ github.sha }}:refs/${{ steps.img.outputs.receipt }}" 2>/tmp/receipt.err; then
echo "recorded: ${{ steps.img.outputs.digest }} booted, built from ${{ github.sha }}"
elif git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1; then
echo "already recorded by another run — same digest, same statement"
else
echo "::error::could not record the smoke receipt for ${{ steps.img.outputs.digest }}. These bytes booted and nothing can prove it, so a resume would smoke them again at best and skip them at worst — refusing to leave that state."
cat /tmp/receipt.err; exit 1
fi
# NOTHING LEAVES THIS JOB UNSMOKED, re-asked of the authority rather than
# remembered from a step output. Cheap, and it is the assertion `rollout`
@@ -800,11 +845,11 @@ jobs:
# unproven image becomes a pin.
- name: These bytes have a smoke receipt
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
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 }}" \
git ls-remote --exit-code "https://x:${FORGE_TOKEN}@git.hanzo.ai/hanzoai/cloud" \
"refs/${{ steps.img.outputs.receipt }}" >/dev/null 2>&1 \
|| { echo "::error::no smoke receipt for ${{ steps.img.outputs.digest }} — these bytes have never been proven to boot. Nothing downstream may pin them."; exit 1; }
echo "${{ steps.img.outputs.digest }} is smoked"
@@ -834,12 +879,12 @@ jobs:
# because the one thing worse than a moved tag is a moved tag that shipped.
- name: The claimed tag still names this commit
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.image.outputs.version }}"
HAVE=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/git/ref/tags/${TAG}" | jq -r '.object.sha // empty')
HAVE=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/tags/${TAG}" | jq -r '.commit.sha // empty')
if [ -z "$HAVE" ]; then
echo "::error::${TAG} was claimed by this run but no longer exists — refusing to ship a release whose receipt was deleted"; exit 1
fi
@@ -1199,7 +1244,7 @@ jobs:
- uses: actions/checkout@v4
- name: Write release.json onto the tag
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.image.outputs.version }}"
@@ -1224,15 +1269,15 @@ jobs:
(.|tostring) + "\n```"' /tmp/release.json)
# Idempotent: create, or update the one that a previous attempt left.
ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
ID=$(curl -fsS -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/tags/${TAG}" 2>/dev/null | jq -r '.id // empty')
if [ -n "$ID" ]; then
curl -fsS -X PATCH -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases/${ID}" \
curl -fsS -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases/${ID}" \
-d "$(jq -nc --arg b "$BODY" '{body:$b}')" >/dev/null
else
curl -fsS -X POST -H "Authorization: Bearer ${GH_PAT}" \
"https://api.github.com/repos/hanzoai/cloud/releases" \
curl -fsS -X POST -H "Authorization: token ${FORGE_TOKEN}" \
"https://git.hanzo.ai/v1/repos/hanzoai/cloud/releases" \
-d "$(jq -nc --arg t "$TAG" --arg b "$BODY" '{tag_name:$t, name:$t, body:$b}')" >/dev/null
fi
echo "receipt written to the ${TAG} release"
+44 -15
View File
@@ -213,19 +213,31 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" \
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
github.com/hanzoai/sqlite
# Go drops comments at compile time, so this pass is the ONLY way a typed handler's
# prose reaches the document: zipdoc lifts it into zipdoc_gen.go, which registers it
# with zip.Describe at init. It must run BEFORE every build below, because the
# generated file is compiled INTO each binary — running it after would be too late.
# NO `go generate -run zipdoc` HERE, DELIBERATELY — and the reason is not that the
# lifted prose stopped mattering. It still is the only way a typed handler's words
# reach /v1/openapi.json: Go drops comments at compile time, zipdoc lifts them into
# zipdoc_gen.go, and that file is compiled INTO each binary below. An image whose
# binaries lack it serves the 1441 description-less operations this step was added
# to fix, and the SDK repos and the CLI read that document.
#
# mk/plugin.mk makes this a prerequisite of the per-app `build`, so the per-app path
# has always had it. This path did not, and the omission is measurable in production:
# api.hanzo.ai/v1/openapi.json serves 1441 operations with ZERO descriptions, which
# is exactly the binary mk/plugin.mk warns about. The SDK repos and the CLI read that
# document, so the prose never reached any of them either.
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
go generate -run zipdoc ./...
# The fix for that was never "regenerate during the build". All 99 zipdoc_gen.go
# files are COMMITTED — they are source, the way generated Go is source everywhere
# else — so the tree `COPY . .` just brought in already contains them, and every
# `go build` below compiles the real prose in whether or not anything regenerates.
# Running the generator here re-derived those 99 files from the same inputs to
# produce the same bytes, for 355.9s of a 17-minute build: 35% of the wall clock
# spent proving a file equals itself.
#
# Freshness is the real requirement, and it is a property of the COMMIT, not of the
# image. So it is enforced where commits are: `make zipdoc-check` regenerates from
# source and fails on any diff (hanzo.yml, step `zipdoc-current`), which runs in
# the test lane every later job already declares `needs:` on. A stale lift now
# cannot be merged — which is strictly stronger than this step, because this step
# would happily build a correct image from a stale commit and leave main wrong.
# That is not hypothetical: main carried a stale apps/agents lift while this ran.
#
# It must stay out. Re-adding it buys nothing a green `zipdoc-current` has not
# already proven, and costs the 355.9s back.
# The commit this image is built FROM, handed in by the SAME builder that already
# feeds it to the OCI label in the final stage (apps/platform buildFrontendCmdRev,
# `--opt build-arg:REVISION=<sha>`; the other lane passes github.sha).
@@ -301,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.
@@ -332,9 +358,12 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
[ -n "$names" ] || { echo "FATAL: no apps parsed from manifest/apps.go — the derivation broke, not the app list"; exit 1; }; \
for p in $names; do \
[ -d "./plugin/$p" ] || { echo "FATAL: manifest app '$p' has no plugin/$p — run 'make generate' and commit"; exit 1; }; \
echo "building plugin $p"; \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
done
done; \
coresident="$(sed -n '/Coresident: *true/{s/.*{Name: "\([^"]*\)".*/\1/p;}' manifest/apps.go)"; \
spawned="$(sed -n '/Coresident: *true/d; s/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)"; \
echo "building $(echo "$spawned" | wc -w) of $(echo "$names" | wc -w) plugins, $(nproc) at a time (coresident, never spawned: ${coresident:-none})"; \
printf '%s\n' $spawned | xargs -P "$(nproc)" -I{} sh -c \
'CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$1" "./plugin/$1" || { echo "FATAL: plugin $1 failed to build" >&2; exit 255; }' _ {}
# THE STAMP LANDED — asked of the ARTIFACT, not of the flag string.
#
# `-X` naming a path or symbol the linker cannot resolve is not an error: it is
+367 -20
View File
@@ -330,19 +330,52 @@ this one map.
### The release index: `CLOUD_PLUGINS``binaries.json` → verified fetch
**The image IS the distribution, and there is no `binaries:` lane.** The
Dockerfile builds the light host plus one binary per manifest app into `/plugins`
beside it, and the host resolves each plugin as a file on disk
(`manifest.App.Plugin`). `hanzo.yml` says so where a lane would otherwise go, and
gives the reason: 112 per-app entries would blow the artifact lane's 16-binary
bound, and its `out:`-glob names every file with one recipe name, which the
per-app/per-arch index in `manifest/release.go` cannot read.
**The image carries a copy; the index is how a host without one gets the same
bytes.** The Dockerfile builds the light host plus one binary per manifest app
into `/plugins` beside it, and the host resolves each plugin as a file on disk
(`manifest.App.Plugin`). That stays the default, because a pod that pulled one
image must be able to serve without reaching the network.
`CLOUD_PLUGINS` (manifest/release.go:21) is the OPT-IN runtime path for a
plugin-less host: point it at a `binaries.json` index and each app resolves to a
published artifact instead of a sibling file. **Nothing publishes such an index
today** — it is a supported input with no producer, kept for a host that ships
without `/plugins`, not a second way the default deployment gets its bits.
**The build half of a `binaries:` lane now exists; the publish half waits on two
things outside this repo.** `make -f mk/fleet.mk dist` builds all 121 plugins for
every platform in `PLATFORMS` (linux/amd64 + linux/arm64) into
`dist/<app>-<os>-<arch>` — the exact filename a release index keys on, and the
same `mk/plugin.mk build` recipe an app's own Makefile runs, so a published
plugin is byte-for-byte what `make -C apps/<app> build` produces. `CGO_ENABLED=0`
there is load-bearing rather than inherited: a plugin fetched over the network
runs on a box we did not build, and a cgo binary would demand a matching
libsqlite3 on it. The image's `/plugins` are built the other way (cgo +
libsqlite3) because there the host owns the filesystem they land on.
`hanzo.yml` carries the declaration commented, with both blockers named:
1. **hanzoai/ci's `run:`/`out:` lane indexes per RECIPE, not per FILE.** It writes
`{name: <the recipe's>, os: any, arch: any}` for every file it collects —
right for a wheel, unreadable to `manifest/release.go`, which resolves by
name+os+arch. The fix is to derive the triple from the filename when it carries
the `<name>-<os>-<arch>` shape ci's OWN Go lane already writes: one naming
convention, either lane. (`out: dist/*` also aborts that lane today — it copies
each match into `dist/` by basename and `cp x x` is an error, not a no-op.)
121 per-app entries is not the alternative: it would blow the platform front
door's 16-binary bound (`apps/platform/artifact.go:86`) and restate
`manifest/apps.go` in YAML.
2. **`bucket:` needs `S3_ADMIN_ACCESS_KEY`/`S3_ADMIN_SECRET_KEY` from KMS, and
those names are in KMS for no org.** Both lanes fail closed on it, so declaring
`bucket:` publishes nothing and reds every tag build — which is what happened
to ci's `site:` lane, which refused every caller that ever declared one. The
answer being built next door is to stop needing the credential: publish through
an authenticated cloud endpoint on the IAM bearer the KMS step already mints,
so CI names no bucket and holds no bucket key. If that is the direction, this
lane wants the same door.
When both hold, the layout is `https://s3.hanzo.ai/plugins/hanzoai/cloud/<tag>/`
— artifacts first, `binaries.json` LAST, so the index never names an object that
is not there — and `plugins` is the bucket `apps/platform/artifact.go:84` already
defaults to, so both publishers write one layout.
`CLOUD_PLUGINS` (manifest/release.go:21) is the runtime path that reads it: point
it at that `binaries.json` and each app resolves to a published artifact instead
of a sibling file.
- **No digest, no trust.** `fetch` drops any index entry missing `url` or
`sha256` (manifest/release.go:82), and `remote` returns a `zip.Plugin` with
@@ -501,7 +534,69 @@ are not named after their app (`zt`→zero-trust, `eval`→evals, `auditlog`→a
licensing, metrics) are external modules with a `plugin/<app>` and no source
directory here; `mk/fleet.mk` runs them through the same recipe by name.
`mk/go.mk` is the toolchain contract every includer shares (GOWORK=off, TMPDIR on
disk, `-p=2`, the dev KMS key, the FTS5 tag).
disk, the dev KMS key, the FTS5 tag, and `NPROC`).
### The fleet is a set of TARGETS, and the numbers that made it one
`mk/fleet.mk` applies that per-app contract to all 121 apps. Every sweep in it was
a shell `for` loop, which can only do one thing at a time; they are now named
targets make schedules. Measured on this repo, 20 cores, from an EMPTY build
cache:
| what | how | cold |
|---|---|---|
| one plugin, alone | `make -C apps/auto build` | 57.6s at `-p=2`, 41.4s at `-p=$(nproc)` |
| all 121, the old shell loop | `for d in apps/*/Makefile; …` | **586s** |
| all 121, scheduled | `make -f mk/fleet.mk binaries` | **300s** (25s warm) |
The FLOOR is why the fleet is so much cheaper than 121 × one: the root package
`github.com/hanzoai/cloud` is 587 packages (214 stdlib, 373 external) and EVERY
app inherits it — `apps/auto` is 588 packages, `o11y` the largest at 2054. So the
57.6s cold floor is paid ONCE into a shared cache and the marginal app costs
~1.4s. **That makes cache SHARING, not floor size, the thing that matters**: 121
apps built in 121 isolated caches would pay that floor 121 times, ~118 minutes of
identical work. No single import dominates the floor either — the largest
exclusive contributors are `hanzo-ds/go` (30 packages, for `datastore.Open` in
audit_mirror.go) and `iam/pkg/model` (23, for the two-string `model.OrgRef` in
token_validator.go); everything else shares a deep common core (`circl`'s 23 PQ
packages arrive via `luxfi/zap`'s handshake, protobuf via prometheus).
- **J apps at once, P compilers each, and `J*P ≈ NPROC`.** The link is what costs
memory: measured peak RSS is 1.67 GB for the heaviest plugin (o11y), 1.4 GB
median, 273 MB for `cmd/cloud`. J is therefore bounded by MEMORY (3 GiB per
concurrent app) 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. Oversubscribing CPU makes a build slower; oversubscribing memory makes it
killed. `J*P` is capped at NPROC because it measurably matters —
`J=20,P=2` (40 actions on 20 cores) took **431s**, the same work at `J=10,P=2`
took **300s**.
- **P stays 2 because the runner asks for 2**, not because 2 is fastest. `fan`
passes `GOFLAGS` on the make command line, which beats the pod's injected
`GOFLAGS=-p=2`; any other P here overrides the operator who sized the cgroup.
`J=5,P=4` measured **268s** against 300s — real, but inside the spread the same
`J=10,P=2` config showed on this machine (300s and 354s), so nothing here
outweighs agreeing with the pod. Re-measure on a quiet box before moving it, and
move the pod's setting with it.
- **Prebuilding the shared floor does NOT help, and was measured twice.** 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 (`go build <root>`) → 327s
(`go build <root>/apps/...`, all 4149 packages). One process on a
dependency-shaped graph leaves the box idle longer than the duplication costs.
Recorded in `mk/fleet.mk` so it is not re-derived.
- **A solo build is J=1, so it gets the whole box**: `mk/go.mk` sets
`-p=$(NPROC)`. The runner injects `GOFLAGS=-p=2` into every job and `?=`
deliberately does not override it — the operator sizing the pod knows what it
holds.
- **`make -k`, not `set -e`.** The old loop stopped at the first failure and the
apps behind it never ran, which reports nothing, and nothing is
indistinguishable from passing. make continues and names each failed target.
- **The exemptions are exemptions from DESCRIBING, never from BUILDING.** kafka
(needs a live broker) and zen (coresident, no standalone mount) were skipped by
the only sweep that touched an app, so nothing ever compiled them — they could
stop linking on main with every gate green. `binaries` carries no exemptions;
`describe` builds those two and skips only the projection.
- `binaries``bin/<app>` (121). `dist``dist/<app>-<os>-<arch>` for every
platform, the publishable layout (4.1 GiB per platform).
## Framework doctrine
@@ -1069,8 +1164,8 @@ one before it.
judged only inside the products that app publishes, because every app binary
links cloud's core and therefore carries other subsystems' declarations.
**1491 of 1491 operations carry prose; 0 orphans.**
- **`openapi.yaml` is a GOLDEN, woven from the per-app subsets, and the ONE
artifact cloud publishes.** `make openapi` writes it (through the weave,
- **`openapi.yaml` is a GOLDEN, woven from the per-app subsets.** `make openapi`
writes it (through the weave,
`-weave`); `make test`, and therefore CI, verifies it with the same weave and no
flag (`TestFleetIsTheWeaveOfItsApps`, openapi/weave_test.go). Same code path both ways — there is no second
generator to disagree with, and no way to change a route without either
@@ -1078,6 +1173,51 @@ one before it.
because JSON is what the document IS (the same value served at
`/v1/openapi.json`); YAML is a rendering, and `encoding/json` orders object
keys so the bytes are stable run to run.
- **TWO PROJECTIONS OF THAT ONE DOCUMENT, AND THE SPLIT IS DECLARED**
(openapi/public.go). `openapi.yaml` is the INTERNAL document — everything the
fleet serves, admin included, and what our own clients are cut from.
`public.yaml` beside it is the PUBLIC contract: the operations that DECLARED
themselves part of it, and nothing else. Both are written by ONE run of the
weave (`-weave` writes the second beside whatever path it names), so they can
never describe two different commits.
- **`openapi.Public(path, method)` is the seam, and it is DEFAULT-DENY.**
`Register` declares an operation's bodies, `Describe` declares its prose,
`Public` declares its AUDIENCE — same law, same init, same inability to
invent an address. Silence means INTERNAL, so a product cannot reach a
published SDK by anyone forgetting; a whole product ships publicly only when
somebody writes the line. There is **no prefix list in the emitter**, and
that is the point: a prefix list is a second copy of the routing table, which
is how a case-sensitive path list let `/V1/EXEC` walk past a credential guard
and how a manifest prefix row disagreed with what an app served until
`/v1/tags` 404'd in production. `Publish` reads ONE per-operation fact and
knows nothing about paths, products, prefixes or case.
- **Keyed by the DOCUMENT's address, not the fiber pattern**, because the
largest public product has no fiber pattern here: hanzoai/ai reaches its whole
surface through one `All("/v1/*")`, so `/v1/models` exists only as an address
in the document its door hands over. Declarations are normalised through the
same `translate` the document is built with, so `:id` and `{id}` are one key.
- **Stamped once, at the END of `Spec`** — after `Fold` (which replaces a
structural operation with the typed one) and after `Project` (which replaces a
door with the registry behind it), both of which would discard a mark written
earlier. It rides as `x-public`, an extension rather than a tag, because the
tag axis already means PRODUCT and `compat` had to be filtered back out of it.
- **v1 is INFERENCE: 18 operations, 10 products** (apps/ai/public.go) — the
model catalog (`/v1/models`, `/v1/models/providers`) plus every model call:
chat/completions, completions, responses, messages (+count_tokens),
embeddings, rerank, images, videos (async create + poll + fetch) and the five
audio verbs. Against 1782 internal paths. The junk a route table carries and a
product surface does not — `/v1/openapi.json`, `/v1/event.js`, `/health`,
`/v1/commands` — is gone for FREE: nothing excludes it, it was never included.
- **The ratchet, split correctly.** `openapi/floor.json` keeps guarding the
INTERNAL document and only it; measuring the public projection would either
wedge CI on a shrink that is not a shrink, or re-base the floor to eighteen
paths and let every internal product vanish unnoticed.
`TestTheFloorGuardsTheInternalDocument` asserts both halves — the floor
accepts the internal document and REFUSES the public one. The public surface
needs no counting scheme of its own: it is small enough to compare WHOLE, so
the committed `public.yaml` is the ratchet, and the drift gate
(`mk/fleet.mk check`, porcelain-scoped to it) catches a shrink and a LEAK
alike.
- **SDK repos PULL; cloud does not push.** A stale spec does not stop at cloud —
it ships wrong clients to four package registries. The repos read
`openapi.yaml`, regenerate, and release on their own cadence.
@@ -1087,6 +1227,100 @@ one before it.
by the same run — one value in two places, not two sources of truth. It is
named `hanzo`, not `cloud`, because the binary serves the WHOLE /v1 surface.
### What the projections MEASURE, asked of the running deployment
Every number below was taken by making the request, not by reading the code. Two
of them refuted a claim that had been repeated confidently for weeks.
- **The published document IS the committed one.** `GET api.hanzo.ai/v1/openapi.json`
serves 1735 paths / 2474 operations, and the `openapi.yaml` committed at the
revision the deployment reports (`x-api-version: sha-8465354e`) has the SAME
1735 / 2474 — identical operation sets, not merely similar counts. The
lazy-host fallthrough that once made this address answer with 8 paths is gone.
Take the version off the header and diff against THAT commit's golden; diffing
against your branch's golden measures the deploy lag, not the defect.
- **`GET /v1/commands` is live**: 2448 commands over 194 services, under a strong
ETag that answers 304 to a matching `If-None-Match`.
- **`POST /v1/mcp` works end to end**: `tools/list` returns 88 tools —
`describe` plus ONE tool per subsystem, each carrying its operations in an
`op` enum — and `tools/call` on `describe` returns the prose zipdoc lifted
off the Go handler. 0 of the 88 have an empty description or a missing input
schema.
- **HALF the surface is not reachable as a tool, and only a tenth of that gap is
declared.** Those 88 tools address **1189 of the deployment's 2422 operations
(49%)**. The `_meta` names 134 as refused by the projection rule (a name that
discloses a bearer secret; a mutating verb on an identity or authority object)
and exactly ONE subsystem as unavailable (`x402`) — so roughly 1,100 operations
are absent with no stated reason. `ai` is the extreme: **1 op in its enum
against ~300 in the document.** An untyped route earns no tool by construction,
so most of this is the typed migration's remaining tail showing up in the one
projection where it is countable — but it is NOT all of it, and nothing today
tells the two apart. Count it per app before believing any "MCP is complete".
### zipdoc needs a router it can RESOLVE, and the gate accepts the gap
`zipdoc` resolves a typed op's path STATICALLY. Register on the `cloud.Router`
parameter and it cannot follow the interface to a prefix, so it refuses to lift —
and the refusal is silent in every gate downstream, because `openapi.Complete`
passes an operation carrying EITHER a summary or a description. `zip.WithSummary`
alone is therefore enough to be green and empty.
`POST /v1/exec` shipped exactly that way: a real doc comment on the handler, no
`//go:generate` directive in the package, no `zipdoc_gen.go`, and an operation
that reached the document, the SDKs and the tool list with a summary and no
prose. The fix is two lines — the directive, and `reg := cloud.ZipApp(app)` so the
registration is spelled where the generator can read it (the pattern `apps/meet`
and `apps/blueprint` already use). ROUTES move to the `*zip.App`; middleware stays
on the scoped router, where the prefix guard applies to it.
**The chain is countable end to end**: 51 operations carry no description →
51 of the 2448 commands at `/v1/commands` have an empty `Description` (49 `ai`,
2 `router`, all owned by `apps/ai`). One hole, three surfaces.
### The field surface is a different fact, counted in a different place
An app can be 100% typed and publish a wholly undescribed shape, because op prose
and FIELD prose are lifted from different comments. Measured on this commit:
**5110 of 11043 published properties (46%) carry no description, and 643 schemas
are 100% undescribed, across 51 of 121 apps.** Only 20 apps carry
`TestEveryPublishedFieldIsDescribed`, which is precisely why the number is that
large — the gate exists and does not run in 101 places.
One tranche closed here (343 properties): `authors` 39→0, `label` 37→0,
`channels` 34→0, `prompts` 29→0, `leaderboard` 40→0, `campaign` 42→6,
`affiliates` 123→10, `exec` 15→0.
- **A field reached through an EMBEDDED struct cannot be described today, and it is
a zip defect, not an app one.** zipdoc files a field's prose under the type that
DECLARES it; zip's schema builder inlines the embedding and looks the prose up
under the type that PROMOTES it (`zip@v1.27.0/openapi.go:701` keys
`fields[t.Name()+"."+name]`). The two never meet. Found twice, independently:
`envelope.msg` vs `directoryOut.msg` — every enveloped `*Out` in the fleet
publishes its `msg` and `status` bare, **167 properties, 3% of the backlog**
and `campaignWrite.audience` vs `campaignUpdate.audience`, where
`internal/zipdoc/extract.go:634` additionally skips the embedded field outright
because `campaignWrite` is unexported. Write the comment on the embedded struct
anyway; do NOT unroll it into hand-copied field pairs, which would duplicate the
shape the type exists to share AND break the embedding that keeps create and
update in step. The fix is one change in zip: `structFields` must recurse into an
embedded struct and re-emit under the OUTER type's name.
- **A defined type over another struct publishes NOTHING.** `type campaignRecord
Campaign` has no struct literal of its own, so zipdoc emitted nothing for it and
all 30 of its properties published bare. Declare the struct under its published
name and make the domain name an ALIAS (`type Campaign = campaignRecord`) — same
type, one shape, and the prose lands.
### There is no hand-authored copy of the surface left
`docs/automations-openapi.yaml` was a 25 KB hand-written OpenAPI document
describing 17 operations of `/v1/automations`. Nothing referenced it and no gate
compared it, so it had drifted exactly as 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 (`POST
/v1/automations/connectors/{id}/run`, `/flows/{id}/versions`,
`/hooks/{source}/{event}`). Deleted. The reference pages at docs.hanzo.ai are
generated per product from this document; nothing else here describes an endpoint.
## The typed migration: THE PLAYBOOK (start here before typing anything)
Worked end to end on `apps/agents/targets.go` (5 ops). Follow it and a partition
@@ -2887,6 +3121,39 @@ semantic is identical — fail closed once armed, allow before.
because a silently-short list and a stale file are the same defect. A `tools/call`
goes to the app that listed the name, verbatim; a name nobody has listed costs one
discovery, then `-32602`.
**The door publishes ONE TOOL PER SUBSYSTEM, not one per operation** (`fleet/grouped.go`).
Measured on the deployed door: the flat projection was **1,189 tools in 977,636
bytes** — ~244k tokens to merely enumerate what can be called — and MCP clients
truncate (Slack keeps 128), so 1,061 operations were unreachable no matter how
they were ordered. Ordering (`rank`) fixes which tools a truncating client keeps;
it cannot fix a hard cap. So the surface is ONE TOOL PER APP, NAMED FOR IT, carrying
`{"op":"<operation>","input":{…}}`, whose `op` enum holds NAMES ONLY, plus
`describe` — which returns one operation's own descriptor, so a model
searches the enum and fetches the schema for the one it picked. The whole
corpus is **118 tools in 106,282 bytes** (`fleet.TestTheWholeFleetFitsInAModelsHead`,
which builds it from `plugin/*/openapi.json`) — 17× less per operation, for 1.9×
MORE operations than the baseline carried. `describe` is FIRST because it is
what makes every other tool usable, so truncation must never take it. The envelope
is a DECODING and not a second route: it yields the (name, message) a direct call
carries, and `refuse()` in `gather` remains the only gate, so a refused name is in
no enum, dispatchable through no envelope, and describable by nothing.
**Headroom: 10 subsystems.** 118 of the 128 a client keeps. The manifest is 121
apps and growing, so the next ten subsystems put the door back over the cap; the
move then is to group by product surface (`productStems`, 17 buckets), not to add
a second projection.
**The tools carry no prefix.** They shipped as `hanzo_<app>` + `hanzo_describe`
and were renamed hours later to the bare app names + `describe`, in one change
with no aliases. The MCP server is the namespace — a client reaches these names
through it and through nothing else — so `hanzo_` disambiguated nothing and cost
a token per tool per turn. Two consequences worth knowing before touching this:
the prefix was also how `composed()` told one of the door's own tools from a
child's operation, which is now an exact membership test against the app set
(`Door.composed`); and the door's tools and the app names now share one
namespace, so **no subsystem may be named `describe`**
`fleet.TestNoSubsystemIsCalledDescribe` reads the manifest and fails the build if
one ever is. A door still on an older image answers the prefixed names; the
refused set is untouched, because `refuse()` reads CHILD operation ids and never
saw the door's own names.
It used to read a BUILD-TIME catalogue — `plugin/<app>/mcp.json`, embedded by
`plugin/embed.go` and handed to zip as `Plugin.Tools` — and `tools/list` was a
memcpy. **Those 116 files are deleted (49,865 lines).** They were a second source
@@ -4549,7 +4816,7 @@ interchangeable and are not:
| relative position | 187 ahead of inc HEAD | 16 ahead of forge/main |
Production runs `ghcr.io/hanzoai/cloud:sha-8465354e6bf3`, and its live behavior —
88 grouped tools, `hanzo_describe` first, 134 refused — exists **only on the inc
88 grouped tools, `describe` first, 134 refused — exists **only on the inc
line**. An earlier draft of § 6 and § 9 was written against a forge-line checkout
and concluded the agent door withholds nothing and that nothing in cloud calls
it. Both were false in production, and false in the dangerous direction:
@@ -4885,7 +5152,7 @@ irreversible.
**The tool surface already refuses, and it refuses our own agents too.**
Measured on the live door, `POST https://api.hanzo.ai/v1/mcp` `tools/list`:
88 tools in 63,468 bytes, the first of them `hanzo_describe`, and
88 tools in 63,468 bytes, the first of them `describe`, and
```
_meta["hanzo.ai/refused"] = {count: 134, rule: "a tool is not projected when its
@@ -5023,9 +5290,9 @@ or forgotten. Two properties come free and both matter here:
forwarded, because a scheduled run has no inbound request and a nested one may
be running for a different principal.
Every rung of § 4 is already on that door: `hanzo_analytics` publishes
`get_v1_errors`, `hanzo_o11y` publishes 318 ops including the review queues,
and `hanzo_tracker` and `hanzo_help` are both projected. So the ladder needs no
Every rung of § 4 is already on that door: `analytics` publishes
`get_v1_errors`, `o11y` publishes 318 ops including the review queues,
and `tracker` and `help` are both projected. So the ladder needs no
new tool surface — only the policy about which rung may be pulled without a
human, which is § 4's job and not the door's.
@@ -5096,3 +5363,83 @@ from under its owner costs their work.
ours names this pod", so a second cloud pointed at the same `SANDBOX_NAMESPACE`
would read the first one's live sandboxes as orphans. Same invariant the stores
already have; stated where breaking it deletes something.
## Metasearch: an engine that says nothing is either empty or BLIND
The browser service is REAL and the code said it was not. `apps/crawl` opened
with "that service was named in config but did not exist —
crawl.hanzo.svc.cluster.local was NXDOMAIN", and browser.go promised escalation
was best-effort "while the browser is not deployed, which is the state this ships
in". Measured: `svc/crawl` 10.124.54.223:11235, pod Running, `/health` 200,
image `ghcr.io/hanzoai/crawl:sha-7b8dc59`. It has been up for days. It needs the
bearer token that reaches both pods from KMS as `crawl-secrets/CRAWL_API_TOKEN`
unauthenticated it answers `{"detail":"Authentication required"}`, so a missing
token is not a degraded render, it is no render.
**The defect that hid everything else: zero results was not a value.**
`fetchEngine` returned `([]webResult, error)` and a bot-challenge page came back
`(nil, nil)` — the same value as a query the web has no answer for. An engine
could stop working entirely and the only symptom was a slightly shorter page.
That is how Brave was dropped rather than fixed and how DDG sat in the default
set contributing nothing. `outcome.go` gives an engine's turn three states:
`answered`, `blind` (the fetch succeeded and the parser read NOTHING), `failed`
(never reached, so it says nothing about the parser).
**Zero is blind per engine, always, because no engine can be trusted to report
its own emptiness.** Bing has no zero state at all: asked three distinct nonsense
strings it returned ten results each time — Edmonton property tax, Bastille Day,
Microsoft support — and for a fourth, pornography. DDG and Mojeek do return zero,
but also return zero when serving a captcha, which is the case worth catching.
The genuine-empty case is recovered where its evidence actually lives, ACROSS
engines: blind while a sibling answered the same query is proof the query has
results and that engine cannot see them.
Two instruments, deliberately different widths. The counter
`hanzo_websearch_engine_total{engine,outcome}` records every turn (nine series,
bounded by construction — the query is never an attribute); an operator reads the
RATIO. The Warn line fires only on the confirmed fault, so it stays worth
reading. `browsed` rides along because it decides who is woken: false is
configuration, true means a real browser drew the page and our parser still read
nothing — selector rot, the loudest signal this package has.
**DDG's challenge is an HTTP 202, and that one fact cost us the engine.** The
fetch accepted only 200, so the challenge became a transport error, the transport
error short-circuited past the escalation, and the engine the browser was
deployed to rescue was the one that could never reach it — `ddg=failed` on every
live query. Any 2xx is now parsed, and there is ONE remedy for "nothing readable
came back" whichever way it happened: render it. Live, from cluster egress, that
turned `[bing=answered(10) ddg=failed(0) mojeek=answered(20)]` into all three
answering, and pushed `post.ca.gov/Training` off the top of "post quantum
cryptography lattice" (now redhat's lattice-based-cryptography, then Wikipedia).
**GOOGLE IS NOT VIABLE FROM THIS NETWORK. Do not add it.** Every path returns the
`/sorry/` interstitial — ~6KB, 19 captcha markers, "unusual traffic", zero
results: headless Crawl with stealth on, `&udm=14`, `&gbv=1`, and a HEADFUL
Chrome 150 on a real X display (`bot-browser`) from a second egress IP. The
control rules out the technique — that same headful browser reads DDG's ten
results and loads google.com's homepage normally (268KB, title "Google", no
captcha). Only `/search` is refused, from two different node IPs, headless and
headful alike. That is reputation attached to datacenter addresses, so no browser
flag reaches it; the fix would be residential egress, which is a different
decision than a parser.
**crawl, not bot-browser, is the instrument.** They measured IDENTICALLY on both
engines, so bot-browser buys nothing on capability and costs concurrency (one
shared headful Chrome behind a single CDP endpoint), tenancy (one cookie jar for
every caller) and a websocket client cloud does not have. bot-browser is an
interactive instrument — VNC 5900 / noVNC 6080 exist so a person can watch a bot
session — and driving search through it would serialize every query through one
browser.
Costs, measured from cluster egress: a browser render of a DDG result page is
**~1.13s** (1119/1137/1160ms, 10 results each), against 400ms1.4s for a whole
static three-engine blend. Escalation is worth paying on zero and not before,
which is what render.go already did. A REFUSAL is still worth a render because
the browser leaves the cluster from a different node than cloud does — but it
will not rescue a refusal aimed at the browser's own address (asked to render a
URL that had just answered it 403, Crawl returned 0 bytes).
Watch for `WEBSEARCH_ENGINES` in universe: it is set to `bing,ddg`, which
excludes Mojeek — an independent index and, with DDG, one of the two engines that
honours `site:` (`site:x.com openai` → 20 real x.com URLs on Mojeek, 10 on DDG,
`blind` on Bing). The in-code default is now all three.
+34 -11
View File
@@ -90,7 +90,7 @@ APP_BINS := $(addprefix bin/,$(APPS))
# 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 test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke zipdoc-check test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -262,10 +262,25 @@ 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
@@ -273,9 +288,13 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
# 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. Same rule the source-walking
# gates in Go state (typed_request_gate_test.go, orgns_test.go).
@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
# 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
@@ -293,9 +312,7 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
@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 check"
@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
$(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:
@@ -312,6 +329,12 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
# 3. the weave composes those subsets into openapi.yaml (openapi/weave.go),
# refusing when two apps claim one path or one schema name. There is no
# monolith left to read: the woven document IS the published spec.
# 4. the same run projects that document twice and writes public.yaml beside it
# (openapi/public.go): the PUBLIC contract, which is the operations that
# DECLARED themselves part of it and nothing else. Default-deny — an
# operation that says nothing is internal, so a product cannot reach a
# published SDK by anyone forgetting. openapi.yaml stays the internal
# document, admin included, and is what our own clients are cut from.
#
# openapi.yaml is a golden file: written here, and verified two different ways —
# and the difference between them is the whole lesson.
+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)
}
}
+60 -4
View File
@@ -1,6 +1,10 @@
package cloud
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/apps/sites"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
@@ -29,16 +33,18 @@ import (
// the kernel answers which process is calling, and the boundary's findings travel
// with the request.
//
// name is what the program calls itself in a diagnostic. tools is the MCP surface,
// which only a program holding a subsystem list can project — everyone else passes
// nil and serves none.
// name is what the program calls itself in a diagnostic. tools is the per-caller
// half of this program's agent door — the tools that exist because of WHO is
// asking, which only a program holding a subsystem list can declare; everyone
// else passes nil and offers none. The door itself is not optional either way:
// see [callerTools].
func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
app := zip.New(zip.Config{
AppName: name,
Logger: deps.Logger,
ReadBufferSize: cfg.ReadBufferSize,
BodyLimit: cfg.BodyLimit,
MCP: zip.MCPConfig{Source: tools},
MCP: zip.MCPConfig{Source: callerTools(tools)},
// Cloud's refusal renderer, in place of zip's default — which reads only a
// *zip.HTTPError and answers 500 for everything else, so a propagated 402
// or 403 reached the console as a dead card. See errmap.go.
@@ -150,6 +156,56 @@ func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
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.
+266 -77
View File
@@ -370,9 +370,14 @@ type affiliateStanding struct {
// to a caller that has not applied.
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
// Handle is the opt-in public leaderboard name; empty means opted out.
Handle *string `json:"handle,omitempty"`
ID string `json:"id,omitempty"`
IsAffiliate bool `json:"isAffiliate"`
Handle *string `json:"handle,omitempty"`
// ID is the affiliate's server-minted handle, "aff_"-prefixed — what staff
// approve, suspend, re-rate and pay against. Absent until the org applies.
ID string `json:"id,omitempty"`
// IsAffiliate says whether the caller org has an affiliate record at all. It is
// the ONE field an org that never applied gets besides defaultRateBps: on false,
// read nothing else here — every other field is absent, not zero.
IsAffiliate bool `json:"isAffiliate"`
// Link is the shareable ?aff URL; empty until a code is minted.
Link *string `json:"link,omitempty"`
// MarginBps is the platform gross-margin fraction commission is a rate OF.
@@ -386,9 +391,15 @@ type affiliateStanding struct {
// RateBps is the affiliate's own direct commission rate, in basis points.
RateBps *int64 `json:"rateBps,omitempty"`
// ReferredCount is how many orgs this affiliate has referred.
ReferredCount *int `json:"referredCount,omitempty"`
ReferredCount *int `json:"referredCount,omitempty"`
// RequestedCode is the vanity code asked for at apply time — a request, not an
// allocation. Approval mints `code`, which may be a different slug if this one
// was already taken.
RequestedCode *string `json:"requestedCode,omitempty"`
Status string `json:"status,omitempty"`
// Status is "applied", "approved" or "suspended". Only an approved affiliate has
// a code that resolves for attribution and accrues commission; suspended keeps
// what it already earned but stops earning more.
Status string `json:"status,omitempty"`
}
// standing answers the caller org's OWN affiliate standing: status, referral
@@ -449,9 +460,18 @@ func (o ops) standing(ctx context.Context, _ *noInput) (*affiliateStanding, erro
// level (1=direct, 2, 3), the commission rate paid at that level, and how many orgs
// sit at that level below the affiliate.
type levelView struct {
Level int `json:"level"`
RateBps int64 `json:"rateBps"`
DownlineCount int `json:"downlineCount"`
// Level is the upline distance from the org whose spend is being shared: 1 is
// the direct referrer, 2 and 3 the referrers above it. Nothing accrues past 3.
Level int `json:"level"`
// RateBps is the commission paid at this level, in basis points OF Hanzo's
// margin (2000 = 20% of margin, never of the customer's bill). Level 1 is the
// affiliate's own negotiated rate; 2 and 3 are platform switches read live, so
// this is the schedule actually in force, not one compiled in.
RateBps int64 `json:"rateBps"`
// DownlineCount is how many orgs sit exactly this many hops below the caller. It
// is 0 in the schedule quoted to a caller that has not applied, which has no
// downline to count.
DownlineCount int `json:"downlineCount"`
}
// affiliateSelf is the richer /me self-view: the standing plus the downline
@@ -459,25 +479,53 @@ type levelView struct {
// enrolled and the not-enrolled shape; a non-enrolled caller gets the level
// SCHEDULE instead of a downline, so the console can show what it would earn.
type affiliateSelf struct {
AccruedCents *int64 `json:"accruedCents,omitempty"`
Code *string `json:"code,omitempty"`
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
// AccruedCents is lifetime commission accrued, in cents. It only grows — a
// payout is recorded against paidCents and never reduces this.
AccruedCents *int64 `json:"accruedCents,omitempty"`
// Code is the minted referral code, the slug the ?aff link carries. Absent until
// staff approve; codes live in ONE global namespace across all affiliates.
Code *string `json:"code,omitempty"`
// DefaultRateBps is the direct rate a new affiliate starts at, in basis points
// of margin (2000 = 20%). Answered ONLY to a caller that has not applied, as the
// quote beside `schedule`.
DefaultRateBps int64 `json:"defaultRateBps,omitempty"`
// DownlineTotal counts every org in the caller's downline across the levels.
DownlineTotal *int `json:"downlineTotal,omitempty"`
Handle *string `json:"handle,omitempty"`
ID string `json:"id,omitempty"`
IsAffiliate bool `json:"isAffiliate"`
DownlineTotal *int `json:"downlineTotal,omitempty"`
// Handle is the opt-in public leaderboard name. Empty means opted out: the
// caller keeps its rank and still sees its own row, it is just not listed.
Handle *string `json:"handle,omitempty"`
// ID is the affiliate's server-minted handle, "aff_"-prefixed. Absent until the
// org applies.
ID string `json:"id,omitempty"`
// IsAffiliate says whether the caller org has an affiliate record. On false the
// answer carries the rate SCHEDULE and the default rate instead of a downline,
// so the console can show what the caller would earn.
IsAffiliate bool `json:"isAffiliate"`
// Levels is the caller's downline per upline level, with the rate paid there.
Levels []levelView `json:"levels,omitempty"`
Link *string `json:"link,omitempty"`
MarginBps *int64 `json:"marginBps,omitempty"`
PaidCents *int64 `json:"paidCents,omitempty"`
Payouts *[]remittance `json:"payouts,omitempty"`
PendingCents *int64 `json:"pendingCents,omitempty"`
RateBps *int64 `json:"rateBps,omitempty"`
Levels []levelView `json:"levels,omitempty"`
// Link is the shareable ?aff URL built from the code. Empty until a code is
// minted, since there is nothing to share before approval.
Link *string `json:"link,omitempty"`
// MarginBps is the platform gross-margin fraction, in basis points, that every
// rate here is a rate OF. Read live per request, so it is the value in force
// now, not the one that applied to commission already accrued.
MarginBps *int64 `json:"marginBps,omitempty"`
// PaidCents is lifetime commission already paid out, in cents — credits grants
// and record-only cash disbursements alike.
PaidCents *int64 `json:"paidCents,omitempty"`
// Payouts is the payout history, newest first, bounded to the last 100 rows.
Payouts *[]remittance `json:"payouts,omitempty"`
// PendingCents is accrued minus paid, in cents — what the platform still owes
// and the ceiling on the next payout. Never negative.
PendingCents *int64 `json:"pendingCents,omitempty"`
// RateBps is the caller's OWN direct (level 1) commission rate, in basis points
// of margin. Levels 2 and 3 are platform-wide and appear in `levels`.
RateBps *int64 `json:"rateBps,omitempty"`
// Schedule is the rate schedule quoted to a caller that has not applied.
Schedule []levelView `json:"schedule,omitempty"`
Status string `json:"status,omitempty"`
// Status is "applied", "approved" or "suspended"; absent for a caller that never
// applied. Only "approved" mints links and accrues.
Status string `json:"status,omitempty"`
}
// self answers the richer self-view: the same lifetime accrued, pending and paid
@@ -574,12 +622,25 @@ type applyRequest struct {
// call made the row, and the answer's status states the same fact on the wire —
// 201 for the first apply, 200 for a re-apply.
type application struct {
Code string `json:"code"`
Created bool `json:"created"`
ID string `json:"id"`
RateBps int64 `json:"rateBps"`
// Code is the minted referral code. Empty on a first apply — applying does not
// mint a code, approval does; a re-apply echoes whatever the row already holds.
Code string `json:"code"`
// Created says whether THIS call made the row. false means the org had already
// applied and nothing changed — no second row, no reset of an existing approval.
// The HTTP status states the same fact: 201 when true, 200 when false.
Created bool `json:"created"`
// ID is the affiliate's server-minted handle, "aff_"-prefixed — the id staff
// approve, suspend, re-rate and pay against.
ID string `json:"id"`
// RateBps is the direct (level 1) commission rate the row carries, in basis
// points OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill).
RateBps int64 `json:"rateBps"`
// RequestedCode echoes the vanity code asked for, normalized to lower case. It
// is a request only: approval mints a different slug if this one is taken.
RequestedCode string `json:"requestedCode"`
Status string `json:"status"`
// Status is "applied" for a row this call created. A re-apply echoes the
// existing row's status, which may already be "approved" or "suspended".
Status string `json:"status"`
}
// StatusCode states which declared status this answer is: 201 on the first
@@ -647,10 +708,19 @@ type attributeRequest struct {
// attribution is the recorded affiliate↔referred-org edge. Created states
// whether this call made it; the status states the same fact on the wire.
type attribution struct {
Code string `json:"code"`
Created bool `json:"created"`
CreatedAt int64 `json:"createdAt"`
ID string `json:"id"`
// Code is the affiliate code the edge was recorded under, normalized to lower
// case. On a re-post it is the code of the STANDING edge, which may differ from
// the one just sent — first touch wins.
Code string `json:"code"`
// Created says whether THIS call made the edge. false means the caller org was
// already attributed and nothing moved. The HTTP status says the same: 201 when
// true, 200 when false.
Created bool `json:"created"`
// CreatedAt is when the edge was FIRST recorded, Unix seconds UTC. On a re-post
// it is the original time, not now.
CreatedAt int64 `json:"createdAt"`
// ID is the attribution edge's server-minted handle, "afr_"-prefixed.
ID string `json:"id"`
}
// StatusCode states which declared status this answer is: 201 for a new edge,
@@ -746,7 +816,11 @@ func (o ops) attribute(ctx context.Context, in *attributeRequest) (*attribution,
// cloud.OK writes, stated as a type so a typed op can declare it. The customer
// /v1/affiliates surface stays bare JSON (read via the /cloud proxy + restGet).
type envelope struct {
Msg string `json:"msg"`
// Msg is an operator-facing note. Empty on every success here — it exists
// because the console's admin unwrapper reads the shape cloud.OK writes.
Msg string `json:"msg"`
// Status is "ok" on every 2xx from this surface; a failure is an HTTP error with
// zip's error body, not this envelope carrying a different word.
Status string `json:"status"`
}
@@ -763,12 +837,17 @@ type page struct {
// directoryData is the admin directory: every affiliate with its org exposed,
// plus the fleet summary.
type directoryData struct {
// Affiliates is one row per affiliate across the whole fleet, ORG EXPOSED,
// oldest first and bounded by the request's limit.
Affiliates []adminAffiliateView `json:"affiliates"`
Summary totals `json:"summary"`
// Summary tallies exactly the rows above — not the whole table — so a limit that
// truncates the page truncates the tally with it.
Summary totals `json:"summary"`
}
// directoryOut is the enveloped GET /v1/admin/affiliates answer.
type directoryOut struct {
// Data is the affiliate directory and its tally.
Data directoryData `json:"data"`
envelope
}
@@ -805,12 +884,22 @@ func (o ops) adminList(ctx context.Context, in *page) (*directoryOut, error) {
// referrerRow is one row of the top-referrers leaderboard on the analytics board.
type referrerRow struct {
Org string `json:"org"`
Code string `json:"code"`
Status string `json:"status"`
ReferredCount int `json:"referredCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
// Org is the partner's own org slug. Named only here, on the SuperAdmin board —
// the partner-facing leaderboard shows an opt-in handle and never an org.
Org string `json:"org"`
// Code is that affiliate's minted referral code; empty if it is not approved.
Code string `json:"code"`
// Status is "applied", "approved" or "suspended".
Status string `json:"status"`
// ReferredCount is how many orgs this affiliate is the DIRECT referrer of —
// its level-1 downline, not the whole three-level chain.
ReferredCount int `json:"referredCount"`
// AccruedCents is lifetime commission accrued, in cents. The board is sorted by
// this, descending.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is accrued minus paid, in cents — what is still owed to this
// affiliate. Never negative.
PendingCents int64 `json:"pendingCents"`
}
// topReferrersLimit bounds the leaderboard on the analytics board.
@@ -819,38 +908,68 @@ const topReferrersLimit = 25
// tally is the analytics board's fleet tally. pendingLiabilityCents is
// what the platform owes but has not paid.
type tally struct {
AccruedLifetimeCents int64 `json:"accruedLifetimeCents"`
Affiliates int `json:"affiliates"`
Approved int `json:"approved"`
PaidLifetimeCents int64 `json:"paidLifetimeCents"`
// AccruedLifetimeCents is all commission ever accrued, summed across every
// affiliate, in cents. It only grows; a payout does not reduce it.
AccruedLifetimeCents int64 `json:"accruedLifetimeCents"`
// Affiliates is how many affiliate rows the board read, at every status. The
// read is bounded at 1000 rows, so a larger fleet reports the bound.
Affiliates int `json:"affiliates"`
// Approved is how many of those rows are approved — the only ones whose code
// resolves for attribution and whose balance can grow.
Approved int `json:"approved"`
// PaidLifetimeCents is all commission ever paid out, in cents: credits grants
// plus record-only cash disbursements.
PaidLifetimeCents int64 `json:"paidLifetimeCents"`
// PendingLiabilityCents is accrued minus paid across every affiliate, in cents.
// Read it as money OWED and not yet disbursed — a liability, not spend.
PendingLiabilityCents int64 `json:"pendingLiabilityCents"`
}
// funnel is the referral conversion: referred orgs that have actually produced
// commission, over all referred orgs.
type funnel struct {
ConvertedOrgs int `json:"convertedOrgs"`
RatePct float64 `json:"ratePct"`
ReferredOrgs int `json:"referredOrgs"`
// ConvertedOrgs is how many distinct referred orgs have produced positive
// commission at least once — a referral that actually spent.
ConvertedOrgs int `json:"convertedOrgs"`
// RatePct is convertedOrgs over referredOrgs as a PERCENTAGE, 0100, and the one
// non-integer figure on this board. It is 0 when nothing has been referred yet,
// not undefined.
RatePct float64 `json:"ratePct"`
// ReferredOrgs is how many attribution edges exist fleet-wide — one per referred
// org, first-touch, so it is also the count of distinct referred orgs.
ReferredOrgs int `json:"referredOrgs"`
}
// levelSplit is the accrual liability broken out by upline level.
type levelSplit struct {
// L1Cents is lifetime commission accrued to DIRECT referrers, in cents.
L1Cents int64 `json:"l1Cents"`
// L2Cents is lifetime commission accrued one step above the direct referrer, in
// cents, at the platform-wide level-2 rate.
L2Cents int64 `json:"l2Cents"`
// L3Cents is lifetime commission accrued two steps above, in cents. Nothing
// accrues past level 3, so l1+l2+l3 is the whole accrual.
L3Cents int64 `json:"l3Cents"`
}
// referralBoard is the analytics board's data plane.
type referralBoard struct {
AccrualByLevel levelSplit `json:"accrualByLevel"`
Conversion funnel `json:"conversion"`
Summary tally `json:"summary"`
TopReferrers []referrerRow `json:"topReferrers"`
// AccrualByLevel splits the lifetime accrual across the three upline levels —
// how much of the liability comes from direct referrals versus the chain above.
AccrualByLevel levelSplit `json:"accrualByLevel"`
// Conversion is the funnel: referred orgs against those that actually earned.
Conversion funnel `json:"conversion"`
// Summary is the fleet tally — population by status, and lifetime accrued, paid
// and still-owed commission.
Summary tally `json:"summary"`
// TopReferrers is the 25 affiliates with the most lifetime accrued commission,
// descending, orgs named.
TopReferrers []referrerRow `json:"topReferrers"`
}
// referralsOut is the enveloped GET /v1/admin/referrals answer.
type referralsOut struct {
// Data is the referral board: leaders, funnel, tally and per-level liability.
Data referralBoard `json:"data"`
envelope
}
@@ -956,12 +1075,15 @@ func (a *approval) UnmarshalJSON(b []byte) error {
// affiliateData carries one affiliate row inside the admin envelope.
type affiliateData struct {
// Affiliate is the row as it stands AFTER the action that returned it. Its
// referredCount is 0 here: these single-affiliate answers do not run the count.
Affiliate adminAffiliateView `json:"affiliate"`
}
// affiliateOut is the enveloped single-affiliate answer approve, suspend and
// rate share.
type affiliateOut struct {
// Data carries the affiliate row the action just wrote.
Data affiliateData `json:"data"`
envelope
}
@@ -1055,12 +1177,16 @@ type disbursal struct {
// settlement is the recorded payout beside the affiliate's updated balances.
type settlement struct {
// Affiliate is the row re-read AFTER the payout, so its paidCents and
// pendingCents already account for the row beside it.
Affiliate adminAffiliateView `json:"affiliate"`
Payout remittance `json:"payout"`
// Payout is the payout row just recorded.
Payout remittance `json:"payout"`
}
// payoutOut is the enveloped POST /v1/admin/affiliates/:id/payout answer.
type payoutOut struct {
// Data is the recorded payout and the balances it left behind.
Data settlement `json:"data"`
envelope
}
@@ -1132,9 +1258,18 @@ func (o ops) adminPayout(ctx context.Context, in *disbursal) (*payoutOut, error)
// accruals reports one accrual run: sources swept, new commission accruals, and
// the OSS-author royalties the same spend read drove.
type accruals struct {
Accrued int `json:"accrued"`
// Accrued is how many NEW commission accruals this run created, counted across
// every upline level. The accrual is latched at most once per (affiliate, source
// org, period), so a re-run inside the same month reports 0 having changed
// nothing — 0 means "already accrued", not "failed".
Accrued int `json:"accrued"`
// RoyaltiesAccrued is how many OSS-author royalty accruals the SAME spend read
// produced in the sibling authors program. One read drives both.
RoyaltiesAccrued int `json:"royaltiesAccrued"`
Swept int `json:"swept"`
// Swept is how many source (referred) orgs the run visited, bounded at 500 per
// run. A source with no spend this period, or one whose spend could not be read,
// still counts as swept.
Swept int `json:"swept"`
// RoyaltyFailures is reported, not swallowed: a sweep that could not reach
// the royalty store must not read as one that found nothing owed. The count
// was already computed and then dropped on the floor, which is the same
@@ -1144,6 +1279,7 @@ type accruals struct {
// accrualsOut is the enveloped POST /v1/admin/affiliates/sweep answer.
type accrualsOut struct {
// Data is what the run did: sources visited, new accruals, royalties alongside.
Data accruals `json:"data"`
envelope
}
@@ -1299,19 +1435,47 @@ func emitAudit(s *cloud.Service[state], ctx context.Context, action string, a Af
// adminAffiliateView is one row in the SuperAdmin directory (org exposed).
type adminAffiliateView struct {
ID string `json:"id"`
Org string `json:"org"`
Code string `json:"code"`
// ID is the affiliate's server-minted handle, "aff_"-prefixed — the id the
// approve, suspend, rate and payout routes address.
ID string `json:"id"`
// Org is the partner's own org slug. It appears ONLY on this cross-tenant admin
// view; no partner-facing read ever names another org.
Org string `json:"org"`
// Code is the minted referral code, the slug the ?aff link carries. Empty until
// approval mints it. Codes are one global namespace across all affiliates.
Code string `json:"code"`
// RequestedCode is the vanity code the applicant asked for. A request, not an
// allocation: approval mints a different slug if this one was taken. Absent when
// none was asked for.
RequestedCode string `json:"requestedCode,omitempty"`
Status string `json:"status"`
RateBps int64 `json:"rateBps"`
ReferredCount int `json:"referredCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
CreatedAt int64 `json:"createdAt"`
ApprovedAt int64 `json:"approvedAt"`
SuspendedAt int64 `json:"suspendedAt"`
// Status is "applied", "approved" or "suspended". Only "approved" resolves for
// attribution and accrues; "suspended" stops future earning and claws nothing
// back.
Status string `json:"status"`
// RateBps is this affiliate's DIRECT (level 1) commission rate in basis points
// OF Hanzo's margin (2000 = 20% of margin, never of the customer's bill). Levels
// 2 and 3 are platform-wide switches and are not carried per affiliate.
RateBps int64 `json:"rateBps"`
// ReferredCount is how many orgs this affiliate is the DIRECT referrer of,
// counted from the attribution edges. It is 0 on the single-affiliate answers
// (approve, suspend, rate, payout), which do not run the count.
ReferredCount int `json:"referredCount"`
// AccruedCents is lifetime commission accrued, in cents. It only grows — a
// payout moves paidCents, never this.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is accrued minus paid, in cents: what is still owed, and the hard
// ceiling the next payout is reserved against. Never negative.
PendingCents int64 `json:"pendingCents"`
// PaidCents is lifetime commission already paid out, in cents — credits grants
// and record-only cash disbursements alike.
PaidCents int64 `json:"paidCents"`
// CreatedAt is when the org applied, Unix seconds UTC.
CreatedAt int64 `json:"createdAt"`
// ApprovedAt is when staff approved, Unix seconds UTC. 0 means never approved.
ApprovedAt int64 `json:"approvedAt"`
// SuspendedAt is when staff suspended, Unix seconds UTC. 0 means never
// suspended; it is not cleared by a later re-approval.
SuspendedAt int64 `json:"suspendedAt"`
}
func adminViewOf(a Affiliate, referred int) adminAffiliateView {
@@ -1325,12 +1489,24 @@ func adminViewOf(a Affiliate, referred int) adminAffiliateView {
// remittance is one row of an affiliate's payout history.
type remittance struct {
ID string `json:"id"`
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference,omitempty"`
Txn string `json:"txn,omitempty"`
CreatedAt int64 `json:"createdAt"`
// ID is the payout row's server-minted handle, "apo_"-prefixed.
ID string `json:"id"`
// AmountCents is the amount disbursed, in cents. It was reserved against pending
// commission atomically when recorded, so it never exceeds what was owed.
AmountCents int64 `json:"amountCents"`
// Method is how it was settled. "credits" issued a commerce grant into the
// affiliate org's own wallet; any other value (wire, paypal, check, …) is a
// RECORD of cash a human moved out of band.
Method string `json:"method"`
// Reference is the operator's settlement note — a bank id, a ledger ref. Free
// text, absent when none was given.
Reference string `json:"reference,omitempty"`
// Txn is the commerce ledger transaction id, set ONLY where a "credits" payout
// actually issued the grant. Absent for cash methods, which write no ledger row.
Txn string `json:"txn,omitempty"`
// CreatedAt is when the payout was recorded, Unix seconds UTC — when the balance
// moved, not necessarily when the cash landed.
CreatedAt int64 `json:"createdAt"`
}
func remittanceOf(p Payout) remittance {
@@ -1347,13 +1523,26 @@ func remittances(ps []Payout) []remittance {
// totals is the fleet tally for the admin directory.
type totals struct {
Total int `json:"total"`
Applied int `json:"applied"`
Approved int `json:"approved"`
Suspended int `json:"suspended"`
// Total is how many affiliate rows this page covered, at every status. It is the
// page, not the table: a limit that truncates truncates this too.
Total int `json:"total"`
// Applied is how many of those rows are still awaiting approval — no code, no
// accrual yet.
Applied int `json:"applied"`
// Approved is how many are approved: the only rows whose code resolves for
// attribution and whose balance can still grow.
Approved int `json:"approved"`
// Suspended is how many were suspended. What they already accrued stays accrued
// and stays payable.
Suspended int `json:"suspended"`
// AccruedCents is lifetime commission accrued summed over those rows, in cents.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is accrued minus paid summed over those rows, in cents — the
// outstanding liability across the page.
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
// PaidCents is lifetime commission already paid out summed over those rows, in
// cents.
PaidCents int64 `json:"paidCents"`
}
func (s *totals) add(a Affiliate) {
+82 -20
View File
@@ -80,9 +80,17 @@ const (
// ── 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
@@ -90,8 +98,13 @@ 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"`
}
// affiliateEarnings is the caller's commission ledger, or the honest
@@ -105,7 +118,10 @@ type affiliateEarnings struct {
// ByReferredOrg is each referral's aggregate contribution — the affiliate's
// OWN share, never the referred org's spend.
ByReferredOrg *[]orgEarningView `json:"byReferredOrg,omitempty"`
IsAffiliate bool `json:"isAffiliate"`
// 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.
@@ -171,24 +187,48 @@ func (o ops) earnings(ctx context.Context, _ *noInput) (*affiliateEarnings, erro
// (orgs attributed with this code), conversions (of those, how many produced a
// commission). Signups/conversions are DERIVED from the ledger, never stored.
type codeView 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"`
// 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"`
}
// 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 string `json:"status,omitempty"`
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:
@@ -262,6 +302,8 @@ type createLinkRequest struct {
// 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"`
}
@@ -359,6 +401,10 @@ type clickRequest struct {
// 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"`
}
@@ -410,6 +456,9 @@ type handleRequest struct {
// 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"`
}
@@ -456,11 +505,24 @@ func (o ops) setHandle(ctx context.Context, in *handleRequest) (*handleSet, erro
// 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"`
}
// affiliateBoard is the public board plus the caller's own exact rank.
+230 -26
View File
@@ -12,11 +12,62 @@ 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{
"page.limit": "Limit caps the rows returned. Absent or non-positive means the default of\n500; anything above 1000 is clamped to 1000.",
"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.",
@@ -25,6 +76,8 @@ func init() {
"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.",
@@ -32,82 +85,219 @@ func init() {
"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.",
"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.downlineTotal": "DownlineTotal counts every org in the caller's downline across the levels.",
"affiliateSelf.levels": "Levels is the caller's downline per upline level, with the rate paid there.",
"affiliateSelf.schedule": "Schedule is the rate schedule quoted to a caller that has not applied.",
"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.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.",
"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.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.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{
"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.",
"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{
"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).",
"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{
"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.",
"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{
"affiliateRef.id": "ID is the affiliate's server-minted handle, \"aff_\"-prefixed.",
"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.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.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"}`),
@@ -116,13 +306,18 @@ func init() {
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{
"clickRequest.code": "Code is the share-link code that was clicked. Body-only: the URL cannot\nsupply it.",
"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"}`),
})
@@ -130,14 +325,23 @@ func init() {
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"}`),
})
+248 -27
View File
@@ -196,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) ----
@@ -270,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,
}
}
@@ -368,6 +385,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// collision, not a precedence.
zip.Get(g, "/metrics", o.metrics)
zip.Get(g, "/activity", o.activity)
zip.Get(g, "/runs", o.orgRuns)
// Live agent-session control plane: /v1/agents/sessions[/...].
mountSessions(s, app)
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
@@ -445,6 +463,18 @@ type runList struct {
Runs []agentRunView `json:"runs"`
}
// orgRunsQuery pages the org's runs across every agent.
type orgRunsQuery struct {
// Limit caps how many runs come back, newest first. Absent, zero or out of
// range (1..200) reads as 50.
Limit int `json:"limit"`
// Status keeps only runs with this outcome ("ok" or "error"). Empty keeps
// both. It is the filter an operator reaches for first — "show me what broke"
// — and answering it here rather than by paging the whole history client-side
// is the difference between a usable feed and a download.
Status string `json:"status"`
}
// metricsQuery selects the dashboard window.
type metricsQuery struct {
// Range is the window to bucket: 24H, 7D or 30D. Anything else reads as 30D.
@@ -876,11 +906,41 @@ 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")
// hanzo.org, not a name of this package's own, because the TRACE PLANE reads
// exactly this key: apps/o11y/planesink.go planeOrg files each row under
// attrs["hanzo.org"] and falls back to the PLATFORM's org when it is absent.
// It is a per-span read — OTel children do not inherit a parent's attributes —
// so the correctly stamped HTTP span above this one buys the run nothing. A
// run that named its tenant in a private spelling was stored as the platform's
// own telemetry: invisible to the org-scoped read the console issues, and
// sitting in the platform's bucket with this tenant's tool names and users in
// it. One tenant attribute, the one the plane already reads.
span.SetAttributes(
attribute.String("hanzo.agent.name", a.Name),
attribute.String("hanzo.agent.org", a.Org),
attribute.String("hanzo.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.
@@ -892,12 +952,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)
@@ -950,19 +1023,40 @@ 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),
// The tenant, on this span too. A step that named no org was filed under
// the platform's, which put the middle of every run's waterfall in a
// bucket the tenant cannot read — the run above it and the tool calls
// below it were visible and the step joining them was not.
attribute.String("hanzo.org", org),
)
prompt := a.Instructions
if in := strings.TrimSpace(input); in != "" {
@@ -972,12 +1066,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)
@@ -988,25 +1108,51 @@ 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)
for i, m := range models {
req.Model = m
resp, attempts, err := completeWithRetry(ctx, ai, req)
// A model call that did not succeed first time, said out loud. Retries and
// failovers were previously invisible AS SUCH: each attempt produced its own
// chat span, so three attempts looked like three unrelated calls and the
// switch to the reliable model looked like an agent that had simply asked
// for a different one. The waterfall showed the cost and never the reason.
//
// It is an EVENT, not an attribute, because completeWithFailover runs once
// per ROUND of the tool loop — an attribute would be overwritten by every
// later round and the span would report only the last one, while events
// accumulate. And nothing at all is recorded for the ordinary case, so the
// presence of one of these always means something happened.
if attempts > 1 || i > 0 {
noteRetry(ctx, m, attempts, i > 0, err)
}
if err == nil {
return resp, m, nil
}
@@ -1023,25 +1169,48 @@ 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) {
// It returns how many attempts it MADE alongside the outcome, so the caller can
// record a retry as a retry. Counting inside is the only place the number is
// known — from outside, three attempts and three unrelated calls look identical.
func completeWithRetry(ctx context.Context, ai types.AIClient, req *types.ChatRequest) (*types.ChatResponse, int, 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
return resp, attempt + 1, nil
}
lastErr = err
if !errors.Is(err, types.ErrUpstreamBusy) {
return nil, err // permanent — do not burn retries repeating it
return nil, attempt + 1, err // permanent — do not burn retries repeating it
}
if attempt == maxAttempts-1 {
break
}
if err := sleepBackoff(ctx, attempt); err != nil {
return nil, err // context cancelled/expired mid-backoff
return nil, attempt + 1, err // context cancelled/expired mid-backoff
}
}
return nil, lastErr
return nil, maxAttempts, lastErr
}
// noteRetry records one model call that needed more than a first attempt, on
// whichever span is current — the step for a plain run, the same step for every
// round of a tool loop.
//
// The reason is carried when there is one: "it retried three times" and "it
// retried three times because the gateway kept answering 429" are different
// facts, and only the second tells an operator whether to look at us or at the
// upstream.
func noteRetry(ctx context.Context, model string, attempts int, failover bool, err error) {
attrs := []attribute.KeyValue{
attribute.String("gen_ai.request.model", model),
attribute.Int("hanzo.agent.model_attempts", attempts),
attribute.Bool("hanzo.agent.failover", failover),
}
if err != nil {
attrs = append(attrs, attribute.String("hanzo.agent.retry_reason", err.Error()))
}
trace.SpanFromContext(ctx).AddEvent("model retry", trace.WithAttributes(attrs...))
}
// sleepBackoff waits an exponential, equal-jittered delay before the next
@@ -1096,6 +1265,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)
}
+95
View File
@@ -0,0 +1,95 @@
package agents
import (
"testing"
"github.com/hanzoai/cloud"
)
// An org that connected Slack and did nothing else has NO agent rows, and the
// bridges ask for the conventional ref — so without a built-in default @hanzo
// answers "the agent hit an error handling that" in every fresh workspace.
func TestBuiltinResolvesTheConventionalRef(t *testing.T) {
a, ok := builtinAgent("acme", "hanzo", "zen-70b")
if !ok {
t.Fatal("the conventional ref must resolve to the built-in default")
}
if a.Org != "acme" {
t.Errorf("the default must be scoped to the asking org, got %q", a.Org)
}
if a.Model != "zen-70b" {
t.Errorf("the default must use the deployment's model, got %q", a.Model)
}
// The default declares the fleet's whole door. The tool loop still decides what
// is OFFERED per run, but an agent that declares nothing is offered nothing —
// which is how the assistant came to report it could not reach a cloud that was
// one socket away.
if len(a.Tools) != 1 || a.Tools[0] != ToolsAll {
t.Errorf("the default must declare the whole door (%q), got %v", ToolsAll, a.Tools)
}
if a.Instructions == "" {
t.Error("the default must know what it is")
}
}
// Case is not a reason to fail: Slack sends whatever the user typed.
func TestBuiltinIsCaseInsensitive(t *testing.T) {
if _, ok := builtinAgent("acme", "Hanzo", "m"); !ok {
t.Error("the ref must match case-insensitively")
}
}
// An UNKNOWN ref stays unknown. Silently substituting the chat agent would make
// a typo in `code: repo` run the wrong thing and look like it worked.
func TestUnknownRefIsStillAMiss(t *testing.T) {
for _, ref := range []string{"deployer", "hanzo-coder", "", "hanz"} {
if _, ok := builtinAgent("acme", ref, "m"); ok {
t.Errorf("%q must not resolve to the default", ref)
}
}
}
// No model configured is an honest miss, not a run that fails deeper in.
func TestNoModelIsAMiss(t *testing.T) {
if _, ok := builtinAgent("acme", "hanzo", " "); ok {
t.Error("with no model configured the default must not resolve")
}
}
// The chat brain is cloud.ChatModel — one constant in the file that owns model
// policy, not a literal here plus a BRIDGE_AGENT_MODEL knob beside it.
//
// The knob was never set in any deployment, and the literal was justified by the
// claim that enso auto-routes per query, which it does not. This test is the guard
// against a second place regrowing: there is exactly one line that names the tier
// and it is not in this package.
func TestBuiltinModelIsTheChatConstant(t *testing.T) {
a, ok := builtinAgent("acme", "hanzo", cloud.ChatModel)
if !ok {
t.Fatal("the conventional ref must resolve to the built-in")
}
if a.Model != cloud.ChatModel {
t.Errorf("the chat brain must be cloud.ChatModel (%q), got %q", cloud.ChatModel, a.Model)
}
if a.Model == cloud.FallbackModel {
t.Error(`"best" is the degraded fallback tier, never the interactive default`)
}
// The menu must be able to express the default, or a person who opens App Home
// sees a blank selector and their own model looks lost.
if !knownChatModel(cloud.ChatModel) {
t.Errorf("the App Home menu must offer the default tier %q", cloud.ChatModel)
}
}
// The App Home pin still wins over the default — a person's explicit choice is
// the one thing that may override it, and only for the built-in.
func TestAppHomePinBeatsTheDefault(t *testing.T) {
for _, m := range []string{"enso", "enso-flash", "enso-ultra"} {
if !knownChatModel(m) {
t.Errorf("App Home offers %q, so the turn must accept it", m)
}
}
if knownChatModel("gpt-4o") || knownChatModel("best") {
t.Error("only the enso family may be pinned from a client")
}
}
+465
View File
@@ -0,0 +1,465 @@
package agents
// door.go — where a run's tools come from once the fleet is more than one
// process: the fleet's OWN agent door, asked over the internal socket.
//
// # Why this is not a new mechanism
//
// The fleet already aggregates. fleet.Door asks every composed app what it
// serves right now, merges the answers, remembers which app listed which name,
// and forwards a tools/call to that app (fleet/mcp.go). It is what serves
// api.hanzo.ai/v1/mcp and what a Slack MCP client already talks to. Building a
// tools_catalog/tools_call op pair on the tool plane would have been a SECOND
// aggregation over the same children, with a second place for the curation rule
// to be applied — or forgotten.
//
// So nothing here aggregates. The host publishes the door it already built on
// the socket every child can already reach (cmd/cloud/wake.go), and an agent is
// simply another MCP client of it. Same JSON-RPC, same union, same order, same
// [fleet] denylist — which is enforced inside gather, where the routing table is
// written, so a name the door will not project is not routable for anyone. An
// agent therefore CANNOT see a surface an external client cannot; there is no
// second surface to see.
//
// # The address, and what "no door" means
//
// plane.HostApp is the router's own socket — the one plane.Reach dials to wake a
// cold app — and the door rides it at manifest.MCPPath. Reaching for it answers
// one of exactly three things, which is the rule plane/ask.go already states:
//
// listening ask it; this is production
// no listener THIS PROCESS IS THE FLEET — a single-app binary, a test, a dev
// box. Fall back to [registryTools], which is the real answer
// there and empty everywhere else.
// unusable an outage. Zero tools, recorded on the run's span, never
// laundered into "this fleet has no tools".
//
// # Identity is stated by the RUN, and the model never touches it
//
// The org and actor a dispatch carries are the run's own — the pair its fee is
// billed under — passed as arguments from executeRun and written onto the
// request as zip's identity headers here. The model contributes a tool NAME and
// an ARGUMENTS object and nothing else, so there is no path by which it can name
// a tenant. The inbound caller's headers are deliberately NOT forwarded: a
// scheduled run has no inbound request at all, and a nested one may be running
// for a different principal than whoever made the outermost HTTP call.
//
// The socket carries no credential and needs none: it is 0700 in the fleet's own
// run directory and the kernel attests the peer, which is the same trust
// zip.WithCaller rides on for every other internal call.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"github.com/hanzoai/cloud/fleet"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
"github.com/zap-proto/zip"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// doorTools is the tool plane read from the FLEET's composed agent door.
type doorTools struct{}
// errNoDoor reports that this process is not part of a fleet: nothing is
// listening on the router's socket, so there is no composed door to ask.
//
// It is the ONE error a caller may read as "fall back", exactly as plane.ErrNoPeer
// is on the peer plane. Every other failure is an outage and is reported as one —
// a door that is present and broken must never read as a fleet with no tools.
var errNoDoor = errors.New("agents: no fleet door on this host")
// catalog resolves the agent's declared names against the fleet's own surface.
//
// It asks the door WHAT IS OFFERED and then, for the handful of names this agent
// declared, what each one takes. Those are two questions because the door's
// tools/list answers only the first: it publishes one tool per subsystem, whose
// `op` enum carries the operation names and no schemas, since the flat list of
// this fleet's operations was 977 KB that no model can hold and every client
// truncates (fleet/grouped.go). fleet.Describe answers the second, one operation
// at a time, out of the same gathered set — so a declared name the door does not
// offer is simply absent, which is the same rule registryTools follows: offering
// a tool that would be refused at dispatch teaches the model a lie.
func (doorTools) catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef {
if org == "" || len(want) == 0 {
return nil
}
// ToolsAll is the ONE way to say "whatever the fleet serves", and it has to be
// said rather than implied.
//
// An agent that declares nothing gets nothing — that default is correct and
// stays, because a user-defined agent's tool list is its authority and an
// empty one means it asked for none. But the DEFAULT ASSISTANT cannot enumerate
// its tools: the door's surface is discovered at runtime (88 grouped tools
// today, and the whole point of grouping was that the set changes without a
// code edit), so any list written here would be stale the next time a
// subsystem ships.
//
// Not stating it cost a full turn of user-visible wrongness: the assistant was
// told in its instructions that it had tools and how to call them, then handed
// an empty offer by this function, so it correctly reported that it could not
// reach the cloud — while the door was serving 88 tools one socket away. The
// two halves have to agree, and this is the half that was missing.
all := false
for _, n := range want {
if strings.TrimSpace(n) == ToolsAll {
all = true
break
}
}
wanted := make(map[string]bool, len(want))
for _, n := range want {
if n = strings.TrimSpace(n); n != "" && n != ToolsAll {
wanted[n] = true
}
}
if len(wanted) == 0 && !all {
return nil
}
res, err := askDoor(ctx, org, actor, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if errors.Is(err, errNoDoor) {
return registryTools{}.catalog(ctx, org, actor, want)
}
if err != nil {
// An outage, and it is SAID so. The run continues with no tools — killing
// a turn the org has paid for because a sibling is down is the worse
// answer — but "declared 3, offered 0" is already a number on the step
// span, and this is the reason beside it.
trace.SpanFromContext(ctx).RecordError(err)
return nil
}
var listed struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
// Meta is the door's own account of why its list may be SHORT: the
// subsystems it could not ask, and how many names policy withheld. The door
// went to the trouble of never shortening quietly, so throwing it away here
// would put the silence back one layer down.
Meta json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(res, &listed); err != nil {
trace.SpanFromContext(ctx).RecordError(fmt.Errorf("agents: the fleet door's tools/list is not a tool list: %w", err))
return nil
}
offered := map[string]bool{}
for _, t := range listed.Tools {
for _, op := range opsOf(t.InputSchema) {
offered[op] = true
}
}
// ToolsAll offers the door's tools AS THE DOOR GROUPS THEM — one per subsystem,
// named for it, carrying an `op` enum, plus [fleet.Describe] — and not the ops
// flattened back out.
//
// The grouping is the whole reason the surface is affordable: 1,189 flat tools
// were 977 KB (~244k tokens) merely to LIST, and the same operations grouped
// are 88 tools in 63 KB. Expanding them here would hand back every byte the
// door just saved and blow the context before the question is read.
//
// It is also what the assistant's instructions describe — pick a subsystem,
// choose an op from its enum, call [fleet.Describe] for a shape you do not know.
// The prose and the offer have to be the same surface or the model is being
// taught a protocol it cannot practise.
if all {
out := make([]types.ToolDef, 0, len(listed.Tools))
for _, t := range listed.Tools {
out = append(out, types.ToolDef{
Name: t.Name, Description: t.Description, Schema: t.InputSchema,
})
}
return out
}
// In the agent's own declared order, which is the order the model meets them
// in, and once each however often it was declared.
out := make([]types.ToolDef, 0, len(wanted))
done := make(map[string]bool, len(wanted))
for _, n := range want {
n = strings.TrimSpace(n)
if !wanted[n] || done[n] || !offered[n] {
continue
}
done[n] = true
def, err := describe(ctx, org, actor, n)
if err != nil {
// It was offered a moment ago, so this is an outage between the two
// asks and not a refusal. Same policy as above: the turn goes on with
// one fewer tool, and the reason is on the span.
trace.SpanFromContext(ctx).RecordError(err)
continue
}
out = append(out, def)
}
// A declared name that resolved to nothing has two very different causes — a
// subsystem that is DOWN and a tool the fleet REFUSES to project — and the
// door already distinguishes them. Carrying its answer onto the span is what
// makes "declared 3, offered 1" diagnosable instead of a shrug.
if len(out) < len(wanted) && len(listed.Meta) > 0 {
trace.SpanFromContext(ctx).SetAttributes(
attribute.String("hanzo.agent.tools_meta", clip(string(listed.Meta), maxDoorMeta)))
}
return out
}
// opsOf reads the operation names out of one subsystem tool's schema — its `op`
// enum, which is where the door carries them.
func opsOf(schema json.RawMessage) []string {
var s struct {
Properties struct {
Op struct {
Enum []string `json:"enum"`
} `json:"op"`
} `json:"properties"`
}
if json.Unmarshal(schema, &s) != nil {
return nil
}
return s.Properties.Op.Enum
}
// describe fetches ONE operation's descriptor through the door's own
// fleet.Describe, and reads the owning subsystem's bytes back out of it.
//
// What the seam guarantees is that the model is offered exactly what it will
// CALL, and op is that name: it came out of a subsystem tool's `op` enum a
// moment ago, and a tools/call naming it reaches the operation's own handler.
// So the offer is named op, with the owner's own description and schema behind
// it.
//
// The descriptor's own `name` is NOT compared to op, and that is a change. The
// door publishes an operation as a verb on an object — `deploy_project` for
// `post_v1_projects_by_slug_deploy` (fleet/verbs.go) — while the descriptor it
// hands back is the owning subsystem's, carried verbatim, so it still says the
// id. Requiring the two to match would reject 1,730 of the fleet's 2,229
// operations for being correctly named.
func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error) {
args, err := json.Marshal(map[string]string{"op": op})
if err != nil {
return types.ToolDef{}, err
}
body, err := toolCallBody(fleet.Describe, string(args))
if err != nil {
return types.ToolDef{}, err
}
res, err := askDoor(ctx, org, actor, body)
if err != nil {
return types.ToolDef{}, err
}
text, err := toolResult(res)
if err != nil {
return types.ToolDef{}, err
}
var d struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name == "" || len(d.InputSchema) == 0 {
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
}
return types.ToolDef{Name: op, Description: d.Description, Schema: d.InputSchema}, nil
}
// maxDoorMeta bounds what one span attribute may carry: `_meta` names every
// subsystem that did not answer, and a fleet-wide outage would otherwise put a
// hundred rows on every run's trace.
const maxDoorMeta = 1024
func clip(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// call runs one tool through the door's own dispatch: the door names the app
// that listed it and forwards this message verbatim to that app's registry, so
// the host can only ever ROUTE a call and never invoke something the owner did
// not declare.
func (doorTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
body, err := toolCallBody(name, args)
if err != nil {
return "", err
}
res, err := askDoor(ctx, org, actor, body)
if errors.Is(err, errNoDoor) {
return registryTools{}.call(ctx, org, actor, name, args)
}
if err != nil {
return "", err
}
return toolResult(res)
}
// toolCallBody builds one MCP tools/call, with the model's arguments carried
// VERBATIM.
//
// The arguments are validated as a JSON OBJECT and then embedded unparsed: they
// belong to the tool that declared the schema, which is the only thing that
// knows how to read them, and re-encoding them here would be this process having
// an opinion about a shape it does not own. A model that emits something else is
// told so — the same sentence registryTools gives it — and the turn goes on.
func toolCallBody(name, args string) ([]byte, error) {
raw := json.RawMessage("{}")
if s := strings.TrimSpace(args); s != "" && s != "null" {
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(s), &probe); err != nil {
return nil, fmt.Errorf("arguments are not a JSON object: %w", err)
}
raw = json.RawMessage(s)
}
return json.Marshal(struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"params"`
}{
JSONRPC: "2.0", ID: 1, Method: "tools/call",
Params: struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}{Name: name, Arguments: raw},
})
}
// toolResult reads one MCP tool result into the text the model is handed.
//
// isError is a FAILURE and comes back as one, so dispatchOne renders it as a
// tool result the model can react to rather than as a success it would believe.
// That is the same distinction the door itself draws when a hop fails.
func toolResult(res json.RawMessage) (string, error) {
var out struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(res, &out); err != nil {
return "", fmt.Errorf("agents: the fleet door answered a tool result that will not decode: %w", err)
}
parts := make([]string, 0, len(out.Content))
for _, c := range out.Content {
if c.Text != "" {
parts = append(parts, c.Text)
}
}
text := strings.Join(parts, "\n")
if out.IsError {
// The tool's OWN sentence, so the model reads what actually went wrong.
if text == "" {
text = "the tool reported a failure with no message"
}
return "", errors.New(truncateToolResult(text))
}
return truncateToolResult(text), nil
}
// askDoor puts one JSON-RPC message to the fleet's door as (org, actor) and
// returns the `result` member.
//
// A JSON-RPC ERROR is an error here, deliberately: a tool the door will not
// route answers -32602, and folding that into an empty result would make "this
// tool is not yours to call" indistinguishable from "it ran and said nothing".
func askDoor(ctx context.Context, org, actor string, body []byte) (json.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
addr, err := doorAddr()
if err != nil {
return nil, err
}
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
req.Header.SetMethod(fasthttp.MethodPost)
req.Header.SetContentType("application/json")
req.SetHost(plane.HostApp)
req.URI().SetPath(manifest.MCPPath)
// The RUN's identity, in zip's own spelling, and nothing else. The door
// copies these onto every hop it makes, so a subsystem whose tools depend on
// the tenant answers for the org this run is billed to. A blank subject is a
// run with no person behind it (a schedule, a service token); the org is the
// authority either way and inventing a user would attribute the call to
// nobody.
req.Header.Set(zip.HeaderOrg, org)
if sub := actorSub(org, actor); sub != "" {
req.Header.Set(zip.HeaderUser, sub)
}
req.SetBody(body)
if err := doorClient(addr).Do(req, resp); err != nil {
return nil, fmt.Errorf("agents: the fleet door at %s did not answer: %w", addr, err)
}
if code := resp.StatusCode(); code < 200 || code > 299 {
return nil, fmt.Errorf("agents: the fleet door answered %d", code)
}
var env struct {
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(resp.Body(), &env); err != nil {
return nil, fmt.Errorf("agents: the fleet door answered something that is not JSON-RPC: %w", err)
}
if env.Error != nil {
return nil, errors.New(env.Error.Message)
}
return append(json.RawMessage(nil), env.Result...), nil
}
// doorAddr resolves the fleet door's socket, or says which of the two failures
// it is. See [errNoDoor].
//
// It probes by CONNECTING, because the file does not answer the question: a
// socket path outlives the process that bound it wherever the run directory is a
// volume. plane.Listening is the one implementation of that rule.
func doorAddr() (string, error) {
plane.Bind()
path := zip.SocketPath(plane.HostApp)
up, err := plane.Listening(path)
if err != nil {
return "", fmt.Errorf("agents: the fleet door's socket is unusable: %w", err)
}
if !up {
return "", fmt.Errorf("%w (%s)", errNoDoor, path)
}
return path, nil
}
// doorClients is one pooled transport per ADDRESS, for the reason fleet keeps
// one: a transport holds a connection pool, so dialing per ask turns every tool
// call into a fresh connect. Keyed by address rather than kept in a single var
// because a test points the run directory somewhere else.
var doorClients sync.Map // addr -> *zaphttp.Transport
func doorClient(addr string) *zaphttp.Transport {
if c, ok := doorClients.Load(addr); ok {
return c.(*zaphttp.Transport)
}
t := zaphttp.Dial("unix", addr)
// The whole run's ceiling, not the library's 30s. A tools/list is a fan-out
// across every composed app and the first ask of a cold one pays that app's
// startup, so a transport that gave up sooner than the run does would report
// an outage for a fleet that was merely waking up.
t.SetReadTimeout(toolRunBudget)
c, _ := doorClients.LoadOrStore(addr, t)
return c.(*zaphttp.Transport)
}
+314
View File
@@ -0,0 +1,314 @@
package agents
// door_test.go — the tool plane, over the wire it actually uses.
//
// Nothing is stubbed at the seam under test. Every test here brings up real
// subsystem apps on real ZAP sockets, composes the REAL fleet.Door over them,
// publishes it on the router's socket exactly as cmd/cloud/wake.go does, and
// then drives doorTools — so what is asserted is what a deployed agent gets.
//
// A fake door would have proved nothing: the two properties worth having are
// that the agent inherits the door's CURATION and that the run's org reaches the
// owning subsystem, and both live in code a stub would have replaced.
import (
"context"
"net"
"os"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/fleet"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
"github.com/zap-proto/zip"
)
type echoIn struct {
Say string `json:"say"`
}
type echoOut struct {
App string `json:"app"`
Say string `json:"say"`
Org string `json:"org"`
}
// subsystem starts one app serving the named ops on its own socket, the shape
// cloud.Serve gives every plugin binary. Each op echoes its input AND the org it
// was reached as, so a test can prove the run's tenant travelled the whole way.
func subsystem(t *testing.T, name string, ops ...string) string {
t.Helper()
sock := shortDir(t) + "/" + name + ".sock"
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
for _, id := range ops {
zip.Post(app, "/v1/"+name+"/"+id, func(ctx context.Context, in *echoIn) (*echoOut, error) {
return &echoOut{App: name, Say: in.Say, Org: zip.CallerOf(ctx).Org}, nil
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
}
go func() { _ = app.Listen(sock) }()
t.Cleanup(func() { _ = app.Shutdown() })
accepts(t, sock)
return sock
}
// fleetDoor composes the real door over those apps and puts it where a child
// looks for it — plane.HostApp's socket, at manifest.MCPPath. This is
// serveWake's two lines, not a reimplementation of them.
//
// at names each app's EDGE door and inside names the PLANE door of the ones that
// have one, which is the pair cmd/cloud registers (locate / inside). An app with
// no entry in inside is reached at its edge door — production's remotely-mounted
// case, where there is no local plane socket to reach.
func fleetDoor(t *testing.T, at map[string]string, inside map[string]string) {
t.Helper()
run := shortDir(t)
t.Setenv("ZIP_RUNTIME_DIR", run)
plane.Unbind()
t.Cleanup(plane.Unbind)
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true, MCP: zip.MCPConfig{Disabled: true}})
apps := make([]string, 0, len(at))
for name := range at {
apps = append(apps, name)
}
edge := func(app string) (addr, path string, err error) {
sock, ok := at[app]
if !ok {
return "", "", &net.AddrError{Err: "no instance running", Addr: app}
}
return sock, manifest.FrameworkMCPPath, nil
}
d := fleet.Mount(host, manifest.MCPPath, apps, edge)
door := zip.New(zip.Config{AppName: "plane", DisableStartupMessage: true})
d.Serve(door, manifest.MCPPath, func(app string) (addr, path string, err error) {
if sock, ok := inside[app]; ok {
return sock, manifest.MCPPath, nil
}
return edge(app)
})
path := zip.SocketPath(plane.HostApp)
go func() { _ = door.Listen(path) }()
t.Cleanup(func() { _ = door.Shutdown() })
accepts(t, path)
}
// noFleetDoor points the run directory at an empty one: nothing is listening, so
// this process is the whole fleet.
func noFleetDoor(t *testing.T) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", shortDir(t))
plane.Unbind()
t.Cleanup(plane.Unbind)
}
// shortDir is a temp directory with a SHORT name, because a unix socket path is
// capped near 104 bytes and t.TempDir() spends most of that on the test's name.
func shortDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func accepts(t *testing.T, sock string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if c, err := net.Dial("unix", sock); err == nil {
_ = c.Close()
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("%s never accepted", sock)
}
// TestAgentResolvesItsToolsFromTheFleetDoor is the whole claim: a declared name
// resolves to the OWNING subsystem's own descriptor, across a process boundary,
// with nothing in this binary that knows what that subsystem serves.
func TestAgentResolvesItsToolsFromTheFleetDoor(t *testing.T) {
fleetDoor(t, map[string]string{
"alpha": subsystem(t, "alpha", "alpha_echo", "alpha_other"),
"beta": subsystem(t, "beta", "beta_echo"),
}, nil)
door := doorTools{}
defs := door.catalog(context.Background(), "acme", "acme/u-1", []string{"alpha_echo", "beta_echo"})
if len(defs) != 2 {
t.Fatalf("the agent declared two tools the fleet serves and was offered %d: %+v", len(defs), defs)
}
got := map[string]bool{}
for _, d := range defs {
got[d.Name] = true
if d.Description == "" {
t.Errorf("%s came back with no description, so the model is offered a tool it cannot choose", d.Name)
}
if len(d.Schema) == 0 {
t.Errorf("%s came back with no schema, so the model cannot fill its arguments", d.Name)
}
}
if !got["alpha_echo"] || !got["beta_echo"] {
t.Fatalf("offered %v, want alpha_echo and beta_echo", got)
}
}
// A declared name NOTHING in the fleet serves is absent, never offered. Offering
// a tool that would be refused at dispatch teaches the model a lie.
func TestUnservedNamesAreNotOffered(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
defs := door.catalog(context.Background(), "acme", "acme/u-1",
[]string{"alpha_echo", "slack_post_message"})
if len(defs) != 1 || defs[0].Name != "alpha_echo" {
t.Fatalf("offered %+v, want alpha_echo alone", defs)
}
}
// TestTheAgentInheritsTheDoorsDenylist is the security bar, as a test.
//
// The curation rule lives in fleet/surface.go and is applied inside gather,
// where the routing table is written. An agent reaching the door through any
// other path would have seen a surface external MCP clients cannot — so this
// asserts BOTH halves: the credential-minting op is not offered, and naming it
// anyway does not run it.
func TestTheAgentInheritsTheDoorsDenylist(t *testing.T) {
fleetDoor(t, map[string]string{
"iam": subsystem(t, "iam", "CreateServiceAccountKey", "GetRole"),
}, nil)
door := doorTools{}
ctx := context.Background()
defs := door.catalog(ctx, "acme", "acme/u-1", []string{"CreateServiceAccountKey", "GetRole"})
if len(defs) != 1 || defs[0].Name != "GetRole" {
t.Fatalf("the agent was offered %+v; the door projects GetRole and refuses CreateServiceAccountKey", defs)
}
if _, err := door.call(ctx, "acme", "acme/u-1", "CreateServiceAccountKey", `{"say":"hi"}`); err == nil {
t.Fatal("a refused tool RAN for an agent that named it directly — the denylist is a suggestion, not a boundary")
}
}
// TestADispatchCarriesTheRunsOrg: the tenant reaches the subsystem that owns the
// tool, and it comes from the run rather than from anything the model emitted.
func TestADispatchCarriesTheRunsOrg(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
out, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `{"say":"pong"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
for _, want := range []string{`"app":"alpha"`, `"say":"pong"`, `"org":"acme"`} {
if !strings.Contains(out, want) {
t.Fatalf("alpha answered %q, which does not carry %s", out, want)
}
}
}
// guarded is one subsystem in its PRODUCTION shape, which is the shape the
// fixture above deliberately is not: the identity boundary in front of it, and its
// tool resolving the tenant the way every org-scoped op does — principal.OrgFrom,
// never a header. It returns its EDGE socket and its PLANE socket.
//
// The bare fixture answers zip.CallerOf directly, so it reports the org for any
// caller that names one. That is why every test above passed while every tool the
// @hanzo Slack agent called refused it: production has a boundary, the boundary
// deletes an authority header no credential backs, and nothing in this file had
// one.
func guarded(t *testing.T, name, op string) (edge, plane string) {
t.Helper()
cloud.ResetPlane()
t.Cleanup(cloud.ResetPlane)
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
cloud.Identify(app, &cloud.Config{})
zip.Post(app, "/v1/"+name+"/"+op, func(ctx context.Context, in *echoIn) (*echoOut, error) {
org, ok := principal.OrgFrom(ctx)
if !ok {
return nil, zip.ErrForbidden("X-Org-Id required")
}
return &echoOut{App: name, Say: in.Say, Org: org}, nil
}, zip.WithOperationID(op), zip.WithSummary("what "+name+" does at "+op))
cloud.Door(app)
dir := shortDir(t)
edge, plane = dir+"/"+name+".sock", dir+"/"+name+"-plane.sock"
go func() { _ = app.Listen(edge) }()
go func() { _ = cloud.Plane().Listen(plane) }()
t.Cleanup(func() { _ = app.Shutdown() })
accepts(t, edge)
accepts(t, plane)
return edge, plane
}
// TestATenantedToolAnswersTheRun is the @hanzo Slack failure, as a test: a run
// with a principal the fleet resolved server-side calls a tool that scopes by
// tenant, and gets an answer instead of `X-Org-Id required`.
//
// It is the same call TestADispatchCarriesTheRunsOrg makes, against a subsystem
// that has the boundary production has. Point the door's internal reach at the
// EDGE door instead and it fails exactly the way the deployed fleet did.
func TestATenantedToolAnswersTheRun(t *testing.T) {
edge, plane := guarded(t, "alpha", "alpha_tenant")
fleetDoor(t, map[string]string{"alpha": edge}, map[string]string{"alpha": plane})
out, err := doorTools{}.call(context.Background(), "acme", "acme/u-1", "alpha_tenant", `{"say":"pong"}`)
if err != nil {
t.Fatalf("a tool that scopes by tenant refused the run it belongs to: %v", err)
}
if !strings.Contains(out, `"org":"acme"`) {
t.Fatalf("alpha answered %q, which does not carry the run's tenant", out)
}
}
// A tool the door cannot route is an ERROR the model reads, never a silent empty
// result and never a killed turn.
func TestAnUnroutableToolIsAnErrorNotAnEmptyResult(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
out, err := door.call(context.Background(), "acme", "acme/u-1", "nobody_serves_this", `{}`)
if err == nil {
t.Fatalf("an unroutable tool answered %q with no error", out)
}
if got := dispatchOne(context.Background(), "acme", "acme/u-1",
types.ToolCall{ID: "c1", Name: "nobody_serves_this", Arguments: `{}`}, "run_test", 0); !strings.Contains(got, "error:") {
t.Fatalf("the model was handed %q for a tool that cannot run", got)
}
}
// Arguments that are not a JSON object are refused HERE, before the wire, and
// the model is told so — the same sentence the co-resident plane gives it.
func TestMalformedArgumentsNeverReachTheDoor(t *testing.T) {
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")}, nil)
door := doorTools{}
if _, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `["not","an","object"]`); err == nil {
t.Fatal("a JSON array was accepted as a tool's arguments")
}
}
// With no router on this host, this process IS the fleet: doorTools falls back
// to the in-process registry rather than reporting an outage — and a run in a
// single-app binary keeps working instead of crashing.
func TestNoFleetDoorFallsBackToThisProcesssRegistry(t *testing.T) {
noFleetDoor(t)
door := doorTools{}
ctx := context.Background()
if defs := door.catalog(ctx, "acme", "acme/u-1", []string{"alpha_echo"}); len(defs) != 0 {
t.Fatalf("this process serves no such tool, so nothing may be offered: %+v", defs)
}
if _, err := door.call(ctx, "acme", "acme/u-1", "alpha_echo", `{}`); err == nil {
t.Fatal("a tool nothing in this process registers reported success")
}
}
+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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`},
+681
View File
@@ -0,0 +1,681 @@
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"
"unicode/utf8"
"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
// result is what a named tool returns; anything unnamed returns a default so
// the common case stays a one-line fixture.
result map[string]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)
}
if out, ok := p.result[name]; ok {
return out, nil
}
return "result of " + name, nil
}
// toolGatewayArgs answers with ONE tool call carrying the given arguments
// verbatim, then a final answer — so a test can pin what a dispatch records about
// arguments it did not choose.
func toolGatewayArgs(t *testing.T, name, args string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
if strings.Contains(string(body), `"role":"tool"`) || !strings.Contains(string(body), `"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
}
call, _ := json.Marshal(args) // the arguments ride as a JSON *string*, as the wire has them
fmt.Fprintf(w, `{"id":"c1","model":"gpt-4o-mini","choices":[{"index":0,"finish_reason":"tool_calls",`+
`"message":{"role":"assistant","tool_calls":[`+
`{"id":"tc0","type":"function","function":{"name":%q,"arguments":%s}}]}}],`+
`"usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}`, name, call)
}))
}
// 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.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)
}
}
}
// TestToolCallRecordsWhatItDidWithoutItsCredential is the "exactly what steps it
// took" contract, and the one place where getting observability right and getting
// security right are the same edit.
//
// A dispatch used to record WHICH tool ran and nothing about what it ran WITH, so
// a trace could say an agent called post_v1_exec_run six times and never say what
// it executed. Recording the arguments closes that. But a tool argument routinely
// carries a live credential — a clone URL with a token in its userinfo is the
// ordinary shape apps/coding builds — and a span store is built to be queried and
// kept, so a trace that records one is worse than no trace.
//
// Both halves are asserted here together, because either alone is a bug: capture
// with no redaction leaks, redaction with no capture is the silence we started
// from.
func TestToolCallRecordsWhatItDidWithoutItsCredential(t *testing.T) {
sink := traced(t)
const token = "ghp_LIVE_TOKEN_VALUE"
const args = `{"cloneUrl":"https://x-access-token:` + token + `@github.com/acme/repo","branch":"main"}`
// The tool hands back a credential too — a result is as capable of carrying one
// as an argument, and it is recorded on the same span.
plane := &stubPlane{offer: []string{"post_v1_exec_run"}, result: map[string]string{
"post_v1_exec_run": `{"ok":true,"remote":"https://deploy:s3cr3t@git.example.com/x"}`,
}}
old := runTools
runTools = plane
t.Cleanup(func() { runTools = old })
gw := toolGatewayArgs(t, "post_v1_exec_run", args)
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_exec_run"}})
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "go"}); code != http.StatusOK {
t.Fatalf("run want 200, got %d (%s)", code, body)
}
sp, ok := sink.find("agent.tool post_v1_exec_run")
if !ok {
t.Fatal("the dispatch produced no span")
}
// CAPTURED: the call is legible as a call.
gotArgs := attr(sp, "gen_ai.tool.call.arguments")
if gotArgs == "" {
t.Fatal("the dispatch recorded no arguments — the trace cannot say what the tool was called with")
}
if !strings.Contains(gotArgs, "github.com/acme/repo") || !strings.Contains(gotArgs, "main") {
t.Fatalf("the recorded arguments lost the detail that makes them worth reading: %s", gotArgs)
}
gotResult := attr(sp, "gen_ai.tool.call.result")
if gotResult == "" {
t.Fatal("the dispatch recorded no result — the trace cannot say what came back")
}
// REDACTED: no credential reached the span, from either half.
for _, secret := range []string{token, "s3cr3t"} {
for _, where := range []struct{ name, v string }{{"arguments", gotArgs}, {"result", gotResult}} {
if strings.Contains(where.v, secret) {
t.Fatalf("a live credential reached the span in %s: %s", where.name, where.v)
}
}
}
// The non-secret half of the userinfo survives — it names HOW the call
// authenticated, which is worth reading and is not the secret.
if !strings.Contains(gotArgs, "x-access-token") {
t.Fatalf("redaction removed the authenticating user, not just its secret: %s", gotArgs)
}
}
// TestRecordedValueIsCutAndSaysSo: a tool argument larger than a span records is
// bounded, and the value itself states that it was — a silently clipped argument
// reads as the whole one, which is how an operator concludes a tool was called
// with something it never saw.
func TestRecordedValueIsCutAndSaysSo(t *testing.T) {
big := `{"blob":"` + strings.Repeat("x", maxRecorded*2) + `"}`
got := recordable(big)
if len(got) > maxRecorded+64 {
t.Fatalf("recorded value is %d bytes, want it bounded near %d", len(got), maxRecorded)
}
if !strings.Contains(got, "cut") {
t.Fatalf("a cut value must say it was cut, got tail %q", got[max(0, len(got)-40):])
}
if !utf8.ValidString(got) {
t.Fatal("cutting produced invalid UTF-8")
}
// A value that fits is returned whole, with no marker inviting a reader to
// wonder what is missing.
if small := recordable(`{"a":"b"}`); small != `{"a":"b"}` {
t.Fatalf("a value within the bound must pass through unchanged, got %q", small)
}
}
// TestEverySpanOfARunIsFiledUnderItsTenant is the one that decides whether any of
// the rest is visible.
//
// The trace plane files each row under ONE attribute — hanzo.org — read per span
// by apps/o11y/planesink.go planeOrg, which falls back to the PLATFORM's own org
// ("hanzo") when it is absent. OTel children do not inherit a parent's
// attributes, so a correctly stamped HTTP server span above the run buys its
// children nothing: every span states its own tenant or is filed under the
// platform.
//
// A run whose spans miss it fails twice over. The tenant opens the console and
// sees a trace with a hole where its agent was, because the org-scoped read never
// returns those rows; and the tenant's tool names, run ids and user subjects sit
// in the platform's bucket instead. That is why this asserts on EVERY exported
// span rather than on the root: one unstamped span is one missing branch of the
// waterfall.
func TestEverySpanOfARunIsFiledUnderItsTenant(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"}})
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", "acme", map[string]any{"input": "hi"}); code != http.StatusOK {
t.Fatalf("run want 200, got %d (%s)", code, body)
}
// The spans a run IS. Named explicitly so a span that stops being produced
// fails here rather than silently shrinking the set under assertion.
want := []string{"agent.run a", "agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"}
for _, name := range want {
spans := sink.all(name)
if len(spans) == 0 {
t.Fatalf("no %q span was exported", name)
}
for _, sp := range spans {
if got := attr(sp, "hanzo.org"); got != "acme" {
t.Fatalf("%s carries hanzo.org=%q, want \"acme\" — the plane files this span "+
"under the platform org, so the tenant cannot see its own run", name, got)
}
}
}
}
// TestOneOrgsRunNeverCarriesAnothersTenant proves the org stamp PARTITIONS the
// spans: two tenants running the same agent name against the same process produce
// two disjoint sets of rows, and neither names the other.
//
// Tenancy on the trace plane is exactly this attribute — the org-scoped read binds
// it as its first predicate — so a run that stamped the wrong tenant, or stamped
// none and fell back to the platform, would disclose one org's tool calls and user
// subjects to a reader scoped to another. The gate is fail-closed downstream; this
// asserts the value it closes on is right at the source.
func TestOneOrgsRunNeverCarriesAnothersTenant(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"))
for _, org := range []string{"acme", "globex"} {
do(t, app, http.MethodPost, "/v1/agents", org, map[string]any{
"name": "a", "model": "gpt-4o-mini", "instructions": "x", "tools": []string{"post_v1_search_query"}})
if code, body := do(t, app, http.MethodPost, "/v1/agents/a/run", org, map[string]any{"input": "hi"}); code != http.StatusOK {
t.Fatalf("%s run want 200, got %d (%s)", org, code, body)
}
}
// Every span belongs to exactly one of the two tenants, and the user subject it
// names is that tenant's own. A span filed under the platform default is
// counted as neither and fails the total below.
seen := map[string]int{}
for _, name := range []string{"agent.run a", "agent.step", "agent.tool post_v1_search_query", "chat gpt-4o-mini"} {
for _, sp := range sink.all(name) {
org := attr(sp, "hanzo.org")
seen[org]++
if org != "acme" && org != "globex" {
t.Fatalf("%s is filed under %q — neither tenant ran it", name, org)
}
// The person is scoped to the same tenant. "acme" carrying globex's
// subject would be a cross-tenant disclosure inside a correctly
// stamped row, which the org assertion alone cannot catch.
if sub := attr(sp, "hanzo.user"); sub != "" && sub != "u-"+org {
t.Fatalf("%s is filed under org %q but names user %q", name, org, sub)
}
}
}
if seen["acme"] == 0 || seen["globex"] == 0 {
t.Fatalf("want spans from both tenants, got acme=%d globex=%d", seen["acme"], seen["globex"])
}
}
+126 -2
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 —
@@ -34,6 +37,18 @@ func RunOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, err
}
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
}
+19 -1
View File
@@ -58,7 +58,25 @@ func planeRunOnBehalf(ctx context.Context, in *plane.RunOnBehalfIn) (*plane.RunO
if strings.TrimSpace(in.Subject) == "" {
return nil, fmt.Errorf("agents: run-on-behalf requires a linked subject")
}
run, err := runOnBehalf(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input)
// 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
}
+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)
}
+435
View File
@@ -0,0 +1,435 @@
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"
"unicode/utf8"
"github.com/hanzoai/cloud/apps/tools"
"github.com/hanzoai/cloud/audit"
"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
// maxRecorded bounds what ONE tool call may put on its span — its arguments or
// its result. A trace is for READING, not for replay, and this is deliberately
// far tighter than maxToolResult: that bound is paid once as prompt tokens,
// this one is stored for every call of every run and kept for the life of the
// trace. 4 KiB is a page of evidence. What exceeds it is cut and SAID to be
// cut — a silently clipped value reads as the whole argument, which is how an
// operator concludes a tool was called with something it never saw.
maxRecorded = 4 * 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
}
// recordable is what a tool call may put on a span: credentials stripped, size
// bounded, and the bound stated.
//
// Both halves are load-bearing. A tool ARGUMENT routinely carries a live
// credential — a clone URL with a token in the userinfo is the ordinary shape
// (apps/coding builds exactly that) — and a trace that records one is worse than
// no trace at all, because the token outlives the sandbox that used it and sits
// in a store built to be queried. audit.RedactText is the fleet's ONE redactor;
// this adds no second policy, it just applies it at the moment of recording.
//
// The order matters: redact FIRST, then cut. Cutting first can split a credential
// and leave the front half of it on the span, which is both a leak and unreadable.
func recordable(s string) string {
s = audit.RedactText(s)
if len(s) <= maxRecorded {
return s
}
// Cut on a rune boundary so the value stays valid UTF-8; a span store that
// rejects or mangles invalid UTF-8 would lose the whole attribute over the
// last byte of a multi-byte character.
cut := maxRecorded
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\u2026[cut: longer than a span records]"
}
// 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),
// The tenant under the key the trace plane files rows by (planeOrg,
// apps/o11y/planesink.go) — see the note on the run span in agents.go.
attribute.String("hanzo.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),
// WHAT it was called with. Without this a trace says an agent called
// "post_v1_exec_run" six times and cannot say what it ran — which is the
// difference between knowing a run touched a tool and knowing what it did.
// The convention's own name for this (opt-in precisely because it can carry
// user data), redacted and bounded by recordable.
attribute.String("gen_ai.tool.call.arguments", recordable(tc.Arguments)),
)
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()
}
// WHAT came back — the other half of the dispatch, and the half that explains
// what the model did next. A tool that "succeeded" while returning an error
// document is invisible without it.
span.SetAttributes(attribute.String("gen_ai.tool.call.result", recordable(out)))
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)
}
}
+17 -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{
@@ -219,6 +231,7 @@ func init() {
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.",
+94
View File
@@ -0,0 +1,94 @@
package ai
// THE PUBLIC CONTRACT, v1: inference.
//
// Every line below puts one operation into the published SDKs, the public CLI,
// the MCP tool list and docs.hanzo.ai. Nothing else in the fleet's 1782 paths is
// there, because the projection is default-deny and this is the only file in the
// repository that has said anything (openapi/public.go).
//
// # The rule that earns a line
//
// AN OPERATION IS IN v1 IF IT IS A MODEL CALL, OR IF IT IS THE CATALOG OF MODELS
// TO CALL. That is the whole of it, and everything below is one of the two. It is
// NOT "everything hanzoai/ai serves": that module also answers 109 paths under
// /v1/ai/* — articles, assets, deployments, connections, dashboards — which are
// the app builder's CRUD and not inference, and which the rule therefore leaves
// out without anyone having to name them.
//
// # What is deliberately NOT here
//
// Each of these was measured and left out, and each is one line away if that is
// wrong. Default-deny means the cost of leaving something out is that somebody
// asks for it, and the cost of leaving something in is that we support it
// forever.
//
// POST /v1/chat a second spelling of /v1/chat/completions,
// down to the same summary. One operation
// has one address in a published contract;
// two would be two SDK methods and two CLI
// verbs for one call. The right fix is for
// hanzoai/ai to tag it openapi.Compat.
// POST /v1/generate-text-to-speech-audio the inherited casdoor-shaped
// GET /v1/generate-text-to-speech-audio-stream spellings of /v1/audio/speech.
// Same call, older name; same answer.
// GET /v1/models/{model}/access entitlement, not inference — the waitlist
// POST /v1/models/{model}/access standing for a gated model. It is console
// workflow, and a customer reaches it in the
// console. Add it the day an SDK needs it.
//
// # Adding the next product
//
// One line per operation, in the app that serves it, in a file named public.go
// beside the routes. Then `make describe`, which regenerates public.yaml, and the
// diff shows exactly what was published next to the line that published it.
import (
"net/http"
"github.com/hanzoai/cloud/openapi"
)
func init() {
// THE CATALOG — what can be called, and by whom. Both are unauthenticated and
// secret-free by their own declaration; a client that cannot read the catalog
// before it holds a credential cannot show a model picker.
openapi.Public("/v1/models", http.MethodGet)
openapi.Public("/v1/models/providers", http.MethodGet)
// TEXT. Four wire formats over one router: OpenAI's chat and legacy
// completions, OpenAI's Responses, and Anthropic's Messages. They are not
// duplicates of each other — a caller picks the one its SDK already speaks,
// which is the entire reason all four are served — and count_tokens is part of
// the Messages contract rather than an operation of its own (Claude Code calls
// it before every request).
openapi.Public("/v1/chat/completions", http.MethodPost)
openapi.Public("/v1/completions", http.MethodPost)
openapi.Public("/v1/responses", http.MethodPost)
openapi.Public("/v1/messages", http.MethodPost)
openapi.Public("/v1/messages/count_tokens", http.MethodPost)
// VECTORS — the two operations that turn text into numbers and numbers back
// into an order. zen-rerank is a first-party model and /v1/rerank is its door.
openapi.Public("/v1/embeddings", http.MethodPost)
openapi.Public("/v1/rerank", http.MethodPost)
// MEDIA. zen-image, zen-video and zen-music are first-party models on the live
// catalog, and each has its own door — they are NOT reachable through
// /v1/chat/completions, so publishing the text surface alone would ship a
// catalog listing models no generated client can call.
openapi.Public("/v1/images/generations", http.MethodPost)
openapi.Public("/v1/audio/speech", http.MethodPost)
openapi.Public("/v1/audio/transcriptions", http.MethodPost)
openapi.Public("/v1/audio/voice", http.MethodPost)
openapi.Public("/v1/audio/music", http.MethodPost)
openapi.Public("/v1/audio/foley", http.MethodPost)
// Video generation is ASYNC — create returns a job, and the client polls it and
// then downloads the result. All three are the one operation from a caller's
// side, so publishing the create alone would publish a call whose answer
// nothing in the SDK can resolve.
openapi.Public("/v1/videos/generations", http.MethodPost)
openapi.Public("/v1/videos/{id}", http.MethodGet)
openapi.Public("/v1/videos/{id}/content", http.MethodGet)
}
+321 -18
View File
@@ -17,8 +17,8 @@
// lands in event.fact under its own signal; nothing here writes storage.
// This file used to sit beside a second storage write (the wide hanzo.events INSERT)
// and hand the plane its only copy of each batch; that double-write is gone — the
// fact publish IS the commit — and what remains here are the two SUBSCRIBER
// hand-offs an accepted batch still owes:
// fact publish IS the commit — and what remains here are the SUBSCRIBER hand-offs an
// accepted batch still owes:
//
// - the ENVELOPE onto the plane (PublishEvents, bus.go) — the webhook-delivery
// contract (apps/webhooks): orgs subscribe to event.<folded name> subjects and
@@ -28,23 +28,43 @@
// - a COPY-taking downstream SINK — the destinations subsystem — which translates
// and forwards each event to the org's connected ad/analytics platforms (GA4, Meta
// CAPI, …).
// - the ERROR sinks: the error-signal slice of the batch, handed to consumers that
// project a failure onto another surface. apps/o11y installs one (errorsink.go)
// that lands each error on the embedded Sentry plane, so a /v1/event error
// surfaces on sentry.hanzo.ai beside the errors a Sentry SDK posts directly.
// - the SPAN sinks: the span-signal slice, on identical terms. apps/o11y installs one
// (spansink.go) that lands each LLM-shaped span on event.span, so a /v1/event span
// surfaces in the LLM views (GET /v1/o11y/llm/observations, /llm/traces, and the
// eval board) beside the gen_ai spans the ai emit path sends over the ZAP wire.
//
// The seam is:
//
// - ONE-WAY. analytics never imports its consumers; a sink (destinations) calls
// AddSink from its own Mount. No sinks means no sink fan-out, so this file changes
// nothing about ingest when they are absent.
// - RAW. The sink receives the event BEFORE the warehouse privacy scrub, because a
// server-side Conversions-API forwarder must hash the match keys (email/phone/
// click ids) the warehouse deliberately drops. The org connected the destination
// and owns that consent; the destination adapters SHA-256 every PII field before
// it leaves the process.
// - FAIL-SOFT. The sink runs detached (a panic-guarded goroutine) so a slow or
// broken destination can never block, fail, or crash an ingest.
// - ONE-WAY. analytics never imports its consumers; a sink (destinations, o11y)
// calls AddSink / AddErrorSink / AddSpanSink from its own Mount. No sinks means no
// sink fan-out, so this file changes nothing about ingest when they are absent.
// - RAW, WHERE THE CONSUMER FORWARDS. The destination sink receives the event BEFORE
// the warehouse privacy scrub, because a server-side Conversions-API forwarder must
// hash the match keys (email/phone/click ids) the warehouse deliberately drops. The
// org connected the destination and owns that consent; the destination adapters
// SHA-256 every PII field before it leaves the process. The exception text an error
// sink reads is already the folded copy foldException scrubbed, and the Sentry
// normalizer scrubs again on its side.
// - SCRUBBED, WHERE THE CONSUMER STORES. The span slice carries the same scrubMap copy
// the write core stores in the fact's attributes, because its consumer writes a ROW:
// scrubText's whole contract is that a token in a property is redacted before
// storage, and a projection that stored more than the plane stores would be a second
// copy of the batch under a weaker rule.
// - FAIL-SOFT. Each sink runs detached (a panic-guarded goroutine) so a slow or
// broken consumer can never block, fail, or crash an ingest.
// - ADDITIVE. A projection failure is invisible to the ingest — the fact publish
// already committed and the honest receipt already returned.
package analytics
import "time"
import (
"strings"
"time"
)
// SinkEvent is one accepted event handed to the downstream fan-out. It carries the
// resolved canonical name plus the commerce + identity fields a conversion needs;
@@ -83,15 +103,100 @@ func AddSink(fn func(org string, evs []SinkEvent)) (remove func()) {
return func() { sinks[i] = nil }
}
// fanOut hands the accepted batch to the sink, detached and fail-soft. org is the
// SERVER-resolved tenant (already an owned copy from principal.Org). It builds
// SinkEvents from the RAW events (skipping unroutable ones, mirroring the write
// core's drop rule) and, if any remain and a sink is installed, dispatches them on a
// panic-guarded goroutine so ingest is never blocked or failed by a destination.
// ErrorEvent is one accepted error occurrence handed to the error fan-out. It is a
// carrier of exactly the fields an error projection needs, so analytics stays
// orthogonal to its consumers — apps/o11y builds the Sentry wire event on its side.
// The exception text is the folded, scrubbed copy (foldException); the tenant is the
// org argument to the sink, never a field here.
type ErrorEvent struct {
MessageID string // client idempotency id / minted; becomes the projection's event id
Time time.Time
ExceptionType string // e.g. "TypeError"; "" ⇒ the consumer groups on the message
Message string // the exception message (the grouping value)
Stack string // raw client stack string, when the wire carried one
Handled *bool // whether the app caught it (nil ⇒ unknown)
Level string // "error" for these events
Platform string // e.g. "javascript" (properties.$platform; descriptive)
Release string // build the error fired in
Environment string // deployment the error fired in
Transaction string // the route the error fired on (path, else url)
URL string
Path string
DistinctID string // the reporting visitor (user id — never PII)
SessionID string
Product string // emitting surface: console|chat|app|site|admin
Site string // deployed property the error came from
Service string // emitting service, when the wire named one
Library string
TraceID string // trace linkage
SpanID string
}
// errorSinks are the error fan-out consumers, on the same terms as sinks: registered
// at Mount, package-global, each dispatch detached and panic-guarded.
var errorSinks []func(org string, errs []ErrorEvent)
// AddErrorSink registers an error fan-out consumer and returns its remover.
func AddErrorSink(fn func(org string, errs []ErrorEvent)) (remove func()) {
i := len(errorSinks)
errorSinks = append(errorSinks, fn)
return func() { errorSinks[i] = nil }
}
// SpanEvent is one accepted span handed to the span fan-out. It is a carrier of exactly
// the fields a span projection needs, so analytics stays orthogonal to its consumers —
// apps/o11y decides on its side which of these spans are LLM calls and what a row of
// event.span looks like. Properties is where a span states its OTel semantic attributes
// (gen_ai.*), carried as the SCRUBBED copy the fact row stores (see the header); the
// tenant is the org argument to the sink, never a field here.
type SpanEvent struct {
MessageID string // client idempotency id / minted; the fallback row identity
Time time.Time
Name string // the span's name, resolved by the plane's own rule
Kind string // client|server|producer|consumer|internal
Status string // how the span ended: ok|error|unset, lowercased
Duration uint64 // elapsed nanoseconds
TraceID string // the trace this span belongs to
SpanID string // this span's own id
Parent string // the enclosing span's id, empty for a root span
Service string // emitting service, when the wire named one
Product string // emitting surface: console|chat|app|site|admin
Site string // deployed property the span came from
Release string // build the span was recorded in
Environment string // deployment the span was recorded in
DistinctID string // the reporting visitor (user id — never PII)
SessionID string
Properties map[string]any // RAW; where gen_ai.* semantic attributes travel
}
// spanSinks are the span fan-out consumers, on the same terms as sinks and errorSinks:
// registered at Mount, package-global, each dispatch detached and panic-guarded.
var spanSinks []func(org string, spans []SpanEvent)
// AddSpanSink registers a span fan-out consumer and returns its remover.
func AddSpanSink(fn func(org string, spans []SpanEvent)) (remove func()) {
i := len(spanSinks)
spanSinks = append(spanSinks, fn)
return func() { spanSinks[i] = nil }
}
// fanOut hands the accepted batch to the plane envelope and to every installed sink,
// each detached and fail-soft. org is the SERVER-resolved tenant (already an owned
// copy from principal.Org). One call site — the ingestEvents tail.
func fanOut(org string, evs []CaptureEvent) {
if len(evs) == 0 {
return
}
fanOutEvents(org, evs)
fanOutErrors(org, evs)
fanOutSpans(org, evs)
}
// fanOutEvents builds SinkEvents from the RAW events (skipping unroutable ones,
// mirroring the write core's drop rule), publishes the envelope onto the plane, and
// dispatches each installed sink on a panic-guarded goroutine so ingest is never
// blocked or failed by a consumer.
func fanOutEvents(org string, evs []CaptureEvent) {
live := make([]func(string, []SinkEvent), 0, len(sinks))
for _, fn := range sinks {
if fn != nil {
@@ -139,3 +244,201 @@ func fanOut(org string, evs []CaptureEvent) {
}()
}
}
// fanOutErrors filters the batch to the events the plane routes as the error signal —
// routeOf is the ONE routing rule, so the projection can never carry a fact the plane
// filed as something else — builds ErrorEvents, and dispatches each installed error
// sink on a panic-guarded goroutine. Non-error events (the overwhelming majority) are
// skipped, so a normal batch never touches this path.
func fanOutErrors(org string, evs []CaptureEvent) {
live := make([]func(string, []ErrorEvent), 0, len(errorSinks))
for _, fn := range errorSinks {
if fn != nil {
live = append(live, fn)
}
}
if len(live) == 0 {
return
}
now := time.Now()
out := make([]ErrorEvent, 0)
for _, e := range evs {
if routeOf(e).signal != signalError {
continue
}
typ, msg, stack, handled := exceptionOf(e)
out = append(out, ErrorEvent{
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
Time: clampTS(e.Timestamp, now),
ExceptionType: trim(typ),
Message: trim(msg),
Stack: stack,
Handled: handled,
Level: "error",
Platform: propStr(e.Properties, "$platform"),
Release: firstNonEmptyStr(trim(e.Release), propStr(e.Properties, "$release")),
Environment: firstNonEmptyStr(trim(e.Environment), propStr(e.Properties, "$environment")),
Transaction: firstNonEmptyStr(trim(e.Path), trim(e.URL)),
URL: trim(e.URL),
Path: trim(e.Path),
DistinctID: trim(e.DistinctID),
SessionID: trim(e.SessionID),
Product: trim(e.Product),
Site: trim(e.Site),
Service: trim(e.Service),
Library: trim(e.Library),
TraceID: firstNonEmptyStr(trim(e.TraceID), propStr(e.Properties, "$trace_id")),
SpanID: firstNonEmptyStr(trim(e.SpanID), propStr(e.Properties, "$span_id")),
})
}
if len(out) == 0 {
return
}
for _, fn := range live {
fn := fn
go func() {
defer func() { _ = recover() }()
fn(org, out)
}()
}
}
// fanOutSpans filters the batch to the events the plane routes as the span signal —
// routeOf is the ONE routing rule, the same pin fanOutErrors holds, so a projection can
// never carry a fact the plane filed as something else — builds SpanEvents, and
// dispatches each installed span sink on a panic-guarded goroutine. Non-span events
// (the overwhelming majority) are skipped, so a normal batch never touches this path.
func fanOutSpans(org string, evs []CaptureEvent) {
live := make([]func(string, []SpanEvent), 0, len(spanSinks))
for _, fn := range spanSinks {
if fn != nil {
live = append(live, fn)
}
}
if len(live) == 0 {
return
}
now := time.Now()
out := make([]SpanEvent, 0)
for _, e := range evs {
r := routeOf(e)
if r.signal != signalSpan {
continue
}
b := spanBodyOf(e)
// The SCRUB the write core applies before it stores a fact's attributes, applied
// once here: the projection reads its property fallbacks out of the very map it
// hands the consumer, so what a row carries and what a fallback saw are the same
// values.
props := scrubMap(e.Properties)
// The plane's OWN precedence, restated (applySpan, fact.go): the route's default
// kind stands unless the envelope names one, and the span BODY refines both —
// a client emitting a span knows its role precisely.
kind := firstNonEmptyStr(trim(e.Kind), r.kind)
if k := trim(b.Kind); k != "" {
kind = k
}
out = append(out, SpanEvent{
MessageID: firstNonEmptyStr(trim(e.MessageID), randID()),
Time: clampTS(e.Timestamp, now),
Name: resolveName(r, e),
Kind: strings.ToLower(kind),
Status: strings.ToLower(trim(b.Status)),
Duration: b.Duration,
// The first-class envelope ids win over the body's — the SAME order the fact
// row resolves them in (the envelope fills trace/span at build, applySpan
// fills only what is still empty), so the projected span and the fact carry
// one identity rather than two. The legacy $ spellings are the last resort
// for a client that has not moved to the first-class field, exactly as the
// error slice above reads them.
TraceID: firstNonEmptyStr(firstNonEmptyStr(trim(e.TraceID), trim(b.Trace)), propStr(props, "$trace_id")),
SpanID: firstNonEmptyStr(firstNonEmptyStr(trim(e.SpanID), trim(b.ID)), propStr(props, "$span_id")),
Parent: trim(b.Parent),
Service: trim(e.Service),
Product: trim(e.Product),
Site: trim(e.Site),
Release: firstNonEmptyStr(trim(e.Release), propStr(props, "$release")),
Environment: firstNonEmptyStr(trim(e.Environment), propStr(props, "$environment")),
DistinctID: trim(e.DistinctID),
SessionID: trim(e.SessionID),
Properties: props,
})
}
if len(out) == 0 {
return
}
for _, fn := range live {
fn := fn
go func() {
defer func() { _ = recover() }()
fn(org, out)
}()
}
}
// spanBodyOf returns the event's span body, or the ZERO body when the wire carried
// none. A span-typed event without a body is still a span — applySpan stores it and the
// fact lands — and the zero value states exactly the "nothing further known" that row
// records, so the projection reads one shape instead of branching on a pointer.
func spanBodyOf(e CaptureEvent) SpanBody {
if e.Span == nil {
return SpanBody{}
}
return *e.Span
}
// exceptionOf extracts the exception's (type, message, stack, handled) from whichever
// carrier holds it: the typed Error (folded events carry it), or properties.$exception
// as either the typed *Exception (post-fold, same process) or a decoded map (from the
// JSON wire). Returns zero values for an error event that carries no exception — a
// bare error-typed event, which the consumer then groups on its message/transaction.
func exceptionOf(e CaptureEvent) (typ, message, stack string, handled *bool) {
if e.Error != nil {
return e.Error.Type, e.Error.Message, e.Error.Stack, e.Error.Handled
}
raw, ok := e.Properties["$exception"]
if !ok {
return "", "", "", nil
}
switch x := raw.(type) {
case *Exception:
if x == nil {
return "", "", "", nil
}
return x.Type, x.Message, x.Stack, x.Handled
case Exception:
return x.Type, x.Message, x.Stack, x.Handled
case map[string]any:
return mapStr(x, "type"), mapStr(x, "message"), mapStr(x, "stack"), mapBool(x, "handled")
default:
return "", "", "", nil
}
}
// propStr reads a string-valued property, "" when absent or non-string. The ingest
// never trusts these for tenancy — they are descriptive only.
func propStr(p map[string]any, key string) string {
if p == nil {
return ""
}
if v, ok := p[key].(string); ok {
return trim(v)
}
return ""
}
// mapStr reads a string value from a decoded exception map.
func mapStr(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
// mapBool reads a *bool from a decoded exception map (JSON bools decode to bool).
func mapBool(m map[string]any, key string) *bool {
if v, ok := m[key].(bool); ok {
return &v
}
return nil
}
+249
View File
@@ -0,0 +1,249 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package analytics
import (
"testing"
"time"
)
// collectErrors installs an error sink that forwards the batch for one org onto a
// channel, and returns the channel + the sink's remover. Mirrors forward_test's
// SinkEvent probe.
func collectErrors(t *testing.T, wantOrg string) (<-chan []ErrorEvent, func()) {
t.Helper()
got := make(chan []ErrorEvent, 1)
remove := AddErrorSink(func(org string, errs []ErrorEvent) {
if org == wantOrg {
got <- errs
}
})
return got, remove
}
// TestFanOutErrorsFoldedException verifies the primary /v1/event path: an event whose
// top-level error was folded (foldException — Type defaulted to "error", the scrubbed
// exception copied into properties.$exception) is routed to the error sink with its
// exception fields + identity/context carried.
func TestFanOutErrorsFoldedException(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
handled := false
// foldException runs in ingestDecoded before the write core; replicate it here so
// the fan-out sees exactly what production hands it.
folded := foldException(CaptureEvent{
Error: &Exception{Type: "TypeError", Message: "x is not a function", Stack: "at f (app.js:1:1)", Handled: &handled},
DistinctID: "u1", SessionID: "s1", Path: "/checkout", URL: "https://acme.ai/checkout",
Product: "app", Library: "@hanzo/event",
Properties: map[string]any{"$release": "v2", "$trace_id": "abc", "keep": "me"},
})
fanOut("acme", []CaptureEvent{
folded,
{Type: "pageview"}, // not an error — must be skipped
{Type: "event", Event: "click"}, // not an error — must be skipped
})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1 error event (pageview+click skipped), got %d", len(errs))
}
e := errs[0]
if e.ExceptionType != "TypeError" || e.Message != "x is not a function" {
t.Errorf("exception not carried: %+v", e)
}
if e.Stack != "at f (app.js:1:1)" {
t.Errorf("stack not carried: %q", e.Stack)
}
if e.Handled == nil || *e.Handled != false {
t.Errorf("handled flag not carried: %v", e.Handled)
}
if e.DistinctID != "u1" || e.SessionID != "s1" {
t.Errorf("identity not carried: %+v", e)
}
if e.Transaction != "/checkout" || e.Path != "/checkout" || e.URL != "https://acme.ai/checkout" {
t.Errorf("route not carried: %+v", e)
}
if e.Product != "app" || e.Library != "@hanzo/event" {
t.Errorf("surface not carried: %+v", e)
}
if e.Release != "v2" || e.TraceID != "abc" {
t.Errorf("property fallbacks not carried: release=%q trace=%q", e.Release, e.TraceID)
}
if e.Level != "error" {
t.Errorf("level = %q, want error", e.Level)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsEnvelopeFields verifies the first-class envelope qualifiers win over
// the property spellings: a wire that states release/environment/service/site/trace on
// the envelope hands exactly those to the sink.
func TestFanOutErrorsEnvelopeFields(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "error", Error: &Exception{Message: "boom"},
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
TraceID: "t-1", SpanID: "s-1",
Properties: map[string]any{"$release": "stale", "$trace_id": "stale"},
}})
select {
case errs := <-got:
e := errs[0]
if e.Release != "v3" || e.Environment != "prod" {
t.Errorf("envelope release/environment must win: %+v", e)
}
if e.Service != "gateway" || e.Site != "acme.ai" {
t.Errorf("service/site not carried: %+v", e)
}
if e.TraceID != "t-1" || e.SpanID != "s-1" {
t.Errorf("envelope trace linkage must win: %+v", e)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsNativeExceptionMap verifies the JSON-wire path: an error-typed event
// carrying $exception as a decoded map (not the typed *Exception). exceptionOf must
// read type/message/stack/handled out of the map.
func TestFanOutErrorsNativeExceptionMap(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "error", Event: "$error",
Properties: map[string]any{
"$exception": map[string]any{
"type": "RangeError", "message": "out of range", "stack": "at g()", "handled": true,
},
},
}})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1, got %d", len(errs))
}
e := errs[0]
if e.ExceptionType != "RangeError" || e.Message != "out of range" || e.Stack != "at g()" {
t.Errorf("map exception not read: %+v", e)
}
if e.Handled == nil || *e.Handled != true {
t.Errorf("handled from map not read: %v", e.Handled)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsTypedErrorNoException verifies a bare type:'error' event with NO
// exception is still carried (the consumer groups it on message/transaction).
func TestFanOutErrorsTypedErrorNoException(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom", Path: "/x"}})
select {
case errs := <-got:
if len(errs) != 1 {
t.Fatalf("want 1, got %d", len(errs))
}
if errs[0].ExceptionType != "" || errs[0].Message != "" {
t.Errorf("bare error should carry no exception: %+v", errs[0])
}
if errs[0].Transaction != "/x" {
t.Errorf("transaction = %q, want /x", errs[0].Transaction)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called for a bare error-typed event")
}
}
// TestFanOutErrorsIsThePlanesRoute pins the filter to routeOf — the projection carries
// exactly the facts the plane files under the error signal, so sentry.hanzo.ai and
// event.fact can never disagree about what an error is. In particular a $exception
// property on an event the plane routes as an act does NOT reach the sink.
func TestFanOutErrorsIsThePlanesRoute(t *testing.T) {
got, done := collectErrors(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "ERROR", Event: "case-folded"}, // routed error: type folds lower
{Error: &Exception{Message: "typeless"}}, // routed error: typeless + exception
{Type: "event", Event: "decorated", Properties: map[string]any{ // routed act: the plane's word wins
"$exception": map[string]any{"message": "not an error fact"},
}},
})
select {
case errs := <-got:
if len(errs) != 2 {
t.Fatalf("want the 2 error-routed events, got %d: %+v", len(errs), errs)
}
case <-time.After(2 * time.Second):
t.Fatal("error sink was not called")
}
}
// TestFanOutErrorsNoErrorsNoDispatch verifies a batch with zero error events never
// dispatches to the error sink (the common case — a normal pageview/event batch).
func TestFanOutErrorsNoErrorsNoDispatch(t *testing.T) {
fired := make(chan struct{}, 1)
remove := AddErrorSink(func(org string, errs []ErrorEvent) { fired <- struct{}{} })
defer remove()
fanOut("acme", []CaptureEvent{
{Type: "pageview"},
{Type: "event", Event: "order_completed", Revenue: 10},
})
select {
case <-fired:
t.Fatal("error sink fired for a batch with no error events")
case <-time.After(200 * time.Millisecond):
// expected: no dispatch
}
}
// TestFanOutErrorsNoSinksIsNoOp verifies fan-out is inert when no error sink is
// installed — the default when the o11y embed is off. A removed sink counts as absent.
func TestFanOutErrorsNoSinksIsNoOp(t *testing.T) {
remove := AddErrorSink(func(string, []ErrorEvent) { t.Error("removed sink must not fire") })
remove()
fanOut("acme", []CaptureEvent{{Type: "error", Event: "boom"}})
time.Sleep(50 * time.Millisecond)
}
// TestExceptionOf covers extraction from each carrier: typed Error, typed post-fold
// $exception, and decoded map.
func TestExceptionOf(t *testing.T) {
h := true
// typed Error field
if typ, msg, stack, handled := exceptionOf(CaptureEvent{Error: &Exception{Type: "E", Message: "m", Stack: "s", Handled: &h}}); typ != "E" || msg != "m" || stack != "s" || handled == nil || !*handled {
t.Errorf("typed Error: %q %q %q %v", typ, msg, stack, handled)
}
// post-fold typed (*Exception in properties)
if typ, msg, _, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": &Exception{Type: "E2", Message: "m2"}}}); typ != "E2" || msg != "m2" {
t.Errorf("post-fold typed: %q %q", typ, msg)
}
// decoded map
if typ, msg, stack, _ := exceptionOf(CaptureEvent{Properties: map[string]any{"$exception": map[string]any{"type": "E3", "message": "m3", "stack": "s3"}}}); typ != "E3" || msg != "m3" || stack != "s3" {
t.Errorf("decoded map: %q %q %q", typ, msg, stack)
}
// none
if typ, msg, _, _ := exceptionOf(CaptureEvent{Type: "error"}); typ != "" || msg != "" {
t.Errorf("no exception should be empty: %q %q", typ, msg)
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package analytics
import (
"strings"
"testing"
"time"
)
// collectSpans installs a span sink that forwards the batch for one org onto a channel,
// and returns the channel + the sink's remover. Mirrors collectErrors next door.
func collectSpans(t *testing.T, wantOrg string) (<-chan []SpanEvent, func()) {
t.Helper()
got := make(chan []SpanEvent, 1)
remove := AddSpanSink(func(org string, spans []SpanEvent) {
if org == wantOrg {
got <- spans
}
})
return got, remove
}
// TestFanOutSpansIsThePlanesRoute pins the filter to routeOf — the projection carries
// exactly the facts the plane files under the span signal, so the LLM views and
// event.fact can never disagree about what a span is. In particular an event carrying a
// span BODY that the plane routes as an act does NOT reach the sink: the wire's `type`
// picks the route, and a body cannot promote itself past it.
func TestFanOutSpansIsThePlanesRoute(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "span", Span: &SpanBody{ID: "s-1", Trace: "t-1"}}, // routed span
{Type: "SPAN", Span: &SpanBody{ID: "s-2", Trace: "t-1"}}, // routed span: type folds lower
{Type: "event", Event: "click", Span: &SpanBody{ID: "x"}}, // routed act: the plane's word wins
{Type: "error", Event: "boom"}, // routed error
{Type: "pageview"}, // routed act
})
select {
case spans := <-got:
if len(spans) != 2 {
t.Fatalf("want the 2 span-routed events, got %d: %+v", len(spans), spans)
}
if spans[0].SpanID != "s-1" || spans[1].SpanID != "s-2" {
t.Errorf("wrong events carried: %+v", spans)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansEnvelopeFields verifies the first-class envelope fields win over both
// the span body and the legacy $ property spellings — the same order the fact row
// resolves its identity in.
func TestFanOutSpansEnvelopeFields(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "span", MessageID: "m-1",
TraceID: "t-envelope", SpanID: "s-envelope",
Release: "v3", Environment: "prod", Service: "gateway", Site: "acme.ai",
Product: "chat", DistinctID: "u1", SessionID: "sess-1",
Span: &SpanBody{ID: "s-body", Trace: "t-body", Parent: "p-1"},
Properties: map[string]any{"$trace_id": "t-stale", "$span_id": "s-stale", "$release": "stale", "$environment": "stale"},
}})
select {
case spans := <-got:
s := spans[0]
if s.TraceID != "t-envelope" || s.SpanID != "s-envelope" {
t.Errorf("envelope ids must win over body and properties: %+v", s)
}
if s.Release != "v3" || s.Environment != "prod" {
t.Errorf("envelope release/environment must win: %+v", s)
}
if s.Service != "gateway" || s.Site != "acme.ai" || s.Product != "chat" {
t.Errorf("origin not carried: %+v", s)
}
if s.Parent != "p-1" {
t.Errorf("parent comes from the body: %q", s.Parent)
}
if s.MessageID != "m-1" || s.DistinctID != "u1" || s.SessionID != "sess-1" {
t.Errorf("identity not carried: %+v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansBodyRefinesTheRoute verifies the body fills what the envelope omits and
// refines the kind — applySpan's own precedence, restated on the projection: the route's
// default kind (internal) stands unless the envelope names one, and the body wins over
// both. The $ spellings are the last resort for a client with no first-class field.
func TestFanOutSpansBodyRefinesTheRoute(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{
{Type: "span", Kind: "SERVER", Span: &SpanBody{ID: "a", Trace: "t", Kind: "Client", Status: "ERROR", Duration: 42}},
{Type: "span", Kind: "server", Span: &SpanBody{ID: "b", Trace: "t"}},
{Type: "span", Span: &SpanBody{ID: "c", Trace: "t"}},
{Type: "span", Properties: map[string]any{"$trace_id": "t-legacy", "$span_id": "s-legacy"}},
})
select {
case spans := <-got:
if len(spans) != 4 {
t.Fatalf("want 4 spans, got %d", len(spans))
}
if spans[0].Kind != "client" || spans[0].Status != "error" || spans[0].Duration != 42 {
t.Errorf("body must refine kind/status/duration, lowercased: %+v", spans[0])
}
if spans[1].Kind != "server" {
t.Errorf("envelope kind stands when the body names none: %q", spans[1].Kind)
}
if spans[2].Kind != kindInternal {
t.Errorf("the route's default kind stands when nobody names one: %q", spans[2].Kind)
}
if spans[3].TraceID != "t-legacy" || spans[3].SpanID != "s-legacy" {
t.Errorf("legacy $ spellings are the last resort: %+v", spans[3])
}
// Every span is named, so the projection can never write an unnamed row.
for i, s := range spans {
if s.Name != nameSpan {
t.Errorf("span %d name = %q, want the route's default %q", i, s.Name, nameSpan)
}
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansStoresTheScrubbedProperties pins the rule that separates this slice from
// the destinations slice: a span sink WRITES A ROW, so it receives the same scrubMap copy
// the write core stores — the credential key is dropped and the token-shaped value is
// redacted before it can reach a stored attribute. The gen_ai marker survives untouched.
func TestFanOutSpansStoresTheScrubbedProperties(t *testing.T) {
got, done := collectSpans(t, "acme")
defer done()
fanOut("acme", []CaptureEvent{{
Type: "span", Span: &SpanBody{ID: "s", Trace: "t"},
Properties: map[string]any{
"gen_ai.system": "openai",
"gen_ai.request.model": "zen-1",
"gen_ai.usage.input_tokens": 128,
"authorization": "Bearer abcdefghijklmnop",
"gen_ai.prompt": "mail me at user@acme.ai",
},
}})
select {
case spans := <-got:
p := spans[0].Properties
if p["gen_ai.system"] != "openai" || p["gen_ai.request.model"] != "zen-1" {
t.Errorf("the gen_ai marker and model must survive the scrub: %+v", p)
}
if _, ok := p["authorization"]; ok {
t.Errorf("credential-shaped key must be dropped before storage: %+v", p)
}
if v, _ := p["gen_ai.prompt"].(string); strings.Contains(v, "user@acme.ai") {
t.Errorf("email must be redacted before storage: %q", v)
}
case <-time.After(2 * time.Second):
t.Fatal("span sink was not called")
}
}
// TestFanOutSpansNoSpansNoDispatch verifies a batch with zero span events never
// dispatches to the span sink (the common case — a normal pageview/event batch).
func TestFanOutSpansNoSpansNoDispatch(t *testing.T) {
fired := make(chan struct{}, 1)
remove := AddSpanSink(func(org string, spans []SpanEvent) { fired <- struct{}{} })
defer remove()
fanOut("acme", []CaptureEvent{
{Type: "pageview"},
{Type: "event", Event: "order_completed", Revenue: 10},
{Type: "error", Event: "boom"},
})
select {
case <-fired:
t.Fatal("span sink fired for a batch with no span events")
case <-time.After(200 * time.Millisecond):
// expected: no dispatch
}
}
// TestFanOutSpansNoSinksIsNoOp verifies fan-out is inert when no span sink is installed —
// the default when the o11y plane sink is off. A removed sink counts as absent, which is
// what makes ShutdownO11y's detach real.
func TestFanOutSpansNoSinksIsNoOp(t *testing.T) {
remove := AddSpanSink(func(string, []SpanEvent) { t.Error("removed sink must not fire") })
remove()
fanOut("acme", []CaptureEvent{{Type: "span", Span: &SpanBody{ID: "s", Trace: "t"}}})
time.Sleep(50 * time.Millisecond)
}
// TestSpanBodyOf covers both carriers: a span that stated a body, and a span-typed event
// that stated none (the zero body, which reads as "nothing further known").
func TestSpanBodyOf(t *testing.T) {
if b := spanBodyOf(CaptureEvent{Span: &SpanBody{ID: "s", Duration: 7}}); b.ID != "s" || b.Duration != 7 {
t.Errorf("stated body not returned: %+v", b)
}
if b := spanBodyOf(CaptureEvent{Type: "span"}); b != (SpanBody{}) {
t.Errorf("absent body must read as the zero body: %+v", b)
}
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build datastore_live
// EVERY SIGNAL, ONE DOOR — the live proof that each kind of fact is reachable from
// the one ingest contract and lands as exactly ONE row under its own signal.
//
// capture_live_test.go proves the act leg end-to-end (emit → fact → row → read
// lens). This file closes the set: error, log and span driven through the SAME
// door in one batch. The signal is `type` on the wire; routeOf (fact.go) is the
// whole routing rule; the writers table (warehouse.go) is the whole storage rule.
// Nothing in between gets an opinion, which is why one door serves every signal.
//
// It also pins what the per-signal reads depend on: a log is read by
// (org, service, time) and a span assembled by trace, so the row carries service
// and severity for the log and trace/span/parent/kind/status/duration for the
// span — and the two share a trace_id, the correlation the identical envelope
// buys. One fact never lands twice: the batch's four events produce exactly four
// rows, one per signal.
//
// Run:
//
// DATASTORE_ADDR=127.0.0.1:9000 DATASTORE_DB=hanzo \
// go test -tags datastore_live -run TestLiveEverySignal -v ./apps/analytics/
package analytics
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/apps/datastore"
)
// TestLiveEverySignalLandsItsOwnRow drives one fact of EACH landable signal
// through the one door and asserts each landed as one row under its own signal.
func TestLiveEverySignalLandsItsOwnRow(t *testing.T) {
ready, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := datastore.Wait(ready); err != nil {
t.Fatalf("datastore did not connect (set DATASTORE_ADDR=127.0.0.1:9000 with a live Datastore): %v", err)
}
ctx := context.Background()
requirePlane(t, ctx)
landDirect(t)
org := "acme-sig-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
app := liveApp(t)
// One batch, four signals. The wire says which; nothing else does.
body := `{"batch":[
{"type":"page","event":"page_viewed","distinctId":"u-1","product":"console","path":"/pricing"},
{"type":"error","distinctId":"u-1","product":"console","service":"web",
"error":{"type":"RangeError","message":"out of range","stack":"at f (https://app.test/a.js:2:1)"}},
{"type":"log","event":"request.served","distinctId":"u-1","product":"console",
"service":"gateway","traceId":"trace-abc","spanId":"span-1",
"log":{"severity":"info","number":9,"body":"served 200 in 12ms"}},
{"type":"span","event":"GET /v1/event","distinctId":"u-1","product":"console",
"service":"gateway",
"span":{"trace":"trace-abc","id":"span-2","parent":"span-1",
"kind":"server","duration":12000000,"status":"OK"}}
]}`
code, resp := livePost(t, app, canonDoor, "u-1", org, body)
if code != http.StatusOK {
t.Fatalf("POST %s = %d (%s)", canonDoor, code, resp)
}
t.Logf("ingest receipt: %s", strings.TrimSpace(string(resp)))
row := func(signal string) map[string]any {
t.Helper()
rows, err := datastore.Query(ctx,
"SELECT name, kind, message, severity, duration, service, trace_id, span_id, parent, status, class, issue "+
"FROM "+factTable+" WHERE org = ? AND signal = ?", org, signal)
if err != nil {
t.Fatalf("readback %s: %v", signal, err)
}
if len(rows) != 1 {
t.Fatalf("signal %q has %d rows for org %s, want exactly 1 — a fact did not land, or landed twice", signal, len(rows), org)
}
return rows[0]
}
act := row("act")
t.Logf("── act landed ── name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
if aString(act["name"]) != "page_viewed" || aString(act["kind"]) != "page" {
t.Fatalf("act row mismatch: name=%q kind=%q", aString(act["name"]), aString(act["kind"]))
}
er := row("error")
t.Logf("── error landed ── class=%q message=%q issue=%q", aString(er["class"]), aString(er["message"]), aString(er["issue"]))
if aString(er["class"]) != "RangeError" || aString(er["message"]) != "out of range" {
t.Fatalf("error columns did not land: class=%q message=%q", aString(er["class"]), aString(er["message"]))
}
if aString(er["issue"]) == "" {
t.Fatal("no issue fingerprint — the error cannot be grouped, and grouping is the read")
}
lr := row("log")
t.Logf("── log landed ── service=%q severity=%v message=%q trace=%q",
aString(lr["service"]), lr["severity"], aString(lr["message"]), aString(lr["trace_id"]))
if aString(lr["service"]) != "gateway" {
t.Fatalf("service = %q, want gateway — the (org, service, time) log read depends on it", aString(lr["service"]))
}
if aInt64(lr["severity"]) != 9 {
t.Fatalf("severity did not land: %v", lr["severity"])
}
if aString(lr["message"]) != "served 200 in 12ms" {
t.Fatalf("log body did not land: %q", aString(lr["message"]))
}
sr := row("span")
t.Logf("── span landed ── kind=%q trace=%q span=%q parent=%q duration=%v status=%q",
aString(sr["kind"]), aString(sr["trace_id"]), aString(sr["span_id"]),
aString(sr["parent"]), sr["duration"], aString(sr["status"]))
if aString(sr["trace_id"]) == "" {
t.Fatal("no trace_id — assembling the trace is the read")
}
// kind is the discriminator WITHIN a signal: track|page|identify|group on an
// act, the OTel span kind here. One column, not two.
if aString(sr["kind"]) != "server" {
t.Fatalf("kind = %q, want server — the span kind IS the envelope's kind column", aString(sr["kind"]))
}
if aString(sr["span_id"]) != "span-2" || aString(sr["parent"]) != "span-1" {
t.Fatalf("span identity did not land: id=%q parent=%q", aString(sr["span_id"]), aString(sr["parent"]))
}
// status is a small named set, normalized to lower case, so `status = 'error'`
// says what it means where a status_code = 2 needed a comment.
if aInt64(sr["duration"]) != 12000000 || aString(sr["status"]) != "ok" {
t.Fatalf("span outcome did not land: duration=%v status=%q", sr["duration"], aString(sr["status"]))
}
// The log and the span share a trace: the correlation the identical envelope buys.
if aString(lr["trace_id"]) != aString(sr["trace_id"]) {
t.Fatalf("log trace %q != span trace %q", aString(lr["trace_id"]), aString(sr["trace_id"]))
}
// One fact, one row: four signals in, exactly four rows out.
total, err := datastore.Query(ctx, "SELECT count() AS n FROM "+factTable+" WHERE org = ?", org)
if err != nil || len(total) == 0 {
t.Fatalf("count readback: %v", err)
}
if n := aInt64(total[0]["n"]); n != 4 {
t.Fatalf("%s has %d rows for org %s, want exactly 4 — a signal landed twice or not at all", factTable, n, org)
}
t.Logf("LIVE OK: one door, four signals — act/error/log/span each landed exactly one row (org=%s)", org)
}
+64
View File
@@ -181,5 +181,69 @@
window.__hanzoEvent = true
window.hanzo = { track: track, identify: identify, page: page, error: error, flush: flush }
// ── config-driven tag injection (the hosted twin of track.js) ─────────────
// Fetch the SITE's connected browser pixels (/v1/tags, dual-resolved per site by
// this key or the host) and inject them first-party. Every event then also fires
// the native pixel — translated through the SAME taxonomy the server-side CAPI uses
// (destinations/translate.go), stamped with a shared event_id carried on the
// /v1/event row too — so the browser pixel and the server CAPI DEDUPLICATE instead
// of double-counting. Keyless already returned above, so this only runs when keyed.
var STD = {
$pageview: 'page_view', pricing_viewed: 'view_content', signup_viewed: 'view_content',
product_viewed: 'view_content', plan_clicked: 'lead', signup_submitted: 'lead',
waitlist_joined: 'lead', referral_used: 'lead', signup_completed: 'signup',
product_added: 'add_to_cart', add_to_cart: 'add_to_cart', checkout_started: 'start_checkout',
begin_checkout: 'start_checkout', order_completed: 'purchase', purchase: 'purchase'
}
var NATIVE = {
ga: { page_view: 'page_view', view_content: 'view_item', add_to_cart: 'add_to_cart', lead: 'generate_lead', signup: 'sign_up', start_checkout: 'begin_checkout', purchase: 'purchase' },
meta: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'Purchase' },
tiktok: { page_view: 'Pageview', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'SubmitForm', signup: 'CompleteRegistration', start_checkout: 'InitiateCheckout', purchase: 'CompletePayment' },
x: { page_view: 'PageView', view_content: 'ViewContent', add_to_cart: 'AddToCart', lead: 'Lead', signup: 'SignUp', start_checkout: 'InitiateCheckout', purchase: 'Purchase' }
}
function nativeName(t, n) { var s = STD[n]; return s ? (NATIVE[t] || {})[s] || null : null }
function loadJS(u) { var e = document.createElement('script'); e.async = true; e.src = u; document.head.appendChild(e) }
function eid() { try { return crypto.randomUUID() } catch (e) { return 'e-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10) } }
var INJECT = {
ga: {
load: function (id) { loadJS('https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id)); window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || function () { dataLayer.push(arguments) }; gtag('js', new Date()); gtag('config', id) },
fire: function (n, p, id) { if (!window.gtag) return; var name = nativeName('ga', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.event_id = id; if (STD[n] === 'purchase') o.transaction_id = id; gtag('event', name, o) }
},
meta: {
load: function (id) { if (!window.fbq) { var n = window.fbq = function () { n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments) }; if (!window._fbq) window._fbq = n; n.push = n; n.loaded = true; n.version = '2.0'; n.queue = []; loadJS('https://connect.facebook.net/en_US/fbevents.js') } fbq('init', id); fbq('track', 'PageView') },
fire: function (n, p, id) { if (!window.fbq) return; var name = nativeName('meta', n); if (name) fbq('track', name, p || {}, { eventID: id }); else fbq('trackCustom', n, p || {}) }
},
tiktok: {
load: function (id) { var q = window.ttq = window.ttq || []; if (!q.methods) { q.methods = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']; q.setAndDefer = function (t, e) { t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } }; for (var i = 0; i < q.methods.length; i++) q.setAndDefer(q, q.methods[i]); loadJS('https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=' + encodeURIComponent(id) + '&lib=ttq') } if (q.load) q.load(id); if (q.page) q.page() },
fire: function (n, p, id) { if (!window.ttq || !window.ttq.track) return; var name = nativeName('tiktok', n) || n; window.ttq.track(name, p || {}, { event_id: id }) }
},
x: {
load: function (id) { if (!window.twq) { var s = window.twq = function () { s.exe ? s.exe.apply(s, arguments) : s.queue.push(arguments) }; s.version = '1.1'; s.queue = []; loadJS('https://static.ads-twitter.com/uwt.js') } twq('config', id) },
fire: function (n, p, id) { if (!window.twq) return; var name = nativeName('x', n) || n; var o = {}; for (var k in p) o[k] = p[k]; o.conversion_id = id; twq('event', name, o) }
}
}
var active = []
try {
fetch(src.origin + '/v1/tags?key=' + encodeURIComponent(key))
.then(function (r) { return r.ok ? r.json() : { tags: [] } })
.then(function (cfg) {
var tags = (cfg && cfg.tags) || []
for (var i = 0; i < tags.length; i++) {
var t = tags[i]
if (INJECT[t.type]) { try { INJECT[t.type].load(t.id); active.push(t) } catch (e) {} }
}
if (active.length) {
var base = window.hanzo.track
window.hanzo.track = function (n, p) {
var id = eid()
for (var j = 0; j < active.length; j++) { try { INJECT[active[j].type].fire(n, p, id) } catch (e) {} }
var o = {}; if (p) for (var k in p) o[k] = p[k]; o.event_id = id
base(n, o)
}
}
})
.catch(function () {})
} catch (e) {}
page()
})()
+24 -4
View File
@@ -118,12 +118,14 @@ function anonOf(r) {
assert.strictEqual(ev.event, undefined, 'naming is resolveEventName server-side')
}
// 4. Without sendBeacon the fetch path carries the key as a bearer.
// 4. Without sendBeacon the fetch path carries the key as a bearer. (A keyed tag also
// fetches /v1/tags for its browser pixels; the /v1/event POST is the one asserted.)
{
const r = run({ 'data-key': 'pk-live-abc' }, { noBeacon: true })
r.fire('pagehide')
assert.strictEqual(r.sent.fetch.length, 1)
const init = r.sent.fetch[0].init
const posts = r.sent.fetch.filter((f) => f.url.indexOf('/v1/event') !== -1)
assert.strictEqual(posts.length, 1)
const init = posts[0].init
assert.strictEqual(init.headers.authorization, 'Bearer pk-live-abc')
assert.strictEqual(init.keepalive, true)
assert.ok(JSON.parse(init.body).batch.length === 1)
@@ -199,4 +201,22 @@ function anonOf(r) {
assert.strictEqual(err.error.handled, false)
}
console.log('tag.js: 10/10 behavioral checks passed')
// 11. Config-driven injection: a keyed tag fetches its site's browser pixels from
// /v1/tags (dual-resolved per site by this key or the host), so the hosted
// one-liner injects GA/Meta/TikTok/X first-party. The pixels + the deduping
// event_id are unit-tested in track.js; here we prove the hosted tag makes the
// request — and that a keyless tag never does.
{
const r = run({ 'data-key': 'pk-live-abc' })
assert.ok(
r.sent.fetch.some((f) => f.url.indexOf('/v1/tags?key=pk-live-abc') !== -1),
'keyed tag fetches /v1/tags for its browser pixels'
)
const keyless = run({})
assert.ok(
!keyless.sent.fetch.some((f) => f.url.indexOf('/v1/tags') !== -1),
'keyless tag never fetches /v1/tags'
)
}
console.log('tag.js: 11/11 behavioral checks passed')
+87
View File
@@ -202,6 +202,93 @@ func (e Engine) Serve(c *zip.Ctx, in Request, q string) error {
})
}
// Report is one researched answer held as a VALUE: the grounded prose and the
// sources it cites. It carries what Serve writes as JSON minus the two keys that
// only mean anything to the advisor contract — `figures`, which the web domain
// leaves empty by construction, and `domain`, which is always "web" here.
type Report struct {
// Answer is the grounded prose, with inline markdown citations. Every link in
// it points at a page in Sources: the citation check runs on the text before
// it leaves the engine, so a cited URL is one THIS call fetched.
Answer string `json:"answer"`
// Sources are the pages the answer was written from, deduplicated and ranked.
// Always an array, never null.
Sources []Source `json:"sources"`
// FollowUps are the questions worth asking next. Best-effort — an empty list
// is a normal outcome, not a fault.
FollowUps []string `json:"follow_ups"`
// Mode is the profile that ran: search, news, research or deep.
Mode string `json:"mode"`
// Model is the model that synthesized the answer.
Model string `json:"model"`
}
// Answer runs the SAME bounded loop Serve runs and hands back its outcome as a
// value instead of writing it to a response.
//
// It exists because the loop's only door was an HTTP handler, and a handler is
// the one shape an agent cannot reach: a typed op — and so an MCP tool, a CLI
// command and an SDK method — is invoked with a context and no request at all.
// So the engine that already separated Run (transport-free) from Serve (HTTP)
// gains its second transport-free door rather than a second engine. Serve keeps
// streaming; this returns one value; Run is still the only loop.
//
// The identity facts Serve reads off the request are read off the CONTEXT here,
// which is where cloud.Bridge parks the server-minted ones. There is no ledger
// claim on a context, so the caller's own org pays — the same fallback Serve
// takes when no billing org was minted.
func (e Engine) Answer(ctx context.Context, in Request, q string) (*Report, error) {
org, _ := principal.OrgFrom(ctx)
project := principal.ProjectFrom(ctx)
m := resolveMode(in.Mode)
fee := feeCents(m.name, m.feeCents)
// MONEY GATE — the same refusal Serve makes, before any work, so an
// out-of-funds caller is told so rather than handed a half answer.
capValidated := principal.ValidatedFrom(ctx) && !principal.IsDefaultProject(project)
if err := e.Bill.Gate(ctx, org, project, capValidated, "web", fee); err != nil {
return nil, err
}
models := synthModels(in.Model, m, e.Model)
p := Params{
q: q,
webQuery: buildQuery(q, m, in.Sources),
mode: m,
model: models[0],
fallbacks: models[1:],
language: strings.TrimSpace(in.Language),
maxSources: clampPositive(in.MaxSources, m.maxSources),
maxQueries: clampPositive(in.MaxQueries, m.maxQueries),
readTop: m.readTop,
rounds: min(m.rounds, maxRounds),
hostCap: m.hostCap,
deadline: m.deadline,
tokenCeiling: m.tokenCeiling,
followUps: in.FollowUps == nil || *in.FollowUps,
system: pickSystem(in.System, m.system),
dataOrg: org,
payer: org,
project: project,
projectScope: project,
fee: fee,
}
rctx, cancel := context.WithTimeout(ctx, p.deadline)
defer cancel()
buf := &bufferSink{}
e.Run(rctx, p, buf)
return &Report{
Answer: buf.answer,
Sources: nonNilSrc(buf.srcs),
FollowUps: nonNilStr(buf.follow),
Mode: p.mode.name,
Model: p.model,
}, nil
}
// Run is the bounded loop, parameterized by mode — the ONE code path for
// search/news/research/deep. It emits the SearchEvent envelope through out, then
// meters the caller ONCE. A failed step degrades (fewer sources, snippets instead
+25
View File
@@ -777,3 +777,28 @@ func TestSynthesisPromptFencesCrawledPages(t *testing.T) {
t.Fatal("the page must still be present — fencing contains it, it does not drop it")
}
}
// A source hint must SCOPE the search, not decorate it. The hints were appended
// as bare words — `x` turned "openai news" into "openai news x", which is the
// original query plus a noise token and reaches X/Twitter not at all. Mojeek
// honours `site:` (measured: site:x.com → 10 results; Bing → 0), so a hint that
// names a site has to become one.
func TestSourceHintsScopeToTheirSite(t *testing.T) {
q := buildQuery("openai", modes["search"], []string{"x"})
if !strings.Contains(q, "site:x.com") {
t.Fatalf("buildQuery with the x hint = %q, want it scoped to site:x.com", q)
}
multi := buildQuery("openai", modes["search"], []string{"github", "reddit"})
if !strings.Contains(multi, "site:github.com") || !strings.Contains(multi, "site:reddit.com") {
t.Fatalf("two site hints = %q, want both sites", multi)
}
// Two sites are alternatives, never both-at-once: `site:a site:b` is an AND
// no page can satisfy, and would answer zero.
if !strings.Contains(multi, " OR ") {
t.Fatalf("two site hints = %q, want them joined as alternatives", multi)
}
// A TOPICAL hint names no site and must stay a plain word.
if s := buildQuery("openai", modes["search"], []string{"web"}); strings.Contains(s, "site:") {
t.Fatalf("topical hint = %q, want no site scope", s)
}
}
+35 -5
View File
@@ -261,15 +261,45 @@ func buildQuery(q string, m mode, sources []string) string {
if m.newsBias {
wq = q + " latest news " + strconv.Itoa(time.Now().Year())
}
var hints []string
var sites, words []string
for _, s := range sources {
t := strings.ToLower(strings.TrimSpace(s))
if knownSourceHints[t] {
hints = append(hints, t)
if !knownSourceHints[t] {
continue
}
if host := siteFor[t]; host != "" {
sites = append(sites, "site:"+host)
continue
}
words = append(words, t)
}
if len(hints) > 0 {
wq = wq + " " + strings.Join(hints, " ")
if len(words) > 0 {
wq = wq + " " + strings.Join(words, " ")
}
switch len(sites) {
case 0:
case 1:
wq = wq + " " + sites[0]
default:
// ALTERNATIVES, never both at once. `site:a site:b` reads as an AND and no
// page is on two hosts, so the naive join answers zero every time.
wq = wq + " (" + strings.Join(sites, " OR ") + ")"
}
return wq
}
// siteFor maps a source hint to the host it scopes the search to. A hint that
// names a place becomes a `site:` operator; a hint that names a TOPIC (web,
// news) has no host and stays a plain word, because there is no one site that
// is "the news".
//
// This is what makes the `x` hint reach X at all. It was appended as the bare
// word "x", so asking for X sources turned "openai" into "openai x" — the same
// open-web query plus a token that matches nothing in particular. The scoped
// form lands on Mojeek, which honours `site:` where Bing does not.
var siteFor = map[string]string{
"academic": "arxiv.org",
"github": "github.com",
"reddit": "reddit.com",
"x": "x.com",
}
+43 -16
View File
@@ -139,23 +139,35 @@ func init() {
"under the caller's own credentials, and only then is the result narrated. So the "+
"figures and their sources are the domain's, resolved before any model call and never "+
"altered by one — a wrong answer is a wrong query, never an invention.\n\n"+
"Domains: books (the org's ledger) and web (search, news, research, deep). A validated "+
"principal is required; the answer is scoped to that principal's org and nothing else.")
"Domains: books (the org's ledger), projects (what is built and what of it is deployed), "+
"git (the org's repositories and what changed in them), and web (search, news, research, "+
"deep). A validated principal is required; the answer is scoped to that principal's org "+
"and nothing else.")
}
// Mount wires POST /v1/ask into cloud, building the contributor registry (books today) over the
// SAME app so a contributor's Gather replays a domain's grounded read in-process. The narration
// model comes from deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the
// ai /v1/* catch-all.
// Mount wires POST /v1/ask into cloud, building the contributor registry from domains() —
// every domain a PEER asked over the internal plane, because this app ships as its own
// process and the domains it grounds in do not run in it. The narration model comes from
// deps.AI. Mount is a distinct route, so it wins Fiber's first-match over the ai /v1/*
// catch-all.
//
// app is not handed to the registry: a contributor reaches its domain by NAME over the
// plane, so there is nothing for it to do with this process's router. Keeping the router
// out of the seam is what makes "which process owns that data" stop being the advisor's
// problem.
func Mount(app cloud.Router, deps cloud.Deps) error {
b := cloud.NewBase(deps, "ask")
svc := &cloud.Service[*state]{Base: b, State: &state{
registry: NewRegistry(newBooksContributor(app)),
registry: NewRegistry(domains()...),
ai: deps.AI,
model: cloud.DefaultModel,
}}
app.Post("/v1/ask", cloud.Handle(svc, askHandler))
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,web", "web_modes", "search,news,research,deep")
// The SAME answer engine, at an address an agent can speak. See web.go.
if err := mountWeb(app, svc.State, b); err != nil {
return err
}
b.Log.Info("ask mounted", "prefix", "/v1/ask", "domains", "books,projects,git,web", "web_modes", "search,news,research,deep")
return nil
}
@@ -196,10 +208,19 @@ func askHandler(s *cloud.Service[*state], c *zip.Ctx) error {
return askJSON(c, honestFallback())
}
// Gather the REAL figures in-process, under the caller's OWN credentials (so the read is
// scoped to the caller's org and no other). A gather error degrades to the honest fallback —
// never a guessed number.
facts, sources, err := domain.Gather(c.Context(), credential(c))
// Gather the REAL figures from the domain, AS THIS CALLER — so the read is scoped to the
// caller's own org and no other. A gather error degrades to the honest fallback — never a
// guessed number.
//
// cloud.As(c, "") and not c.Context(), and the difference is the whole tenancy story on
// this path. This is an UNTYPED handler: zip attaches the in-flight request to the context
// it hands a TYPED op, not to this one, so c.Context() answers "nobody is calling" and a
// domain that correctly refuses an anonymous read would refuse every question ever asked.
// As carries THIS request's principal — read off the headers the edge already validated,
// which is also what the gate above just checked — onto a context the peer can read it
// from. The empty org argument is "keep the caller's own tenant": there is no widening
// here, and no place for one, because a question is only ever asked about the asker.
facts, sources, err := domain.Gather(cloud.As(c, ""), credential(c))
if err != nil {
s.Log.Warn("ask gather failed", "domain", domain.Name(), "err", err)
return askJSON(c, honestFallback())
@@ -305,22 +326,28 @@ func templateAnswer(facts []Fact) string {
// figure the advisor cannot ground is a figure it must not state.
func honestFallback() askAnswer {
return askAnswer{
Answer: "I can answer questions about your finances today — MRR, revenue, burn, runway, margin, cash, and P&L. Infra and usage advisors are coming.",
Answer: "I can answer questions about your finances (MRR, revenue, burn, runway, margin, cash, P&L), " +
"your projects and what of them is deployed, and your repositories and what changed in them.",
Figures: []Fact{},
Followups: []string{"What's my MRR?", "How long is my runway?", "What is my gross margin?"},
Followups: []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"},
Sources: []string{},
Domain: "",
}
}
// followups returns sharp next questions for a domain — deterministic, so the advisor always
// offers a path forward. Extended per domain as new contributors join.
// offers a path forward. One case per contributor; the default is the cross-domain menu, which
// is also what a caller sees when no domain matched.
func followups(domain string) []string {
switch domain {
case "books":
return []string{"How long is my runway?", "What is my gross margin?", "How much of revenue is recurring?"}
case "projects":
return []string{"Which projects are live?", "What did I deploy most recently?", "How many repositories do I have?"}
case "git":
return []string{"Which repositories changed recently?", "What have I deployed?", "How much code do I have?"}
default:
return []string{"What's my MRR?", "How long is my runway?"}
return []string{"What's my MRR?", "What have I deployed?", "How many repositories do I have?"}
}
}
+188 -63
View File
@@ -1,17 +1,26 @@
package ask
// ask_test.go — proofs for the unified grounded advisor. The whole point is GROUNDING: every
// figure the advisor states is a REAL value read from a domain endpoint in-process; the model
// only narrates the figures it is handed and can NEVER override one. These tests stand up a fake
// in-process app whose /v1/books/metrics returns known figures scoped to the org the replay
// carries, plus a recording fakeAI, and assert:
// figure the advisor states is a REAL value read from a domain, over the internal plane; the
// model only narrates the figures it is handed and can NEVER override one.
//
// 1. a financial question returns the REAL figure the books read produced, cited in sources;
// 2. the model is fed the EXACT figure (the prompt contains it) and a hallucinated number in the
// model's reply does NOT override the grounded figure the caller receives;
// The domains here are STAND-IN PEERS, not stand-in transports: each test registers a real op
// on the real plane under the domain's real app name and operation id, so the advisor reaches
// them through the same generated client (plane/books, plane/projects, plane/git) it uses in
// production. What the fakes replace is the STORE behind the op, never the path to it — which
// is the distinction the previous version of this file got wrong. It faked the transport too,
// mounting /v1/books/metrics on the advisor's own router, and so it passed for months while
// production answered every question from the fallback: the advisor ships as its own process
// and never had that route.
//
// They assert:
//
// 1. a financial question returns the REAL figure the books peer produced, cited in sources;
// 2. the model is fed the EXACT figure and a hallucinated number does NOT override it;
// 3. a non-groundable question returns the honest fallback with ZERO fabricated figures;
// 4. org isolation — the replay carries the CALLER's org, so a books read can only ever surface
// the caller's own org's data, never another's.
// 4. org isolation — the peer is answered for the CALLER's org, so a domain read can only
// ever surface the caller's own org's data, never another's;
// 5. every wired domain — books, projects, git — actually contributes.
import (
"context"
@@ -19,11 +28,13 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
@@ -46,40 +57,89 @@ func (r *recordingAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float
return nil, nil
}
// fakeBooks mounts a stand-in GET /v1/books/metrics that returns figures scoped to the org it
// SEES on the request — the same principal.Org gate the real books read uses. A figure is tagged
// with the org, so a test can prove the advisor only ever surfaces the caller's own org's data.
// This is the grounded read the books contributor replays in-process.
func fakeBooks(app *zip.App, mrrByOrg map[string]string) {
app.Get("/v1/books/metrics", func(c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrUnauthorized("sign in")
}
mrr, seen := mrrByOrg[org]
if !seen {
mrr = "$0"
}
return c.JSON(http.StatusOK, map[string]any{
"figures": []Fact{
{Label: "MRR", Value: mrr, Period: "2026-07"},
{Label: "org-echo", Value: org, Period: "2026-07"},
},
})
})
// byOrg is a stand-in domain store: the figures each org holds. A peer built over it answers
// for the org the CALLER was, which is what makes the isolation proof mean something.
type byOrg map[string][]plane.Figure
// peer declares one stand-in domain on the real plane, under the real app name and the real
// operation id — so plane.Ask resolves it exactly as it resolves the live app.
//
// The handler derives the org the SAME way every real figures op does (cloud.Who(ctx).Org,
// anonymous refused). Nothing in the test hands it an org: it reads the one zip carried from
// the advisor's own in-flight request, which is the mechanism under proof.
//
// Declaring and SERVING are separate steps because the plane app freezes the first time it
// listens: every op has to be on it before any socket is bound, which is the same order Serve
// uses in production (mount everything, then bind).
func peer(app, path, opID string, data byOrg) {
zip.Post[plane.FiguresIn, plane.FiguresOut](cloud.Plane(), path,
func(ctx context.Context, _ *plane.FiguresIn) (*plane.FiguresOut, error) {
org := cloud.Who(ctx).Org
if org == "" {
return nil, zip.ErrForbidden(app + " figures: org required")
}
figs := data[org]
// An org this store has never heard of holds nothing — an empty slice, never
// another org's rows and never an error.
return &plane.FiguresOut{Figures: append([]plane.Figure(nil), figs...)}, nil
},
zip.WithOperationID(opID))
}
// newAskApp stands up an app with the fake books read + the ask advisor over a recording AI.
func newAskApp(t *testing.T, ai types.AIClient, mrrByOrg map[string]string) *zip.App {
// servePeers binds the plane socket for each named domain, after every op is declared.
func servePeers(t *testing.T, names ...string) {
t.Helper()
for _, n := range names {
stop, err := cloud.ServePlane(n, luxlog.New("test"))
if err != nil {
t.Fatalf("serve plane %s: %v", n, err)
}
t.Cleanup(func() { _ = stop() })
}
}
// newAskApp stands up the advisor over a recording AI, with stand-in books/projects/git peers
// on the plane. Each test gets its own runtime dir and its own plane, so no test is ever
// answered by a previous test's handler.
func newAskApp(t *testing.T, ai types.AIClient, books, projects, git byOrg) *zip.App {
t.Helper()
// A short run dir: a unix socket path is capped near 104 bytes and t.TempDir() spends
// most of that on the test's own name.
dir, err := os.MkdirTemp("", "askp")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
// ZIP_RUNTIME_DIR and not CLOUD_RUN_DIR: an operator's own runtime dir wins
// unconditionally, whereas CLOUD_RUN_DIR is consulted only when nothing has bound
// yet — and something always has by the second test, so every test after the first
// would keep the first one's sockets and be answered by its handlers.
t.Setenv("ZIP_RUNTIME_DIR", dir)
plane.Unbind()
cloud.ResetPlane()
t.Cleanup(func() {
cloud.ResetPlane()
plane.Unbind()
_ = os.RemoveAll(dir)
})
peer("books", "/books/figures", plane.BooksFigures, books)
peer("projects", "/projects/figures", plane.ProjectsFigures, projects)
peer("git", "/git/figures", plane.GitFigures, git)
servePeers(t, "books", "projects", "git")
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, mrrByOrg)
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: ai}); err != nil {
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: filepath.Join(dir, "data"), AI: ai}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
// money is the one-line stand-in ledger used where the test is about the ADVISOR rather than
// about a particular domain.
func money(mrr string) []plane.Figure {
return []plane.Figure{{Label: "MRR", Value: mrr, Period: "2026-07"}}
}
// ask POSTs a question as a VALIDATED principal for org (X-User-Id set, exactly as the gateway
// mints it — the test app has no sanitizer). Empty org exercises the anonymous 403 path.
func ask(t *testing.T, app *zip.App, org, question string) (int, askAnswer) {
@@ -124,11 +184,11 @@ func hasSource(r askAnswer, src string) bool {
return false
}
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books read
// TestGroundedFinancialAnswer: a financial question returns the REAL figure the books peer
// produced, tagged to the books domain and cited in sources.
func TestGroundedFinancialAnswer(t *testing.T) {
ai := &recordingAI{reply: "Your MRR is $4,200 for July."}
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
code, r := ask(t, app, "acme", "what is my MRR right now?")
if code != http.StatusOK {
@@ -140,19 +200,47 @@ func TestGroundedFinancialAnswer(t *testing.T) {
if v, ok := figure(r, "MRR"); !ok || v != "$4,200" {
t.Fatalf("MRR figure must be the real $4,200 from the books read, got %q (ok=%v)", v, ok)
}
if !hasSource(r, "books/metrics") {
t.Fatalf("answer must cite the books/metrics read, got %v", r.Sources)
if !hasSource(r, "books/figures") {
t.Fatalf("answer must cite the books/figures read, got %v", r.Sources)
}
}
// TestEveryWiredDomainContributes is the anti-regression for the defect this file was rewritten
// over: a domain in the registry that cannot actually be REACHED is worse than an absent one,
// because it degrades to the fallback and looks like "no domain matched". Each wired domain
// must route, return its own figure, and cite its own source.
func TestEveryWiredDomainContributes(t *testing.T) {
app := newAskApp(t, nil,
byOrg{"acme": money("$4,200")},
byOrg{"acme": {{Label: "Projects", Value: "7"}, {Label: "Deployed and serving", Value: "3"}}},
byOrg{"acme": {{Label: "Repositories", Value: "12"}, {Label: "Code stored", Value: "48.0 MB"}}},
)
for _, tc := range []struct{ question, domain, source, label, want string }{
{"what is my MRR?", "books", "books/figures", "MRR", "$4,200"},
{"what have I deployed?", "projects", "projects/figures", "Deployed and serving", "3"},
{"how many repositories do I have?", "git", "git/figures", "Repositories", "12"},
} {
_, r := ask(t, app, "acme", tc.question)
if r.Domain != tc.domain {
t.Fatalf("%q must route to %q, got %q (answer=%q)", tc.question, tc.domain, r.Domain, r.Answer)
}
if v, ok := figure(r, tc.label); !ok || v != tc.want {
t.Fatalf("%q must state the real %s=%s, got %q (ok=%v)", tc.question, tc.label, tc.want, v, ok)
}
if !hasSource(r, tc.source) {
t.Fatalf("%q must cite %s, got %v", tc.question, tc.source, r.Sources)
}
}
}
// TestModelFedExactFigureAndCannotOverride is THE grounding proof: the model is handed the EXACT
// figure in its prompt, and even when it replies with a HALLUCINATED number the grounded figure
// the caller receives is unchanged. The prose may carry the model's words; the figures array is
// the ledger's, never the model's.
// the domain's, never the model's.
func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
// The model hallucinates $9,999 in its narration — a number that is NOT the real figure.
ai := &recordingAI{reply: "Your MRR is a whopping $9,999 this month!"}
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "acme", "how's my recurring revenue?")
@@ -173,7 +261,7 @@ func TestModelFedExactFigureAndCannotOverride(t *testing.T) {
// TestNoModelStillGrounded: with no AI wired the advisor still answers with the REAL figures — the
// deterministic template states them, so the numbers are identical whether the model is up or down.
func TestNoModelStillGrounded(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "acme", "what's my mrr?")
if v, _ := figure(r, "MRR"); v != "$4,200" {
t.Fatalf("figure must be the real $4,200 with no model, got %q", v)
@@ -187,9 +275,9 @@ func TestNoModelStillGrounded(t *testing.T) {
// it names what the advisor CAN answer and carries ZERO figures. It must NEVER invent a number.
func TestHonestFallbackNoFabrication(t *testing.T) {
ai := &recordingAI{reply: "42 widgets shipped."} // the model would happily make something up
app := newAskApp(t, ai, map[string]string{"acme": "$4,200"})
app := newAskApp(t, ai, byOrg{"acme": money("$4,200")}, nil, nil)
code, r := ask(t, app, "acme", "how many widgets did we ship to Mars?")
code, r := ask(t, app, "acme", "how many widgets did we sell on Mars?")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
@@ -202,24 +290,27 @@ func TestHonestFallbackNoFabrication(t *testing.T) {
if len(r.Sources) != 0 {
t.Fatalf("the fallback must cite no sources, got %v", r.Sources)
}
if !strings.Contains(strings.ToLower(r.Answer), "finance") {
if !strings.Contains(strings.ToLower(r.Answer), "financ") {
t.Fatalf("the fallback must name what it CAN answer, got %q", r.Answer)
}
}
// TestOrgIsolation proves the in-process replay carries the CALLER's org: acme sees acme's figure,
// beta sees beta's, and neither can ever surface the other's data — tenant isolation is inherited
// from the caller's own creds on the replay, never re-implemented.
// TestOrgIsolation proves the domain read is answered for the CALLER's org: acme sees acme's
// figures, beta sees beta's, and neither can ever surface the other's. Nothing in the advisor
// states a tenant — the identity zip forwards off the caller's own request is the whole of it,
// which is why there is no argument here a caller could have supplied instead.
func TestOrgIsolation(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200", "beta": "$77,000"})
app := newAskApp(t, nil,
byOrg{"acme": money("$4,200"), "beta": money("$77,000")},
byOrg{
"acme": {{Label: "Projects", Value: "7"}},
"beta": {{Label: "Projects", Value: "999"}},
}, nil)
_, a := ask(t, app, "acme", "what's my mrr?")
if v, _ := figure(a, "MRR"); v != "$4,200" {
t.Fatalf("acme must see its own $4,200, got %q", v)
}
if echo, _ := figure(a, "org-echo"); echo != "acme" {
t.Fatalf("the books read must have been scoped to acme, got org-echo=%q", echo)
}
_, b := ask(t, app, "beta", "what's my mrr?")
if v, _ := figure(b, "MRR"); v != "$77,000" {
@@ -228,15 +319,35 @@ func TestOrgIsolation(t *testing.T) {
if v, _ := figure(b, "MRR"); v == "$4,200" {
t.Fatalf("beta must NEVER surface acme's $4,200")
}
if echo, _ := figure(b, "org-echo"); echo != "beta" {
t.Fatalf("the books read must have been scoped to beta, got org-echo=%q", echo)
// The same rule on a second domain, because tenancy is a property of the SEAM and not of
// one contributor that happened to get it right.
_, ap := ask(t, app, "acme", "what have I deployed?")
if v, _ := figure(ap, "Projects"); v != "7" {
t.Fatalf("acme must see its own 7 projects, got %q", v)
}
_, bp := ask(t, app, "beta", "what have I deployed?")
if v, _ := figure(bp, "Projects"); v != "999" {
t.Fatalf("beta must see its own 999 projects, got %q", v)
}
}
// TestUnknownOrgGetsNothingNotSomebodyElses: an org the domain has never heard of is answered
// with no figures — never a default, never the first org in the store.
func TestUnknownOrgGetsNothingNotSomebodyElses(t *testing.T) {
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
_, r := ask(t, app, "stranger", "what's my mrr?")
for _, f := range r.Figures {
if strings.Contains(f.Value, "4,200") {
t.Fatalf("an unknown org must never receive acme's figures, got %+v", r.Figures)
}
}
}
// TestAnonymousRefused: /v1/ask is a data plane — a request with no validated principal is 401,
// so an off-gateway forge can neither probe nor read a ledger through the advisor.
func TestAnonymousRefused(t *testing.T) {
app := newAskApp(t, nil, map[string]string{"acme": "$4,200"})
app := newAskApp(t, nil, byOrg{"acme": money("$4,200")}, nil, nil)
// Forged X-Org-Id with NO X-User-Id (no validated principal) — the anonymous forge.
body, _ := json.Marshal(askRequest{Question: "what's my mrr?"})
req := httptest.NewRequest(http.MethodPost, "/v1/ask", strings.NewReader(string(body)))
@@ -252,16 +363,30 @@ func TestAnonymousRefused(t *testing.T) {
}
}
// TestClassifierMatchesFinancialVocab locks the books classifier: the founder-vocabulary that
// grounds against the ledger routes to books, and off-topic questions do not.
func TestClassifierMatchesFinancialVocab(t *testing.T) {
reg := NewRegistry(newBooksContributor(nil))
for _, q := range []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"} {
if reg.Match(q) == nil {
t.Fatalf("financial question %q must match the books contributor", q)
// TestClassifierRoutesEachDomainsVocab locks the classifiers: each domain's vocabulary routes to
// it, and off-topic questions match nothing at all rather than being swept into whichever domain
// happens to be first.
func TestClassifierRoutesEachDomainsVocab(t *testing.T) {
reg := NewRegistry(domains()...)
for _, tc := range []struct {
want string
questions []string
}{
{"books", []string{"what's my MRR?", "how long is my runway", "are we profitable?", "how much cash do we have", "what's my gross margin", "show me the P&L", "how much did we make"}},
{"projects", []string{"what have I deployed?", "which projects are live", "what sites have I published", "what is running in production", "what did we ship"}},
{"git", []string{"how many repositories do I have?", "what changed recently", "how much code do we have", "list my repos", "which branches are there"}},
} {
for _, q := range tc.questions {
c := reg.Match(q)
if c == nil {
t.Fatalf("%q must match the %s contributor, matched nothing", q, tc.want)
}
if c.Name() != tc.want {
t.Fatalf("%q must route to %s, routed to %s", q, tc.want, c.Name())
}
}
}
for _, q := range []string{"what's the weather", "how many users signed up", "deploy the app"} {
for _, q := range []string{"what's the weather", "how many users signed up", "who is the CEO of France"} {
if c := reg.Match(q); c != nil {
t.Fatalf("off-topic question %q must NOT match any domain, matched %q", q, c.Name())
}
-92
View File
@@ -1,92 +0,0 @@
package ask
// books.go — the FIRST contributor: BOOKS (financial). It grounds MRR/ARR/revenue/burn/runway/
// margin/cash/deferred-revenue/P&L/balance questions by replaying the books domain's OWN
// grounded read — GET /v1/books/metrics — in-process under the caller's own credentials, then
// surfacing the REAL figures it returns. It NEVER recomputes: books stays the single source of
// the numbers AND their formatting (the endpoint returns formatUSD-formatted figures). Per-tenant
// isolation is inherited from the replay carrying the caller's own X-Org-Id — a books read can
// only ever return the caller's own org's ledger.
import (
"context"
"encoding/json"
"fmt"
fiber "github.com/zap-proto/fiber/v3"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/hanzoai/cloud"
)
// booksMetricsPath is the books domain's grounded read the contributor replays. It is the ONE
// endpoint that returns the deterministic metric snapshot as formatted figures.
const booksMetricsPath = "/v1/books/metrics"
// maxMetricsResponse bounds the in-process read so a broken upstream cannot balloon memory.
const maxMetricsResponse = 1 << 20
// booksContributor grounds financial questions against the books domain. It holds the app it
// replays against (the SAME binary; the read flows the normal middleware chain), exactly as
// agent.go's aiCompleter holds the app to replay /v1/chat/completions.
type booksContributor struct{ app cloud.Router }
func newBooksContributor(app cloud.Router) *booksContributor { return &booksContributor{app: app} }
func (booksContributor) Name() string { return "books" }
// CanAnswer is the books classifier: does the question ask about money the ledger knows? Keyword
// match over the founder-vocabulary the metrics engine grounds. First-match in the registry, so
// a books question routes here deterministically. An LLM classifier can replace this body later
// without touching the router or the seam.
func (booksContributor) CanAnswer(question string) bool {
l := strings.ToLower(question)
for _, kw := range booksKeywords {
if strings.Contains(l, kw) {
return true
}
}
return false
}
// booksKeywords is the financial vocabulary that routes a question to the books ledger. It
// mirrors the books intent router's keywords so /v1/ask grounds exactly what /v1/books/ask does.
var booksKeywords = []string{
"mrr", "arr", "recurring", "subscription", "annualized",
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
"runway", "how long", "cash last", "out of money", "out of cash",
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
"cash", "bank", "in the bank", "balance", "cogs",
"deferred", "wallet", "prepaid", "liabilit", "owe",
"p&l", "pnl", "p and l", "financ",
}
// Gather replays GET /v1/books/metrics in-process under the caller's own credentials and returns
// the REAL figures it read, with the domain read that backed them. The figures are books' own —
// this contributor formats nothing and invents nothing; an empty ledger yields honest zero
// figures. The caller's creds carry the caller's org, so the read is scoped to that org and no
// other — tenant isolation is inherited, never re-implemented here.
func (b booksContributor) Gather(ctx context.Context, cred map[string]string) ([]Fact, []string, error) {
req := httptest.NewRequest(http.MethodGet, booksMetricsPath, nil).WithContext(ctx)
for k, v := range cred {
req.Header.Set(k, v)
}
resp, err := b.app.Fiber().Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
return nil, nil, fmt.Errorf("books metrics replay: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return nil, nil, fmt.Errorf("books metrics status %d", resp.StatusCode)
}
var out struct {
Figures []Fact `json:"figures"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxMetricsResponse)).Decode(&out); err != nil {
return nil, nil, fmt.Errorf("books metrics decode: %w", err)
}
return out.Figures, []string{"books/metrics"}, nil
}
+148
View File
@@ -0,0 +1,148 @@
package ask
// domains.go — the domains the advisor can ground a question in.
//
// There is ONE contributor type here, not one per domain, because the domains
// differ in exactly two things: which questions they recognise, and which peer
// they ask. Everything else — refuse anonymous, read the figures, name the
// source — is the same sentence written once. Adding a domain is a value in the
// registry, never a new type and never a router edit.
//
// EVERY DOMAIN IS A PLANE CALL, and that is the whole correction this file
// carries. /v1/ask ships as its own plugin binary (plugin/ask/main.go mounts
// ask.Mount and nothing else), so the pod runs the advisor and every domain it
// asks as separate pids. The first contributor read its figures by replaying an
// HTTP request against the advisor's OWN router — which in production holds one
// route, /v1/ask — so the read 404'd, the gather failed, and every money
// question in production was answered by the "I can answer questions about your
// finances" fallback with an empty figures array, while books sat healthy one
// socket away. An in-process replay cannot cross a process boundary. A plane
// call is the one thing that can.
//
// TENANCY. The org rides the CALLER and is never an argument: [plane.FiguresIn]
// is an empty struct, and zip forwards the gateway's own assertion off the
// in-flight request to the peer (zip caller.go, forwardIdentity). So a
// contributor cannot name an org, cannot be handed one, and cannot widen the one
// it was called with — the peer answers for whoever the edge said was asking,
// and refuses when that is nobody. Nothing here states a tenant, deliberately:
// cloud.For on a context with a request behind it is a silent no-op, so code
// that appeared to set the org would read as correct and scope nothing.
import (
"context"
"strings"
"github.com/hanzoai/cloud/plane"
booksplane "github.com/hanzoai/cloud/plane/books"
gitplane "github.com/hanzoai/cloud/plane/git"
projectsplane "github.com/hanzoai/cloud/plane/projects"
)
// domain is one grounded peer behind the advisor: the name the answer is tagged
// with, the vocabulary that routes a question to it, the read it names as its
// source, and the typed plane client that performs it.
//
// ask is the GENERATED client function (plane/<app>), never plane.Ask with loose
// strings — the compiler is what checks that an op name belongs to the app it is
// sent to and that In and Out are the pair that op declared.
type domain struct {
name string
source string
keywords []string
ask func(context.Context, *plane.FiguresIn) (*plane.FiguresOut, error)
}
func (d domain) Name() string { return d.name }
// CanAnswer is the classifier: does this domain's vocabulary appear in the
// question? Deterministic keyword match, first-match-wins in registry order. An
// LLM classifier can replace this body without touching the seam or the router.
func (d domain) CanAnswer(question string) bool {
l := strings.ToLower(question)
for _, kw := range d.keywords {
if strings.Contains(l, kw) {
return true
}
}
return false
}
// Gather reads the domain's REAL figures over the internal plane, as the caller.
//
// cred is ignored, and its absence from this body is the point: the identity is
// already on the context zip hands the peer, so a credential copied by hand here
// would be a second, weaker answer to a question the transport has already
// answered. The parameter stays because it is the seam's, not this domain's.
//
// An empty org answers an empty figures slice — the domain read succeeded and
// the org has nothing — which the advisor narrates honestly. A FAILURE returns
// an error and the advisor falls back rather than stating a number it could not
// read.
func (d domain) Gather(ctx context.Context, _ map[string]string) ([]Fact, []string, error) {
out, err := d.ask(ctx, &plane.FiguresIn{})
if err != nil {
return nil, nil, err
}
facts := make([]Fact, 0, len(out.Figures))
for _, f := range out.Figures {
facts = append(facts, Fact{Label: f.Label, Value: f.Value, Period: f.Period})
}
return facts, []string{d.source}, nil
}
// domains is the advisor's registry contents, in classification order. Money
// first: it is the most-asked question and its vocabulary is the most specific,
// so a question that is about money is never taken by a domain that merely
// shares a word with it.
func domains() []Contributor {
return []Contributor{
domain{
name: "books",
source: "books/figures",
keywords: booksKeywords,
ask: booksplane.BooksFigures,
},
domain{
name: "projects",
source: "projects/figures",
keywords: projectKeywords,
ask: projectsplane.ProjectsFigures,
},
domain{
name: "git",
source: "git/figures",
keywords: gitKeywords,
ask: gitplane.GitFigures,
},
}
}
// booksKeywords is the financial vocabulary that routes a question to the
// ledger. It mirrors the books intent router's keywords so /v1/ask grounds
// exactly what /v1/books/ask does.
var booksKeywords = []string{
"mrr", "arr", "recurring", "subscription", "annualized",
"revenue", "sales", "top line", "income", "how much did we make", "how much money",
"burn", "spend", "spending", "expense", "expenses", "opex", "costs", "cost of",
"runway", "how long", "cash last", "out of money", "out of cash",
"margin", "profitab", "profit", "net income", "bottom line", "earnings", "break even", "break-even",
"cash", "bank", "in the bank", "balance", "cogs",
"deferred", "wallet", "prepaid", "liabilit", "owe",
"p&l", "pnl", "p and l", "financ",
}
// projectKeywords is the vocabulary of what the org has BUILT and what of it is
// serving — the "what have we shipped" question.
var projectKeywords = []string{
"project", "deploy", "deployed", "deployment", "shipped", "ship",
"site", "sites", "website", "web site", "published", "publish",
"live", "serving", "in production", "hosting", "hosted",
}
// gitKeywords is the vocabulary of the org's SOURCE — how much there is and what
// moved lately.
var gitKeywords = []string{
"repo", "repos", "repositor", "git", "codebase", "source code",
"commit", "commits", "branch", "branches", "merge", "pushed",
"how much code", "lines of code", "what changed", "recently updated",
}
+5 -13
View File
@@ -9,26 +9,18 @@ import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/openapi"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// askApp mounts the door with a stubbed books contributor and a stubbed model, on
// a bare app — the same harness the behaviour suite uses.
// askApp mounts the door with a stand-in books peer and a stubbed model — the
// same harness the behaviour suite uses, which is the point: one harness, so a
// wire proof and a behaviour proof are made against the same door.
func askApp(t *testing.T) *zip.App {
t.Helper()
noNetworkSearch(t)
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, map[string]string{"acme": "$4,200"})
if err := Mount(app, cloud.Deps{
Logger: luxlog.New("test"), DataDir: t.TempDir(),
AI: &webAI{answer: "Clojure was created by Rich Hickey."},
}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
return newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
byOrg{"acme": money("$4,200")}, nil, nil)
}
func askRaw(t *testing.T, app *zip.App, body string, hdr map[string]string) *http.Response {
+147
View File
@@ -0,0 +1,147 @@
// The researched answer, as a typed op — the door an agent can actually reach.
//
// The loop itself is not new and none of it lives here: apps/answer has run the
// bounded plan → search → read → rank → synthesize → cite pass since it was
// written, behind POST /v1/ask with a `mode`. What it did not have was a door a
// MODEL could open. /v1/ask is untyped for three wire reasons that are all still
// true (see the init in ask.go), and an untyped route reaches REST and nothing
// else — no MCP tool, no CLI command, no SDK method. So the fleet's deep-research
// capability was complete, correct, metered, and invisible to every agent in it.
//
// This is the same engine offered at an address the agent can speak, which is
// exactly what apps/websearch already did for search: a compat door the model
// cannot use, and beside it a native typed op running the SAME code. One engine,
// two doors, no second implementation to drift.
package ask
import (
"context"
"strings"
"sync/atomic"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/answer"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// Go drops comments at compile time, so cmd/zipdoc is the ONLY path from the
// handler's prose to the published document, the SDKs and the MCP tool
// description. Its output is committed; `make zipdoc-check` fails on drift.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// webPath is the researched answer's own address, under the prefix this app
// already owns so the composed deployment routes it here without a manifest
// change. /v1/research is NOT free — it belongs to the grants subsystem.
const webPath = "/v1/ask/web"
// engine holds what the typed op runs on. A typed handler is handed a context
// and an In and nothing else, so the engine has to be reachable from package
// scope; Mount stores it and the handler loads it. Same shape apps/sandbox uses
// for its own typed ops, and for the same reason — a named handler, never a
// closure, is what zipdoc can lift prose from.
var engine atomic.Pointer[answer.Engine]
// webQuestion is the POST /v1/ask/web body: the question, and the few knobs that
// change how hard the engine looks for the answer.
type webQuestion struct {
// Q is the question, in plain language. Required.
Q string `json:"q"`
// Mode is how much work to do: `search` (fast, one pass), `news` (recency
// biased), `research` (a plan and several rounds) or `deep` (the widest
// survey). Empty means research.
Mode string `json:"mode,omitempty"`
// Sources narrows where the evidence comes from: any of `web`, `news`,
// `academic`, `github`, `reddit`, `x`. Each becomes a site-scoped search, so
// `["x"]` researches X/Twitter posts rather than the open web.
Sources []string `json:"sources,omitempty"`
// Language narrows the search to a locale, BCP-47-ish ("en", "ja"). Empty
// means no narrowing.
Language string `json:"language,omitempty"`
// MaxSources caps how many pages are read. Empty means the mode's own budget.
MaxSources int `json:"max_sources,omitempty"`
}
// defaultMode is what an unspecified mode means. `research` and not `search`,
// because a caller who reached for THIS op rather than search_web has already
// said they want the reading done for them; a one-pass answer is what the other
// op is.
const defaultMode = "research"
// researchWeb researches a question on the live web and answers it with its
// sources cited.
//
// This is the DEEP one. It plans the question into topics, runs several web
// searches, FETCHES AND READS the pages it finds, ranks them, and writes a
// grounded answer with inline markdown citations. Use it for anything that needs
// evidence, comparison or current fact — "what changed in X", "compare A and B",
// "is this claim true". For a plain list of links, use search_web instead; for
// one page you already have the URL of, use read_page.
//
// `mode` buys depth: `search` is a single fast pass, `news` biases to recency,
// `research` plans and iterates, `deep` surveys widest. `sources` narrows the
// evidence to `web`, `news`, `academic`, `github`, `reddit` or `x` — each becomes
// a site-scoped search, which is how this reaches X/Twitter posts.
//
// EVERY CITATION IS A PAGE THIS CALL FETCHED. That is a property of the text and
// not an instruction to the model: each source is fenced with a per-request nonce
// so a crawled page cannot print itself a source number, and every markdown link
// in the answer is checked against the gathered set before it is returned. So a
// link in `answer` always appears in `sources`, and a page that was not read
// cannot be cited.
//
// It is BOUNDED and it degrades rather than failing: a mode's rounds, wall clock
// and token ceiling all cap it, and a search that finds little or a page that
// will not load yields a thinner answer, never an error. A validated principal is
// required, and the answer is billed once to that principal's org.
func researchWeb(ctx context.Context, in *webQuestion) (*answer.Report, error) {
if !principal.ValidatedFrom(ctx) {
return nil, zip.ErrUnauthorized("sign in to research the web")
}
e := engine.Load()
if e == nil {
return nil, zip.Errorf(503, "ask: the answer engine is not mounted")
}
q := strings.TrimSpace(in.Q)
if q == "" {
return nil, zip.ErrBadRequest("q required")
}
if len(q) > maxQuestion {
q = q[:maxQuestion]
}
mode := strings.TrimSpace(in.Mode)
if mode == "" {
mode = defaultMode
}
if !answer.IsMode(mode) {
return nil, zip.ErrBadRequest("mode must be one of search, news, research, deep")
}
return e.Answer(ctx, answer.Request{
Mode: mode,
Sources: in.Sources,
Language: in.Language,
MaxSources: in.MaxSources,
}, q)
}
// mountWeb stores the engine the typed op runs on and registers it.
//
// The registration is on the *zip.App and with an ABSOLUTE path, because that is
// what zipdoc can resolve statically — it cannot follow a cloud.Router interface
// to a prefix.
func mountWeb(app cloud.Router, s *state, b cloud.Base) error {
engine.Store(&answer.Engine{Base: b, AI: s.ai, Model: s.model})
reg := cloud.ZipApp(app)
if reg == nil {
return nil // single-binary hosts without a typed registry keep the REST door
}
// Named, not derived. A POST to this path derives `create_ask_web`, which
// reads as "make an ask web" — a resource nothing here has. `research_web` is
// the verb over the noun, and it sits beside `search_web` so the two web verbs
// read as the pair they are: search returns links, research returns an answer.
zip.Post(reg, webPath, researchWeb,
zip.WithOperationID("research_web"),
zip.WithSummary("Research a question on the live web and answer it with sources cited"))
return nil
}
+2 -8
View File
@@ -14,10 +14,7 @@ import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/types"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// webAI answers the engine's synthesis prompt with a fixed string; the plan and
@@ -49,11 +46,8 @@ func noNetworkSearch(t *testing.T) {
// UNTOUCHED (a no-mode financial question still routes to books).
func TestAskWebModeDispatch(t *testing.T) {
noNetworkSearch(t)
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
fakeBooks(app, map[string]string{"acme": "$4,200"})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), AI: &webAI{answer: "Clojure was created by Rich Hickey."}}); err != nil {
t.Fatalf("Mount: %v", err)
}
app := newAskApp(t, &webAI{answer: "Clojure was created by Rich Hickey."},
byOrg{"acme": money("$4,200")}, nil, nil)
// web mode → the answer engine
body, _ := json.Marshal(askRequest{Q: "who created clojure and why", Mode: "search"})
+25
View File
@@ -0,0 +1,25 @@
// Code generated by zipdoc; DO NOT EDIT.
package ask
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("POST /v1/ask/web", zip.Doc{
Description: "Researches a question on the live web and answers it with its\nsources cited.\n\nThis is the DEEP one. It plans the question into topics, runs several web\nsearches, FETCHES AND READS the pages it finds, ranks them, and writes a\ngrounded answer with inline markdown citations. Use it for anything that needs\nevidence, comparison or current fact — \"what changed in X\", \"compare A and B\",\n\"is this claim true\". For a plain list of links, use search_web instead; for\none page you already have the URL of, use read_page.\n\n`mode` buys depth: `search` is a single fast pass, `news` biases to recency,\n`research` plans and iterates, `deep` surveys widest. `sources` narrows the\nevidence to `web`, `news`, `academic`, `github`, `reddit` or `x` — each becomes\na site-scoped search, which is how this reaches X/Twitter posts.\n\nEVERY CITATION IS A PAGE THIS CALL FETCHED. That is a property of the text and\nnot an instruction to the model: each source is fenced with a per-request nonce\nso a crawled page cannot print itself a source number, and every markdown link\nin the answer is checked against the gathered set before it is returned. So a\nlink in `answer` always appears in `sources`, and a page that was not read\ncannot be cited.\n\nIt is BOUNDED and it degrades rather than failing: a mode's rounds, wall clock\nand token ceiling all cap it, and a search that finds little or a page that\nwill not load yields a thinner answer, never an error. A validated principal is\nrequired, and the answer is billed once to that principal's org.",
Fields: map[string]string{
"Report.answer": "Answer is the grounded prose, with inline markdown citations. Every link in\nit points at a page in Sources: the citation check runs on the text before\nit leaves the engine, so a cited URL is one THIS call fetched.",
"Report.follow_ups": "FollowUps are the questions worth asking next. Best-effort — an empty list\nis a normal outcome, not a fault.",
"Report.mode": "Mode is the profile that ran: search, news, research or deep.",
"Report.model": "Model is the model that synthesized the answer.",
"Report.sources": "Sources are the pages the answer was written from, deduplicated and ranked.\nAlways an array, never null.",
"webQuestion.language": "Language narrows the search to a locale, BCP-47-ish (\"en\", \"ja\"). Empty\nmeans no narrowing.",
"webQuestion.max_sources": "MaxSources caps how many pages are read. Empty means the mode's own budget.",
"webQuestion.mode": "Mode is how much work to do: `search` (fast, one pass), `news` (recency\nbiased), `research` (a plan and several rounds) or `deep` (the widest\nsurvey). Empty means research.",
"webQuestion.q": "Q is the question, in plain language. Required.",
"webQuestion.sources": "Sources narrows where the evidence comes from: any of `web`, `news`,\n`academic`, `github`, `reddit`, `x`. Each becomes a site-scoped search, so\n`[\"x\"]` researches X/Twitter posts rather than the open web.",
},
})
}
+156 -35
View File
@@ -528,20 +528,66 @@ func emitAudit(s *cloud.Service[state], ctx context.Context, action string, a Au
// adminAuthorView is one row in the SuperAdmin directory (org exposed).
type adminAuthorView struct {
ID string `json:"id"`
Org string `json:"org"`
GithubLogin string `json:"githubLogin"`
Status string `json:"status"`
Verified bool `json:"verified"`
ShareBps int64 `json:"shareBps"`
RepoCount int `json:"repoCount"`
DeployCount int `json:"deployCount"`
AccruedCents int64 `json:"accruedCents"`
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
CreatedAt int64 `json:"createdAt"`
ApprovedAt int64 `json:"approvedAt"`
SuspendedAt int64 `json:"suspendedAt"`
// ID is the author record's server-minted handle, "aut_"-prefixed. It is the id
// the approve, suspend, payout and admin-basis routes address.
ID string `json:"id"`
// Org is the tenant org that owns this author record — UNIQUE, one author per
// org. It is exposed HERE and nowhere else (Author.Org is json:"-" on the tenant
// surface), and it is the org excluded from this author's own accrual: deploying
// your own repo earns you nothing.
Org string `json:"org"`
// GithubLogin is the linked forge account, lowercased. It comes from IAM's
// linked account when the connect had one — which is also what sets verified —
// and otherwise from the login the caller declared. The treasury author carries
// "<brand>-maintainers".
GithubLogin string `json:"githubLogin"`
// Status is connected, approved or suspended. Only an approved author accrues;
// a connected one may verify repos and collect deploy edges but earns nothing
// until a reviewer admits it.
Status string `json:"status"`
// Verified is IDENTITY proof of the login, NOT proof of any repository: true
// when the connect took the login from IAM's linked forge account (and for the
// seeded treasury author), false when the caller merely declared it. A false
// here still earns — repository ownership is proven separately, per claim.
Verified bool `json:"verified"`
// ShareBps is the royalty rate accrual applies, in basis points of a deploying
// org's metered spend for the period: 2000 (the platform default) is 20%, 10000
// would be the entire spend. The platform keeps 10000 shareBps. Changing it
// never rewrites history — each ledger row keeps the rate it was written with.
ShareBps int64 `json:"shareBps"`
// RepoCount is how many of this author's repository claims are VERIFIED, counted
// for this response in one GROUP BY over the whole table rather than a query per
// row. The single-author replies from approve, suspend and payout report 0: they
// carry the mutated row, not a re-listing.
RepoCount int `json:"repoCount"`
// DeployCount is how many attribution edges point at this author — one per
// (repository, project, deploying org), so re-deploying the same project adds
// none. It includes self-deploys, which are recorded for provenance and excluded
// from accrual, so it measures reach, not the earning set.
DeployCount int `json:"deployCount"`
// AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of
// every latched accrual (spend × shareBps / 10000). It only ever rises — a
// payout is recorded against paidCents and never reduces this.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is what a payout may still draw against — accrued paid, floored
// at zero. It is derived for each response, never stored, and it is the exact
// figure the atomic payout guard refuses to exceed.
PendingCents int64 `json:"pendingCents"`
// PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises
// the moment a payout reserves against pending — recording, not settling; a human
// moves the money out of band — and falls back only when a payout is voided.
PaidCents int64 `json:"paidCents"`
// CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the
// login and leaves this alone, so it dates the enrolment, not the latest link.
CreatedAt int64 `json:"createdAt"`
// ApprovedAt is unix seconds of the first approval, and 0 means never approved —
// which is also "has never been able to accrue". Re-approving to renegotiate the
// share leaves it at the original date.
ApprovedAt int64 `json:"approvedAt"`
// SuspendedAt is unix seconds of the most recent suspension. 0 means the author
// is not suspended: either never was, or was and has since been approved again,
// which clears this back to 0.
SuspendedAt int64 `json:"suspendedAt"`
}
func adminViewOf(a Author, repos, deploys int) adminAuthorView {
@@ -556,12 +602,35 @@ func adminViewOf(a Author, repos, deploys int) adminAuthorView {
// authorRepo is one row of an author's verified/claimed repos, with the ready-to-paste
// Deploy-on-Hanzo markdown snippet.
type authorRepo struct {
RepoURL string `json:"repoUrl"`
Verified bool `json:"verified"`
Method string `json:"method,omitempty"`
// RepoURL is the claim key in canonical form — lowercased "host/owner/name",
// no scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is
// normalized through the same function before attribution, so the two sides can
// never miss on a cosmetic difference. UNIQUE across every author: first proven
// claim wins.
RepoURL string `json:"repoUrl"`
// Verified reports that ownership was proven. Only a proven claim is ever
// written, so it is true on every row this surface returns; the deploy path
// re-reads it regardless, because an unverified claim attributes nothing.
Verified bool `json:"verified"`
// Method is HOW ownership was proven: "oauth" — an IAM-linked forge token showed
// admin or push on the repository; "file" — a hanzo.json on the default branch
// carried this author's verify code; or "maintainer" — the repository sits in a
// first-party namespace, where ownership is intrinsic and the treasury author
// holds it with no proof step. Omitted on a row written before the method was
// recorded.
Method string `json:"method,omitempty"`
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
// from this deployment's badge host and never stored: a "Deploy on Hanzo" image
// linking to the one-click import of this repository. Re-hosting the builder
// changes every badge without touching a row.
BadgeMarkdown string `json:"badgeMarkdown"`
VerifiedAt int64 `json:"verifiedAt"`
CreatedAt int64 `json:"createdAt"`
// VerifiedAt is unix seconds of the most recent successful proof. Re-verifying
// refreshes it, and the method beside it, in place.
VerifiedAt int64 `json:"verifiedAt"`
// CreatedAt is unix seconds when the claim was first recorded. It equals
// verifiedAt on the first proof and then stays put while verifiedAt moves, so the
// pair reads as "claimed since / last proven".
CreatedAt int64 `json:"createdAt"`
}
func authorRepoOf(r AuthorRepo, badgeBase string) authorRepo {
@@ -583,12 +652,32 @@ func authorRepos(rs []AuthorRepo, badgeBase string) []authorRepo {
// orgView is one row of an author's verified OWNER-WIDE claims: the owner url + a
// ready-to-paste badge deep-linking that owner's Hanzo template import.
type orgView struct {
OwnerURL string `json:"ownerUrl"`
Verified bool `json:"verified"`
Method string `json:"method,omitempty"`
// OwnerURL is the claim key in canonical form — lowercased "host/owner" with NO
// repository segment, host ∈ {github.com, gitlab.com}. It covers every repository
// under that owner, so code with no claim of its own still earns; a per-repository
// claim outranks it. UNIQUE across every author: first proven claim wins.
OwnerURL string `json:"ownerUrl"`
// Verified reports that ownership of the WHOLE owner was proven — against that
// owner's ".github" control repository, which is exactly as strong as a
// per-repository claim. Only a proven claim is written, so every row returned
// here is true.
Verified bool `json:"verified"`
// Method is HOW the owner was proven, always against its ".github" control
// repository: "oauth" — an IAM-linked forge token showed admin or push on it; or
// "file" — a hanzo.json on its default branch carried this author's verify code.
// The "maintainer" shortcut is a per-repository attribution and never appears
// here. Omitted on a row written before the method was recorded.
Method string `json:"method,omitempty"`
// BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response
// from this deployment's badge host and never stored — here it deep-links the
// OWNER's template import rather than one repository's.
BadgeMarkdown string `json:"badgeMarkdown"`
VerifiedAt int64 `json:"verifiedAt"`
CreatedAt int64 `json:"createdAt"`
// VerifiedAt is unix seconds of the most recent successful proof of the owner;
// re-verifying refreshes it, and the method beside it, in place.
VerifiedAt int64 `json:"verifiedAt"`
// CreatedAt is unix seconds when the owner claim was first recorded — equal to
// verifiedAt on the first proof, then fixed while verifiedAt moves.
CreatedAt int64 `json:"createdAt"`
}
func orgViewOf(o AuthorOrg, badgeBase string) orgView {
@@ -625,16 +714,34 @@ func deployViews(es []DeployEvent) []deployView {
// payoutView is one row of an author's payout history.
type payoutView struct {
ID string `json:"id"`
AmountCents int64 `json:"amountCents"`
Method string `json:"method"`
Reference string `json:"reference,omitempty"`
Txn string `json:"txn,omitempty"`
// ID is the payout row's server-minted handle, "apo_"-prefixed. A caller never
// supplies it; it is what an operator quotes when reconciling a settlement.
ID string `json:"id"`
// AmountCents is the amount RESERVED against pending royalty, in integer USD
// cents, always positive. The reservation is atomic and can never exceed
// accrued paid, so this is owed money moved out of pending — not money moved.
AmountCents int64 `json:"amountCents"`
// Method is how the operator says this settles, lowercased as recorded.
// "credits" is the one method that means the author's own wallet; anything else
// — wire, paypal, check — is a cash disbursement a human performs. Recording it
// pays nobody either way.
Method string `json:"method"`
// Reference is the operator's external handle for the settlement: a wire
// confirmation, a PayPal transaction id. Absent when none was given.
Reference string `json:"reference,omitempty"`
// Txn is the commerce ledger transaction id of a SETTLED credits payout, and it
// is absent on every payout this service records. Recording moves no money, and
// authors asks the money plane exactly one question — what has this org spent? —
// with no write to answer it with, so there is no receipt to carry. It fills in
// only when a settlement stamps its transaction back onto the row.
Txn string `json:"txn,omitempty"`
// Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author
// and to the admin mirror alike — the disclosure that keeps a first-party
// settlement legible as internal accounting.
Settlement string `json:"settlement,omitempty"`
CreatedAt int64 `json:"createdAt"`
// CreatedAt is unix seconds when the payout was RECORDED — the moment the amount
// left pending, not the moment a human moved the money.
CreatedAt int64 `json:"createdAt"`
}
func payoutViewOf(p Payout) payoutView {
@@ -655,13 +762,27 @@ func payoutViews(ps []Payout) []payoutView {
// product-qualified because the fleet's schema namespace is FLAT and apps/referrals
// already publishes an "adminSummary" of its own.
type authorProgramSummary struct {
Total int `json:"total"`
Connected int `json:"connected"`
Approved int `json:"approved"`
Suspended int `json:"suspended"`
// Total is how many author records this response actually carried. The roll-up
// is folded over the SAME page as authors — newest first, bounded by limit
// (default 500, ceiling 1000) — so on a program larger than the page it
// summarizes that page, not the fleet.
Total int `json:"total"`
// Connected is how many of those are enrolled but not yet admitted to earning.
Connected int `json:"connected"`
// Approved is how many are admitted and accruing.
Approved int `json:"approved"`
// Suspended is how many have been stopped from accruing further. An author holds
// exactly one status, so the three buckets never overlap and connected +
// approved + suspended = total.
Suspended int `json:"suspended"`
// AccruedCents is the page's lifetime royalty accrued, in integer USD cents.
AccruedCents int64 `json:"accruedCents"`
// PendingCents is what the platform still owes across the page, in integer USD
// cents — the sum of each author's own accrued paid, each floored at zero.
PendingCents int64 `json:"pendingCents"`
PaidCents int64 `json:"paidCents"`
// PaidCents is what has been RECORDED as paid across the page, in integer USD
// cents. Recorded, not settled: the money leaves in a human's hands.
PaidCents int64 `json:"paidCents"`
}
func (s *authorProgramSummary) add(a Author) {
+112 -31
View File
@@ -12,12 +12,33 @@ func init() {
zip.Describe("GET /v1/admin/authors", zip.Doc{
Description: "Returns the platform's whole author program — every org's author\nrecord, not the caller's — with each one's repository and deploy counts and a\nfleet roll-up of the money accrued, pending and paid.\n\nIt is a Hanzo platform operation: a caller who is not a SuperAdmin gets 403. It\nexposes the owning org of each author, which no tenant-facing read ever does.",
Fields: map[string]string{
"adminBook.data": "Data is the book.",
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"adminBook.data": "Data is the book.",
"adminBook.msg": "Msg is the envelope's message slot, empty on success.",
"adminBook.status": "Status is \"ok\" — the operator console's envelope discriminator.",
"adminBookData.authors": "Authors are the author records, with each one's repository and deploy counts.",
"adminBookData.summary": "Summary is the fleet roll-up: how many authors at each status and the money\naccrued, pending and paid across all of them.",
"adminLimit.limit": "Limit bounds the page. 0 or less means the default of 500; anything above\n1000 is clamped to 1000.",
"authorProgramSummary.accruedCents": "AccruedCents is the page's lifetime royalty accrued, in integer USD cents.",
"authorProgramSummary.approved": "Approved is how many are admitted and accruing.",
"authorProgramSummary.connected": "Connected is how many of those are enrolled but not yet admitted to earning.",
"authorProgramSummary.paidCents": "PaidCents is what has been RECORDED as paid across the page, in integer USD\ncents. Recorded, not settled: the money leaves in a human's hands.",
"authorProgramSummary.pendingCents": "PendingCents is what the platform still owes across the page, in integer USD\ncents — the sum of each author's own accrued paid, each floored at zero.",
"authorProgramSummary.suspended": "Suspended is how many have been stopped from accruing further. An author holds\nexactly one status, so the three buckets never overlap and connected +\napproved + suspended = total.",
"authorProgramSummary.total": "Total is how many author records this response actually carried. The roll-up\nis folded over the SAME page as authors — newest first, bounded by limit\n(default 500, ceiling 1000) — so on a program larger than the page it\nsummarizes that page, not the fleet.",
},
})
zip.Describe("GET /v1/admin/authors/:id/basis", zip.Doc{
@@ -44,39 +65,87 @@ func init() {
zip.Describe("POST /v1/admin/authors/:id/approve", zip.Doc{
Description: "Admits one author to EARNING, optionally on a negotiated royalty\nshare. Until this runs, a connected author accrues nothing however many verified\nrepositories they have.\n\nA share override applies from here forward only — existing ledger rows keep the\nshare that was applied when they were written, because a rate change must never\nrewrite what was already owed.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"approveRequest.id": "ID is the author to approve, from the path.",
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (010000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"approveRequest.id": "ID is the author to approve, from the path.",
"approveRequest.shareBps": "ShareBps overrides this author's royalty share, in basis points (010000).\n0 keeps the platform default. A share change never rewrites history: existing\nledger rows keep the share that was applied when they were written.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
},
Example: json.RawMessage(`{"id":"aut_1f…","shareBps":2500}`),
})
zip.Describe("POST /v1/admin/authors/:id/payout", zip.Doc{
Description: "Records a payout of accrued royalty and settles it.\n\nThe amount is RESERVED against the author's pending royalty atomically before\nanything is paid, so a payout can never exceed what is owed even under concurrent\ncalls. An external author's payout is then BACKED against the platform reserve\nfund — a second, independent guard — and refused with 402 if the reserve cannot\ncover it, with the reservation voided. A \"credits\" method issues the actual wallet\ngrant after both guards; a cash method is record-only. A first-party (treasury)\nauthor's royalty is realized into Hanzo's own reserve instead of an external\nwallet, and every payout row discloses which of the three it was.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
"payoutRequest.id": "ID is the author to pay, from the path.",
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
"payoutResult.data": "Data carries the payout and the author.",
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
"payoutResult.status": "Status is \"ok\".",
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"payoutData.author": "Author is the author record after the payout, with the balances updated.",
"payoutData.payout": "Payout is the recorded payout, including where it settled.",
"payoutRequest.amountCents": "AmountCents is how much to pay, in cents. Must be positive and can never\nexceed the author's pending royalty (accrued minus paid).",
"payoutRequest.id": "ID is the author to pay, from the path.",
"payoutRequest.method": "Method is how it settles: \"credits\" issues a grant into the author's wallet;\nwire, paypal and the like are record-only. Required.",
"payoutRequest.reference": "Reference is the operator's external reference for a cash settlement — a wire\nconfirmation, a PayPal transaction id.",
"payoutResult.data": "Data carries the payout and the author.",
"payoutResult.msg": "Msg is the envelope's message slot, empty on success.",
"payoutResult.status": "Status is \"ok\".",
"payoutView.amountCents": "AmountCents is the amount RESERVED against pending royalty, in integer USD\ncents, always positive. The reservation is atomic and can never exceed\naccrued paid, so this is owed money moved out of pending — not money moved.",
"payoutView.createdAt": "CreatedAt is unix seconds when the payout was RECORDED — the moment the amount\nleft pending, not the moment a human moved the money.",
"payoutView.id": "ID is the payout row's server-minted handle, \"apo_\"-prefixed. A caller never\nsupplies it; it is what an operator quotes when reconciling a settlement.",
"payoutView.method": "Method is how the operator says this settles, lowercased as recorded.\n\"credits\" is the one method that means the author's own wallet; anything else\n— wire, paypal, check — is a cash disbursement a human performs. Recording it\npays nobody either way.",
"payoutView.reference": "Reference is the operator's external handle for the settlement: a wire\nconfirmation, a PayPal transaction id. Absent when none was given.",
"payoutView.settlement": "Settlement discloses treasury-vs-wallet-vs-cash on every payout, to the author\nand to the admin mirror alike — the disclosure that keeps a first-party\nsettlement legible as internal accounting.",
"payoutView.txn": "Txn is the commerce ledger transaction id of a SETTLED credits payout, and it\nis absent on every payout this service records. Recording moves no money, and\nauthors asks the money plane exactly one question — what has this org spent? —\nwith no write to answer it with, so there is no receipt to carry. It fills in\nonly when a settlement stamps its transaction back onto the row.",
},
Example: json.RawMessage(`{"id":"aut_1f…","amountCents":25000,"method":"credits"}`),
})
zip.Describe("POST /v1/admin/authors/:id/suspend", zip.Doc{
Description: "Stops one author earning. Their record, verified claims and ledger\nare untouched — suspension halts future accrual, it does not erase what was already\nowed, and it does not delete the evidence behind it.\n\nA Hanzo platform operation: a caller who is not a SuperAdmin gets 403.",
Fields: map[string]string{
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
"adminAuthorView.accruedCents": "AccruedCents is lifetime royalty accrued, in integer USD cents: the sum of\nevery latched accrual (spend × shareBps / 10000). It only ever rises — a\npayout is recorded against paidCents and never reduces this.",
"adminAuthorView.approvedAt": "ApprovedAt is unix seconds of the first approval, and 0 means never approved —\nwhich is also \"has never been able to accrue\". Re-approving to renegotiate the\nshare leaves it at the original date.",
"adminAuthorView.createdAt": "CreatedAt is unix seconds at the FIRST connect. Re-connecting re-links the\nlogin and leaves this alone, so it dates the enrolment, not the latest link.",
"adminAuthorView.deployCount": "DeployCount is how many attribution edges point at this author — one per\n(repository, project, deploying org), so re-deploying the same project adds\nnone. It includes self-deploys, which are recorded for provenance and excluded\nfrom accrual, so it measures reach, not the earning set.",
"adminAuthorView.githubLogin": "GithubLogin is the linked forge account, lowercased. It comes from IAM's\nlinked account when the connect had one — which is also what sets verified —\nand otherwise from the login the caller declared. The treasury author carries\n\"<brand>-maintainers\".",
"adminAuthorView.id": "ID is the author record's server-minted handle, \"aut_\"-prefixed. It is the id\nthe approve, suspend, payout and admin-basis routes address.",
"adminAuthorView.org": "Org is the tenant org that owns this author record — UNIQUE, one author per\norg. It is exposed HERE and nowhere else (Author.Org is json:\"-\" on the tenant\nsurface), and it is the org excluded from this author's own accrual: deploying\nyour own repo earns you nothing.",
"adminAuthorView.paidCents": "PaidCents is lifetime royalty RECORDED as paid, in integer USD cents. It rises\nthe moment a payout reserves against pending — recording, not settling; a human\nmoves the money out of band — and falls back only when a payout is voided.",
"adminAuthorView.pendingCents": "PendingCents is what a payout may still draw against — accrued paid, floored\nat zero. It is derived for each response, never stored, and it is the exact\nfigure the atomic payout guard refuses to exceed.",
"adminAuthorView.repoCount": "RepoCount is how many of this author's repository claims are VERIFIED, counted\nfor this response in one GROUP BY over the whole table rather than a query per\nrow. The single-author replies from approve, suspend and payout report 0: they\ncarry the mutated row, not a re-listing.",
"adminAuthorView.shareBps": "ShareBps is the royalty rate accrual applies, in basis points of a deploying\norg's metered spend for the period: 2000 (the platform default) is 20%, 10000\nwould be the entire spend. The platform keeps 10000 shareBps. Changing it\nnever rewrites history — each ledger row keeps the rate it was written with.",
"adminAuthorView.status": "Status is connected, approved or suspended. Only an approved author accrues;\na connected one may verify repos and collect deploy edges but earns nothing\nuntil a reviewer admits it.",
"adminAuthorView.suspendedAt": "SuspendedAt is unix seconds of the most recent suspension. 0 means the author\nis not suspended: either never was, or was and has since been approved again,\nwhich clears this back to 0.",
"adminAuthorView.verified": "Verified is IDENTITY proof of the login, NOT proof of any repository: true\nwhen the connect took the login from IAM's linked forge account (and for the\nseeded treasury author), false when the caller merely declared it. A false\nhere still earns — repository ownership is proven separately, per claim.",
"authorData.author": "Author is the author record after the change. Its repository and deploy counts\nare 0 here — this is the mutated row, not a re-listing.",
"authorRef.id": "ID is the author record's handle, \"aut_\"-prefixed.",
"authorResult.data": "Data carries the author.",
"authorResult.msg": "Msg is the envelope's message slot, empty on success.",
"authorResult.status": "Status is \"ok\".",
},
})
zip.Describe("POST /v1/admin/authors/sweep", zip.Doc{
@@ -124,10 +193,22 @@ func init() {
zip.Describe("POST /v1/authors/repos/verify", zip.Doc{
Description: "Proves that the caller owns a repository — or a whole OWNER — and\nrecords the claim, which is what makes deploys of that code earn royalty.\n\nOwnership is proven the SAME two ways in both cases, tried in order: an IAM-linked\nforge token with admin or push permission, or a hanzo.json on the default branch\ncarrying the author's verify code. Claiming an OWNER proves it against that\nowner's \".github\" control repository, and is exactly as strong as a per-repository\nclaim — an owner the caller cannot prove is refused with 422, never assumed.\n\nA per-repository claim wins over an owner-wide one, so a specifically-claimed\nrepository always earns for its own author. A repository another author has\nalready verified is a 409. The org must have connected first.\n\nAnswers 201 when it recorded a new claim and 200 when the claim already existed.",
Fields: map[string]string{
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
"authorRepo.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored: a \"Deploy on Hanzo\" image\nlinking to the one-click import of this repository. Re-hosting the builder\nchanges every badge without touching a row.",
"authorRepo.createdAt": "CreatedAt is unix seconds when the claim was first recorded. It equals\nverifiedAt on the first proof and then stays put while verifiedAt moves, so the\npair reads as \"claimed since / last proven\".",
"authorRepo.method": "Method is HOW ownership was proven: \"oauth\" — an IAM-linked forge token showed\nadmin or push on the repository; \"file\" — a hanzo.json on the default branch\ncarried this author's verify code; or \"maintainer\" — the repository sits in a\nfirst-party namespace, where ownership is intrinsic and the treasury author\nholds it with no proof step. Omitted on a row written before the method was\nrecorded.",
"authorRepo.repoUrl": "RepoURL is the claim key in canonical form — lowercased \"host/owner/name\",\nno scheme, no .git, host ∈ {github.com, gitlab.com}. A deploy's source repo is\nnormalized through the same function before attribution, so the two sides can\nnever miss on a cosmetic difference. UNIQUE across every author: first proven\nclaim wins.",
"authorRepo.verified": "Verified reports that ownership was proven. Only a proven claim is ever\nwritten, so it is true on every row this surface returns; the deploy path\nre-reads it regardless, because an unverified claim attributes nothing.",
"authorRepo.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof. Re-verifying\nrefreshes it, and the method beside it, in place.",
"claim.created": "Created reports whether this call recorded a new claim (201) or found an\nexisting one (200).",
"claim.org": "Org is the verified owner-wide claim, present when an owner was claimed. It\ncovers every repository the author publishes under that owner.",
"claim.repo": "Repo is the verified repository claim, present when a repository was claimed.",
"orgView.badgeMarkdown": "BadgeMarkdown is the ready-to-paste README snippet, DERIVED for each response\nfrom this deployment's badge host and never stored — here it deep-links the\nOWNER's template import rather than one repository's.",
"orgView.createdAt": "CreatedAt is unix seconds when the owner claim was first recorded — equal to\nverifiedAt on the first proof, then fixed while verifiedAt moves.",
"orgView.method": "Method is HOW the owner was proven, always against its \".github\" control\nrepository: \"oauth\" — an IAM-linked forge token showed admin or push on it; or\n\"file\" — a hanzo.json on its default branch carried this author's verify code.\nThe \"maintainer\" shortcut is a per-repository attribution and never appears\nhere. Omitted on a row written before the method was recorded.",
"orgView.ownerUrl": "OwnerURL is the claim key in canonical form — lowercased \"host/owner\" with NO\nrepository segment, host ∈ {github.com, gitlab.com}. It covers every repository\nunder that owner, so code with no claim of its own still earns; a per-repository\nclaim outranks it. UNIQUE across every author: first proven claim wins.",
"orgView.verified": "Verified reports that ownership of the WHOLE owner was proven — against that\nowner's \".github\" control repository, which is exactly as strong as a\nper-repository claim. Only a proven claim is written, so every row returned\nhere is true.",
"orgView.verifiedAt": "VerifiedAt is unix seconds of the most recent successful proof of the owner;\nre-verifying refreshes it, and the method beside it, in place.",
"verifyRequest.repoUrl": "RepoURL is what to claim: a repository (github.com/owner/name) or a whole\nOWNER (github.com/owner, no repository segment). gitlab.com is accepted too.",
},
Example: json.RawMessage(`{"repoUrl":"github.com/octocat/hello-world"}`),
})
+3
View File
@@ -86,6 +86,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
svc := &cloud.Service[*state]{Base: b, State: mounted}
routes(app, svc)
// The ledger's headline figures, for the peers that live in other processes —
// the unified advisor above all (see figures_rpc.go).
exposeFigures()
// No "commerce configured" bit to report: the ledger is reached BY NAME, so
// there is nothing a deployment sets and nothing that can be set wrong.
b.Log.Info("books mounted", "prefix", "/v1/books")
+77
View File
@@ -0,0 +1,77 @@
package books
// figures_rpc.go — the ledger's headline numbers, on the internal plane.
//
// It is the SAME snapshot GET /v1/books/metrics returns, answered over the
// socket instead of the edge, because the caller that wants it most is in
// another process. The unified advisor (/v1/ask) ships as its own plugin
// binary: it mounts /v1/ask and nothing else, so the in-process read it used to
// make for these figures could never reach this app and every money question
// fell through to "I can answer questions about your finances today" with no
// figures behind it. One op fixes that, and fixes it for every future peer at
// the same time.
//
// It recomputes nothing. computeMetrics is the one aggregation and
// metricsFigures is the one formatter, both shared verbatim with the HTTP read,
// so a figure here is byte-identical to the same figure on the edge — books owns
// both the number and how it is spelled, in exactly one place.
import (
"context"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// exposeFigures publishes the ledger's headline read on the internal plane.
// Called from Mount.
func exposeFigures() {
zip.Post[plane.FiguresIn, plane.FiguresOut](cloud.Plane(), "/books/figures", planeFigures,
zip.WithOperationID(plane.BooksFigures),
zip.WithSummary("The caller's headline ledger figures"))
}
// planeFigures answers the caller's own all-time ledger snapshot as formatted
// figures.
//
// The org is the CALLER's plane identity and there is no argument that could
// name another: [plane.FiguresIn] is empty, and the identity zip forwards is the
// one the gateway minted. Anonymous is REFUSED rather than defaulted — a books
// read with no principal behind it is not a read of nobody's ledger, it is a
// read of the first org whose name a bug supplies.
//
// The LIVE ledger, never the sandbox. A sandbox figure narrated as an answer
// about the business would be a fabricated number wearing a real one's label,
// and the selector that would let a caller ask for it is the same argument this
// op refuses to grow.
func planeFigures(ctx context.Context, _ *plane.FiguresIn) (*plane.FiguresOut, error) {
who := cloud.Who(ctx)
if who.Org == "" {
return nil, zip.ErrForbidden("books figures: org required")
}
if mounted == nil {
return nil, zip.Errorf(503, "books not mounted")
}
st, err := mounted.storeFor(who.Org, false)
if err != nil {
return nil, zip.ErrInternal("books figures: open failed")
}
m, err := computeMetrics(ctx, st, "", "")
if err != nil {
return nil, zip.ErrInternal("books figures: metrics failed")
}
return &plane.FiguresOut{Figures: planeFiguresOf(metricsFigures(m))}, nil
}
// planeFiguresOf carries books' own figures onto the wire shape unchanged. It is
// a projection and never a computation: same labels, same already-formatted
// values, same period. The two types are separate because the plane package
// cannot import an app, not because the figures differ.
func planeFiguresOf(in []Figure) []plane.Figure {
out := make([]plane.Figure, 0, len(in))
for _, f := range in {
out = append(out, plane.Figure{Label: f.Label, Value: f.Value, Period: f.Period})
}
return out
}
+3
View File
@@ -178,6 +178,9 @@ func init() {
},
Example: json.RawMessage(`{"sandbox":"false"}`),
})
zip.Describe("POST /books/figures", zip.Doc{
Description: "Answers the caller's own all-time ledger snapshot as formatted\nfigures.\n\nThe org is the CALLER's plane identity and there is no argument that could\nname another: [plane.FiguresIn] is empty, and the identity zip forwards is the\none the gateway minted. Anonymous is REFUSED rather than defaulted — a books\nread with no principal behind it is not a read of nobody's ledger, it is a\nread of the first org whose name a bug supplies.\n\nThe LIVE ledger, never the sandbox. A sandbox figure narrated as an answer\nabout the business would be a fabricated number wearing a real one's label,\nand the selector that would let a caller ask for it is the same argument this\nop refuses to grow.",
})
zip.Describe("POST /v1/books/ask", zip.Doc{
Description: "Answers a plain-language question about the caller's own books — \"what is my\nMRR?\", \"how long is my runway?\" — with figures taken from their ledger, never a guessed\nnumber. A deterministic keyword router picks the intent and reads the real metrics, and\nthose figures, followups and report sources are computed BEFORE any model call and are\nnever altered by one: the optional narration seam only rephrases the sentence, and it\ndegrades silently to the templated answer when no AI plane is wired. It is strictly\nread-only — it restates the books, it never posts to them.",
Fields: map[string]string{
+1 -1
View File
@@ -85,7 +85,7 @@ func mountRelay(app cloud.Router, deps cloud.Deps) error {
return fmt.Errorf("bots.mountRelay: nil deps.Logger")
}
s := &relay{
target: executorURL(),
target: executorURL(""),
log: deps.Logger.New("subsystem", "bots"),
cc: &http.Client{Timeout: 60 * time.Second},
}
+25 -8
View File
@@ -96,6 +96,17 @@ type Call struct {
User string
Body any
Secret bool
// Base overrides the destination for THIS call. Empty means the bot runtime.
//
// This stays a transport concern and not a meaning one: a caller names WHERE
// its bytes go, and this file still does not know a coding run from a channel
// relay. It exists because a sandbox is not the bot — deep research, bare
// exec and coding all want a SANDBOX, and whatever runs sandboxes may not be
// the service that runs channels. Resolving that address inside here would
// mean this file learning what a run is, which is exactly the line the header
// draws.
Base string
}
// Do invokes c and discards any response payload — the command form. It returns
@@ -173,7 +184,7 @@ func Stream(ctx context.Context, c Call, fn func(msg []byte)) error {
// off: the runtime then fails the request closed at its own auth gate.
func send(ctx context.Context, c Call, method, accept string) (*http.Response, error) {
if c.Secret {
if err := requireSecure(); err != nil {
if err := requireSecure(c.Base); err != nil {
return nil, err
}
}
@@ -185,7 +196,7 @@ func send(ctx context.Context, c Call, method, accept string) (*http.Response, e
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, executorURL()+c.Op, body)
req, err := http.NewRequestWithContext(ctx, method, executorURL(c.Base)+c.Op, body)
if err != nil {
return nil, fmt.Errorf("runtime: build call: %w", err)
}
@@ -262,8 +273,12 @@ func ErrBody(resp *http.Response) string {
return strings.TrimSpace(string(b))
}
// executorURL resolves the executor base, no trailing slash.
func executorURL() string {
// executorURL resolves the base for one call, no trailing slash. A Call's own
// Base wins; otherwise the bot runtime.
func executorURL(base string) string {
if base != "" {
return strings.TrimRight(base, "/")
}
if v := getenv(urlEnv); v != "" {
return strings.TrimRight(v, "/")
}
@@ -279,13 +294,15 @@ func executorURL() string {
// This guard exists because the transport does not authenticate its peer. A ZAP
// entry point pins X25519MLKEM768 and refuses a classical-only peer structurally,
// which is what makes this check unnecessary rather than merely satisfied.
func requireSecure() error {
u := executorURL()
// It checks the base the call will ACTUALLY use: guarding the default while the
// bytes go somewhere else would be a guard on the wrong hop.
func requireSecure(base string) error {
u := executorURL(base)
if strings.HasPrefix(u, "https://") || getenv(plaintextEnv) == "1" {
return nil
}
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set %s to https, or %s=1 if the hop is mesh-mTLS secured)",
u, urlEnv, plaintextEnv)
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set the destination to https, or %s=1 if the hop is mesh-mTLS secured)",
u, plaintextEnv)
}
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
+89 -26
View File
@@ -26,37 +26,95 @@ const defaultRangeDays = 30
// ChannelMetric is one channel's spend contribution to a campaign's metrics.
type ChannelMetric struct {
Kind string `json:"kind"`
Platform string `json:"platform"`
Status string `json:"status"`
// Kind is which channel this row is: paid, organic or email. It is also the
// row's identity — a campaign carries at most one channel per kind.
Kind string `json:"kind"`
// Platform is the provider the spend was read from: meta, google, x, instagram,
// or the email provider.
Platform string `json:"platform"`
// Status is the channel's launch state on the campaign — pending, live, paused,
// failed or unavailable. Only a live channel is asked for its spend at all.
Status string `json:"status"`
// ExternalID is the provider-side id of the execution the spend belongs to.
// Absent until the channel has launched.
ExternalID string `json:"externalId,omitempty"`
SpendCents int64 `json:"spendCents"`
SpendError string `json:"spendError,omitempty"` // honest: connector spend read failed
// SpendCents is what the provider itself reports this channel spent, in CENTS.
// 0 when the channel never launched, when no executor is wired for it, or when
// the read failed — SpendError tells the last case apart from a genuine zero.
SpendCents int64 `json:"spendCents"`
// SpendError is why this channel's spend could not be read (connector not
// connected, provider error), as one secret-free line. Present only on failure;
// the campaign total then simply omits this channel rather than failing.
SpendError string `json:"spendError,omitempty"`
}
// Metrics is a campaign's results view: the analytics-sourced funnel + the
// campaignResults is a campaign's results view: the analytics-sourced funnel + the
// connector-sourced spend + the derived growth KPIs. Available reflects the
// analytics events lens (false = warehouse not yet emitting, honest-empty).
type Metrics struct {
CampaignID string `json:"campaignId"`
Name string `json:"name"`
Status string `json:"status"`
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Available bool `json:"available"`
Impressions int64 `json:"impressions"`
Clicks int64 `json:"clicks"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
Visitors int64 `json:"visitors"`
SpendCents int64 `json:"spendCents"`
CTR float64 `json:"ctr"` // clicks / impressions
CVR float64 `json:"cvr"` // conversions / clicks
CAC float64 `json:"cac"` // spend $ per conversion
ROAS float64 `json:"roas"` // revenue per spend $
Channels []ChannelMetric `json:"channels"`
Source string `json:"source"`
//
// Declared under its published name for the reason campaignRecord is (store.go): a
// field's prose reaches the document only under the name its struct literal is
// declared with.
type campaignResults struct {
// CampaignID is the campaign these results are for, echoed from the request.
CampaignID string `json:"campaignId"`
// Name is the campaign's display name at read time, so a result can be labelled
// without a second fetch.
Name string `json:"name"`
// Status is the campaign's lifecycle state at read time — draft, live, paused,
// completed or failed. A draft has never run, so its funnel is legitimately zero.
Status string `json:"status"`
// Range is the window actually used: 24h, 7d, 30d, 90d, or "custom" when an
// explicit start/end pair was honored. An unparseable or absent range reads 30d,
// so this is the value to trust, not the one that was sent.
Range string `json:"range"`
// Start is the window's inclusive start, RFC3339 UTC.
Start string `json:"start"`
// End is the window's end, RFC3339 UTC — the read's own clock unless an explicit
// pair was given. The window is a LOOKBACK, not the campaign's own lifetime.
End string `json:"end"`
// Available is false when the analytics warehouse is not connected or the query
// failed: the funnel below is then zero because nothing could be read, not
// because nothing happened. Spend and Channels are still real — they come from
// the connectors, not the warehouse.
Available bool `json:"available"`
// Impressions is how many times the campaign's creatives were shown, counted
// from its utm_campaign-tagged impression events.
Impressions int64 `json:"impressions"`
// Clicks is the campaign's click events over the window.
Clicks int64 `json:"clicks"`
// Conversions is the terminal funnel events attributed to the campaign — orders
// completed, signups completed, explicit conversion events.
Conversions int64 `json:"conversions"`
// Revenue is the summed revenue attribute of the campaign's events, in whole
// CURRENCY UNITS (dollars) — the one money value here that is not in cents.
Revenue float64 `json:"revenue"`
// Visitors is how many distinct people the campaign reached, counted by event
// identity across ALL its events in the window — not a subset of Impressions, so
// it can exceed them for a campaign whose provider reports clicks but not views.
Visitors int64 `json:"visitors"`
// SpendCents is the campaign's total spend in CENTS: the sum of what each live
// channel's provider reports. A channel whose spend could not be read
// contributes 0 and says so on its own row.
SpendCents int64 `json:"spendCents"`
// CTR is clicks per impression, a fraction rounded to 4 places (0.0123 = 1.23%),
// not a percentage. 0 when there were no impressions to divide by.
CTR float64 `json:"ctr"`
// CVR is conversions per click, a fraction rounded to 4 places. 0 when there
// were no clicks.
CVR float64 `json:"cvr"`
// CAC is customer acquisition cost: spend DOLLARS per conversion, rounded to
// cents. 0 when nothing converted — that is "not yet computable", not "free".
CAC float64 `json:"cac"`
// ROAS is return on ad spend: revenue per spend DOLLAR, rounded to 2 places
// (2.5 = $2.50 back per $1). 0 when nothing was spent.
ROAS float64 `json:"roas"`
// Channels is the per-channel spend breakdown that SpendCents sums, one row per
// channel on the campaign including the ones that never launched.
Channels []ChannelMetric `json:"channels"`
// Source names the analytics table the funnel was read from, so an operator can
// see exactly what was counted. Set even when Available is false.
Source string `json:"source"`
// ABTest is the creative A/B analysis from the experiments primitive
// (experiments.Analyze, pull-model), present only when the campaign runs
// more than one creative and an experiment is wired. Opaque JSON — campaign
@@ -64,6 +122,11 @@ type Metrics struct {
ABTest json.RawMessage `json:"abTest,omitempty"`
}
// Metrics is the domain spelling of campaignResults — an ALIAS, the same type, for
// the same reason Campaign is one for campaignRecord (store.go): apps/agents
// already publishes a "Metrics" into the fleet's flat schema namespace.
type Metrics = campaignResults
// channelSpend fans the spend read across a campaign's live channels. Each read
// is best-effort: a connector-disabled or provider-error channel contributes 0
// with an honest SpendError, never failing the whole metrics read. The org is
+80 -19
View File
@@ -52,33 +52,94 @@ const (
// provider-side id + status the orchestrator recorded. It carries NO credential;
// the executor resolves the org's connector token itself at launch time.
type ChannelSpec struct {
Kind string `json:"kind"` // paid | organic | email
Platform string `json:"platform"` // meta | google | x | instagram | (email provider)
Account string `json:"account,omitempty"` // provider account ref (ad-account/page/list id)
// Kind is the channel and the identity a campaign holds at most one of: paid,
// organic or email. It picks the executor the launch fans out to.
Kind string `json:"kind"`
// Platform is the provider within the kind — meta, google, x, instagram, or the
// email provider.
Platform string `json:"platform"`
// Account is the provider account this channel runs under: an ad-account, a page
// or a mailing-list id. An executor may replace it at launch with the account it
// actually used.
Account string `json:"account,omitempty"`
// ExternalID is the provider-side id of the running execution, recorded by the
// orchestrator at launch and handed back verbatim to read spend or to pause.
// Server-owned and absent until this channel has launched; anything a caller
// sends for it is dropped.
ExternalID string `json:"externalId,omitempty"`
Status string `json:"status"` // pending | live | paused | failed | unavailable
Detail string `json:"detail,omitempty"` // honest last-outcome detail (never a secret)
// Status is this channel's own launch outcome, not the campaign's: pending (added,
// never launched), live, paused, failed (Detail says why) or unavailable (no
// executor wired on this deployment). Server-owned — a caller can never assert it.
Status string `json:"status"`
// Detail is the last outcome in one secret-free line — the failure reason, or
// what the executor reported. Absent when there is nothing to explain.
Detail string `json:"detail,omitempty"`
}
// Campaign is the top-level GTM object — a VALUE that spans channels. Budget is
// minor units (cents). Content is the ordered creative set (Content[0] is the
// campaignRecord is the top-level GTM object — a VALUE that spans channels. Budget
// is minor units (cents). Content is the ordered creative set (Content[0] is the
// active creative; the rest are A/B variants when an experiment is composed).
// Metrics are deliberately NOT a field: they are read at query time from the ONE
// analytics plane (metrics.go), never stored here.
type Campaign struct {
ID string `json:"id"`
Org string `json:"-"` // tenant key — server-set from the validated owner claim, never client
Name string `json:"name"`
Audience string `json:"audience,omitempty"` // segment/audience selector ref
Content []string `json:"content"` // creative(s)
Channels []ChannelSpec `json:"channels"` // fan-out targets
ScheduleAt int64 `json:"scheduleAt,omitempty"`
Budget int64 `json:"budget"` // cents
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
//
// It is DECLARED under its published name and spelled Campaign everywhere else,
// rather than the reverse, because prose only reaches the document keyed by the
// name the FIELDS are declared under: zipdoc lifts a field's comment from the
// struct literal it stands in, and the schema builder looks it up under the type
// reflect names. A defined type over another struct (type campaignRecord Campaign)
// has no literal of its own, so every field of it published bare.
type campaignRecord struct {
// ID is the campaign's server-minted handle — "cmp_" and 128 random bits — and
// the id every other campaign call is addressed by. Never read off the wire: a
// create that sends one has it ignored.
ID string `json:"id"`
// Org is the owning tenant, set from the validated bearer's owner claim. It is
// the isolation key on every query and is deliberately NOT published: a caller
// only ever sees their own org's campaigns, so the field would say nothing.
Org string `json:"-"`
// Name is the campaign's display name. Required on write, trimmed, and capped at
// 2048 characters.
Name string `json:"name"`
// Audience is an opaque reference to the segment this campaign targets. It is
// stored and echoed but not yet handed to the executors — a channel targets
// through the provider account it runs under — so it is documentation for now.
// Absent when never set.
Audience string `json:"audience,omitempty"`
// Content is the ordered creative set, at most 32, empty entries dropped.
// Content[0] is the creative that runs; the rest are A/B variants a wired
// experiment can assign per launch.
Content []string `json:"content"`
// Channels are the fan-out targets, at most one per kind and at most 12, each
// carrying its own post-launch state. Empty means nothing to launch, which is
// what makes a launch of this campaign a 400.
Channels []ChannelSpec `json:"channels"`
// ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means
// launch immediately. It is passed to each executor; nothing in this service
// wakes up to launch it for you.
ScheduleAt int64 `json:"scheduleAt,omitempty"`
// Budget is the campaign's total budget in CENTS, handed to each executor as the
// budget for its channel. 0 means none was set.
Budget int64 `json:"budget"`
// Status is the lifecycle state, server-owned and never accepted from a caller.
// Four values actually occur: draft (inert and fully mutable — nothing is sent
// and no budget is committed), live, paused and failed. After a fan-out live
// means AT LEAST ONE channel launched — read the channel rows for the rest —
// and failed means none did.
Status string `json:"status"`
// CreatedAt is when the campaign was created, in unix seconds. Server-set.
CreatedAt int64 `json:"createdAt"`
// UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.
// Server-set on every save.
UpdatedAt int64 `json:"updatedAt"`
}
// Campaign is the domain spelling of campaignRecord. An ALIAS, so it is the SAME
// type and not a second shape to keep in sync: the store, the fan-out and the
// executors read in domain language while the document keeps the name the fleet's
// flat schema namespace needs (apps/marketing already publishes an email
// "Campaign", and openapi.Weave refuses one name meaning two things).
type Campaign = campaignRecord
// Store is the campaign database. ONE SQLite file — the system namespace's
// "campaign" — holds every org's records; tenant isolation is the `org` column,
// enforced on EVERY query. Mirrors clients/ads exactly (the ONE storage
+13 -22
View File
@@ -106,18 +106,12 @@ type campaignRef struct {
ID string `json:"id"`
}
// campaignRecord is a campaign as the API publishes it. It is a DEFINED type over
// Campaign, not a second shape: the fields and their json tags are the same value,
// so the wire is byte-identical. It exists because the fleet's schema namespace is
// FLAT — openapi.Weave refuses one name meaning two things and apps/marketing
// already publishes an email "Campaign". One name, one shape, so this one says which
// plane it belongs to.
type campaignRecord Campaign
// campaignResults is a campaign's metrics as the API publishes it — a DEFINED type
// over Metrics for the same reason campaignRecord is one: apps/agents already
// publishes a "Metrics".
type campaignResults Metrics
// campaignRecord (store.go) and campaignResults (metrics.go) are what this surface
// publishes. Each is declared under its published name — with Campaign and Metrics
// as domain aliases of the same type — because the fleet's schema namespace is FLAT
// (openapi.Weave refuses one name meaning two things, and apps/marketing already
// publishes an email "Campaign", apps/agents a "Metrics"), and because a field's
// doc comment only reaches the document under the name its struct literal carries.
// campaignFilter narrows the org's campaign list. Both fields are query
// parameters and both are optional; an unparseable limit reads as the default,
@@ -262,9 +256,7 @@ func (o ops) list(ctx context.Context, in *campaignFilter) (*campaignPage, error
return nil, zip.Errorf(http.StatusInternalServerError, "list: %v", err)
}
page := make([]campaignRecord, 0, len(rows))
for _, r := range rows {
page = append(page, campaignRecord(r))
}
page = append(page, rows...)
return &campaignPage{Data: page}, nil
}
@@ -323,10 +315,10 @@ func (o ops) create(ctx context.Context, in *campaignWrite) (*campaignRecord, er
return record(saved), nil
}
// record publishes a stored campaign under the API's own schema name. The
// conversion is free — campaignRecord IS Campaign — and keeps every call site one
// line rather than a temporary per return.
func record(c Campaign) *campaignRecord { r := campaignRecord(c); return &r }
// record answers a stored campaign by pointer. campaignRecord IS Campaign — one
// type, two spellings — so this only exists to keep every call site one line
// rather than a temporary per return.
func record(c campaignRecord) *campaignRecord { return &c }
// GetCampaign returns one campaign of the caller's org — its name, audience,
// creatives, channels with their per-channel launch state, schedule, budget and
@@ -446,7 +438,7 @@ func (o ops) metrics(ctx context.Context, in *metricsQuery) (*campaignResults, e
// Spend — each live channel's connector-reported spend, fanned in fail-soft.
spendCents, chMetrics := channelSpend(ctx, org, camp)
m := Metrics{
m := campaignResults{
CampaignID: camp.ID,
Name: camp.Name,
Status: camp.Status,
@@ -470,8 +462,7 @@ func (o ops) metrics(ctx context.Context, in *metricsQuery) (*campaignResults, e
// A/B lens: the experiments primitive's pull-model analysis (nil when the
// campaign runs a single creative or no experiment is wired).
m.ABTest = analyzeExperiment(ctx, org, camp, start, end)
out := campaignResults(m)
return &out, nil
return &m, nil
}
// AddCampaignChannel adds a channel to a campaign, or REPLACES the one it already
+149 -58
View File
@@ -18,48 +18,106 @@ func init() {
zip.Describe("DELETE /v1/campaign/:id/channels/:kind", zip.Doc{
Description: "Drops one channel from a campaign and returns the updated\ncampaign. 404 when the campaign carries no channel of that kind.\n\nIt removes the channel from the PLAN. A channel that is live at its provider\nshould be paused first — dropping the row here leaves nothing to pause it with\nafterwards.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"channelRef.id": "ID is the campaign, from the path.",
"channelRef.kind": "Kind is the channel to remove: paid, organic or email.",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
"channelRef.id": "ID is the campaign, from the path.",
"channelRef.kind": "Kind is the channel to remove: paid, organic or email.",
},
})
zip.Describe("GET /v1/campaign", zip.Doc{
Description: "Returns the org's campaigns, newest first, optionally narrowed to\none status.\n\nA campaign is the top-level go-to-market object: a value that SPANS channels\n(paid, organic, email) and fans out to the executor for each. The listing is\norg-scoped server-side, so one org can never see another's campaigns.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"campaignFilter.limit": "Limit bounds the page. 0 or less means the default of 200; anything above\n1000 is clamped to 1000.",
"campaignFilter.status": "Status keeps only campaigns in that state: draft, live, paused or failed.\nEmpty means any.",
"campaignPage.data": "Data are the campaigns on this page.",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignFilter.limit": "Limit bounds the page. 0 or less means the default of 200; anything above\n1000 is clamped to 1000.",
"campaignFilter.status": "Status keeps only campaigns in that state: draft, live, paused or failed.\nEmpty means any.",
"campaignPage.data": "Data are the campaigns on this page.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
},
Example: json.RawMessage(`{"status":"live","limit":50}`),
})
zip.Describe("GET /v1/campaign/:id", zip.Doc{
Description: "Returns one campaign of the caller's org — its name, audience,\ncreatives, channels with their per-channel launch state, schedule, budget and\nstatus. 404 when the org has no campaign with that id.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"campaignRef.id": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
"campaignRef.id": "ID is the campaign's server-minted handle, \"cmp_\"-prefixed.",
},
})
zip.Describe("GET /v1/campaign/:id/metrics", zip.Doc{
Description: "Returns a campaign's results over a window: the analytics\nfunnel (impressions, clicks, conversions, revenue, visitors), the spend each\nchannel's connector reports, and the derived growth KPIs — CTR, CVR, CAC and\nROAS.\n\nThere is exactly ONE metrics plane and nothing is stored here: the funnel is an\nanalytics query over the campaign's utm_campaign-tagged events, and the spend is\neach provider's own number read through the org's connector. A warehouse that is\nnot emitting yet degrades to available:false with zeroes — honest-empty, never a\n500 and never a fabricated number. When the campaign runs more than one creative\nand an experiment is wired, abTest carries the A/B analysis.",
Fields: map[string]string{
"ChannelMetric.spendError": "honest: connector spend read failed",
"metricsQuery.end": "End is an explicit RFC3339 window end.",
"metricsQuery.id": "ID is the campaign to report on, from the path.",
"metricsQuery.range": "Range is the lookback window: 24h, 7d, 30d or 90d. Anything else, including\nempty, reads as 30d.",
"metricsQuery.start": "Start is an explicit RFC3339 window start. Honored only together with End,\nand only when End is after it.",
"ChannelMetric.externalId": "ExternalID is the provider-side id of the execution the spend belongs to.\nAbsent until the channel has launched.",
"ChannelMetric.kind": "Kind is which channel this row is: paid, organic or email. It is also the\nrow's identity — a campaign carries at most one channel per kind.",
"ChannelMetric.platform": "Platform is the provider the spend was read from: meta, google, x, instagram,\nor the email provider.",
"ChannelMetric.spendCents": "SpendCents is what the provider itself reports this channel spent, in CENTS.\n0 when the channel never launched, when no executor is wired for it, or when\nthe read failed — SpendError tells the last case apart from a genuine zero.",
"ChannelMetric.spendError": "SpendError is why this channel's spend could not be read (connector not\nconnected, provider error), as one secret-free line. Present only on failure;\nthe campaign total then simply omits this channel rather than failing.",
"ChannelMetric.status": "Status is the channel's launch state on the campaign — pending, live, paused,\nfailed or unavailable. Only a live channel is asked for its spend at all.",
"campaignResults.abTest": "ABTest is the creative A/B analysis from the experiments primitive\n(experiments.Analyze, pull-model), present only when the campaign runs\nmore than one creative and an experiment is wired. Opaque JSON — campaign\nstays decoupled from the experiments analysis type.",
"campaignResults.available": "Available is false when the analytics warehouse is not connected or the query\nfailed: the funnel below is then zero because nothing could be read, not\nbecause nothing happened. Spend and Channels are still real — they come from\nthe connectors, not the warehouse.",
"campaignResults.cac": "CAC is customer acquisition cost: spend DOLLARS per conversion, rounded to\ncents. 0 when nothing converted — that is \"not yet computable\", not \"free\".",
"campaignResults.campaignId": "CampaignID is the campaign these results are for, echoed from the request.",
"campaignResults.channels": "Channels is the per-channel spend breakdown that SpendCents sums, one row per\nchannel on the campaign including the ones that never launched.",
"campaignResults.clicks": "Clicks is the campaign's click events over the window.",
"campaignResults.conversions": "Conversions is the terminal funnel events attributed to the campaign — orders\ncompleted, signups completed, explicit conversion events.",
"campaignResults.ctr": "CTR is clicks per impression, a fraction rounded to 4 places (0.0123 = 1.23%),\nnot a percentage. 0 when there were no impressions to divide by.",
"campaignResults.cvr": "CVR is conversions per click, a fraction rounded to 4 places. 0 when there\nwere no clicks.",
"campaignResults.end": "End is the window's end, RFC3339 UTC — the read's own clock unless an explicit\npair was given. The window is a LOOKBACK, not the campaign's own lifetime.",
"campaignResults.impressions": "Impressions is how many times the campaign's creatives were shown, counted\nfrom its utm_campaign-tagged impression events.",
"campaignResults.name": "Name is the campaign's display name at read time, so a result can be labelled\nwithout a second fetch.",
"campaignResults.range": "Range is the window actually used: 24h, 7d, 30d, 90d, or \"custom\" when an\nexplicit start/end pair was honored. An unparseable or absent range reads 30d,\nso this is the value to trust, not the one that was sent.",
"campaignResults.revenue": "Revenue is the summed revenue attribute of the campaign's events, in whole\nCURRENCY UNITS (dollars) — the one money value here that is not in cents.",
"campaignResults.roas": "ROAS is return on ad spend: revenue per spend DOLLAR, rounded to 2 places\n(2.5 = $2.50 back per $1). 0 when nothing was spent.",
"campaignResults.source": "Source names the analytics table the funnel was read from, so an operator can\nsee exactly what was counted. Set even when Available is false.",
"campaignResults.spendCents": "SpendCents is the campaign's total spend in CENTS: the sum of what each live\nchannel's provider reports. A channel whose spend could not be read\ncontributes 0 and says so on its own row.",
"campaignResults.start": "Start is the window's inclusive start, RFC3339 UTC.",
"campaignResults.status": "Status is the campaign's lifecycle state at read time — draft, live, paused,\ncompleted or failed. A draft has never run, so its funnel is legitimately zero.",
"campaignResults.visitors": "Visitors is how many distinct people the campaign reached, counted by event\nidentity across ALL its events in the window — not a subset of Impressions, so\nit can exceed them for a campaign whose provider reports clicks but not views.",
"metricsQuery.end": "End is an explicit RFC3339 window end.",
"metricsQuery.id": "ID is the campaign to report on, from the path.",
"metricsQuery.range": "Range is the lookback window: 24h, 7d, 30d or 90d. Anything else, including\nempty, reads as 30d.",
"metricsQuery.start": "Start is an explicit RFC3339 window start. Honored only together with End,\nand only when End is after it.",
},
Example: json.RawMessage(`{"id":"cmp_1f…","range":"7d"}`),
})
@@ -75,32 +133,54 @@ func init() {
zip.Describe("POST /v1/campaign", zip.Doc{
Description: "Creates a campaign as a DRAFT and returns it.\n\nA draft is inert: nothing is sent, no connector is touched and no budget is\ncommitted until the campaign is launched. The channels named here are validated\nand de-duplicated by kind (one executor per kind), and every channel starts\n\"pending\" whatever the caller claims — a client can never assert a launched\nstate.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
},
Example: json.RawMessage(`{"name":"Spring launch","budget":250000,"content":["Ship faster"],"channels":[{"kind":"paid","platform":"meta"}]}`),
})
zip.Describe("POST /v1/campaign/:id/channels", zip.Doc{
Description: "Adds a channel to a campaign, or REPLACES the one it already\nhas of that kind, and returns the updated campaign.\n\nA campaign carries at most one channel per kind, because the kind IS the\nexecutor: adding a second \"paid\" channel would mean two ad accounts running one\ncampaign with no way to tell their results apart. The new channel starts\n\"pending\" — adding it does not launch it.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"channelAdd.account": "Account is the provider account this channel runs under: an ad-account, a\npage, or a mailing-list id.",
"channelAdd.id": "ID is the campaign to add the channel to, from the path.",
"channelAdd.kind": "Kind is the channel kind and the identity a campaign holds at most one of:\npaid, organic or email.",
"channelAdd.platform": "Platform is the provider within the kind — meta, google, x, instagram, or\nthe email provider.",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
"channelAdd.account": "Account is the provider account this channel runs under: an ad-account, a\npage, or a mailing-list id.",
"channelAdd.id": "ID is the campaign to add the channel to, from the path.",
"channelAdd.kind": "Kind is the channel kind and the identity a campaign holds at most one of:\npaid, organic or email.",
"channelAdd.platform": "Platform is the provider within the kind — meta, google, x, instagram, or\nthe email provider.",
},
Example: json.RawMessage(`{"id":"cmp_1f…","kind":"email","platform":"sendgrid","account":"list_42"}`),
})
@@ -113,18 +193,29 @@ func init() {
zip.Describe("PUT /v1/campaign/:id", zip.Doc{
Description: "Rewrites a campaign's core fields — name, audience, creatives,\nschedule and budget — and returns the updated campaign.\n\nChannels are replaced ONLY while the campaign is still a draft. Once it is\nlaunched its channels carry provider state (an external id, a live status), so\nthey are added and removed explicitly through the channels sub-resource\ninstead; a whole-object write would silently orphan a running execution.",
Fields: map[string]string{
"ChannelSpec.account": "provider account ref (ad-account/page/list id)",
"ChannelSpec.detail": "honest last-outcome detail (never a secret)",
"ChannelSpec.kind": "paid | organic | email",
"ChannelSpec.platform": "meta | google | x | instagram | (email provider)",
"ChannelSpec.status": "pending | live | paused | failed | unavailable",
"campaignUpdate.id": "ID is the campaign to update, from the path.",
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
"ChannelSpec.account": "Account is the provider account this channel runs under: an ad-account, a page\nor a mailing-list id. An executor may replace it at launch with the account it\nactually used.",
"ChannelSpec.detail": "Detail is the last outcome in one secret-free line — the failure reason, or\nwhat the executor reported. Absent when there is nothing to explain.",
"ChannelSpec.externalId": "ExternalID is the provider-side id of the running execution, recorded by the\norchestrator at launch and handed back verbatim to read spend or to pause.\nServer-owned and absent until this channel has launched; anything a caller\nsends for it is dropped.",
"ChannelSpec.kind": "Kind is the channel and the identity a campaign holds at most one of: paid,\norganic or email. It picks the executor the launch fans out to.",
"ChannelSpec.platform": "Platform is the provider within the kind — meta, google, x, instagram, or the\nemail provider.",
"ChannelSpec.status": "Status is this channel's own launch outcome, not the campaign's: pending (added,\nnever launched), live, paused, failed (Detail says why) or unavailable (no\nexecutor wired on this deployment). Server-owned — a caller can never assert it.",
"campaignRecord.audience": "Audience is an opaque reference to the segment this campaign targets. It is\nstored and echoed but not yet handed to the executors — a channel targets\nthrough the provider account it runs under — so it is documentation for now.\nAbsent when never set.",
"campaignRecord.budget": "Budget is the campaign's total budget in CENTS, handed to each executor as the\nbudget for its channel. 0 means none was set.",
"campaignRecord.channels": "Channels are the fan-out targets, at most one per kind and at most 12, each\ncarrying its own post-launch state. Empty means nothing to launch, which is\nwhat makes a launch of this campaign a 400.",
"campaignRecord.content": "Content is the ordered creative set, at most 32, empty entries dropped.\nContent[0] is the creative that runs; the rest are A/B variants a wired\nexperiment can assign per launch.",
"campaignRecord.createdAt": "CreatedAt is when the campaign was created, in unix seconds. Server-set.",
"campaignRecord.id": "ID is the campaign's server-minted handle — \"cmp_\" and 128 random bits — and\nthe id every other campaign call is addressed by. Never read off the wire: a\ncreate that sends one has it ignored.",
"campaignRecord.name": "Name is the campaign's display name. Required on write, trimmed, and capped at\n2048 characters.",
"campaignRecord.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. 0 (absent) means\nlaunch immediately. It is passed to each executor; nothing in this service\nwakes up to launch it for you.",
"campaignRecord.status": "Status is the lifecycle state, server-owned and never accepted from a caller.\nFour values actually occur: draft (inert and fully mutable — nothing is sent\nand no budget is committed), live, paused and failed. After a fan-out live\nmeans AT LEAST ONE channel launched — read the channel rows for the rest —\nand failed means none did.",
"campaignRecord.updatedAt": "UpdatedAt is the last write in unix seconds — an edit, a launch or a pause.\nServer-set on every save.",
"campaignUpdate.id": "ID is the campaign to update, from the path.",
"campaignWrite.audience": "Audience is the segment or audience selector this campaign targets.",
"campaignWrite.budget": "Budget is the campaign's total budget in CENTS. Negative reads as 0.",
"campaignWrite.channels": "Channels are the fan-out targets, at most one per kind (paid, organic,\nemail) and at most 12. A channel's status and provider id are server-owned:\nwhatever the caller sends for them is replaced with \"pending\".",
"campaignWrite.content": "Content is the ordered creative set. Content[0] is the active creative and\nthe rest are A/B variants; at most 32, empty entries dropped.",
"campaignWrite.name": "Name is the campaign's display name. Required; trimmed and capped at 2048\ncharacters.",
"campaignWrite.scheduleAt": "ScheduleAt is when the campaign should run, in unix seconds. Negative reads\nas 0 (immediately).",
},
Example: json.RawMessage(`{"name":"Spring launch","budget":500000}`),
})
+41 -4
View File
@@ -15,10 +15,47 @@ import (
// media:false / actions:false this pass — renderText (envelope.go) is the ONE
// downgrade path; native rendering is a named follow-up.
type capabilities struct {
DM bool `json:"dm"`
Group bool `json:"group"`
Thread bool `json:"thread"`
Media bool `json:"media"`
// DM is whether the transport carries a DIRECT message at all. True for slack,
// teams and telegram. False for discord, honestly: that ingress is guild-scoped
// slash commands — an interaction without a guild id is refused at the door —
// so nothing ever arrives classified as a DM, no reply route is ever learned
// for one, and a send addressed at a Discord DM is refused 409.
DM bool `json:"dm"`
// Group is whether the transport carries multi-person rooms — a Discord guild
// channel, a Slack channel, a Teams channel or group chat, a Telegram group or
// supergroup. True on all four.
Group bool `json:"group"`
// Thread is whether a reply can be threaded UNDER a specific message. True for
// slack alone: it is the only transport whose ingress reports a thread
// (thread_ts, published as the envelope's replyTo) and whose door posts back
// into it. Discord's replyTo makes an inline reply rather than a thread,
// Telegram's answers one message id, and Teams carries no reply target at all —
// a replyTo sent to it is ignored.
Thread bool `json:"thread"`
// Media is whether the transport renders an ATTACHMENT natively. False on all
// four this pass, and a send is not refused for it: renderText flattens each
// attachment to one `kind: url (mime)` line after the text rather than dropping
// it.
Media bool `json:"media"`
// Actions is whether the transport renders an INTERACTIVE control natively, and
// it is the flag to read before composing one. The vocabulary is a closed
// kind-tagged union (envelope.go), exactly four kinds, each carrying only its
// own field plus an optional label:
//
// command — a bot command to run (`command`), rendered as a button that
// invokes it.
// url — an external link (`url`), rendered as a link button.
// select — a menu (`options`, each a label and the value choosing it
// returns), rendered as a picker.
// approval — a reference to an approval request (`approval.id`), rendered as
// approve/deny controls bound to that id.
//
// False on all four transports this pass, and nothing refuses a send for it:
// actions are accepted, validated per kind, and flattened by renderText to one
// line each after the text — `[label] command`, `[label] url`,
// `[label] opt | opt`, `[label] approval requested: <id>`. So a caller that
// needs a real control must read this flag and degrade itself; a caller that
// only needs the choice communicated can send actions and take the text form.
Actions bool `json:"actions"`
}
+162 -27
View File
@@ -138,44 +138,179 @@ func requireOrgAdmin(ctx context.Context) error {
// ── JSON projections (camelCase, closed shapes) ──────────────────────────────
// channelView is one chat transport as this org sees it: the fixed transport
// facts, the org's connection to it, and the org's access policy for it.
type channelView struct {
ID string `json:"id"`
Connected bool `json:"connected"`
Account string `json:"account"`
AccountLabel string `json:"accountLabel"`
Capabilities capabilities `json:"capabilities"`
DMPolicy DMPolicy `json:"dmPolicy"`
GroupPolicy GroupPolicy `json:"groupPolicy"`
PendingPairing int `json:"pendingPairing"`
// ID is the fixed transport identifier — discord, slack, teams or telegram —
// and the value every route on this surface names a channel by, including the
// `:channel` segment of the send path. The listing is always in that order.
ID string `json:"id"`
// Connected is whether integrations holds a connection for (this org, this
// transport) — whether someone finished its connect flow. False leaves Account
// and AccountLabel empty, and a send is then refused downstream rather than
// here: by the transport's own binding check (403 for a Telegram chat this org
// has not bound, 409 for a Discord or Teams room with no inbound-learned
// route), or on Slack by the absent per-org bot token, which surfaces as 502.
Connected bool `json:"connected"`
// Account is the id-shaped fact about that connection: the lowercased external
// id integrations custodies for it — a Discord guild id, a Slack team
// (workspace) id, a Teams AAD tenant id, or the Telegram chat the org bound.
// Empty when not connected. Informational: the access policy keys on
// (org, channel), so exactly one account is representable per pair.
Account string `json:"account"`
// AccountLabel is the human label of that same account — the Discord guild
// name, the Slack team name, the Teams tenant name (falling back to the tenant
// id), the Telegram chat title. DISPLAY ONLY: never a key, and never swapped
// with Account, on any surface.
AccountLabel string `json:"accountLabel"`
// Capabilities is what this transport renders natively — read it before
// composing a message that needs threading, media or interactive actions.
Capabilities capabilities `json:"capabilities"`
// DMPolicy is how this org admits direct messages here: "pairing", "allowlist"
// or "open", defaulting to "pairing" when the org has never set one.
DMPolicy DMPolicy `json:"dmPolicy"`
// GroupPolicy is how this org admits group and thread rooms here: "open",
// "allowlist" or "disabled", defaulting to "open". Both policy fields come
// back EMPTY — rather than the listing failing — when the policy cannot be
// read; GET /v1/channels/allowlist carries the same two with the entries they
// consult.
GroupPolicy GroupPolicy `json:"groupPolicy"`
// PendingPairing counts the org's UNEXPIRED pairing requests on this channel:
// exactly the rows GET /v1/channels/pairing returns for it, one per person
// waiting on an admin. It never exceeds three — the pending cap per
// (org, channel) — and expired requests are not counted.
PendingPairing int `json:"pendingPairing"`
}
// inboxView is one stored inbound message: the portable envelope (envelope.go)
// as this API publishes it, identical in shape whichever transport it came from.
type inboxView struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
Account string `json:"account"`
RoomID string `json:"roomId"`
RoomKind string `json:"roomKind"`
Sender string `json:"sender"`
// ID is the store's row id, assigned on insert — SERVER-SET, and the cursor:
// pass a page's last id back as `since`. It rises with arrival order but is
// not contiguous, because one sequence is shared by every org in the store and
// a caller reads only its own rows.
ID int64 `json:"id"`
// Channel is the transport this message arrived on — discord, slack, teams or
// telegram — and the `:channel` segment to reply through.
Channel string `json:"channel"`
// Account is the lowercased external id of the org's connected account on that
// transport: the Discord guild id, the Slack team id, the Teams AAD tenant id,
// or the bound Telegram chat id. Informational only — the gate keys on
// (org, channel), never on the account.
Account string `json:"account"`
// RoomID is the conversation on the ORIGINATING transport, and the value to
// send back as `room.id`: a Discord channel snowflake, a Slack conversation id
// (D… IM, C… public channel, G… private or mpim), a Teams conversation id
// (19:…@thread.… for a channel or group chat, a:… for a personal chat), or a
// Telegram chat id in decimal (negative for a group, positive for a DM). It is
// stable for the life of the room, so every message from one conversation
// carries the same value.
RoomID string `json:"roomId"`
// RoomKind is how ingest classified the room: "dm", "group" or "thread". It
// decides which policy gated the message — dmPolicy for "dm", groupPolicy for
// BOTH "group" and "thread". Only Slack ever reports "thread"; Telegram's
// reply-to id becomes ReplyTo instead, and Discord's ingress is guild-scoped
// so its rooms are always "group".
RoomKind string `json:"roomKind"`
// Sender is the TRANSPORT-NATIVE user id of whoever wrote the message — a
// Discord member.user.id, a Slack U… user id, a Teams aadObjectId (falling
// back to from.id), a Telegram from.id in decimal. Stable per person per
// transport, and the identity the gate keys on: an allow entry, an access-group
// member and a pairing approval all name exactly this value.
Sender string `json:"sender"`
// SenderUser is the HANZO account subject that chat identity is linked to,
// resolved at ingest through the org's user link. Best-effort and omitted when
// absent: a person who never linked their chat account — or a link store that
// could not be read — leaves it empty and is never blocked for it.
SenderUser string `json:"senderUser,omitempty"`
Text string `json:"text"`
ReplyTo string `json:"replyTo,omitempty"`
CreatedAt int64 `json:"createdAt"`
// Text is the body as the transport delivered it, with the bot mention already
// stripped by the ingress adapter (on Discord it is the /hanzo prompt argument,
// since that ingress is slash commands only), truncated to 8 KiB on store.
// Inbound attachments are not stored — this is the whole of what was said.
Text string `json:"text"`
// ReplyTo is the transport's reply target for this message: Slack's thread_ts,
// or the Telegram message id it arrived as. Send it back as the body's
// `replyTo` to answer in the SAME thread. Empty means the transport reported
// none — a top-level Slack message, and every Discord and Teams message, since
// neither carries one — and a reply then lands at the top level of the room.
ReplyTo string `json:"replyTo,omitempty"`
// CreatedAt is Unix SECONDS, stamped by the ingest goroutine when the message
// was accepted — not the transport's own send time. Rows are dropped 30 days
// after it.
CreatedAt int64 `json:"createdAt"`
}
// pairingView is one pending pairing request: someone who messaged a
// pairing-gated channel and is waiting on an org admin.
type pairingView struct {
Channel string `json:"channel"`
Sender string `json:"sender"`
Code string `json:"code"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
// Channel is the transport the request arrived on — discord, slack, teams or
// telegram — and half of what approval names. The cap of three unapproved
// requests applies per (org, channel); while it is full no further code is
// minted until one is approved or expires.
Channel string `json:"channel"`
// Sender is the transport-native user id waiting for access — the same
// identity inbox messages carry. Approving mints a DM allow entry for exactly
// this value and nothing wider: pairing never grants group access.
Sender string `json:"sender"`
// Code is the CAPABILITY that authorises the approval: eight characters from a
// 32-symbol uppercase alphabet (A-Z0-9 minus the confusables 0, O, 1 and I),
// minted with crypto/rand and also sent to the requester in chat. An org admin
// passes it with the channel to POST /v1/channels/pairing/approve, which
// CONSUMES it — the request row is deleted, so a code approves once — and which
// takes org admin as well as the code. It lives ONE HOUR from CreatedAt;
// expired requests are not listed here, and approving one is a 404. It is shown
// on this admin surface and NEVER logged.
Code string `json:"code"`
// CreatedAt is Unix SECONDS of FIRST contact: when the request was minted and
// the code sent. Expiry is measured from here and from nowhere else.
CreatedAt int64 `json:"createdAt"`
// LastSeen is Unix SECONDS of the MOST RECENT message from this sender while
// the request has been pending. It moves as they keep writing, which is how an
// admin tells a live request from an abandoned one — but it does not extend the
// hour and does not re-send the code, since one request sends exactly one chat
// reply.
LastSeen int64 `json:"lastSeen"`
}
// allowlistView is one channel's access policy for this org: what the gate does
// to an inbound message, and every entry it consults doing it. Both the GET and
// the PUT answer this shape.
type allowlistView struct {
DMPolicy DMPolicy `json:"dmPolicy"`
GroupPolicy GroupPolicy `json:"groupPolicy"`
DM []string `json:"dm"`
Group []string `json:"group"`
Paired []string `json:"paired"`
// DMPolicy decides every inbound DIRECT message, defaulting to "pairing" when
// the org has never set one. "pairing": a sender with no entry is sent a
// pairing code and the message is DROPPED — it never reaches the inbox — and
// they are admitted only once an admin approves. "allowlist": only DM admits,
// and Paired senders are suspended, since a pairing grant counts under
// "pairing" alone. "open" is not unconditional either — it still requires `*`
// or a matching entry in DM.
DMPolicy DMPolicy `json:"dmPolicy"`
// GroupPolicy decides every inbound GROUP or THREAD message — a thread is a
// group surface — defaulting to "open". "open" admits every sender in the room.
// "allowlist" admits only what Group lists, so an EMPTY Group blocks the
// channel's group rooms outright. "disabled" drops all of them.
GroupPolicy GroupPolicy `json:"groupPolicy"`
// DM is the CONFIG-managed DM allow entries — the list PUT
// /v1/channels/allowlist owns and replaces wholesale. An entry matches a sender
// either EXACTLY, as the transport-native id inbox messages carry, or as
// `accessGroup:<name>` resolved through AccessGroups. A bare `*` admits
// everyone, but only while DMPolicy is "open": it is gate syntax, not an
// identity, so under "allowlist" it matches nobody.
DM []string `json:"dm"`
// Group is the CONFIG-managed group allow entries, consulted only while
// GroupPolicy is "allowlist". Entries match the same two ways as DM, and here a
// bare `*` admits every sender in the room.
Group []string `json:"group"`
// Paired is the senders admitted by PAIRING — the entries POST
// /v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this
// endpoint: the PUT writes config entries and can never revoke one of these
// (listing a paired sender under DM instead promotes that entry to config,
// which the admin then owns). They admit only while DMPolicy is "pairing".
Paired []string `json:"paired"`
// AccessGroups is the org's named sender sets, as group name -> channel ->
// member entries, held once for the whole org. A DM or Group entry written
// `accessGroup:<name>` admits any sender listed under that name for THIS
// channel, or under the channel `*`, which is how one set covers all four
// transports. Replaced wholesale by the PUT.
AccessGroups map[string]map[string][]string `json:"accessGroups"`
}
+47 -7
View File
@@ -12,30 +12,64 @@ func init() {
zip.Describe("GET /v1/channels", zip.Doc{
Description: "Returns every chat transport channels can talk to — Discord, Slack, Teams\nand Telegram — with the caller org's own facts on each: whether it is\nconnected and to which account, what the transport supports, the org's DM and\ngroup access policies, and how many pairing requests are pending approval. The\norder is fixed, so a console can render the same rows every time. A policy that\ncannot be read leaves that channel's policy fields empty rather than failing\nthe whole listing.",
Fields: map[string]string{
"chatChannels.channels": "Channels is every chat transport this deployment supports, in a fixed\norder, each carrying whether the org has connected it, the account behind\nthe connection, what the transport can do, the org's DM/group access\npolicies for it, and how many pairing requests are waiting.",
"capabilities.actions": "Actions is whether the transport renders an INTERACTIVE control natively, and\nit is the flag to read before composing one. The vocabulary is a closed\nkind-tagged union (envelope.go), exactly four kinds, each carrying only its\nown field plus an optional label:\n\n\tcommand — a bot command to run (`command`), rendered as a button that\n\t invokes it.\n\turl — an external link (`url`), rendered as a link button.\n\tselect — a menu (`options`, each a label and the value choosing it\n\t returns), rendered as a picker.\n\tapproval — a reference to an approval request (`approval.id`), rendered as\n\t approve/deny controls bound to that id.\n\nFalse on all four transports this pass, and nothing refuses a send for it:\nactions are accepted, validated per kind, and flattened by renderText to one\nline each after the text — `[label] command`, `[label] url`,\n`[label] opt | opt`, `[label] approval requested: <id>`. So a caller that\nneeds a real control must read this flag and degrade itself; a caller that\nonly needs the choice communicated can send actions and take the text form.",
"capabilities.dm": "DM is whether the transport carries a DIRECT message at all. True for slack,\nteams and telegram. False for discord, honestly: that ingress is guild-scoped\nslash commands — an interaction without a guild id is refused at the door —\nso nothing ever arrives classified as a DM, no reply route is ever learned\nfor one, and a send addressed at a Discord DM is refused 409.",
"capabilities.group": "Group is whether the transport carries multi-person rooms — a Discord guild\nchannel, a Slack channel, a Teams channel or group chat, a Telegram group or\nsupergroup. True on all four.",
"capabilities.media": "Media is whether the transport renders an ATTACHMENT natively. False on all\nfour this pass, and a send is not refused for it: renderText flattens each\nattachment to one `kind: url (mime)` line after the text rather than dropping\nit.",
"capabilities.thread": "Thread is whether a reply can be threaded UNDER a specific message. True for\nslack alone: it is the only transport whose ingress reports a thread\n(thread_ts, published as the envelope's replyTo) and whose door posts back\ninto it. Discord's replyTo makes an inline reply rather than a thread,\nTelegram's answers one message id, and Teams carries no reply target at all —\na replyTo sent to it is ignored.",
"channelView.account": "Account is the id-shaped fact about that connection: the lowercased external\nid integrations custodies for it — a Discord guild id, a Slack team\n(workspace) id, a Teams AAD tenant id, or the Telegram chat the org bound.\nEmpty when not connected. Informational: the access policy keys on\n(org, channel), so exactly one account is representable per pair.",
"channelView.accountLabel": "AccountLabel is the human label of that same account — the Discord guild\nname, the Slack team name, the Teams tenant name (falling back to the tenant\nid), the Telegram chat title. DISPLAY ONLY: never a key, and never swapped\nwith Account, on any surface.",
"channelView.capabilities": "Capabilities is what this transport renders natively — read it before\ncomposing a message that needs threading, media or interactive actions.",
"channelView.connected": "Connected is whether integrations holds a connection for (this org, this\ntransport) — whether someone finished its connect flow. False leaves Account\nand AccountLabel empty, and a send is then refused downstream rather than\nhere: by the transport's own binding check (403 for a Telegram chat this org\nhas not bound, 409 for a Discord or Teams room with no inbound-learned\nroute), or on Slack by the absent per-org bot token, which surfaces as 502.",
"channelView.dmPolicy": "DMPolicy is how this org admits direct messages here: \"pairing\", \"allowlist\"\nor \"open\", defaulting to \"pairing\" when the org has never set one.",
"channelView.groupPolicy": "GroupPolicy is how this org admits group and thread rooms here: \"open\",\n\"allowlist\" or \"disabled\", defaulting to \"open\". Both policy fields come\nback EMPTY — rather than the listing failing — when the policy cannot be\nread; GET /v1/channels/allowlist carries the same two with the entries they\nconsult.",
"channelView.id": "ID is the fixed transport identifier — discord, slack, teams or telegram —\nand the value every route on this surface names a channel by, including the\n`:channel` segment of the send path. The listing is always in that order.",
"channelView.pendingPairing": "PendingPairing counts the org's UNEXPIRED pairing requests on this channel:\nexactly the rows GET /v1/channels/pairing returns for it, one per person\nwaiting on an admin. It never exceeds three — the pending cap per\n(org, channel) — and expired requests are not counted.",
"chatChannels.channels": "Channels is every chat transport this deployment supports, in a fixed\norder, each carrying whether the org has connected it, the account behind\nthe connection, what the transport can do, the org's DM/group access\npolicies for it, and how many pairing requests are waiting.",
},
})
zip.Describe("GET /v1/channels/allowlist", zip.Doc{
Description: "Returns the caller org's access policy for one channel: whether\nDMs are pairing-gated, allowlisted or open, whether group rooms are open,\nallowlisted or disabled, the config-managed DM and group allow entries, the\nsenders approved through PAIRING (read-only here), and the org's named access\ngroups. An unknown channel is a 404.",
Fields: map[string]string{
"allowlistRef.channel": "Channel is the transport to read: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
"allowlistRef.channel": "Channel is the transport to read: discord, slack, teams or telegram.\nRequired; an unknown value is a 404.",
"allowlistView.accessGroups": "AccessGroups is the org's named sender sets, as group name -> channel ->\nmember entries, held once for the whole org. A DM or Group entry written\n`accessGroup:<name>` admits any sender listed under that name for THIS\nchannel, or under the channel `*`, which is how one set covers all four\ntransports. Replaced wholesale by the PUT.",
"allowlistView.dm": "DM is the CONFIG-managed DM allow entries — the list PUT\n/v1/channels/allowlist owns and replaces wholesale. An entry matches a sender\neither EXACTLY, as the transport-native id inbox messages carry, or as\n`accessGroup:<name>` resolved through AccessGroups. A bare `*` admits\neveryone, but only while DMPolicy is \"open\": it is gate syntax, not an\nidentity, so under \"allowlist\" it matches nobody.",
"allowlistView.dmPolicy": "DMPolicy decides every inbound DIRECT message, defaulting to \"pairing\" when\nthe org has never set one. \"pairing\": a sender with no entry is sent a\npairing code and the message is DROPPED — it never reaches the inbox — and\nthey are admitted only once an admin approves. \"allowlist\": only DM admits,\nand Paired senders are suspended, since a pairing grant counts under\n\"pairing\" alone. \"open\" is not unconditional either — it still requires `*`\nor a matching entry in DM.",
"allowlistView.group": "Group is the CONFIG-managed group allow entries, consulted only while\nGroupPolicy is \"allowlist\". Entries match the same two ways as DM, and here a\nbare `*` admits every sender in the room.",
"allowlistView.groupPolicy": "GroupPolicy decides every inbound GROUP or THREAD message — a thread is a\ngroup surface — defaulting to \"open\". \"open\" admits every sender in the room.\n\"allowlist\" admits only what Group lists, so an EMPTY Group blocks the\nchannel's group rooms outright. \"disabled\" drops all of them.",
"allowlistView.paired": "Paired is the senders admitted by PAIRING — the entries POST\n/v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this\nendpoint: the PUT writes config entries and can never revoke one of these\n(listing a paired sender under DM instead promotes that entry to config,\nwhich the admin then owns). They admit only while DMPolicy is \"pairing\".",
},
Example: json.RawMessage(`{"channel":"slack"}`),
})
zip.Describe("GET /v1/channels/inbox", zip.Doc{
Description: "Returns the messages people have sent to the caller org's connected chat\nbots, oldest first, in the portable envelope shape every transport normalises\ninto. It is a CURSOR feed, not a search: pass the returned cursor back as\n`since` to get only what has arrived since. Only this org's messages are\nstored under this org, so the feed can never carry another tenant's chat.",
Fields: map[string]string{
"inboxIn.limit": "Limit caps how many messages come back. Empty or 0 uses the store's\ndefault page size. Must parse as an integer.",
"inboxIn.since": "Since is the exclusive cursor: only messages with a higher row id come\nback. Empty starts at the beginning. Must parse as an integer.",
"inboxPage.cursor": "Cursor is the row id to pass back as `since` for the next page. It is the\nlast message's id, or the requested cursor when the page is empty.",
"inboxPage.messages": "Messages are the inbound messages, oldest first.",
"inboxIn.limit": "Limit caps how many messages come back. Empty or 0 uses the store's\ndefault page size. Must parse as an integer.",
"inboxIn.since": "Since is the exclusive cursor: only messages with a higher row id come\nback. Empty starts at the beginning. Must parse as an integer.",
"inboxPage.cursor": "Cursor is the row id to pass back as `since` for the next page. It is the\nlast message's id, or the requested cursor when the page is empty.",
"inboxPage.messages": "Messages are the inbound messages, oldest first.",
"inboxView.account": "Account is the lowercased external id of the org's connected account on that\ntransport: the Discord guild id, the Slack team id, the Teams AAD tenant id,\nor the bound Telegram chat id. Informational only — the gate keys on\n(org, channel), never on the account.",
"inboxView.channel": "Channel is the transport this message arrived on — discord, slack, teams or\ntelegram — and the `:channel` segment to reply through.",
"inboxView.createdAt": "CreatedAt is Unix SECONDS, stamped by the ingest goroutine when the message\nwas accepted — not the transport's own send time. Rows are dropped 30 days\nafter it.",
"inboxView.id": "ID is the store's row id, assigned on insert — SERVER-SET, and the cursor:\npass a page's last id back as `since`. It rises with arrival order but is\nnot contiguous, because one sequence is shared by every org in the store and\na caller reads only its own rows.",
"inboxView.replyTo": "ReplyTo is the transport's reply target for this message: Slack's thread_ts,\nor the Telegram message id it arrived as. Send it back as the body's\n`replyTo` to answer in the SAME thread. Empty means the transport reported\nnone — a top-level Slack message, and every Discord and Teams message, since\nneither carries one — and a reply then lands at the top level of the room.",
"inboxView.roomId": "RoomID is the conversation on the ORIGINATING transport, and the value to\nsend back as `room.id`: a Discord channel snowflake, a Slack conversation id\n(D… IM, C… public channel, G… private or mpim), a Teams conversation id\n(19:…@thread.… for a channel or group chat, a:… for a personal chat), or a\nTelegram chat id in decimal (negative for a group, positive for a DM). It is\nstable for the life of the room, so every message from one conversation\ncarries the same value.",
"inboxView.roomKind": "RoomKind is how ingest classified the room: \"dm\", \"group\" or \"thread\". It\ndecides which policy gated the message — dmPolicy for \"dm\", groupPolicy for\nBOTH \"group\" and \"thread\". Only Slack ever reports \"thread\"; Telegram's\nreply-to id becomes ReplyTo instead, and Discord's ingress is guild-scoped\nso its rooms are always \"group\".",
"inboxView.sender": "Sender is the TRANSPORT-NATIVE user id of whoever wrote the message — a\nDiscord member.user.id, a Slack U… user id, a Teams aadObjectId (falling\nback to from.id), a Telegram from.id in decimal. Stable per person per\ntransport, and the identity the gate keys on: an allow entry, an access-group\nmember and a pairing approval all name exactly this value.",
"inboxView.senderUser": "SenderUser is the HANZO account subject that chat identity is linked to,\nresolved at ingest through the org's user link. Best-effort and omitted when\nabsent: a person who never linked their chat account — or a link store that\ncould not be read — leaves it empty and is never blocked for it.",
"inboxView.text": "Text is the body as the transport delivered it, with the bot mention already\nstripped by the ingress adapter (on Discord it is the /hanzo prompt argument,\nsince that ingress is slash commands only), truncated to 8 KiB on store.\nInbound attachments are not stored — this is the whole of what was said.",
},
Example: json.RawMessage(`{"since":"1042","limit":"100"}`),
})
zip.Describe("GET /v1/channels/pairing", zip.Doc{
Description: "Returns the pairing requests waiting for the caller org to approve\n— one per person who messaged a connected bot on a channel whose DM policy is\n\"pairing\" and who is not allowed yet. Each row carries the CODE an org admin\npasses to POST /v1/channels/pairing/approve. Expired requests are not\nreturned. Codes are capability strings: they are shown here, and never logged.",
Fields: map[string]string{
"pairingQueue.pending": "Pending is every unexpired pairing request waiting on an org admin, each\ncarrying the channel, the requesting sender and the code to approve it with.",
"pairingQueue.pending": "Pending is every unexpired pairing request waiting on an org admin, each\ncarrying the channel, the requesting sender and the code to approve it with.",
"pairingView.channel": "Channel is the transport the request arrived on — discord, slack, teams or\ntelegram — and half of what approval names. The cap of three unapproved\nrequests applies per (org, channel); while it is full no further code is\nminted until one is approved or expires.",
"pairingView.code": "Code is the CAPABILITY that authorises the approval: eight characters from a\n32-symbol uppercase alphabet (A-Z0-9 minus the confusables 0, O, 1 and I),\nminted with crypto/rand and also sent to the requester in chat. An org admin\npasses it with the channel to POST /v1/channels/pairing/approve, which\nCONSUMES it — the request row is deleted, so a code approves once — and which\ntakes org admin as well as the code. It lives ONE HOUR from CreatedAt;\nexpired requests are not listed here, and approving one is a 404. It is shown\non this admin surface and NEVER logged.",
"pairingView.createdAt": "CreatedAt is Unix SECONDS of FIRST contact: when the request was minted and\nthe code sent. Expiry is measured from here and from nowhere else.",
"pairingView.lastSeen": "LastSeen is Unix SECONDS of the MOST RECENT message from this sender while\nthe request has been pending. It moves as they keep writing, which is how an\nadmin tells a live request from an abandoned one — but it does not extend the\nhour and does not re-send the code, since one request sends exactly one chat\nreply.",
"pairingView.sender": "Sender is the transport-native user id waiting for access — the same\nidentity inbox messages carry. Approving mints a DM allow entry for exactly\nthis value and nothing wider: pairing never grants group access.",
},
})
zip.Describe("POST /v1/channels/:channel/send", zip.Doc{
@@ -60,6 +94,12 @@ func init() {
"allowlistPutIn.dmPolicy": "DMPolicy sets how direct messages are admitted: \"pairing\" (a person must be\napproved first), \"allowlist\" (only listed senders) or \"open\". Empty leaves\nit unchanged.",
"allowlistPutIn.group": "Group REPLACES the config-managed group allow entries. Absent or null\nleaves them alone; an empty list clears them.",
"allowlistPutIn.groupPolicy": "GroupPolicy sets how group and thread rooms are admitted: \"open\",\n\"allowlist\" or \"disabled\". Empty leaves it unchanged.",
"allowlistView.accessGroups": "AccessGroups is the org's named sender sets, as group name -> channel ->\nmember entries, held once for the whole org. A DM or Group entry written\n`accessGroup:<name>` admits any sender listed under that name for THIS\nchannel, or under the channel `*`, which is how one set covers all four\ntransports. Replaced wholesale by the PUT.",
"allowlistView.dm": "DM is the CONFIG-managed DM allow entries — the list PUT\n/v1/channels/allowlist owns and replaces wholesale. An entry matches a sender\neither EXACTLY, as the transport-native id inbox messages carry, or as\n`accessGroup:<name>` resolved through AccessGroups. A bare `*` admits\neveryone, but only while DMPolicy is \"open\": it is gate syntax, not an\nidentity, so under \"allowlist\" it matches nobody.",
"allowlistView.dmPolicy": "DMPolicy decides every inbound DIRECT message, defaulting to \"pairing\" when\nthe org has never set one. \"pairing\": a sender with no entry is sent a\npairing code and the message is DROPPED — it never reaches the inbox — and\nthey are admitted only once an admin approves. \"allowlist\": only DM admits,\nand Paired senders are suspended, since a pairing grant counts under\n\"pairing\" alone. \"open\" is not unconditional either — it still requires `*`\nor a matching entry in DM.",
"allowlistView.group": "Group is the CONFIG-managed group allow entries, consulted only while\nGroupPolicy is \"allowlist\". Entries match the same two ways as DM, and here a\nbare `*` admits every sender in the room.",
"allowlistView.groupPolicy": "GroupPolicy decides every inbound GROUP or THREAD message — a thread is a\ngroup surface — defaulting to \"open\". \"open\" admits every sender in the room.\n\"allowlist\" admits only what Group lists, so an EMPTY Group blocks the\nchannel's group rooms outright. \"disabled\" drops all of them.",
"allowlistView.paired": "Paired is the senders admitted by PAIRING — the entries POST\n/v1/channels/pairing/approve minted, DM scope only. READ-ONLY on this\nendpoint: the PUT writes config entries and can never revoke one of these\n(listing a paired sender under DM instead promotes that entry to config,\nwhich the admin then owns). They admit only while DMPolicy is \"pairing\".",
},
Example: json.RawMessage(`{"channel":"slack","dmPolicy":"allowlist","dm":["U024BE7LH"]}`),
})
+236 -56
View File
@@ -2,76 +2,256 @@ package coding
import (
"context"
"fmt"
"sync"
"time"
"github.com/hanzoai/cloud/apps/agents"
"github.com/hanzoai/cloud/apps/tracker"
"github.com/hanzoai/cloud/plane"
)
// adapters.go binds the session + tracker seams to the real in-process packages.
// This is the ONLY file in clients/coding that imports agents/tracker; coding.go
// stays pure so the orchestration is unit-tested against fakes. Neither agents nor
// tracker imports clients/git or clients/integrations, so these imports are
// cycle-free. The third seam, Runner, is bound in task.go — coding's own wire
// contract with the bot runtime.
// adapters.go binds coding's seams to the apps that OWN them — across the
// process boundary, because that is where they are.
//
// It used to bind them to agents and tracker in-process, which was right while
// one binary held every subsystem. It is not right now: each app is its own
// process, and every one of those calls reads the callee's `mounted` package
// global. A package global is per-PROCESS, so in the process that runs a coding
// run they are all nil and each seam answered its zero value — "tracker: not
// mounted", an empty clone URL the dispatcher reads as "git is not available",
// and a VerifyRef that reports every pushed branch absent and fails the run
// closed with no PR. The chat turn died of exactly this shape one file over.
//
// The orchestration in coding.go is untouched. It always reached its
// collaborators through injected seams, which is what makes this a re-binding
// and not a rewrite: same Dispatcher, same order, same fail-closed rules, the
// calls simply land on a socket instead of a nil global.
//
// The Runner is the exception that proves it: the bot-gateway sandbox was
// always an HTTP client (task.go), so it never had a boundary to cross.
// NewDispatcher assembles the production Dispatcher: sessions on the live agent
// registry, PRs on the tracker, the runner on coding's own runtime stub, plus the
// two git seams (cloneURL, verifyRef) the composition root passes from clients/git
// (which coding cannot import directly). log is the structured logger for
// best-effort mirror failures.
func NewDispatcher(
cloneURL func(org, repo string) string,
verifyRef func(ctx context.Context, org, repo, branch string) (string, bool),
log func(msg string, kv ...any),
) Dispatcher {
// seamTimeout bounds ONE seam call. Every seam here is a small read or write —
// open a row, append an event, resolve a name — so a call that has not answered
// in this long is a wedged peer, not a slow one, and the run gets an honest
// error instead of hanging inside a step.
//
// It sits UNDER the plane transport's own response-read ceiling, which in a
// plugin process is zaphttp's 30s default (zap-proto/http client.go: readTimeout
// 30s; only the cmd/cloud host re-registers the zap scheme with a longer one,
// and a plugin does not link the host). Under it, the deadline that fires is
// always this one — the one whose error names the seam — instead of a bare 502
// from the wire. It is also why the RUN itself is not a plane call: a 25-minute
// coding run cannot be a request, so it stays a bounded goroutine on the trigger
// side and only its seams cross.
const seamTimeout = 20 * time.Second
// NewDispatcher assembles the production Dispatcher: every seam a peer call over
// the internal plane, the runner on coding's own bot-gateway wire. log is the
// structured logger for best-effort mirror failures (nil is fine).
//
// It also wires the routed completion seam to THIS dispatcher, so the durable
// delivery activity verifies the pushed ref, files the PR and closes the session
// exactly as the local path does.
func NewDispatcher(log func(msg string, kv ...any)) Dispatcher {
d := Dispatcher{
Sessions: sessionAdapter{},
Tracker: trackerAdapter{},
Runner: runner{},
CloneURL: cloneURL,
VerifyRef: verifyRef,
Log: log,
// #48 route-work: enqueue a routed run on the ONE embedded tasks engine,
// gated by the agents liveness check. Both bind to the real in-process
// packages; a routed run with no live engine/target fails closed.
Route: enqueueRoutedRun,
TargetGate: agents.TargetDispatchable,
Sessions: planeSessions{},
PR: planePR{},
// The SANDBOX runner. The docker-CLI path it replaces could not run at all:
// bot-gateway has neither that binary nor a socket, so every dispatch 503d
// while the chain read as configured. Same gVisor/Kata boundary, asked of the
// apiserver instead of a CLI that is not installed — and over the plane, so
// there is no HTTP hop to port.
Runner: sandboxRunner{},
CloneURL: planeCloneURL,
VerifyRef: planeVerifyRef,
Log: log,
Route: planeRoute,
TargetGate: planeTargetGate,
}
// #48 completion parity: bind the routed completion seam to THIS dispatcher's
// git/tracker/session seams, so the durable delivery activity verifies the
// pushed ref, files the PR, and closes the session exactly as the local path
// does. The two git functions resolve their state at call time, so binding here
// (init, before any run) is safe.
setRoutedFinalizer(d.finalizeRoutedDurable)
return d
}
// sessionAdapter forwards to the agents in-process session API (inproc.go).
type sessionAdapter struct{}
func (sessionAdapter) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
return agents.OpenSession(ctx, org, actor, agent, title)
}
func (sessionAdapter) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
return agents.OpenSessionOn(ctx, org, actor, agent, title, target)
}
func (sessionAdapter) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
return agents.LogSessionEvent(ctx, org, sessionID, kind, actor, payload)
}
func (sessionAdapter) Close(ctx context.Context, org, sessionID, status string) error {
return agents.CloseSession(ctx, org, sessionID, status)
// bounded gives one seam call its own deadline without letting it outlive the
// run's. A run already cancelled fails here rather than on the wire.
func bounded(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, seamTimeout)
}
// trackerAdapter forwards to the tracker in-process agent-PR create (agentpr.go).
type trackerAdapter struct{}
// planeSessions is the live agent-session registry, in the agents process.
type planeSessions struct{}
func (trackerAdapter) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
pr, err := tracker.CreateAgentPR(ctx, tracker.AgentPRInput{
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
})
func (planeSessions) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
return planeSessions{}.OpenOn(ctx, org, actor, agent, title, "")
}
// OpenOn opens the session, tagged with the machine a routed run was sent to so
// mission-control shows it where it is executing. An empty target is the
// ordinary sandbox session — ONE op, because "no machine" is a value of the
// target and not a different question.
func (planeSessions) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
ctx, cancel := bounded(ctx)
defer cancel()
out, err := plane.Ask[plane.SessionOpenIn, plane.SessionOpened](ctx, agentsApp, plane.AgentsSessionOpen,
&plane.SessionOpenIn{Org: org, Actor: actor, Agent: agent, Title: title, Target: target})
if err != nil {
return "", err
}
if out == nil || out.SessionID == "" {
return "", fmt.Errorf("coding: agents opened no session")
}
return out.SessionID, nil
}
func (planeSessions) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
ctx, cancel := bounded(ctx)
defer cancel()
_, err := plane.Ask[plane.SessionEventIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionEvent,
&plane.SessionEventIn{Org: org, SessionID: sessionID, Kind: kind, Actor: actor, Payload: payload})
return err
}
func (planeSessions) Close(ctx context.Context, org, sessionID, status string) error {
ctx, cancel := bounded(ctx)
defer cancel()
_, err := plane.Ask[plane.SessionCloseIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionClose,
&plane.SessionCloseIn{Org: org, SessionID: sessionID, Status: status})
return err
}
// planePR opens the pull request: the work item on our board (tracker), and the
// proposal where the code lives (git — a GitHub pull request for a repository
// that mirrors there, the branch's page here otherwise).
//
// Two peers, ONE seam. The board row is ours and is filed for every run, whatever
// host the code is on; the address is a property of the host and only git can
// answer it. Splitting them at the caller would put the choice of backend in the
// orchestration, which is exactly where it must not be.
type planePR struct{}
func (planePR) Open(ctx context.Context, in PRInput) (PRRef, error) {
ctx, cancel := bounded(ctx)
defer cancel()
// The org travels in the ENVELOPE (cloud.For), not in the body. Both spell the
// same word here, but only the envelope is checked: the plane's identity slot
// is read back through the same OrgOf rule as the HTTP boundary, so a call
// crossing the plane cannot be granted an org key the boundary would refuse.
// A body field would arrive unchecked — which is what made this a
// cross-tenant write. Same shape as cloud.UpsertIssue's Ask.
ctx = plane.For(ctx, in.Org)
out, err := plane.Ask[plane.AgentPRIn, plane.AgentPROut](ctx, trackerApp, plane.TrackerAgentPR,
&plane.AgentPRIn{
Project: in.Project, Repo: in.Repo, Base: in.Base,
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
})
if err != nil {
return PRRef{}, err
}
return PRRef{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
if out == nil {
return PRRef{}, fmt.Errorf("coding: tracker filed no PR")
}
ref := PRRef{Identifier: out.Identifier, ProjectKey: out.ProjectKey, Number: out.Number}
// The address. Its failure is returned BESIDE the row rather than instead of
// it: the work is pushed and tracked either way, and a run that could not
// reach GitHub must say so out loud instead of quietly answering with a forge
// link that is not where the review will happen.
p, perr := plane.Ask[plane.ProposeIn, plane.Proposed](ctx, gitApp, plane.GitPropose,
&plane.ProposeIn{
Project: in.Project, Repo: in.Repo, Base: in.Base,
Head: in.Head, Title: in.Title, Body: in.Body,
})
if perr != nil {
return ref, perr
}
if p != nil {
ref.URL = p.URL
}
return ref, nil
}
// planeCloneURL asks git for the org's clone URL. An error is an EMPTY url,
// which the dispatcher already reads as "git is not available" and refuses the
// run on — the same fail-closed answer the in-process seam gave when git was
// absent, so no caller learns a new failure mode.
func planeCloneURL(ctx context.Context, org, repo string) string {
ctx, cancel := bounded(ctx)
defer cancel()
out, err := plane.Ask[plane.RepoRefIn, plane.RepoCloneURL](ctx, gitApp, plane.GitCloneURL,
&plane.RepoRefIn{Org: org, Repo: repo})
if err != nil || out == nil {
return ""
}
return out.URL
}
// planeVerifyRef is the integrity gate: git reads the tip off its own storage.
// An unreachable git is an UNVERIFIABLE ref, which is treated as absent — the
// run fails closed and files no PR, rather than trusting the sandbox's claim.
func planeVerifyRef(ctx context.Context, org, repo, branch string) (string, bool) {
ctx, cancel := bounded(ctx)
defer cancel()
out, err := plane.Ask[plane.RefIn, plane.RefTip](ctx, gitApp, plane.GitVerifyRef,
&plane.RefIn{Org: org, Repo: repo, Branch: branch})
if err != nil || out == nil || !out.Found {
return "", false
}
return out.SHA, true
}
// planeTargetGate is the fail-closed existence+liveness check for a routed run's
// machine: it exists in THIS org, is online, and has a live runner.
func planeTargetGate(ctx context.Context, org, targetID string) error {
ctx, cancel := bounded(ctx)
defer cancel()
_, err := plane.Ask[plane.TargetGateIn, plane.CodingAck](ctx, agentsApp, plane.AgentsTargetGate,
&plane.TargetGateIn{Org: org, TargetID: targetID})
return err
}
// planeRoute hands the routed run to agents to enqueue.
//
// It does NOT enqueue the workflow here. The durable delivery activity offers
// the run to an in-memory mailbox that the machine long-polls through agents'
// HTTP surface, so an enqueue in any other process would hand the run to a
// mailbox nobody reads and burn the whole budget before failing. The engine and
// the mailbox have to be one process; agents is that process.
func planeRoute(ctx context.Context, run RoutedRun) error {
ctx, cancel := bounded(ctx)
defer cancel()
_, err := plane.Ask[plane.RouteRunIn, plane.CodingAck](ctx, agentsApp, plane.AgentsRouteRun,
&plane.RouteRunIn{
Org: run.Org, TargetID: run.TargetID, SessionID: run.SessionID,
Repo: run.Repo, Project: run.Project, Base: run.Base, Branch: run.Branch,
Prompt: run.Prompt, CloneURL: run.CloneURL, TimeoutSeconds: run.TimeoutSeconds,
Actor: run.Actor, AgentRef: run.AgentRef,
})
return err
}
// Enqueue is the SERVER half of planeRoute, run by the agents process: put the
// routed run on the durable engine THERE, where the mailbox the machine polls
// lives and where the delivery activity can therefore hand the run over.
//
// It builds the process's Dispatcher on first use, which is what binds the
// routed COMPLETION seam (setRoutedFinalizer): when the machine reports, the
// delivery activity has to verify the pushed ref, file the PR and close the
// session, and it reaches those seams through that dispatcher. Enqueueing
// without it would queue runs whose sessions never close.
func Enqueue(ctx context.Context, in plane.RouteRunIn, log func(msg string, kv ...any)) error {
enqueueOnce.Do(func() { NewDispatcher(log) })
return enqueueRoutedRun(ctx, RoutedRun{
Org: in.Org, TargetID: in.TargetID, SessionID: in.SessionID,
Repo: in.Repo, Project: in.Project, Base: in.Base, Branch: in.Branch,
Prompt: in.Prompt, CloneURL: in.CloneURL, TimeoutSeconds: in.TimeoutSeconds,
Actor: in.Actor, AgentRef: in.AgentRef,
})
}
var enqueueOnce sync.Once
// The peers, spelled once.
const (
agentsApp = "agents"
gitApp = "git"
trackerApp = "tracker"
)
+142 -34
View File
@@ -5,7 +5,7 @@
//
// It is a LIBRARY, not an app: no route, no plugin, no manifest row. Its one
// caller is apps/integrations (the Slack `code:` trigger). It touches its
// collaborators only through interface seams (Sessions, Tracker, Runner) plus two
// collaborators only through interface seams (Sessions, PR, Runner) plus two
// git functions (CloneURL, VerifyRef), so the whole orchestration is unit-testable
// with fakes and — critically — coding does NOT import apps/git: git imports
// apps/integrations, integrations calls coding, so coding->git would cycle.
@@ -22,9 +22,41 @@ package coding
import (
"context"
"encoding/json"
"regexp"
"strings"
)
// RepoRE is the repo-name rule every door validates against, mirroring the git
// subsystem's own name check. It is here and exported because a door that
// invented its own rule would eventually accept a token carrying a path
// separator, and a repo name that can carry a path can address another org's
// namespace.
var RepoRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// OrgRE is the tenant-name rule, mirroring the git subsystem's own (git.go
// orgRE). A run's org is interpolated into a git namespace, so it is
// shape-checked rather than merely required: a tenant name that can carry a
// separator or a `..` can address another tenant's repositories.
var OrgRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
// BaseRE is the branch-name rule, mirroring the git subsystem's own (push.go
// branchRE) — nested names allowed, so `release/2.1` works.
//
// The load-bearing property is the FIRST character class: a branch is alnum-led,
// so a base can never begin with '-'. The base reaches a `git clone -b <base>`
// argument on a machine we do not own, where a leading dash makes it a flag
// rather than a branch, and `--upload-pack=` / `--config=core.fsmonitor=` are
// each arbitrary command execution on that machine. Length and the absence of a
// space matter too, but the dash is the one that turns data into a program.
var BaseRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
// BranchFor is the ONE branch a run is permitted to write, derived from the
// session that owns it. It is a pure function of the session id — not a name the
// caller supplies and not a name the model chooses — which is what lets the
// forge's ref policy state the rule structurally: a coding run writes
// refs/heads/agent/<something>, and nothing else, ever.
func BranchFor(sessionID string) string { return "agent/" + shortID(sessionID) }
// Event kinds mirrored into the agent session. These are the agents session
// vocabulary (a stable wire contract): a phase is a tool-call, a free line is a
// log, a lifecycle transition is a status. Kept as local constants so coding does
@@ -51,9 +83,16 @@ type Sessions interface {
Close(ctx context.Context, org, sessionID, status string) error
}
// Tracker is the work-item seam (clients/tracker in-process).
type Tracker interface {
CreatePR(ctx context.Context, in PRInput) (PRRef, error)
// PR opens the pull request for a finished run and says where it can be read.
//
// It is ONE seam with two backends behind it, because a repository can live in
// two places and a run must not care which: the work item lands on our board
// either way, and the address comes back from the forge for a repository that
// lives only here or from GitHub for one that mirrors there. A caller that had to
// ask which host it was talking to would be two orchestrations pretending to be
// one, and they would drift.
type PR interface {
Open(ctx context.Context, in PRInput) (PRRef, error)
}
// Runner is the bot-gateway coding-task seam (clients/bot in-process client).
@@ -78,10 +117,29 @@ type PRRef struct {
Identifier string
ProjectKey string
Number int
// URL is where the proposal is READ — the GitHub pull request when the
// repository mirrors there, the branch's page in the forge when it does not.
// It is the only part of a finished run a person can click, so it travels all
// the way out to the chat thread; empty is a real answer (a project-scoped
// repository has no browsable page) and costs a link, never a run.
URL string
}
// RunRequest / Step / RunResult mirror the bot coding contract.
type RunRequest struct {
// Tool names what runs inside the sandbox: dev | claude | codex | python |
// node. Empty means dev. The runtime owns the name→argv table; cloud only
// carries the name, so adding a tool is one edit over there and none here.
Tool string
// Desktop selects the xvfb IMAGE VARIANT — a tag, not a mode. A desktop run
// gets an X server, so a real browser window exists to drive; every class can
// already drive a headless one.
Desktop bool
// The repo is OPTIONAL. CloneURL empty means the run has no checkout — and
// then CredUser/CredToken MUST be empty too, because a credential a run can
// never use only exists to leak. The runtime refuses the combination.
CloneURL string
BaseBranch string
Branch string
@@ -129,6 +187,17 @@ type Req struct {
CredToken string
TimeoutSeconds int
TargetID string // when set, route to this registered machine instead of the sandbox
// Tool / Desktop are the caller's choice of harness and whether it needs a
// screen. They travel to RunRequest unchanged; classFor and argvFor over in
// sandboxrunner.go are the only two things that read them.
Tool string
Desktop bool
// SessionID adopts an ALREADY-OPEN session instead of opening one. The door
// (Start) opens it so it can answer with a real handle the moment the run is
// admitted, rather than an empty promise the caller cannot watch. Empty keeps
// the original behaviour — Run opens its own — which is what the unit tests
// exercise and what a direct Dispatcher caller still gets.
SessionID string
}
// RoutedRun is the NON-SECRET spec coding hands the Route seam to enqueue on the
@@ -184,12 +253,14 @@ const (
)
// Dispatcher wires the seams. The two git functions are injected (not an
// interface) because they are pure reads with no cloud-side state.
// interface) because they are pure reads with no cloud-side state. Both take a
// ctx: git is another PROCESS, so both are calls that can be slow, refused, or
// cancelled with the run.
type Dispatcher struct {
Sessions Sessions
Tracker Tracker
PR PR
Runner Runner
CloneURL func(org, repo string) string
CloneURL func(ctx context.Context, org, repo string) string
VerifyRef func(ctx context.Context, org, repo, branch string) (string, bool)
// Log is an optional structured log seam for best-effort mirror failures; nil
// is fine (mirror failures are non-fatal and simply dropped).
@@ -202,6 +273,20 @@ type Dispatcher struct {
// target (agents.TargetDispatchable): the target exists in this org, is
// online, and has a live runner. Nil disables routing.
TargetGate func(ctx context.Context, org, targetID string) error
// Watch observes every event the run mirrors into its session — the SAME
// events, at the same moment, from the one place a run narrates itself.
//
// It exists because a run is watched from more than one place and must not
// grow a second narration to serve each. The session stream is the primary
// feed; a chat thread is a second reader of the same story. Adding a hook
// here rather than a Slack call inside the orchestration is what keeps
// coding.go ignorant of chat: it emits events, and who listens is a
// composition decision made where the run is started.
//
// Nil is the ordinary case and costs nothing. It is best-effort by
// construction — it runs beside a mirror that is itself best-effort, and a
// watcher that fails must never fail the run whose work already happened.
Watch func(ctx context.Context, kind string, payload []byte)
}
// Run executes one coding job end to end and returns its Result. It never
@@ -241,7 +326,7 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
}
cloneURL := ""
if d.CloneURL != nil {
cloneURL = d.CloneURL(org, repo)
cloneURL = d.CloneURL(ctx, org, repo)
}
if cloneURL == "" {
res.Error = "git is not available"
@@ -254,14 +339,19 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
}
actor := strings.TrimSpace(req.UserID)
// 1. Register the live session (the durable record + live stream root).
sessionID, err := d.Sessions.Open(ctx, org, actor, agentRef, codingTitle(repo, prompt))
if err != nil {
res.Error = "could not start a session: " + err.Error()
return res
// 1. Register the live session (the durable record + live stream root), or
// adopt the one the door already opened to answer its caller with.
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
var err error
sessionID, err = d.Sessions.Open(ctx, org, actor, agentRef, codingTitle(repo, prompt))
if err != nil {
res.Error = "could not start a session: " + err.Error()
return res
}
}
res.SessionID = sessionID
branch := "agent/" + shortID(sessionID)
branch := BranchFor(sessionID)
res.Branch = branch
// Terminal bookkeeping (final status mirror, session close, PR row) runs on a
@@ -276,6 +366,7 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
// 2. Dispatch to the sandbox runtime, mirroring every progress line live.
runReq := RunRequest{
Tool: req.Tool, Desktop: req.Desktop,
CloneURL: cloneURL, BaseBranch: strings.TrimSpace(req.Base), Branch: branch,
Prompt: prompt, SessionID: sessionID, RunTimeoutSeconds: timeoutOr(req.TimeoutSeconds),
CredUser: req.CredUser, CredToken: req.CredToken,
@@ -296,10 +387,17 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
res.Diffstat = runRes.Diffstat
res.Changed = runRes.Changed
res.LogTail = runRes.LogTail
if runRes.Branch != "" {
res.Branch = runRes.Branch
branch = runRes.Branch
}
// runRes.Branch is NOT read. The branch is BranchFor(sessionID) — issued by
// cloud, named after a session the sandbox did not choose — and adopting the
// sandbox's self-report let a compromised one answer `main`, which then flowed
// into VerifyRef (which only asks whether a ref EXISTS, and main does) and out
// as PR.Open{Head: "main"}. A PR headed at the trunk, filed by us, from a run
// that never had permission to write there.
//
// The sandbox has nothing to report here: it was TOLD which branch to push,
// and its grant admits that one ref and no other, so a disagreement between
// what it claims and what we issued is not new information — it is the
// signal that something is wrong.
res.CommitSha = runRes.CommitSha
if !runRes.OK {
@@ -352,19 +450,22 @@ func (d Dispatcher) completeChanged(ctx context.Context, c completion, res Resul
res.CommitSha = sha // authoritative tip from our own storage
}
}
pr, perr := d.Tracker.CreatePR(ctx, PRInput{
pr, perr := d.PR.Open(ctx, PRInput{
Org: c.org, Project: strings.TrimSpace(c.project), Repo: c.repo,
Base: baseOr(c.base), Head: c.branch, Title: codingTitle(c.repo, c.prompt),
Body: prBody(c.prompt, c.base, c.branch, res.CommitSha, c.diffstat, c.sessionID), Assignee: c.agentRef,
})
// Whatever came back is kept, even beside an error: opening a PR is two acts
// in two places, and a run that filed its work item but could not reach GitHub
// has a real handle to report. Discarding it would hide the half that worked.
res.PR = pr
if perr != nil {
d.logf("coding: tracker PR create failed", "org", c.org, "repo", c.repo, "err", perr)
d.mirror(ctx, c.org, c.sessionID, c.actor, kindLog, map[string]any{"message": "tracker PR not created: " + perr.Error()})
} else {
res.PR = pr
d.logf("coding: PR open failed", "org", c.org, "repo", c.repo, "err", perr)
d.mirror(ctx, c.org, c.sessionID, c.actor, kindLog, map[string]any{"message": "pull request: " + perr.Error()})
}
d.mirror(ctx, c.org, c.sessionID, c.actor, kindStatus, map[string]any{
"status": "done", "changed": true, "branch": c.branch, "commit": res.CommitSha, "pr": pr.Identifier,
"status": "done", "changed": true, "branch": c.branch,
"commit": res.CommitSha, "pr": pr.Identifier, "url": pr.URL,
})
_ = d.Sessions.Close(ctx, c.org, c.sessionID, statusDone)
res.OK = true
@@ -400,10 +501,10 @@ func (d Dispatcher) finalizeRouted(ctx context.Context, in RoutedRun, res Routed
_ = d.Sessions.Close(ctx, in.Org, in.SessionID, statusDone)
return
}
branch := strings.TrimSpace(res.Branch)
if branch == "" {
branch = in.Branch
}
// res.Branch is NOT read, for the reason stated in Run: the branch is the one
// cloud issued and put in the RoutedRun, and a machine that reports a
// different one is reporting something it was never asked.
branch := in.Branch
out.Branch = branch
out.CommitSha = res.CommitSha
out.Changed = true
@@ -452,7 +553,7 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
// clone URL (non-secret) to hand it.
cloneURL := ""
if d.CloneURL != nil {
cloneURL = d.CloneURL(org, repo)
cloneURL = d.CloneURL(ctx, org, repo)
}
if cloneURL == "" {
res.Error = "git is not available"
@@ -472,13 +573,17 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
}
actor := strings.TrimSpace(req.UserID)
sessionID, err := d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
if err != nil {
res.Error = "could not start a session: " + err.Error()
return res
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
var err error
sessionID, err = d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
if err != nil {
res.Error = "could not start a session: " + err.Error()
return res
}
}
res.SessionID = sessionID
branch := "agent/" + shortID(sessionID)
branch := BranchFor(sessionID)
res.Branch = branch
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{
@@ -528,6 +633,9 @@ func (d Dispatcher) mirror(ctx context.Context, org, sessionID, actor, kind stri
if err := d.Sessions.Log(ctx, org, sessionID, kind, actor, b); err != nil {
d.logf("coding: session event mirror failed", "org", org, "session", sessionID, "err", err)
}
if d.Watch != nil {
d.Watch(ctx, kind, b)
}
}
func (d Dispatcher) logf(msg string, kv ...any) {
+14 -14
View File
@@ -56,14 +56,14 @@ func (f *fakeSessions) Close(_ context.Context, org, session, status string) err
return nil
}
type fakeTracker struct {
type fakePR struct {
mu sync.Mutex
inputs []PRInput
ref PRRef
err error
}
func (f *fakeTracker) CreatePR(_ context.Context, in PRInput) (PRRef, error) {
func (f *fakePR) Open(_ context.Context, in PRInput) (PRRef, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.inputs = append(f.inputs, in)
@@ -94,11 +94,11 @@ func (f *fakeRunner) Run(_ context.Context, org, userID string, req RunRequest,
// dispatcherFor builds a Dispatcher whose git seams are org-aware fakes: CloneURL
// echoes the (org, repo) so a test can assert the sandbox is pointed only at the
// caller's namespace; VerifyRef succeeds for a configured branch.
func dispatcherFor(sess *fakeSessions, tr *fakeTracker, run *fakeRunner, verifyOK bool) (Dispatcher, *[]string) {
func dispatcherFor(sess *fakeSessions, tr *fakePR, run *fakeRunner, verifyOK bool) (Dispatcher, *[]string) {
var cloneCalls []string
d := Dispatcher{
Sessions: sess, Tracker: tr, Runner: run,
CloneURL: func(org, repo string) string {
Sessions: sess, PR: tr, Runner: run,
CloneURL: func(_ context.Context, org, repo string) string {
cloneCalls = append(cloneCalls, org+"/"+repo)
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
},
@@ -125,7 +125,7 @@ func baseReq() Req {
func TestRun_HappyPath_PushVerifyPR_NoCredentialLeak(t *testing.T) {
sess := &fakeSessions{id: "sess_abc123def456"}
tr := &fakeTracker{ref: PRRef{Identifier: "API-1", ProjectKey: "API", Number: 1}}
tr := &fakePR{ref: PRRef{Identifier: "API-1", ProjectKey: "API", Number: 1}}
run := &fakeRunner{
steps: []Step{{Type: "step", Step: "clone", Status: "ok"}, {Type: "log", Message: "editing handler.go"}, {Type: "step", Step: "push", Status: "ok"}},
result: RunResult{Branch: "agent/abc123def456", CommitSha: "deadbeef", Diffstat: "1 file changed", Changed: true, OK: true},
@@ -188,7 +188,7 @@ func TestRun_EventVocabulary_StepIsToolCall_LogIsLog(t *testing.T) {
steps: []Step{{Type: "step", Step: "clone"}, {Type: "log", Message: "hi"}},
result: RunResult{Branch: "agent/x", Changed: true, OK: true},
}
d, _ := dispatcherFor(sess, &fakeTracker{}, run, true)
d, _ := dispatcherFor(sess, &fakePR{}, run, true)
d.Run(context.Background(), baseReq())
var toolCalls, logs, statuses int
@@ -214,7 +214,7 @@ func TestRun_EventVocabulary_StepIsToolCall_LogIsLog(t *testing.T) {
func TestRun_NoChanges_NoPR_DoneNotError(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
tr := &fakeTracker{}
tr := &fakePR{}
run := &fakeRunner{result: RunResult{Changed: false, OK: true}}
d, _ := dispatcherFor(sess, tr, run, true)
@@ -232,7 +232,7 @@ func TestRun_NoChanges_NoPR_DoneNotError(t *testing.T) {
func TestRun_RunnerError_ClosesError_NoPR(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
tr := &fakeTracker{}
tr := &fakePR{}
run := &fakeRunner{err: context.DeadlineExceeded}
d, _ := dispatcherFor(sess, tr, run, true)
@@ -250,7 +250,7 @@ func TestRun_RunnerError_ClosesError_NoPR(t *testing.T) {
func TestRun_VerifyRefFails_FailsClosed_NoPR(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
tr := &fakeTracker{}
tr := &fakePR{}
run := &fakeRunner{result: RunResult{Branch: "agent/x", Changed: true, OK: true}}
d, _ := dispatcherFor(sess, tr, run, false) // verify fails
@@ -268,7 +268,7 @@ func TestRun_VerifyRefFails_FailsClosed_NoPR(t *testing.T) {
func TestRun_MissingCredential_FailsBeforeOpeningSession(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
d, _ := dispatcherFor(sess, &fakeTracker{}, &fakeRunner{}, true)
d, _ := dispatcherFor(sess, &fakePR{}, &fakeRunner{}, true)
req := baseReq()
req.CredToken = ""
@@ -282,7 +282,7 @@ func TestRun_MissingCredential_FailsBeforeOpeningSession(t *testing.T) {
}
func TestRun_MissingRepoOrPrompt_FailsClosed(t *testing.T) {
d, _ := dispatcherFor(&fakeSessions{id: "s"}, &fakeTracker{}, &fakeRunner{}, true)
d, _ := dispatcherFor(&fakeSessions{id: "s"}, &fakePR{}, &fakeRunner{}, true)
if r := d.Run(context.Background(), Req{Org: "acme", CredToken: "x", Prompt: "do it"}); r.OK || r.Error == "" {
t.Fatalf("missing repo must fail: %+v", r)
}
@@ -291,9 +291,9 @@ func TestRun_MissingRepoOrPrompt_FailsClosed(t *testing.T) {
}
}
func TestRun_TrackerFailure_DoesNotFailRun(t *testing.T) {
func TestRun_PRFailure_DoesNotFailRun(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
tr := &fakeTracker{err: context.Canceled}
tr := &fakePR{err: context.Canceled}
run := &fakeRunner{result: RunResult{Branch: "agent/x", Changed: true, OK: true}}
d, _ := dispatcherFor(sess, tr, run, true)
+98
View File
@@ -0,0 +1,98 @@
package coding
// The credential the agent inside a sandbox answers the gateway with.
//
// Without one the whole product stops one step short of working: the box leases,
// the repo clones, `dev` starts, hanzo-mcp answers — and then the model call
// returns `Missing environment variable: HANZO_API_KEY`, or, with a key that
// names nobody, the gateway's 402 `a billable tenant is required (no anonymous
// usage)`. Every part was built and nothing could finish a task.
//
// IT IS OUR OWN MACHINE IDENTITY, MINTED PER RUN, and it is minted the way the
// rest of the fleet mints: client_credentials against IAM, through the same
// golang.org/x/oauth2 config clients/aihttp.go uses for inference. There is no
// second notion of "who a sandbox is" — a run authenticates as the deployment
// that started it, which is the identity the gateway already prices and meters.
//
// A STATIC KEY WAS THE OTHER OPTION AND IS WORSE IN EVERY DIRECTION. A long-lived
// hk- key would have to be stored, rotated, and handed to a box that is about to
// execute a model's output; this token expires on its own, is scoped to one
// identity, and nothing has to remember to revoke it.
//
// The token URL is not resolved here. IAMBaseURL is the fleet's split-horizon
// policy — the public issuer is Cloudflare-fronted and 403s an in-cluster
// server-side POST (edge 1006), so an in-cluster address must win — and that
// policy having exactly one home is why the agent runner works at all.
import (
"context"
"fmt"
"os"
"strings"
"sync"
"github.com/hanzoai/cloud"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
// source is built once and reused: the oauth2 client_credentials source caches a
// token until it expires, so a burst of runs mints one token rather than one
// each. Nil when the deployment carries no machine identity, which is the honest
// answer for a dev box and reads as "no credential" at the call site rather than
// as an empty string that looks like one.
var source = sync.OnceValue(func() oauth2.TokenSource {
id := strings.TrimSpace(os.Getenv("IAM_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv("IAM_CLIENT_SECRET"))
base := cloud.IAMBaseURL(cloud.IAMIssuer())
if id == "" || secret == "" || base == "" {
return nil
}
cc := &clientcredentials.Config{
ClientID: id,
ClientSecret: secret,
TokenURL: base + "/v1/iam/oauth/token",
// hanzo.id reads the credentials from the form body, not Basic auth —
// the same style clients/aihttp.go proved against this issuer.
AuthStyle: oauth2.AuthStyleInParams,
}
return cc.TokenSource(context.Background())
})
// key returns the bearer a run authenticates with, or "" when this deployment
// holds no machine identity. An error is returned only when an identity IS
// configured and minting failed, because that is a broken deployment rather than
// an unconfigured one and the difference is what a caller needs to report.
func key(ctx context.Context) (string, error) {
ts := source()
if ts == nil {
return "", nil
}
tok, err := ts.Token()
if err != nil {
return "", fmt.Errorf("mint sandbox credential: %w", err)
}
return tok.AccessToken, nil
}
// keyed wraps an agent's argv so the credential arrives on STDIN and becomes an
// environment variable inside the box, and is never an argument.
//
// ARGV IS PUBLIC AND STDIN IS NOT. Every process in the pod can read another's
// command line out of /proc, the argv is what a run echoes into its own audit
// line and its session narration, and the process we are handing it to is about
// to execute a language model's output. Stdin reaches this one process and
// nothing else, the exec stream already carries it, and the value never touches
// a file, a layer, or the pod spec.
//
// `IFS= read -r` takes exactly the first line and leaves the rest of stdin for
// the agent, so this does not consume input a tool might want. `exec "$@"`
// replaces the shell, so the agent keeps PID-of-the-command and signals and exit
// codes travel unchanged — the wrapper is gone by the time the agent runs.
func keyed(argv []string) []string {
return append([]string{
"sh", "-c",
`IFS= read -r HANZO_API_KEY; export HANZO_API_KEY; exec "$@"`,
"sh",
}, argv...)
}
+75
View File
@@ -0,0 +1,75 @@
package coding
// Where the sandbox credential is allowed to appear.
//
// The run hands a bearer to a process that is about to execute a language
// model's output, in a pod that also holds the model's own tools. Argv is the
// one place it must never be: /proc makes another process's command line
// readable, and this argv is echoed into the run's session narration and its
// audit line, so a credential there is published three ways at once.
import (
"strings"
"testing"
)
func TestTheCredentialIsNeverAnArgument(t *testing.T) {
const secret = "eyJhbGciOiJSUzI1NiJ9.THE-BEARER.sig"
got := keyed([]string{"dev", "exec", "--full-auto", "--", "fix the bug"})
for i, a := range got {
if strings.Contains(a, secret) {
t.Fatalf("argv[%d] carries the credential: %q", i, a)
}
}
// It is read from stdin into the environment, and the agent still runs.
if !strings.Contains(got[2], "read -r HANZO_API_KEY") {
t.Errorf("the wrapper does not read the credential: %q", got[2])
}
if !strings.Contains(got[2], "export HANZO_API_KEY") {
t.Errorf("the credential is read but never exported: %q", got[2])
}
// `exec` matters: without it the shell outlives the agent and swallows its
// signals and exit code, so a cancelled run would report the shell's status.
if !strings.Contains(got[2], `exec "$@"`) {
t.Errorf("the wrapper does not exec the agent, so it keeps the process: %q", got[2])
}
}
func TestTheAgentArgvSurvivesIntact(t *testing.T) {
// A wrapper that drops or reorders the agent's own arguments would change the
// task being run. `$0` is spent on the shell's name, so the agent's argv must
// begin immediately after it.
argv := []string{"dev", "exec", "--full-auto", "--skip-git-repo-check", "--", "a prompt with spaces"}
got := keyed(argv)
if len(got) != len(argv)+4 {
t.Fatalf("got %d words, want %d: %v", len(got), len(argv)+4, got)
}
if got[0] != "sh" || got[1] != "-c" || got[3] != "sh" {
t.Fatalf("wrapper prefix is not `sh -c <script> sh`: %v", got[:4])
}
for i, want := range argv {
if got[i+4] != want {
t.Errorf("argv[%d] = %q, want %q", i, got[i+4], want)
}
}
}
func TestNoIdentityIsNotAnError(t *testing.T) {
// A dev box holds no machine identity. That must read as "no credential" and
// let the run proceed to the harness's own honest failure, not as a broken
// deployment — the lease, the clone and the branch all still happened.
t.Setenv("IAM_CLIENT_ID", "")
t.Setenv("IAM_CLIENT_SECRET", "")
if got := source(); got != nil {
// source() is memoized per process, so this only asserts the shape when
// this test is the one that built it.
t.Skip("a machine identity was already resolved in this process")
}
k, err := key(t.Context())
if err != nil {
t.Fatalf("no identity reported an error: %v", err)
}
if k != "" {
t.Fatalf("no identity produced a credential: %q", k)
}
}
+241
View File
@@ -0,0 +1,241 @@
package coding
import (
"context"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// plane_test.go runs a whole coding job through REAL SOCKETS.
//
// The seam fakes elsewhere in this package call the Dispatcher's fields
// directly, which is the right shape for testing the orchestration and the
// wrong shape for testing this: the bug being fixed here was never in the
// orchestration. It was that each seam landed on a package global belonging to
// another PROCESS, and no in-process test can fail on that — the calls all
// resolve, against the nil that ships.
//
// So the peers here are actual zip apps on actual unix sockets, serving the
// actual ops the production plugins serve, and the Dispatcher's seams are the
// production plane clients. What is proven is what could not be proven before:
// every argument ENCODES (a map field would die inside zip.Call, before the
// socket — see plane_encodable_test.go), every reply decodes, and a run whose
// collaborators are all elsewhere still opens its session, points the sandbox at
// its own org, verifies the pushed ref and files its PR.
// peers stands up the three apps a coding run reaches, each backed by the same
// recording fakes the in-process tests use, so an assertion can be made about
// what ARRIVED on the far side rather than what was sent.
type peers struct {
sessions *fakeSessions
tracker *fakePR
clone string
tip string
found bool
gated []string
proposed string
}
func servePeers(t *testing.T, p *peers) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
agentsApp := zip.New(zip.Config{AppName: "agents", DisableStartupMessage: true})
zip.Post[plane.SessionOpenIn, plane.SessionOpened](agentsApp, "/agents/session/open",
func(ctx context.Context, in *plane.SessionOpenIn) (*plane.SessionOpened, error) {
id, err := p.sessions.OpenOn(ctx, in.Org, in.Actor, in.Agent, in.Title, in.Target)
if err != nil {
return nil, err
}
return &plane.SessionOpened{SessionID: id}, nil
}, zip.WithOperationID(plane.AgentsSessionOpen))
zip.Post[plane.SessionEventIn, plane.CodingAck](agentsApp, "/agents/session/event",
func(ctx context.Context, in *plane.SessionEventIn) (*plane.CodingAck, error) {
if err := p.sessions.Log(ctx, in.Org, in.SessionID, in.Kind, in.Actor, in.Payload); err != nil {
return nil, err
}
return &plane.CodingAck{OK: true}, nil
}, zip.WithOperationID(plane.AgentsSessionEvent))
zip.Post[plane.SessionCloseIn, plane.CodingAck](agentsApp, "/agents/session/close",
func(ctx context.Context, in *plane.SessionCloseIn) (*plane.CodingAck, error) {
if err := p.sessions.Close(ctx, in.Org, in.SessionID, in.Status); err != nil {
return nil, err
}
return &plane.CodingAck{OK: true}, nil
}, zip.WithOperationID(plane.AgentsSessionClose))
zip.Post[plane.TargetGateIn, plane.CodingAck](agentsApp, "/agents/target-gate",
func(_ context.Context, in *plane.TargetGateIn) (*plane.CodingAck, error) {
p.gated = append(p.gated, in.Org+"/"+in.TargetID)
return &plane.CodingAck{OK: true}, nil
}, zip.WithOperationID(plane.AgentsTargetGate))
gitApp := zip.New(zip.Config{AppName: "git", DisableStartupMessage: true})
zip.Post[plane.RepoRefIn, plane.RepoCloneURL](gitApp, "/git/clone-url",
func(_ context.Context, in *plane.RepoRefIn) (*plane.RepoCloneURL, error) {
p.clone = in.Org + "/" + in.Repo
return &plane.RepoCloneURL{URL: "https://git.test/v1/git/" + in.Org + "/" + in.Repo + ".git"}, nil
}, zip.WithOperationID(plane.GitCloneURL))
zip.Post[plane.RefIn, plane.RefTip](gitApp, "/git/verify-ref",
func(_ context.Context, _ *plane.RefIn) (*plane.RefTip, error) {
return &plane.RefTip{SHA: p.tip, Found: p.found}, nil
}, zip.WithOperationID(plane.GitVerifyRef))
zip.Post[plane.ProposeIn, plane.Proposed](gitApp, "/git/propose",
func(_ context.Context, in *plane.ProposeIn) (*plane.Proposed, error) {
p.proposed = in.Head
return &plane.Proposed{URL: "https://git.test/git/acme/api?ref=" + in.Head}, nil
}, zip.WithOperationID(plane.GitPropose))
trackerApp := zip.New(zip.Config{AppName: "tracker", DisableStartupMessage: true})
zip.Post[plane.AgentPRIn, plane.AgentPROut](trackerApp, "/tracker/agent-pr",
func(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
// No in.Org: the org is the caller's plane identity, mirroring the real
// handler (plugin/tracker/seams.go) after the cross-tenant write was closed.
ref, err := p.tracker.Open(ctx, PRInput{
Org: zip.CallerOf(ctx).Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
})
if err != nil {
return nil, err
}
return &plane.AgentPROut{Identifier: ref.Identifier, ProjectKey: ref.ProjectKey, Number: ref.Number}, nil
}, zip.WithOperationID(plane.TrackerAgentPR))
for name, app := range map[string]*zip.App{"agents": agentsApp, "git": gitApp, "tracker": trackerApp} {
app := app
plane.Bind()
go func(path string) { _ = app.Listen(path) }(zip.SocketPath(name))
t.Cleanup(func() { _ = app.Shutdown() })
waitListening(t, name)
}
}
func waitListening(t *testing.T, app string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if up, err := plane.Listening(zip.SocketPath(app)); err == nil && up {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("peer %s never came up", app)
}
// planeDispatcher is the production seam set (adapters.go) with only the sandbox
// runner faked — the runner was always an HTTP client and never had a boundary.
func planeDispatcher(run *fakeRunner) Dispatcher {
return Dispatcher{
Sessions: planeSessions{}, PR: planePR{}, Runner: run,
CloneURL: planeCloneURL, VerifyRef: planeVerifyRef, TargetGate: planeTargetGate,
}
}
// A changed run completes with every seam on the far side of a socket.
func TestRun_OverThePlane_CompletesAcrossProcesses(t *testing.T) {
p := &peers{
sessions: &fakeSessions{id: "sess_abc123def456"},
tracker: &fakePR{ref: PRRef{Identifier: "API-7", ProjectKey: "API", Number: 7}},
tip: "verifiedsha", found: true,
}
servePeers(t, p)
run := &fakeRunner{
steps: []Step{{Type: "step", Step: "clone", Message: "cloning", Status: "ok"}},
result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef", Diffstat: " 1 file changed"},
}
res := planeDispatcher(run).Run(context.Background(), Req{
Org: "acme", UserID: "u_1", AgentRef: "hanzo", Repo: "api",
Prompt: "fix the flake", CredUser: "x-access-token", CredToken: "sk-secret",
})
if !res.OK || !res.Verified {
t.Fatalf("run must complete over the plane: %+v", res)
}
if res.PR.Identifier != "API-7" {
t.Fatalf("the PR must be filed through tracker's door, got %q", res.PR.Identifier)
}
// ONE seam, both backends: the row landed on the board AND git answered where
// the proposal is read. A run whose result carries no address gives a person
// in a thread nothing to click.
if p.proposed != "agent/abc123def456" {
t.Fatalf("git was never asked to propose the branch, got %q", p.proposed)
}
if res.PR.URL != "https://git.test/git/acme/api?ref=agent/abc123def456" {
t.Fatalf("the address did not come back with the run: %q", res.PR.URL)
}
if res.CommitSha != "verifiedsha" {
t.Fatalf("the tip must come from git's own storage, got %q", res.CommitSha)
}
// ISOLATION: the sandbox is pointed only at THIS org's namespace, and that
// survives the crossing rather than being re-derived on the far side.
if p.clone != "acme/api" {
t.Fatalf("git was asked for %q, want acme/api", p.clone)
}
if !strings.Contains(run.gotReq.CloneURL, "/acme/api.git") {
t.Fatalf("clone url did not survive the crossing: %q", run.gotReq.CloneURL)
}
if run.gotReq.CredToken != "sk-secret" {
t.Fatal("the credential must reach the sandbox unchanged")
}
// The session opened, streamed and closed — all three ops, all across.
if len(p.sessions.opened) != 1 || p.sessions.opened[0].org != "acme" {
t.Fatalf("session open did not arrive: %+v", p.sessions.opened)
}
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusDone {
t.Fatalf("session close did not arrive: %+v", p.sessions.closes)
}
// The event payload is bytes precisely so its shape can cross; a dropped
// payload would leave mission-control with empty events.
var sawStarted bool
for _, e := range p.sessions.events {
if e.kind == kindStatus && strings.Contains(e.payload, `"status":"started"`) {
sawStarted = true
}
}
if !sawStarted {
t.Fatalf("event payloads did not survive the crossing: %+v", p.sessions.events)
}
}
// A branch git cannot see fails the run CLOSED across the boundary too: the
// integrity gate is the reason the PR exists, and an unreachable git must not
// read as a verified push.
func TestRun_OverThePlane_UnverifiedRefFilesNoPR(t *testing.T) {
p := &peers{
sessions: &fakeSessions{id: "sess_abc123def456"},
tracker: &fakePR{ref: PRRef{Identifier: "API-8"}},
found: false,
}
servePeers(t, p)
run := &fakeRunner{result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef"}}
res := planeDispatcher(run).Run(context.Background(), Req{
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
})
if res.OK || !strings.Contains(res.Error, "not found in native git") {
t.Fatalf("an unverified ref must fail closed, got %+v", res)
}
if len(p.tracker.inputs) != 0 {
t.Fatal("no PR may be filed for a branch git cannot see")
}
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusError {
t.Fatalf("the session must close error: %+v", p.sessions.closes)
}
}
// A peer that is not part of the deployment is an honest error, not a run that
// proceeds without it. This is the shape the whole path had in production.
func TestRun_OverThePlane_MissingPeerFailsHonestly(t *testing.T) {
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir()) // nothing listening at all
res := planeDispatcher(&fakeRunner{}).Run(context.Background(), Req{
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
})
if res.OK || res.Error != "git is not available" {
t.Fatalf("an absent git must stop the run before it starts, got %+v", res)
}
}
+213
View File
@@ -0,0 +1,213 @@
package coding
// progress.go reports a run into a chat thread while it is still running.
//
// # Build for the stream that exists
//
// Slack has no server-sent events. There is no socket to push a token down and
// no way for a client to subscribe to a run. What the platform actually offers
// is chat.update: post one message, get its id, and rewrite that message as
// often as you like. So this posts ONE message into the thread and edits it —
// the run's status line, in place — instead of the dozen messages a
// phase-per-message design would bury the channel under.
//
// # The engine does not hold the token
//
// It says "put this text at this address" over the plane, and the process that
// owns the workspace's bot credential does the posting (integrations_slack_send,
// which takes the org from the caller and never from an argument). The engine
// therefore reports into a workspace it cannot otherwise reach: it has no bot
// token, so a compromised run cannot post anywhere but the thread it was asked
// from, and cannot read that workspace at all.
//
// # Everything here is best-effort, and that is a decision
//
// A run's work — the branch, the commit, the PR — has already happened by the
// time most of these fire. A failed edit must never fail a run that succeeded,
// so every error is swallowed after being counted out of the retry budget. The
// opposite choice would let a Slack outage roll back real work.
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/hanzoai/cloud/plane"
)
// editBudget bounds how many edits one run spends. A run emits a line per step
// and a hostile prompt can make a model emit a great many; without a cap, one
// run could issue thousands of chat.update calls and exhaust the workspace's
// Slack rate limit for every other feature that shares the token. When the
// budget runs out the message simply stops moving — the terminal card still
// posts, because that one is reserved below.
const editBudget = 60
// editFloor is the minimum gap between two edits of the same message. Slack's
// chat.update tier allows roughly one per second per channel; a run narrating
// faster than that gets coalesced rather than throttled, so the thread shows the
// LATEST state instead of a backlog of stale ones.
const editFloor = time.Second
// progress is one run's status line in one thread.
type progress struct {
org, channel, thread string
mu sync.Mutex
ts string // the message being rewritten; empty until the first post lands
spent int
lastAt time.Time
lastMsg string
dead bool // a post/edit failed in a way that will not get better
}
// newProgress returns a sink for the given address, or nil when there is no
// address — a nil *progress's methods are no-ops, so the caller never branches.
func newProgress(org, channel, thread string) *progress {
if strings.TrimSpace(channel) == "" {
return nil
}
return &progress{org: org, channel: channel, thread: thread}
}
// watch is the Dispatcher.Watch seam: it turns one mirrored session event into
// the message's next state. It reads only the fields it needs and never the
// whole payload, and it cannot see a credential because no payload the run
// mirrors carries one.
func (p *progress) watch(ctx context.Context, kind string, payload []byte) {
if p == nil {
return
}
line, terminal := renderLine(kind, payload)
if line == "" {
return
}
p.set(ctx, line, terminal)
}
// renderLine turns one mirrored session event into the message's next state.
// PURE, so what a thread will say about a run is testable without a Slack, and
// so the injection rules below are checkable as themselves rather than as a
// side effect of posting.
//
// An unparsable or uninteresting event renders "" and changes nothing: a run
// that has already pushed a branch must not be disturbed by a payload it cannot
// read. terminal reports the last word, which is always spent even when the
// edit budget is gone — a finished run must never leave a thread reading
// "working…".
func renderLine(kind string, payload []byte) (line string, terminal bool) {
var e struct {
Step string `json:"step"`
Message string `json:"message"`
Status string `json:"status"`
Branch string `json:"branch"`
PR string `json:"pr"`
URL string `json:"url"`
Error string `json:"error"`
Changed bool `json:"changed"`
}
if json.Unmarshal(payload, &e) != nil {
return "", false
}
// EVERY value below is untrusted. A step name, a log line, a branch and an
// error are all derived from model output or from repo content the model
// read, and this text is posted into a Slack channel as mrkdwn. Unescaped, a
// run could emit `<!channel>` and page a whole workspace, or a link element
// and put an arbitrary URL under Hanzo's name. Escaped once, HERE, where the
// value meets the markup — not at the transport, which would double-escape
// text that was already safe.
terminal = e.Status == "done" || e.Status == "error"
switch {
case e.Status == "error":
return ":x: " + esc(firstLine(e.Error)), true
case e.Status == "done" && !e.Changed:
return ":white_check_mark: No changes were needed.", true
case e.Status == "done":
l := ":sparkles: Pushed `" + esc(e.Branch) + "`"
if e.PR != "" {
l += " · PR `" + esc(e.PR) + "`"
}
// The address goes out BARE. Slack turns a plain URL into a link on its
// own, so nothing here has to build `<url|text>` — which is the one markup
// element that carries an arbitrary destination, and therefore the one
// esc() exists to stop a run from writing. A link nobody had to construct
// cannot be constructed by a prompt.
if e.URL != "" {
l += " " + esc(e.URL)
}
return l, true
case e.Status == "started":
return ":hourglass_flowing_sand: Working `" + esc(e.Branch) + "`…", false
case kind == kindToolCall && e.Step != "":
l := ":gear: " + esc(e.Step)
if e.Message != "" {
l += " — " + esc(firstLine(e.Message))
}
return l, false
case e.Message != "":
return ":speech_balloon: " + esc(firstLine(e.Message)), false
}
return "", terminal
}
// set moves the message to text, posting it the first time and editing it after.
func (p *progress) set(ctx context.Context, text string, terminal bool) {
p.mu.Lock()
if p.dead || text == p.lastMsg {
p.mu.Unlock()
return
}
if !terminal {
if p.spent >= editBudget || time.Since(p.lastAt) < editFloor {
p.mu.Unlock()
return
}
}
p.spent++
p.lastAt = time.Now()
p.lastMsg = text
ts := p.ts
p.mu.Unlock()
// A short, independent deadline: a wedged Slack must not hold a run's step
// open, and this call is not on the run's critical path.
sctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := plane.Ask[plane.SlackSendIn, plane.SlackSent](sctx, "integrations", plane.IntegrationsSlackSend,
&plane.SlackSendIn{Channel: p.channel, Thread: p.thread, Text: text, Update: ts})
p.mu.Lock()
defer p.mu.Unlock()
if err != nil {
// One failure is transient; a failure with nothing posted yet means there
// is no message to edit and never will be. Stop trying either way once the
// budget is gone, so a broken workspace costs a run one call and not sixty.
if p.ts == "" {
p.dead = true
}
return
}
if out != nil && out.TS != "" && p.ts == "" {
p.ts = out.TS // first post: remember what to rewrite
}
}
// esc neutralizes the three mrkdwn-meaningful characters so agent-derived text
// cannot inject a link or a <!channel> broadcast, and caps the length so one
// enormous log line cannot become the whole message. & goes first, or the
// entities it writes get escaped again by the two that follow.
func esc(s string) string {
if len(s) > maxLine {
s = s[:maxLine] + "…"
}
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// maxLine bounds one interpolated value. Slack truncates a long message anyway;
// this makes the truncation ours, so the status line stays readable and a
// hostile prompt cannot push the run's actual state off the end of it.
const maxLine = 300
+15 -17
View File
@@ -47,8 +47,8 @@ func (g *fakeGate) gate(_ context.Context, org, target string) error {
func routedDispatcher(sess *fakeSessions, run *fakeRunner, router *fakeRouter, gate *fakeGate) (Dispatcher, *[]string) {
var cloneCalls []string
d := Dispatcher{
Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
CloneURL: func(org, repo string) string {
Sessions: sess, PR: &fakePR{}, Runner: run,
CloneURL: func(_ context.Context, org, repo string) string {
cloneCalls = append(cloneCalls, org+"/"+repo)
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
},
@@ -220,8 +220,10 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
sess := &fakeSessions{id: "sess_x"}
run := &fakeRunner{}
// No Route / TargetGate seams.
d := Dispatcher{Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
CloneURL: func(org, repo string) string { return "https://git.test/v1/git/" + org + "/" + repo + ".git" }}
d := Dispatcher{Sessions: sess, PR: &fakePR{}, Runner: run,
CloneURL: func(_ context.Context, org, repo string) string {
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
}}
res := d.Run(context.Background(), routedReq())
if res.OK || !strings.Contains(res.Error, "routing is not available") {
@@ -234,9 +236,9 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
// ---- routed completion parity (#48 I2): verify + PR + session close ----
func finalizeDispatcher(sess *fakeSessions, tr *fakeTracker, verifyOK bool) Dispatcher {
func finalizeDispatcher(sess *fakeSessions, tr *fakePR, verifyOK bool) Dispatcher {
return Dispatcher{
Sessions: sess, Tracker: tr,
Sessions: sess, PR: tr,
VerifyRef: func(_ context.Context, _, _, _ string) (string, bool) {
if verifyOK {
return "verifiedsha", true
@@ -250,7 +252,7 @@ func finalizeDispatcher(sess *fakeSessions, tr *fakeTracker, verifyOK bool) Disp
// side completion as the local path: the PR is filed and the session is closed done.
func TestFinalizeRouted_ChangedVerifyPasses_FilesPR_ClosesDone(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{ref: PRRef{Identifier: "API-9"}}
tr := &fakePR{ref: PRRef{Identifier: "API-9"}}
d := finalizeDispatcher(sess, tr, true)
in := RoutedRun{Org: "acme", SessionID: "sess_r", Repo: "api", Base: "main", Branch: "agent/r", Prompt: "add a test", Actor: "u-1", AgentRef: "hanzo"}
@@ -282,7 +284,7 @@ func TestFinalizeRouted_ChangedVerifyPasses_FilesPR_ClosesDone(t *testing.T) {
// — trust the tips we can read, not the machine's self-report.
func TestFinalizeRouted_VerifyFails_NoPR_ClosesError(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
tr := &fakePR{}
d := finalizeDispatcher(sess, tr, false) // verify fails
in := RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/r"}
@@ -299,7 +301,7 @@ func TestFinalizeRouted_VerifyFails_NoPR_ClosesError(t *testing.T) {
// A routed run that reported NO changes closes the session done with no PR.
func TestFinalizeRouted_NoChanges_NoPR_ClosesDone(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
tr := &fakePR{}
d := finalizeDispatcher(sess, tr, true)
d.finalizeRouted(context.Background(), RoutedRun{Org: "acme", SessionID: "s", Repo: "api"}, RoutedResult{OK: true, Changed: false})
@@ -315,9 +317,9 @@ func TestFinalizeRouted_NoChanges_NoPR_ClosesDone(t *testing.T) {
// even VerifyRef is never consulted (there is nothing to verify).
func TestFinalizeRouted_ReportedError_ClosesError_NoPR(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{}
tr := &fakePR{}
verifyCalled := false
d := Dispatcher{Sessions: sess, Tracker: tr, VerifyRef: func(context.Context, string, string, string) (string, bool) {
d := Dispatcher{Sessions: sess, PR: tr, VerifyRef: func(context.Context, string, string, string) (string, bool) {
verifyCalled = true
return "", true
}}
@@ -351,11 +353,7 @@ func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
prev := routedFinalizer
t.Cleanup(func() { routedFinalizer = prev })
routedFinalizer = nil
_ = NewDispatcher(
func(_, _ string) string { return "https://git.test" },
func(context.Context, string, string, string) (string, bool) { return "", true },
nil,
)
_ = NewDispatcher(nil)
if routedFinalizer == nil {
t.Fatal("NewDispatcher must wire the routed completion seam (else routed sessions never close)")
}
@@ -365,7 +363,7 @@ func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
// the attribution the completion needs.
func TestFinalizeRoutedDurable_BridgesFields(t *testing.T) {
sess := &fakeSessions{}
tr := &fakeTracker{ref: PRRef{Identifier: "API-1"}}
tr := &fakePR{ref: PRRef{Identifier: "API-1"}}
d := finalizeDispatcher(sess, tr, true)
d.finalizeRoutedDurable(context.Background(),
agents.RoutedRun{Org: "acme", SessionID: "s", Repo: "api", Branch: "agent/b", AgentRef: "hanzo", Actor: "u-9"},
+645
View File
@@ -0,0 +1,645 @@
package coding
// sandboxrunner.go runs a coding task in OUR sandbox — a gVisor pod in
// hanzo-sandboxes — reached over the internal plane.
//
// # Why this exists
//
// The Runner seam had exactly one implementation, and it could not run. It
// POSTed to bot's /v1/coding-tasks, which invokes the `docker` CLI —
// bot-gateway has neither that binary nor a socket, so every dispatch 503'd.
// The whole chain apps/coding → bots.Stream → /v1/coding-tasks was dead in
// production while reading as configured.
//
// The defect was never the ISOLATION. That path asked for --runtime=runsc or
// kata-runtime, which is the same gVisor/Kata boundary a sandbox pod gets; the
// defect was asking through a CLI that is not installed. apps/sandbox asks the
// apiserver for a Pod with runtimeClassName instead — same boundary, a caller
// that exists — and it was reachable the whole time with nothing dispatching to
// it: a real pod under runsc, digest-pinned, no service-account token, with
// hanzo-mcp answering over stdio inside it.
//
// SANDBOX_RUNTIME_CLASS stays one string (gvisor | kata-fc | kata-clh | empty)
// and never a fork in code. That is load-bearing rather than tidy: a benchmark
// inverted the expected answer — Firecracker beat gVisor on BOTH axes (git
// status 82ms vs 980ms, start 294ms vs 881ms, ~57 MiB either way) — so the
// boundary has to be switchable by deployment, not by rewrite.
//
// # Why the plane and not HTTP
//
// apps/bots' transport is net/http by its own admission — its doc says the bytes
// "should move over ZAP" and that the swap "is meant to be a change to THIS FILE
// plus each caller's one stub". This is that stub, and it skips the migration
// rather than performing it: a plane op IS ZAP, so there is no HTTP hop to port.
// apps/bots keeps its transport for actual bot traffic, which is what it is for.
//
// # The five ops are the whole vocabulary
//
// lease → run → end, with read/write for files. They are the same ops the fleet
// door now publishes to agents (lease_sandbox, run_in_sandbox, …), so a human
// driving a sandbox from chat and this Runner driving one for a coding task are
// using ONE surface. A second private path into a sandbox is the duplication
// this file exists to avoid.
import (
"context"
"encoding/base64"
"fmt"
"sort"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// sandboxRunner is the Runner over apps/sandbox.
type sandboxRunner struct{}
// classFor maps a tool to the sandbox class that carries its binary.
//
// The classes are a CLOSED set — exec, dev, desktop — and the agentic binaries
// (dev, hanzo-mcp, claude, codex) live only in dev and desktop. `exec` carries a
// bare interpreter, which is why a run asking for an agent got a pod that could
// not host one: apps/exec hardcodes exec and nothing consulted the tool.
//
// desktop is dev plus an X server, so it is selected by the DESKTOP flag rather
// than by a tool name — an image variant, never a fourth kind of run.
func classFor(tool string, desktop bool) string {
if desktop {
return "desktop"
}
switch strings.TrimSpace(tool) {
case "python", "node":
return "exec" // an interpreter needs no agent harness
default:
return "dev" // dev | claude | codex | "" all need the toolchain
}
}
// argvFor is the command that runs inside the sandbox.
//
// Each harness names its OWN non-interactive verb, and they do not agree. That
// is the whole content of this table: `-p` means print-the-answer to claude and
// --profile to dev, so one spelling copied across both is not a style choice,
// it is a run that never starts.
//
// Measured in a dev-class pod (oci.hanzo.ai/hanzoai/sandbox 1.0.1, dev 0.6.91):
//
// dev -p "say hi" -> Error loading configuration: config profile `say hi` not found
// dev exec "say hi" -> runs
//
// Every coding run cloud has ever dispatched took the first line. The prompt
// was read as the name of a config profile, no profile by that name existed,
// and the process was gone before a model was ever asked anything.
//
// TWO FLAGS, AND NEITHER IS A PREFERENCE.
//
// - --full-auto is `-a on-failure --sandbox workspace-write`: nobody is at a
// terminal to approve a command, so a run that asks is a run that hangs.
// - --skip-git-repo-check because CloneURL is optional. dev refuses to run
// outside a git repo to protect a laptop from itself; the pod is not a
// laptop, and the checkout is the thing it was given rather than the thing
// it wandered into.
//
// dev keeps its own workspace-write sandbox on TOP of the pod's isolation. It
// is not the boundary — runtimeClass is — but it costs nothing, it was measured
// to work under gVisor, and a harness that confines its own writes is one fewer
// way for a bad turn to reach the checkout.
//
// `--` ENDS THE OPTIONS, AND IT IS THE SECURITY-CARRYING TOKEN HERE.
//
// The prompt is caller text in an argv position, so a prompt that BEGINS WITH A
// DASH is not a prompt — it is a flag to the harness. Measured in the same pod,
// all three agree:
//
// dev exec --full-auto "--version" -> hanzo dev-exec 0.6.83 (printed, exited)
// codex exec "--version" -> codex-cli-exec 0.146.1
// claude -p "--version" -> 2.1.223 (Claude Code)
//
// None of those ran a task. The reachable end of that is worse than a wasted
// run: dev's `-c key=value` overrides its own config, including the sandbox
// policy it was just given, so the text a caller sends could choose how much of
// the box the model is allowed to touch. With `--` the same string is the
// prompt and nothing else. python3 -c / node -e need none: there the prompt is
// the VALUE of a flag, which is already a position no parser reinterprets.
func argvFor(tool, prompt string) []string {
switch strings.TrimSpace(tool) {
case "claude":
return []string{"claude", "-p", "--", prompt}
case "codex":
return []string{"codex", "exec", "--", prompt}
case "python":
return []string{"python3", "-c", prompt}
case "node":
return []string{"node", "-e", prompt}
default:
return []string{"dev", "exec", "--full-auto", "--skip-git-repo-check", "--", prompt}
}
}
// tools is the closed set a caller may name, and it is closed for the reason
// every other set in this package is: `default:` in the two switches above
// reads dev, so an unknown name did not fail — it silently ran a DIFFERENT
// harness than the one that was asked for, and answered as though it had run
// the right one. A typo is refused at the door instead.
var tools = map[string]bool{"dev": true, "claude": true, "codex": true, "python": true, "node": true}
// CheckTool refuses a harness we do not carry. Empty is not a request, so it is
// not an error — it is dev, which is what the whole default path already is.
func CheckTool(tool string) error {
if tool = strings.TrimSpace(tool); tool == "" || tools[tool] {
return nil
}
names := make([]string, 0, len(tools))
for t := range tools {
names = append(names, t)
}
sort.Strings(names)
return fmt.Errorf("coding: %q is not a harness we run (%s)", tool, strings.Join(names, ", "))
}
// Run leases a sandbox, does the work in it, and ends the lease.
//
// The lease is ended on EVERY exit including a panic-free error path, because a
// sandbox that outlives its run is the orphan class we spent a day clearing —
// and the TTL backstop (exec 900s, dev/desktop 14400s) is a floor for crashes,
// never a substitute for saying goodbye.
func (sandboxRunner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
step := func(name, msg, status string) {
if onStep != nil {
onStep(Step{Type: "step", Step: name, Message: msg, Status: status})
}
}
// THE ONE REF THIS RUN MAY WRITE, checked before anything is leased. The name
// is cloud's (BranchFor, a pure function of the session), the forge refuses
// anything else independently (apps/git/refpolicy.go), and this is the third
// statement of the same rule at the only other place that could break it — the
// process holding the credential. A run handed `main` stops here rather than
// discovering at push time that it was never allowed.
branch := strings.TrimSpace(req.Branch)
if strings.TrimSpace(req.CloneURL) != "" && !strings.HasPrefix(branch, agentPrefix) {
return RunResult{}, fmt.Errorf("coding: %q is not an agent branch", branch)
}
class := classFor(req.Tool, req.Desktop)
ttl := req.RunTimeoutSeconds
if ttl <= 0 {
ttl = 3600
}
// COMPUTE IS NOT FREE, and the gate is here rather than after the work
// because a lease holds a pod whether or not anyone can pay for it.
if err := affordable(ctx, org); err != nil {
return RunResult{}, fmt.Errorf("coding: %w", err)
}
step("lease", "leasing a "+class+" sandbox", "running")
// NO PROJECT, and that is the whole of it.
//
// A project NAMES A DISK. apps/sandbox keys one 20Gi PVC per (org, project) so a
// second lease on the same project finds the first disk and a checkout survives
// between sessions — which is right, and which is why a disk is KEPT unless
// somebody explicitly purges it.
//
// This run used to pass its SESSION id as the project. A session is opened once
// per dispatch and never reopened, so that name could not be asked for twice:
// every run minted a disk that was by construction unreusable, and the
// deliberate keep-by-default then made it immortal. 20Gi a run, and DO bills a
// volume whether or not anything is attached to it.
//
// The fix is not to purge harder. A purge on the way out still strands the disk
// of any run killed, OOM'd, or evicted between the lease and the goodbye. The
// fix is to stop asking for the wrong primitive: a run wants scratch space that
// dies with its pod, and that is EXACTLY what an emptyDir is — which is what
// runtime.go mounts whenever there is no volume. Its lifetime is the pod's by
// construction, so there is nothing to remember to delete and nothing to leak.
//
// The work does not live on that disk anyway: deliver() commits it and pushes it
// to the run's ref, which is the only reason anything survives a run at all.
leased, err := plane.Ask[plane.LeaseIn, plane.Leased](ctx, "sandboxes", plane.SandboxLease,
&plane.LeaseIn{Class: class, TTLSec: ttl})
if err != nil {
return RunResult{}, fmt.Errorf("coding: lease sandbox: %w", err)
}
if leased == nil || strings.TrimSpace(leased.ID) == "" {
return RunResult{}, fmt.Errorf("coding: lease sandbox: no id")
}
id := leased.ID
// THE SANDBOX'S ID IS SAID OUT LOUD, because it is the handle for the only two
// things a person watching a run can do to it: stop the work (stop_run) or
// release it (end_sandbox). A run that never named its sandbox could be watched
// and not touched.
step("leased", "sandbox "+id+" ("+class+")", "running")
// END ON EVERY PATH. A detached context because the caller's may already be
// cancelled by the time we unwind — the lease still has to be released, and
// releasing it is not the caller's deadline to spend.
defer func() {
end, cancel := context.WithTimeout(cloud.For(context.Background(), org), 30*time.Second)
defer cancel()
_, _ = plane.Ask[plane.EndIn, struct{}](end, "sandboxes", plane.SandboxEnd,
&plane.EndIn{ID: id})
// Said AFTER the lease is actually gone, so "ended" means the pod is
// released and not that we intended to release it.
step("ended", "released sandbox "+id, "running")
}()
// The checkout, when there is one. No repo means no clone and no credential —
// the request shape already guarantees the second (CredToken must be empty
// when CloneURL is), so there is nothing to strip here.
in := sandbox{ctx: ctx, id: id, ttl: ttl, session: req.SessionID,
token: req.CredToken, basic: basicOf(req)}
base := ""
if u := strings.TrimSpace(req.CloneURL); u != "" {
step("clone", "cloning "+u, "running")
// The clone narrates into the session like everything else. It is the step
// that most often hangs — a big repo, a slow forge — and a watcher seeing
// git count objects knows the difference between slow and stuck.
if _, err := in.do(cloneArgv(req), "clone"); err != nil {
return RunResult{}, err
}
// The run's branch exists BEFORE the first edit, so a tool that commits for
// itself commits onto the run's branch and never onto the base.
if _, err := in.do([]string{"git", "switch", "-c", branch}, "branch"); err != nil {
return RunResult{}, err
}
var err error
if base, err = in.tip(); err != nil {
return RunResult{}, err
}
}
step(req.Tool, "running the task", "running")
// The credential the agent answers the gateway with. Absent on a deployment
// that holds no machine identity, and then the run proceeds without one and
// fails at the model call with the harness's own message — which is a truer
// report than refusing here would be, because everything up to that point
// (lease, clone, branch) still happened and is still worth showing.
cred, err := key(ctx)
if err != nil {
return RunResult{}, fmt.Errorf("coding: %w", err)
}
argv, stdin := argvFor(req.Tool, req.Prompt), ""
if cred != "" {
argv, stdin = keyed(argv), cred+"\n"
}
ran, err := runIn(ctx, id, argv, ttl, req.SessionID, stdin)
if err != nil {
return RunResult{}, fmt.Errorf("coding: run: %w", err)
}
// Branch is deliberately NOT reported. coding.Run does not read it — the branch
// is the one cloud issued, and a sandbox that answers a different one is
// reporting something it was never asked — so filling it in would only offer a
// value nobody may trust.
out := RunResult{
OK: ran.ExitCode == 0,
LogTail: in.scrub(tail(ran.Stdout, ran.Stderr)),
}
if !out.OK {
// A tool that failed produced nothing anyone should review, so its checkout
// stays here and dies with the lease. Pushing it would file work against a
// branch whose own run says it did not finish.
out.Error = fmt.Sprintf("exit %d", ran.ExitCode)
step("done", "finished", statusOf(false))
return out, nil
}
if base != "" {
if err := in.deliver(req, branch, base, &out, step); err != nil {
return RunResult{}, err
}
}
step("done", "finished", statusOf(out.OK))
return out, nil
}
// ── the work leaves the sandbox ──────────────────────────────────────────────
//
// Everything above is how a run HAPPENS; this is how it SURVIVES. Without it the
// rest is a model talking to itself: the sandbox is per-run and reaped, so an
// edit that stays in the checkout is deleted by the lease ending. The Runner did
// lease → clone → run → end and threw the work away.
//
// It is four facts and one rule.
//
// the branch is the one cloud issued for the session, created before the
// first edit.
// the change is the difference between two object ids — what we checked out
// and what is there now. Not a status parse and not a guess: it is
// the only reading that stays true both when a tool leaves the tree
// dirty and when it commits for itself, and a run that reports "no
// changes" because it never looked is the failure this file was.
// the tip is what rev-parse says AFTER the commit, so the sha we report is
// one that exists.
// the diffstat is measured over exactly that range, so what a reviewer reads and
// what was pushed are the same thing.
//
// THE RULE: the credential is applied to ONE INVOCATION and never recorded.
// agentPrefix is the machine namespace, spelled as coding's own BranchFor builds
// it. apps/git states the same rule over full refs (refpolicy.go agentRefPrefix)
// and the two are deliberately independent: this one keeps an honest run inside
// its lane, that one keeps a compromised one there.
const agentPrefix = "agent/"
// Who the commit is by. An agent is not a person and does not borrow one: the
// author of a run's commit is the run's harness, and the human who asked for it
// is on the PR and in the session.
const (
authorName = "hanzo-agent"
authorEmail = "agent@hanzo.ai"
)
// sandbox is one leased sandbox, addressed. It carries what every command of a
// run needs — where, for how long, who is watching, and the grant that must never
// come back out — so no call site repeats them and no error path can forget the
// last one.
type sandbox struct {
ctx context.Context
id string
ttl int
session string
// Both spellings of the grant, because both exist: the token as the forge
// issued it, and the base64 git presents it as. Scrubbing one and not the
// other would leave the credential in the output in the only form that
// actually travelled.
token, basic string
}
// deliver commits what the tool left behind, pushes it to the run's ref, and
// fills in what actually happened.
//
// The commit's EXIT CODE IS NOT READ. `git commit` with nothing staged fails, and
// that failure is indistinguishable from a tool that had already committed — so
// the question is answered by the sha instead, which is a fact rather than an
// opinion about one. When the tip has not moved, nothing changed, and nothing is
// pushed: a PR against a branch with no commits teaches a reviewer to ignore the
// agent, which is worse than the missing PR it replaces.
func (in sandbox) deliver(req RunRequest, branch, base string, out *RunResult, step func(name, msg, status string)) error {
if _, err := in.do([]string{"git", "add", "-A"}, "stage"); err != nil {
return err
}
// The identity rides the invocation for the same reason the credential does:
// `git -c` before a subcommand is not written into the repository's config.
if _, err := runIn(in.ctx, in.id, []string{"git",
"-c", "user.name=" + authorName, "-c", "user.email=" + authorEmail,
"commit", "--quiet", "-m", message(req.Prompt, in.session)}, in.ttl, in.session, ""); err != nil {
return fmt.Errorf("coding: commit: %w", err)
}
tip, err := in.tip()
if err != nil {
return err
}
if tip == base {
step("done", "no changes were needed", "running")
return nil // and Changed stays false, which is now a measurement
}
stat, err := in.do([]string{"git", "diff", "--stat", base, tip}, "diff")
if err != nil {
return err
}
step("push", "pushing "+branch, "running")
if _, err := in.do(pushArgv(req, branch), "push"); err != nil {
return err
}
out.Changed, out.CommitSha, out.Diffstat = true, tip, strings.TrimSpace(stat.Stdout)
return nil
}
// cloneArgv checks out the repo. The credential is a per-invocation rewrite, so
// what git records as the remote is the plain URL and the grant is gone the
// moment the process is.
func cloneArgv(req RunRequest) []string {
argv := append(gitAs(req), "clone", "--depth", "1")
if b := strings.TrimSpace(req.BaseBranch); b != "" {
argv = append(argv, "-b", b)
}
return append(argv, req.CloneURL, ".")
}
// pushArgv writes ONE ref, named in full, and never forces.
//
// The refspec is explicit rather than `push origin <branch>` because the local
// name and the remote ref are then the same string we checked and the grant
// names, with no configured remote and no push.default in between to resolve it
// into something else.
func pushArgv(req RunRequest, branch string) []string {
return append(gitAs(req), "push", req.CloneURL, "HEAD:refs/heads/"+branch)
}
// gitAs is `git`, carrying the grant when there is one — as an Authorization
// header CONFINED to the one URL it is for, applied to this invocation alone.
//
// Three properties, each of which cost a bug to learn. All three were verified
// against git 2.43 with a server that answers like the forge.
//
// IT ARRIVES. The credential used to ride the clone URL, and git does not send a
// URL-embedded credential until it is CHALLENGED: it makes an anonymous request
// first and waits for 401 WWW-Authenticate. The forge answers a caller it does
// not recognise with 403 (smart_http.go resolvePackRepo), never 401 — so the
// grant was never sent at all, and a clone of any private repo could only 403.
// Presented as a header it is on the FIRST request, which is the same thing
// apps/git's own wire test does and the reason that test passes.
//
// IT DOES NOT PERSIST. `git -c` before the subcommand is not written into the new
// repository's config — unlike `git clone -c`, which is — so the checkout the
// model then edits holds no credential. The URL form wrote one into .git/config
// verbatim, where every later step of the run could read it, including the step
// that executes untrusted model output.
//
// IT GOES NOWHERE ELSE. The key is scoped to the URL, so git attaches the header
// to that repository and to nothing else. A bare http.extraHeader is sent to
// WHATEVER the command reaches — a redirect, another host — and what a repository
// makes git reach is chosen by whoever wrote the repository.
//
// One function, because clone and push are the same act — reach that URL as this
// bearer — and a second spelling is a second place to get it wrong.
func gitAs(req RunRequest) []string {
if req.CredToken == "" || !strings.HasPrefix(req.CloneURL, "https://") {
return []string{"git"}
}
return []string{"git", "-c", "http." + req.CloneURL + ".extraHeader=Authorization: Basic " + basicOf(req)}
}
// basicOf is the grant as git presents it: base64 of user:token. The user is
// ignored by the forge — the password is the whole credential — but basic auth
// has a shape and it has to be filled.
func basicOf(req RunRequest) string {
user := req.CredUser
if user == "" {
user = "x-access-token"
}
return base64.StdEncoding.EncodeToString([]byte(user + ":" + req.CredToken))
}
// tip reads the checkout's current commit.
func (in sandbox) tip() (string, error) {
ran, err := in.do([]string{"git", "rev-parse", "HEAD"}, "read the tip")
if err != nil {
return "", err
}
return strings.TrimSpace(ran.Stdout), nil
}
// do is a command whose failure is the RUN's failure.
//
// runIn answers a non-zero exit as data, deliberately — "the tests failed" and
// "the sandbox is broken" are different facts. For the commands here they are the
// same fact: a clone that did not clone or a push that did not push leaves
// nothing to report, so the exit code is read and the run stops.
func (in sandbox) do(argv []string, what string) (*plane.Ran, error) {
ran, err := runIn(in.ctx, in.id, argv, in.ttl, in.session, "")
if err != nil {
return nil, fmt.Errorf("coding: %s: %s", what, in.scrub(err.Error()))
}
if ran.ExitCode != 0 {
return nil, fmt.Errorf("coding: %s: exit %d: %s", what, ran.ExitCode,
in.scrub(strings.TrimSpace(tail(ran.Stdout, ran.Stderr))))
}
return ran, nil
}
// message is what the commit says: the task, and the run that did it.
//
// The session is in the trailer because the commit outlives every other record of
// the run — the Slack thread scrolls away, the sandbox is reaped — and a commit
// nobody can trace back to the conversation that asked for it is an orphan in
// somebody's history.
func message(prompt, session string) string {
m := firstLine(prompt)
if len(m) > maxTitlePrompt {
m = strings.TrimSpace(m[:maxTitlePrompt]) + "…"
}
if m == "" {
m = "agent changes"
}
return m + "\n\nRun: " + session + "\n"
}
// scrub takes the grant out of anything that leaves the sandbox.
//
// git redacts the userinfo from the URLs it prints — verified, including through
// an insteadOf rewrite — and this does not rely on that. Everything here becomes a
// session event, a Slack line, a log field and a span; a credential is one string
// away from all four, and being sure costs four lines. It hangs off the sandbox
// rather than sitting loose so that every path out of a command goes through it
// without anyone having to remember.
func (in sandbox) scrub(s string) string {
if strings.TrimSpace(in.token) == "" {
return s
}
return strings.NewReplacer(in.token, redacted, in.basic, redacted).Replace(s)
}
const redacted = "[redacted]"
// runIn runs one command in the sandbox and narrates it into the run's session.
//
// The session travels with the command rather than the output travelling back
// here, because the bytes are IN THE SANDBOX and this call does not return until
// the command is over. Streamed from where they are produced, a twenty-five
// minute agent edit loop is watchable; collected here, it is a silence.
func runIn(ctx context.Context, id string, argv []string, ttl int, session, stdin string) (*plane.Ran, error) {
ran, err := plane.Ask[plane.RunIn, plane.Ran](ctx, "sandboxes", plane.SandboxRun,
&plane.RunIn{ID: id, Argv: argv, TimeoutSec: ttl, Session: session, Stdin: stdin})
if err != nil {
return nil, err
}
if ran == nil {
return nil, fmt.Errorf("no result")
}
return ran, nil
}
// tail keeps the end of the output, which is where a failure says why. The cap
// is a Slack message's worth: this is read by a person in a thread, not archived.
func tail(stdout, stderr string) string {
s := stdout
if strings.TrimSpace(stderr) != "" {
s = strings.TrimRight(s, "\n") + "\n" + stderr
}
const cap = 3000
if len(s) > cap {
return "…" + s[len(s)-cap:]
}
return s
}
func statusOf(ok bool) string {
if ok {
return "ok"
}
return "error"
}
// ── the funded-account gate ──────────────────────────────────────────────────
// minFundedSeconds is how much compute an org must be able to pay for BEFORE a
// sandbox is leased.
//
// Fifteen minutes, and the number is the argument: a lease is not a request, it
// is a pod held on our nodes for as long as the run wants it — dev and desktop
// carry a 4-HOUR TTL. Charging afterwards only works if there is something to
// charge, so the check has to happen before the pod exists. Fifteen minutes is
// long enough that no honest run is refused for a rounding error, and short
// enough that an unfunded account cannot open a four-hour hole and walk away.
const minFundedSeconds = 15 * 60
// minFundedDecimal is minFundedSeconds of compute as money — the amount the gate
// is asked to authorize.
//
// A constant rather than a catalog read on purpose: this figure exists to refuse
// an EMPTY account, not to quote a bill. The real charge is metered as the lease
// runs, and a gate that tried to predict it exactly would be a second pricing
// implementation. It only has to be the right order of magnitude.
const minFundedDecimal = "0.04" // ~15 min at ~$0.15/hour
// affordable asks COMMERCE whether org can pay for minFundedSeconds of compute.
//
// It asks the gate rather than reading a balance and doing arithmetic here.
// finance_authorize is labelled "the prepaid gate" in plane.go and it already
// knows things a subtraction does not: spend caps, which is why Verdict carries
// NoFunds and CapSpent as SEPARATE bits — one says add money, the other says
// wait for the period to roll. A balance comparison in this file would be a
// second pricing implementation that can disagree with the first.
//
// The org is stated on a DETACHED context because commerce takes the org from
// the CALLER identity and never from an argument, and a Runner has no inbound
// request to carry one. cloud.For is read only where no request is behind the
// ctx — on a request-bearing one it is a silent no-op that cost four deploys.
//
// It fails CLOSED on an unreadable verdict, and plane.go says why in the type
// itself: Reason "is an upstream failure the caller must treat as UNKNOWN and
// fail closed on, and never read as permission." Failing open costs a pod held
// for four hours by an account that cannot pay; failing closed costs a retry.
func affordable(ctx context.Context, org string) error {
v, err := plane.Ask[plane.AuthorizeIn, plane.Verdict](
cloud.For(context.Background(), org), "commerce", plane.FinanceAuthorize,
&plane.AuthorizeIn{
Subject: org,
Amount: plane.Money{Decimal: minFundedDecimal, Currency: "usd"},
Service: "sandbox",
})
if err != nil {
return fmt.Errorf("compute is not free and the prepaid gate is unreachable: %w", err)
}
if v == nil {
return fmt.Errorf("compute is not free and the prepaid gate answered nothing")
}
switch {
case v.OK:
return nil
case v.NoFunds:
return fmt.Errorf("a sandbox needs at least %d minutes of funded compute (%s); top up to start a run",
minFundedSeconds/60, minFundedDecimal)
case v.CapSpent:
return fmt.Errorf("this org has spent its cap for the period; a sandbox cannot start until it rolls over")
default:
return fmt.Errorf("the prepaid gate refused a sandbox: %s", v.Reason)
}
}
+506
View File
@@ -0,0 +1,506 @@
package coding
// sandboxrunner_test.go drives the REAL sandboxRunner against a fake sandbox on a
// real socket, and asserts the exact sequence of commands a run sends into the
// pod.
//
// That sequence IS the product. A run whose edits never leave the checkout is a
// model talking to itself: the sandbox is reaped when the lease ends and the work
// dies with it. So the assertions here are about commands and not about a return
// value — a runner can be made to answer Changed:true with no test noticing that
// nothing was ever pushed.
//
// The worse of the two failures is the FALSE POSITIVE. A run that reports "no
// changes" when it changed something is a lost afternoon; a run that reports
// changes when it made none files a PR against an empty branch, and a reviewer
// learns to distrust every PR the agent opens. Both are covered, and the
// no-changes case is first.
import (
"context"
"encoding/base64"
"strings"
"sync"
"testing"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
// theGrant is the push credential a run holds. Spelled once so every assertion
// about where it may and may not appear is asking about the same string.
const theGrant = "hgg_LIVEGRANTVALUE"
const (
cleanURL = "https://git.test/v1/git/acme/api.git"
theSHA = "1111111111111111111111111111111111111111"
newSHA = "2222222222222222222222222222222222222222"
)
// pod is a fake sandbox that records every command and answers whatever the test
// scripts. It records the argv VERBATIM, because the questions worth asking of
// this file are all questions about arguments.
type pod struct {
mu sync.Mutex
ran [][]string
answer func(argv []string) plane.Ran
// leased is every lease the run asked for, verbatim. A lease is a REQUEST for
// resources — a class, a ttl, and whether a disk is wanted — and the only place
// that request is visible is here, before it reaches a cluster.
leased []plane.LeaseIn
}
func (p *pod) exec(argv []string) plane.Ran {
p.mu.Lock()
defer p.mu.Unlock()
p.ran = append(p.ran, append([]string(nil), argv...))
if p.answer == nil {
return plane.Ran{}
}
return p.answer(argv)
}
// lines renders every command as one string, which is how a human reads a
// transcript and how these assertions read it too.
func (p *pod) lines() []string {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]string, 0, len(p.ran))
for _, argv := range p.ran {
out = append(out, strings.Join(argv, " "))
}
return out
}
// did returns the first command containing want, and whether there was one.
func (p *pod) did(want string) (string, bool) {
for _, l := range p.lines() {
if strings.Contains(l, want) {
return l, true
}
}
return "", false
}
// gitVerb reports whether any command asked git to do verb — matched on the
// argv, so "push" the verb is not confused with "push" inside a URL or a prompt.
func (p *pod) gitVerb(verb string) bool {
p.mu.Lock()
defer p.mu.Unlock()
for _, argv := range p.ran {
for i, a := range argv {
if a == verb && i > 0 && argv[0] == "git" {
return true
}
}
}
return false
}
// servePod stands up the two peers a sandbox run reaches — the sandbox itself and
// the prepaid gate — on real unix sockets, so the runner under test is the
// production one with a production transport.
func servePod(t *testing.T, p *pod) {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
sandboxes := zip.New(zip.Config{AppName: "sandboxes", DisableStartupMessage: true})
zip.Post[plane.LeaseIn, plane.Leased](sandboxes, "/sandbox/lease",
func(_ context.Context, in *plane.LeaseIn) (*plane.Leased, error) {
p.mu.Lock()
p.leased = append(p.leased, *in)
p.mu.Unlock()
return &plane.Leased{ID: "sbx_1", Class: in.Class, Status: "ready", Workdir: "/work"}, nil
}, zip.WithOperationID(plane.SandboxLease))
zip.Post[plane.RunIn, plane.Ran](sandboxes, "/sandbox/run",
func(_ context.Context, in *plane.RunIn) (*plane.Ran, error) {
r := p.exec(in.Argv)
return &r, nil
}, zip.WithOperationID(plane.SandboxRun))
zip.Post[plane.EndIn, struct{}](sandboxes, "/sandbox/end",
func(_ context.Context, _ *plane.EndIn) (*struct{}, error) {
return &struct{}{}, nil
}, zip.WithOperationID(plane.SandboxEnd))
commerce := zip.New(zip.Config{AppName: "commerce", DisableStartupMessage: true})
zip.Post[plane.AuthorizeIn, plane.Verdict](commerce, "/commerce/authorize",
func(_ context.Context, _ *plane.AuthorizeIn) (*plane.Verdict, error) {
return &plane.Verdict{OK: true}, nil
}, zip.WithOperationID(plane.FinanceAuthorize))
for name, app := range map[string]*zip.App{"sandboxes": sandboxes, "commerce": commerce} {
app := app
plane.Bind()
go func(path string) { _ = app.Listen(path) }(zip.SocketPath(name))
t.Cleanup(func() { _ = app.Shutdown() })
waitListening(t, name)
}
}
// aRun is the request a real dispatch builds: a checkout, the branch cloud issued
// for the session, and the grant that may write exactly that one ref.
func aRun() RunRequest {
return RunRequest{
CloneURL: cleanURL, BaseBranch: "main", Branch: "agent/abc123def456",
Prompt: "fix the flake", SessionID: "sess_abc123def456",
RunTimeoutSeconds: 60, CredUser: "x-access-token", CredToken: theGrant,
}
}
// steps collects what the run said out loud, which is what a person watching in
// Slack actually reads.
func steps(out *[]string) func(Step) {
return func(s Step) { *out = append(*out, s.Step+" "+s.Message+" "+s.Status) }
}
// A RUN ASKS FOR NO PROJECT, so it leaves no disk behind.
//
// A project names a per-(org, project) 20Gi PVC that apps/sandbox deliberately
// KEEPS when a lease ends, so a checkout survives between sessions. A run used to
// pass its SESSION id there — a name minted per dispatch and never reopened — so
// each run created a disk that could never be addressed again and was then kept
// forever. That is the whole mechanism of the leak, and it is visible only here,
// in what the lease ASKS for; the run's own result looks identical either way.
//
// The assertion is on Project and not on some later cleanup on purpose. A purge on
// the way out cannot cover a run that is killed, OOM'd, or evicted before it says
// goodbye, whereas a lease that never asks for a disk cannot strand one. The pod's
// emptyDir already has exactly the lifetime a run wants.
func TestSandboxRun_LeasesNoDiskBecauseARunIsNotAProject(t *testing.T) {
p := &pod{answer: func(argv []string) plane.Ran {
if has(argv, "rev-parse") {
return plane.Ran{Stdout: theSHA + "\n"}
}
return plane.Ran{}
}}
servePod(t, p)
if _, err := (sandboxRunner{}).Run(context.Background(), "acme", "u_1", aRun(), nil); err != nil {
t.Fatalf("run: %v", err)
}
p.mu.Lock()
defer p.mu.Unlock()
if len(p.leased) != 1 {
t.Fatalf("a run took %d leases, want exactly 1", len(p.leased))
}
if got := p.leased[0].Project; got != "" {
t.Fatalf("the run asked for project %q, which mints a 20Gi disk keyed to a name "+
"no later lease can ever reuse — a run must ask for no project at all", got)
}
}
// A run that edited NOTHING says so, and touches no ref. This is the first test
// because the false PR is the worse failure: a branch with no commits, filed as
// work, teaches a reviewer to ignore the agent.
func TestSandboxRun_NoEditsReportNoChangesAndWriteNoRef(t *testing.T) {
p := &pod{answer: func(argv []string) plane.Ran {
switch {
case has(argv, "rev-parse"):
return plane.Ran{Stdout: theSHA + "\n"} // the tip never moves
case has(argv, "commit"):
return plane.Ran{ExitCode: 1, Stdout: "nothing to commit, working tree clean\n"}
}
return plane.Ran{}
}}
servePod(t, p)
var said []string
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
if err != nil {
t.Fatalf("a clean run is not an error: %v", err)
}
if !res.OK {
t.Fatalf("a run that changed nothing still succeeded: %+v", res)
}
if res.Changed || res.CommitSha != "" || res.Diffstat != "" {
t.Fatalf(`"no changes" must mean no changes: %+v`, res)
}
if p.gitVerb("push") {
t.Fatalf("nothing changed and something was pushed:\n%s", strings.Join(p.lines(), "\n"))
}
}
// A run that edited something commits it, pushes it to its own ref, and reports
// what it actually did — the tip it created and the diffstat it measured.
func TestSandboxRun_EditsAreCommittedPushedAndReportedHonestly(t *testing.T) {
tip := theSHA
p := &pod{}
p.answer = func(argv []string) plane.Ran {
switch {
case has(argv, "commit"):
tip = newSHA // the commit is what moves it
return plane.Ran{}
case has(argv, "rev-parse"):
return plane.Ran{Stdout: tip + "\n"}
case has(argv, "diff"):
return plane.Ran{Stdout: " 2 files changed, 9 insertions(+), 1 deletion(-)\n"}
}
return plane.Ran{}
}
servePod(t, p)
var said []string
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
if err != nil {
t.Fatalf("run: %v", err)
}
if !res.OK || !res.Changed {
t.Fatalf("the run changed something and must say so: %+v", res)
}
if res.CommitSha != newSHA {
t.Fatalf("the reported tip is not the one that was created: %q", res.CommitSha)
}
if !strings.Contains(res.Diffstat, "2 files changed") {
t.Fatalf("the diffstat was not measured: %q", res.Diffstat)
}
// THE BRANCH IS THE RUN'S OWN, and it is created before the first edit so
// nothing is ever committed onto the base.
if _, ok := p.did("switch -c agent/abc123def456"); !ok {
t.Fatalf("the run never left the base branch:\n%s", strings.Join(p.lines(), "\n"))
}
push, ok := p.did(" push ")
if !ok {
t.Fatalf("nothing was pushed:\n%s", strings.Join(p.lines(), "\n"))
}
// ONE ref, named in full, never a branch name git could resolve to something
// else and never a force.
if !strings.HasSuffix(push, "HEAD:refs/heads/agent/abc123def456") {
t.Fatalf("the push does not name exactly one ref: %q", push)
}
for _, forbidden := range []string{"--force", "-f ", "--mirror", "--all", "--delete"} {
if strings.Contains(push, forbidden) {
t.Fatalf("the push carries %q: %q", forbidden, push)
}
}
}
// The branch is the one cloud issued, and the runner will not write anywhere
// else even when it is handed a name that says otherwise. The forge refuses this
// too (apps/git/refpolicy.go); neither is the other's backstop.
func TestSandboxRun_RefusesToWriteOutsideTheAgentNamespace(t *testing.T) {
p := &pod{}
servePod(t, p)
req := aRun()
req.Branch = "main"
r := sandboxRunner{}
if _, err := r.Run(context.Background(), "acme", "u_1", req, nil); err == nil {
t.Fatal("a run pointed at main must be refused")
}
if len(p.lines()) != 0 {
t.Fatalf("a refused run still did work:\n%s", strings.Join(p.lines(), "\n"))
}
}
// A tool that failed has produced nothing anyone should review, so its checkout
// stays in the sandbox and dies there.
func TestSandboxRun_AFailedToolWritesNoRef(t *testing.T) {
p := &pod{answer: func(argv []string) plane.Ran {
if has(argv, "rev-parse") {
return plane.Ran{Stdout: theSHA + "\n"}
}
if has(argv, "dev") {
return plane.Ran{ExitCode: 3, Stderr: "the model gave up\n"}
}
return plane.Ran{}
}}
servePod(t, p)
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), nil)
if err != nil {
t.Fatalf("a failed tool is a reported failure, not a transport error: %v", err)
}
if res.OK || res.Changed {
t.Fatalf("a failed run must not report work: %+v", res)
}
if p.gitVerb("push") {
t.Fatalf("a failed run pushed:\n%s", strings.Join(p.lines(), "\n"))
}
}
// THE GRANT NEVER TOUCHES DISK AND IS NEVER SAID OUT LOUD.
//
// It may appear in exactly one place — the git option that applies it to a single
// invocation — and nowhere else: not as a URL operand (git writes those into
// .git/config), not in a config write, and not in a step, a log line or the tail
// a run hands back for a person to read.
func TestSandboxRun_TheGrantStaysOffDiskAndOutOfWhatIsSaid(t *testing.T) {
p := &pod{answer: func(argv []string) plane.Ran {
if has(argv, "rev-parse") {
return plane.Ran{Stdout: theSHA + "\n"}
}
return plane.Ran{Stdout: "done\n"}
}}
servePod(t, p)
var said []string
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), steps(&said))
if err != nil {
t.Fatalf("run: %v", err)
}
// The grant travels as base64, which is how git presents basic auth. Both
// spellings are asked about: the literal must appear NOWHERE, and the one
// that actually travels must appear only where it is confined.
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + theGrant))
wantOpt := "http." + cleanURL + ".extraHeader=Authorization: Basic " + basic
for _, argv := range p.ran {
for i, a := range argv {
if strings.Contains(a, theGrant) {
t.Fatalf("the grant appears verbatim in argv[%d]: %q", i, strings.Join(argv, " "))
}
if !strings.Contains(a, basic) {
continue
}
// Exactly one shape: an option applied to THIS invocation, scoped to the
// ONE url it is for. `git -c` before the subcommand is not written into
// the new repository's config, so the checkout the model then edits holds
// no credential; the url scope is what stops git attaching it to a
// redirect or another host the repository's own contents point at.
if i == 0 || argv[i-1] != "-c" || a != wantOpt {
t.Fatalf("the credential is in argv[%d] as %q, want the confined option %q", i, a, wantOpt)
}
}
// Nothing may persist it. A `git config` write, a credential helper with a
// store behind it, or a redirect into a file each survive the command.
line := strings.Join(argv, " ")
for _, persists := range []string{"git config", "credential.helper=store", "credential.helper=cache", ".git/config"} {
if strings.Contains(line, persists) {
t.Fatalf("a command persists a credential: %q", line)
}
}
}
// The URL OPERAND — what git records as the remote — is the clean one.
for _, argv := range p.ran {
for _, a := range argv {
if strings.HasPrefix(a, "https://") && strings.Contains(a, "@") {
t.Fatalf("a credential rode a URL operand: %q", a)
}
}
}
// Nothing a person reads carries it, in either spelling.
for _, s := range said {
if strings.Contains(s, theGrant) || strings.Contains(s, basic) {
t.Fatalf("the grant was narrated: %q", s)
}
}
if strings.Contains(res.LogTail, theGrant) || strings.Contains(res.LogTail, basic) {
t.Fatalf("the grant came back in the log tail: %q", res.LogTail)
}
}
// A command's output is scrubbed of BOTH spellings on the way out, so a git that
// ever echoed what it was given cannot put a live credential in a session event,
// a span or a Slack thread.
func TestSandboxRun_AnEchoedCredentialIsScrubbedOnTheWayOut(t *testing.T) {
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + theGrant))
p := &pod{answer: func(argv []string) plane.Ran {
if has(argv, "rev-parse") {
return plane.Ran{Stdout: theSHA + "\n"}
}
if has(argv, "dev") {
return plane.Ran{Stdout: "used " + theGrant + " and header " + basic + "\n"}
}
return plane.Ran{}
}}
servePod(t, p)
res, err := sandboxRunner{}.Run(context.Background(), "acme", "u_1", aRun(), nil)
if err != nil {
t.Fatalf("run: %v", err)
}
if strings.Contains(res.LogTail, theGrant) || strings.Contains(res.LogTail, basic) {
t.Fatalf("an echoed credential survived: %q", res.LogTail)
}
if !strings.Contains(res.LogTail, "[redacted]") {
t.Fatalf("nothing was scrubbed: %q", res.LogTail)
}
}
func has(argv []string, want string) bool {
for _, a := range argv {
if a == want {
return true
}
}
return false
}
// The three properties of argvFor that a run actually dies of, each pinned
// against what the harness was MEASURED to do in a dev-class pod
// (oci.hanzo.ai/hanzoai/sandbox 1.0.1: dev 0.6.91, claude 2.1.223, codex 0.146.1).
func TestArgvForNonInteractiveVerb(t *testing.T) {
// `-p` is --profile to dev, not "prompt". `dev -p "<task>"` answered
// `config profile "<task>" not found` and exited before asking a model
// anything — which is what every coding run cloud dispatched used to do.
argv := argvFor("", "add a README")
if len(argv) < 2 || argv[0] != "dev" || argv[1] != "exec" {
t.Fatalf("the default harness must start with `dev exec`, got %q", argv)
}
}
func TestArgvForEndsOptionsBeforeThePrompt(t *testing.T) {
// A prompt is caller text in an argv position. Without `--`, a prompt that
// begins with a dash is a FLAG: all three agent harnesses printed their own
// version and exited when handed "--version" as the task.
for _, tool := range []string{"", "dev", "claude", "codex"} {
argv := argvFor(tool, "--version")
last := argv[len(argv)-1]
if last != "--version" {
t.Fatalf("%s: prompt must be last, got %q", tool, argv)
}
if argv[len(argv)-2] != "--" {
t.Fatalf("%s: prompt must be preceded by `--` or it parses as a flag, got %q", tool, argv)
}
}
// python3 -c / node -e take the prompt as a flag VALUE, which no parser
// reinterprets, so they neither need nor take a separator.
for tool, flag := range map[string]string{"python": "-c", "node": "-e"} {
argv := argvFor(tool, "--version")
if len(argv) != 3 || argv[1] != flag || argv[2] != "--version" {
t.Fatalf("%s: want [%s %s --version], got %q", tool, argv[0], flag, argv)
}
}
}
func TestCheckToolRefusesAHarnessWeDoNotCarry(t *testing.T) {
// Empty is dev, which is the whole default path, so it is not an error.
if err := CheckTool(""); err != nil {
t.Fatalf("empty tool must be accepted as dev: %v", err)
}
for _, ok := range []string{"dev", "claude", "codex", "python", "node"} {
if err := CheckTool(ok); err != nil {
t.Fatalf("%s is carried by the dev image: %v", ok, err)
}
}
// Without this, `default:` in classFor and argvFor read a typo as dev and
// answered as though the asked-for harness had run.
if err := CheckTool("clade"); err == nil {
t.Fatal("an unknown harness must be refused, not silently run as dev")
}
}
func TestClassForCarriesTheBinaryTheToolNeeds(t *testing.T) {
// Measured `command -v` in each published 1.0.1 class: exec carries dev,
// hanzo and hanzo-mcp but NOT claude/codex/gh; dev and desktop carry all.
for tool, want := range map[string]string{
"": "dev", "dev": "dev", "claude": "dev", "codex": "dev",
"python": "exec", "node": "exec",
} {
if got := classFor(tool, false); got != want {
t.Fatalf("classFor(%q): want %s, got %s", tool, want, got)
}
}
// A screen is an image variant, so it wins over every harness — desktop is
// dev plus an X server and therefore carries every binary dev does.
for _, tool := range []string{"", "dev", "claude", "python"} {
if got := classFor(tool, true); got != "desktop" {
t.Fatalf("classFor(%q, desktop): want desktop, got %s", tool, got)
}
}
}
+419
View File
@@ -0,0 +1,419 @@
package coding
// start.go is the ONE way a coding run begins.
//
// Every door — the Slack `code:` trigger, `POST /v1/coding`, and anything added
// later — arrives here. That is not tidiness: a door that assembled its own
// Dispatcher would be a second ENGINE with its own pool and its own in-flight
// set, and a run started from chat would be invisible to the app that shares
// its name. One Start, one pool, one process.
//
// # The tenant, and the bug that made every run fail
//
// A run is a long chain of cross-process calls: open the session (agents), read
// the clone URL (git), dispatch the sandbox (bot), verify the pushed ref (git),
// file the PR (tracker). Every one of those authorizes on the CALLER's org,
// never on an argument, because a caller able to name the org could name
// somebody else's.
//
// So the org has to ride the caller. `cloud.For` states it — but zip reads a
// stated caller only where there is NO REQUEST behind the context
// (caller.go:352-356), deliberately, so that stating an identity can never
// override an authenticated one. Applied to a context that has a request, it is
// a SILENT NO-OP.
//
// The coding path did exactly that. The run was spawned on a bare
// context.Background() with no tenant stated at all, and the routed path's
// target lookup ran on the inbound webhook's request context, where a statement
// would have been discarded anyway. Every seam call in every coding run
// therefore answered `authorize: no org on the call`: no session, an empty clone
// URL the dispatcher reads as "git is not available", and a run dead before a
// model was ever asked anything. The chat turn died of this shape one file over
// and was fixed there (bridgeRunContext); this is the same fix for the other
// half, and runContext below is the only place a coding run's context is made.
//
// It is not a laundering hole. For SUPPLIES a tenant where there is none and
// cannot override one, and the org it supplies was resolved server-side — from a
// Slack-signature-verified team_id, or from the authenticated caller of
// /v1/coding — never from a field a client can set.
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
const (
// runBudget bounds one coding run end to end. Far longer than a chat turn: a
// real run clones, drives a model edit loop, runs tests, and pushes.
defaultRunBudget = 25 * time.Minute
// The pool bounds SANDBOXES, not model turns — a different resource with a
// different lifetime from the chat pool, which is why it is sized separately.
// The per-org cap is the availability isolation: one tenant cannot starve the
// others out of sandbox capacity.
defaultConcurrency = 8
defaultOrgConcurrency = 2
// agentCredUser is the basic-auth username a git client must send beside the
// grant. git ignores it — the password is the whole credential — but it must
// be something, and naming it here means the sandbox is not inventing one.
agentCredUser = "x-access-token"
// maxRunBudget is the longest run the engine will admit, whatever a caller
// asks for. Without it TimeoutSeconds was unbounded: a caller could hold a
// pool slot — one of two its org has — for a day, and ask the forge to
// delegate a push for just as long. A budget nobody bounds is a resource
// nobody bounds.
maxRunBudget = 30 * time.Minute
)
// Accepted is what a door returns the instant a run is admitted. A run takes
// minutes; the door answers in milliseconds and hands back the session that is
// the run's record, its live stream, and the handle for every later question.
type Accepted struct {
SessionID string
Branch string
Repo string
Routed bool
TargetID string
}
// ErrBusy is the honest refusal when the pool is full. It is separated from a
// validation failure because a caller should retry this one and only this one.
var ErrBusy = fmt.Errorf("coding: at capacity")
var (
engineOnce sync.Once
engine Dispatcher
pool *limiter
)
// Engine returns the process's ONE Dispatcher, assembled on first use. log
// carries the run's best-effort failures (a dropped session mirror, a PR that
// would not file) into the host's log rather than dropping them.
func Engine(log func(msg string, kv ...any)) Dispatcher {
engineOnce.Do(func() {
engine = NewDispatcher(log)
pool = newLimiter(concurrency(), orgConcurrency())
})
return engine
}
// Start admits one coding run and returns its handle.
//
// org is the CALLER's tenant, read off the caller by the door and never taken
// from the request body. Everything the run then does happens in that org and
// nowhere else: its session, its repo, its credential, its PR.
//
// It is synchronous up to the point the run is admitted — validate, resolve the
// credential, open the session — and detached after it. That split is what lets
// a door answer immediately with a real handle instead of an empty promise, and
// it is why the session is opened HERE rather than inside Run.
func Start(ctx context.Context, org string, in plane.CodingStartIn, log func(msg string, kv ...any)) (Accepted, error) {
org = strings.TrimSpace(org)
if !OrgRE.MatchString(org) {
// Shape-checked, not merely non-empty. The org becomes a git namespace and
// the tenant every seam call authorizes on, so an org carrying a separator
// or a dot segment would address another tenant. It arrives from the
// gateway or the plane already validated; this is the second lock, on the
// side that would actually be harmed if the first ever failed.
return Accepted{}, fmt.Errorf("coding: a run needs a tenant")
}
subject := strings.TrimSpace(in.Subject)
if subject == "" {
// A run that lost its human must not execute AS THE ORG: that bills the
// tenant for an unattributable act and hands an unlinked caller the org's
// agent and its repos. Refused, never defaulted.
return Accepted{}, fmt.Errorf("coding: a run needs a linked subject")
}
repo := strings.TrimSpace(in.Repo)
prompt := strings.TrimSpace(in.Prompt)
if repo == "" || prompt == "" {
return Accepted{}, fmt.Errorf("coding: repo and task are required")
}
if !RepoRE.MatchString(repo) {
// A hostile repo token could otherwise smuggle a path segment and address
// another org's namespace. Same rule the git subsystem's own name check uses.
return Accepted{}, fmt.Errorf("coding: %q is not a repo name", repo)
}
// Base and Project are shape-checked for the same reason Repo and Org are,
// and they were the two that were not.
//
// Base is the worse of the two. It travels to RunRequest.BaseBranch and, on
// the routed path, onto a CUSTOMER'S machine, where it lands in a `git clone
// -b <base>` argument position. A value beginning with '-' is then not a
// branch but a FLAG — `--upload-pack=...` or `--config=core.fsmonitor=...`
// makes git run a command of the caller's choosing on the executor. BaseRE
// is git's own branch shape, which is alnum-led and therefore cannot begin
// with a dash; that is the property doing the work, not the length bound.
base := strings.TrimSpace(in.Base)
if base != "" && !BaseRE.MatchString(base) {
return Accepted{}, fmt.Errorf("coding: %q is not a branch name", base)
}
project := strings.TrimSpace(in.Project)
if project != "" && !RepoRE.MatchString(project) {
return Accepted{}, fmt.Errorf("coding: %q is not a project name", project)
}
if err := CheckTool(in.Tool); err != nil {
return Accepted{}, err
}
if len(prompt) > maxPromptLen {
prompt = prompt[:maxPromptLen]
}
d := Engine(log)
if !pool.acquire(org) {
return Accepted{}, ErrBusy
}
// Bind this run's narration, if the door gave it somewhere to talk. The
// Dispatcher is a VALUE, so attaching a per-run watcher is a copy and never a
// mutation of the shared engine — two concurrent runs cannot end up narrating
// into each other's threads.
if pr := newProgress(org, in.ReplyChannel, in.ReplyThread); pr != nil {
d.Watch = pr.watch
}
req := Req{
Org: org, UserID: subject, AgentRef: in.AgentRef, Repo: repo,
Project: project, Base: base, Tool: strings.TrimSpace(in.Tool), Desktop: in.Desktop,
Prompt: prompt, TimeoutSeconds: in.TimeoutSeconds, TargetID: strings.TrimSpace(in.TargetID),
}
// The session is opened on the DOOR's context, which already carries the
// tenant (the door stated it, or it arrived on the wire). It is the one
// synchronous seam call, and it is what makes the handle real.
sessionID, err := d.Sessions.OpenOn(ctx, org, subject, agentRefOr(in.AgentRef),
codingTitle(repo, prompt), req.TargetID)
if err != nil {
pool.release(org)
return Accepted{}, fmt.Errorf("coding: could not start a session: %w", err)
}
req.SessionID = sessionID
branch := BranchFor(sessionID)
// THE CREDENTIAL IS RESOLVED HERE AND NOWHERE ELSE, and it is resolved AFTER
// the session, because the session id is what names the branch and the branch
// is what the grant is FOR. A credential that had to be fetched before we knew
// what it was for is a credential that could not have been bounded.
//
// A routed run needs none — the machine authenticates git with its own — so
// the request is skipped and a workspace whose repo the forge does not hold
// can still route. A sandbox run without one fails closed: an empty token is
// an error, never a run that proceeds and discovers at push time it cannot
// write.
var credHandle string
if req.TargetID == "" {
// The grant lasts exactly as long as the run may — the run's own bounded
// budget, plus a minute so a push at the very end of it still lands. Tying
// the two together is what makes "the capability dies with the run" true
// even when nothing gets to withdraw it.
token, handle, err := agentCredential(ctx, repo, project, "refs/heads/"+branch,
budget(req.TimeoutSeconds)+time.Minute)
if err != nil {
_ = d.Sessions.Close(ctx, org, sessionID, statusError)
pool.release(org)
return Accepted{}, err
}
req.CredUser, req.CredToken, credHandle = agentCredUser, token, handle
}
// Detach, and state the tenant again on the way out.
//
// Both halves are load-bearing. DETACHED because the run outlives the door's
// request by minutes — on the door's context the model call is cancelled the
// instant we answer. STATED because detaching drops the request the tenant was
// riding on, and every seam call left in the run authorizes on the caller.
// This is the exact pairing the chat bridge uses, and the exact one whose
// absence made every coding run fail.
runCtx, cancel := runContext(org, req.TimeoutSeconds)
go func() {
defer cancel()
defer pool.release(org)
// The grant dies with the run rather than with its TTL. Registered before
// the panic guard so it runs after it — a run that panicked still gives
// the capability back — and on a cancel-immune context, because the run
// that most needs its credential withdrawn is the one that hit its
// deadline, and that is exactly when runCtx is already dead.
defer releaseCredential(context.WithoutCancel(runCtx), credHandle)
defer func() {
// A run executes untrusted model output through a long seam chain. An
// unrecovered panic here would take down every tenant sharing this
// process, so it is contained — registered last so it runs first.
if r := recover(); r != nil && log != nil {
log("coding: run panic (recovered)", "org", org, "repo", repo, "err", r)
}
}()
// Run is its own terminal: it mirrors the outcome into the session and
// closes it, on a cancel-immune context, whether it succeeded or failed.
// There is nothing left to report here and nobody left to report it to —
// the door answered minutes ago, and the session is the record.
d.Run(runCtx, req)
}()
return Accepted{
SessionID: sessionID, Branch: branch, Repo: repo,
Routed: req.TargetID != "", TargetID: req.TargetID,
}, nil
}
// runContext is the context ONE coding run executes on: detached from the door's
// request, carrying the tenant it acts for, bounded by the run budget.
//
// It takes an org and a budget and NOTHING ELSE — deliberately. A ctx parameter
// here would be an invitation to pass the door's, which both cancels the run
// when the door answers and silently discards the tenant. The signature is the
// guard; bridgeRunContext is the same shape for the same reason.
func runContext(org string, timeoutSeconds int) (context.Context, context.CancelFunc) {
return context.WithTimeout(cloud.For(context.Background(), org), budget(timeoutSeconds))
}
// agentCredential asks the forge to delegate ONE ref write, and returns the
// bearer plus the handle that withdraws it.
//
// It used to read the org's sealed `agent` git token out of KMS. That token was
// an ordinary IAM secret key, so IAM resolved it to a user and cloud minted a
// full org principal from it: the process running untrusted model output held a
// credential that opened /v1/kms/secrets — every other secret the org has,
// including the one that posts to its Slack — and every other org-scoped API.
// The push was confined and the credential was not, and the credential is what a
// compromised run actually holds.
//
// A grant is not an identity. It resolves to no principal at all, so every gate
// in the platform refuses it by default, and the one exception is the pack
// protocol on the single repository it names (apps/git/grant.go). It also
// removes an org-wide standing secret from the world rather than guarding it
// better: there is nothing left for an operator to seal, and nothing left to
// leak.
//
// Fail-closed, exactly as the KMS read was: an unreachable forge, a repository
// that is not there, or an empty token each return an error and never a value.
//
// The org is NOT an argument. plane.GrantIn has no org field, on purpose: the
// tenant rides the caller, so this delegates within the caller's own namespace
// and a run can never reach another tenant's repository by naming it.
func agentCredential(ctx context.Context, repo, project, ref string, ttl time.Duration) (token, handle string, err error) {
g, err := plane.Ask[plane.GrantIn, plane.Granted](ctx, "git", plane.GitGrant,
&plane.GrantIn{Repo: repo, Project: project, Ref: ref, TTLSeconds: int(ttl.Seconds())})
if err != nil {
return "", "", fmt.Errorf("coding: the forge would not delegate a push for %s: %w", repo, err)
}
if g == nil || strings.TrimSpace(g.Token) == "" {
return "", "", fmt.Errorf("coding: the forge returned no push grant for %s", repo)
}
return g.Token, g.Handle, nil
}
// releaseCredential withdraws the grant when the run is over, so a grant's life
// is the RUN's life and not its TTL. Best-effort: the TTL is what makes this
// safe to miss, and a run that already finished must not fail because the forge
// was slow to hear about it.
func releaseCredential(ctx context.Context, handle string) {
if strings.TrimSpace(handle) == "" {
return
}
_, _ = plane.Ask[plane.RevokeIn, plane.Revoked](ctx, "git", plane.GitRevoke,
&plane.RevokeIn{Handle: handle})
}
// ---- the bounded pool ------------------------------------------------------
// limiter bounds concurrent runs two ways: a GLOBAL cap across all orgs, and a
// PER-ORG cap. Tenant isolation of data already holds via the resolved org; this
// is the AVAILABILITY isolation that stops one workspace exhausting the sandbox
// capacity every other workspace shares.
type limiter struct {
mu sync.Mutex
inflight map[string]int
perOrg int
global chan struct{}
}
func newLimiter(global, perOrg int) *limiter {
if global < 1 {
global = 1
}
if perOrg < 1 {
perOrg = 1
}
if perOrg > global {
perOrg = global
}
return &limiter{inflight: make(map[string]int), perOrg: perOrg, global: make(chan struct{}, global)}
}
// acquire takes one global and one per-org slot, non-blocking. It returns false
// with NOTHING acquired when the org is at its cap or the pool is full — the
// per-org check precedes the global take, and the count only rises on a
// successful send, so a refusal can never leak a slot.
func (l *limiter) acquire(org string) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.inflight[org] >= l.perOrg {
return false
}
select {
case l.global <- struct{}{}:
l.inflight[org]++
return true
default:
return false
}
}
func (l *limiter) release(org string) {
l.mu.Lock()
if n := l.inflight[org]; n > 1 {
l.inflight[org] = n - 1
} else {
delete(l.inflight, org)
}
l.mu.Unlock()
<-l.global
}
// ---- config (env, read at call time — operator-injected from KMS) ----------
// budget is how long ONE run may take, and therefore how long its delegated
// push stays usable. Capped at maxRunBudget: a caller names a timeout, it does
// not name an unbounded one.
func budget(timeoutSeconds int) time.Duration {
d := defaultRunBudget
switch {
case timeoutSeconds > 0:
d = time.Duration(timeoutSeconds) * time.Second
default:
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_TIMEOUT_SEC"))); err == nil && v > 0 {
d = time.Duration(v) * time.Second
}
}
return min(d, maxRunBudget)
}
func concurrency() int {
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_CONCURRENCY"))); err == nil && v > 0 {
return v
}
return defaultConcurrency
}
func orgConcurrency() int {
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv("CODING_ORG_CONCURRENCY"))); err == nil && v > 0 {
return v
}
return defaultOrgConcurrency
}
func agentRefOr(ref string) string {
if r := strings.TrimSpace(ref); r != "" {
return r
}
return "hanzo"
}
+228
View File
@@ -0,0 +1,228 @@
package coding
// The tenant a coding run acts for has to travel ON THE WIRE, and these pin the
// two halves of why that is not obvious — the same pair apps/integrations keeps
// for the chat turn, kept here because this is the other place it shipped wrong.
//
// Every seam a run touches (session, git, tracker, the balance gate behind them)
// authorizes on the CALLER's org and never on an argument, so no caller can name
// the tenant it acts for. The org therefore rides 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 — so
// cloud.For applied to an inbound request is a SILENT NO-OP.
//
// The coding path had neither half: the run was spawned on a bare
// context.Background() with no statement at all, and the routed target lookup
// ran on the webhook's request context where a statement would have been
// discarded anyway. Every seam call in every run answered "authorize: no org on
// the call", which is a run that dies before a model is asked anything.
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/plane"
)
// A run's context must name the tenant, or every seam it touches refuses it.
func TestRunContextStatesTheTenant(t *testing.T) {
ctx, cancel := runContext("acme", 0)
defer cancel()
if got := cloud.Who(ctx).Org; got != "acme" {
t.Fatalf("a run must act for a named tenant, got %q", got)
}
}
// The statement is only READABLE off a context with no request behind it. This
// is the regression that shipped twice: the same call, the wrong base context,
// silently no org.
func TestStatingOnARequestContextIsANoOp(t *testing.T) {
if got := cloud.Who(cloud.For(context.Background(), "acme")).Org; got != "acme" {
t.Fatalf("a stated tenant must be readable off a background context, got %q", got)
}
// runContext must not be derivable from a caller-supplied context: it takes
// an org and a budget and nothing else, so there is no parameter through
// which a request could be threaded back in. If this ever grows a
// context.Context argument the bug returns — the SIGNATURE is the guard.
var _ func(string, int) (context.Context, context.CancelFunc) = runContext
}
// An empty org states nothing rather than a blank tenant: downstream must refuse
// on "no org" rather than act for an account named "".
func TestEmptyTenantIsNotStated(t *testing.T) {
ctx, cancel := runContext("", 0)
defer cancel()
if got := cloud.Who(ctx).Org; got != "" {
t.Errorf("an empty org must not become a tenant, got %q", got)
}
}
// A run outlives the door that started it by minutes, so its deadline must come
// from the run budget and not from a request that has already been answered.
func TestRunContextIsBoundedAndNotAlreadyDone(t *testing.T) {
ctx, cancel := runContext("acme", 60)
defer cancel()
select {
case <-ctx.Done():
t.Fatal("a fresh run context is already done; the run would be cancelled before it starts")
default:
}
d, ok := ctx.Deadline()
if !ok {
t.Fatal("a run must be bounded")
}
if left := time.Until(d); left > 2*time.Minute {
t.Errorf("the caller's budget was ignored: %v left on a 60s run", left)
}
}
// The dispatch door must not accept a tenant, a repo that is a path, or a run
// with no human behind it. Each of these is refused BEFORE a slot is taken, so a
// bad request cannot consume capacity.
func TestStartRefusesWhatItMustRefuse(t *testing.T) {
for name, tc := range map[string]struct{ org, subject, repo, prompt string }{
"no tenant": {"", "u", "api", "do a thing"},
"tenant is a path": {"../other", "u", "api", "do a thing"},
"tenant has a dot segment": {"a/../b", "u", "api", "do a thing"},
"no subject": {"acme", "", "api", "do a thing"},
"no repo": {"acme", "u", "", "do a thing"},
"no task": {"acme", "u", "api", ""},
"repo is a path": {"acme", "u", "../other-org/api", "do a thing"},
"repo escapes": {"acme", "u", "a/b", "do a thing"},
} {
_, err := Start(context.Background(), tc.org, startIn(tc.subject, tc.repo, tc.prompt), nil)
if err == nil {
t.Errorf("%s: accepted; it must be refused before a slot is spent", name)
}
}
}
// The pool is the availability isolation: one tenant may not exhaust the
// sandbox capacity every other tenant shares.
func TestOneTenantCannotStarveTheOthers(t *testing.T) {
l := newLimiter(4, 2)
if !l.acquire("acme") || !l.acquire("acme") {
t.Fatal("an org could not reach its own cap")
}
if l.acquire("acme") {
t.Fatal("an org exceeded its per-org cap; one workspace could take the whole pool")
}
if !l.acquire("other") {
t.Fatal("a second org was starved by the first")
}
// A refusal must not leak a slot, or the pool bleeds down to nothing.
l.release("acme")
if !l.acquire("acme") {
t.Fatal("a released slot was not reusable; refusals leak capacity")
}
}
// A run narrates itself into a chat thread, and every value in that narration is
// derived from model output or from repo content the model read. Unescaped, a
// run could page an entire workspace with <!channel>, or post a link with an
// arbitrary URL under Hanzo's name. THIS IS AN INJECTION TEST.
func TestARunCannotInjectSlackMarkup(t *testing.T) {
hostile := `<!channel> <https://evil.example|click here> & <@U123>`
for name, payload := range map[string][]byte{
"in an error": []byte(`{"status":"error","error":` + q(hostile) + `}`),
"in a branch": []byte(`{"status":"done","changed":true,"branch":` + q(hostile) + `}`),
"in a PR key": []byte(`{"status":"done","changed":true,"branch":"b","pr":` + q(hostile) + `}`),
"in a log line": []byte(`{"message":` + q(hostile) + `}`),
"in a step": []byte(`{"step":` + q(hostile) + `}`),
} {
line, _ := renderLine(kindToolCall, payload)
if line == "" {
t.Errorf("%s: rendered nothing; the case is not being exercised", name)
continue
}
if strings.ContainsAny(line, "<>") {
t.Errorf("%s: raw markup reached the message — a run can broadcast or forge a link: %q", name, line)
}
if strings.Contains(line, "&amp;lt;") {
t.Errorf("%s: double-escaped, the & pass must run first: %q", name, line)
}
}
// One enormous value must not become the whole message.
long := []byte(`{"message":"` + strings.Repeat("x", 50000) + `"}`)
if line, _ := renderLine(kindLog, long); len(line) > maxLine+32 {
t.Errorf("an unbounded value reached the message: %d chars", len(line))
}
}
func q(s string) string { b, _ := json.Marshal(s); return string(b) }
// startIn builds the door's request. The tenant is deliberately NOT a field of
// it — that is the whole point of the contract — so it is passed to Start
// separately, exactly as a real door passes what it read off the caller.
func startIn(subject, repo, prompt string) plane.CodingStartIn {
return plane.CodingStartIn{Subject: subject, Repo: repo, Prompt: prompt}
}
// A finished run must always get the last word, and it must say which ending it
// was. A terminal that rendered empty would leave the thread reading "working…"
// forever on a run that is over.
func TestEveryTerminalGetsTheLastWord(t *testing.T) {
for name, tc := range map[string]struct {
payload []byte
want string
}{
"failed": {[]byte(`{"status":"error","error":"it broke"}`), "it broke"},
"no changes": {[]byte(`{"status":"done","changed":false}`), "No changes"},
"pushed": {[]byte(`{"status":"done","changed":true,"branch":"agent/abc","pr":"ENG-1"}`), "agent/abc"},
} {
line, terminal := renderLine(kindStatus, tc.payload)
if !terminal {
t.Errorf("%s: not reported as terminal; the edit budget could swallow the run's ending", name)
}
if !strings.Contains(line, tc.want) {
t.Errorf("%s: the thread would not say what happened: %q", name, line)
}
}
// A run in flight is NOT terminal, or the first step would spend the reserve.
if _, terminal := renderLine(kindStatus, []byte(`{"status":"started","branch":"agent/abc"}`)); terminal {
t.Error("a started run was treated as terminal")
}
}
// The last word carries the ADDRESS. A run that pushed a branch and filed a PR
// is finished work, and a thread that names it without saying where to read it
// leaves the person who asked to go hunting for their own change.
func TestTheLastWordSaysWhereToReadIt(t *testing.T) {
line, _ := renderLine(kindStatus, []byte(
`{"status":"done","changed":true,"branch":"agent/abc","pr":"ENG-1","url":"https://github.com/hanzo-inc/api/pull/7"}`))
if !strings.Contains(line, "https://github.com/hanzo-inc/api/pull/7") {
t.Fatalf("the address never reached the thread: %q", line)
}
// BARE, so Slack links it on its own. A `<url|text>` element is the one piece
// of mrkdwn that carries an arbitrary destination, and nothing a run emits may
// construct one.
if strings.Contains(line, "<") || strings.Contains(line, ">") {
t.Fatalf("the address was wrapped in a link element: %q", line)
}
// And a run with no address still gets its last word.
if l, _ := renderLine(kindStatus, []byte(`{"status":"done","changed":true,"branch":"agent/abc"}`)); !strings.Contains(l, "agent/abc") {
t.Fatalf("a run without an address lost its ending: %q", l)
}
}
// The sink reads untrusted payloads. A malformed one must be ignored, never
// panic a run that has already pushed a branch.
func TestProgressSurvivesAHostilePayload(t *testing.T) {
for _, payload := range [][]byte{
nil, []byte(""), []byte("not json"), []byte("[]"), []byte(`{"step":123}`), []byte(`{`),
} {
if line, _ := renderLine(kindLog, payload); line != "" {
t.Errorf("an unreadable payload rendered %q; it must change nothing", line)
}
}
// No address means no sink, and a nil sink is a no-op so no caller branches.
var none *progress
none.watch(context.Background(), kindLog, []byte(`{"message":"hi"}`))
if newProgress("acme", "", "") != nil {
t.Error("a sink was built with nowhere to post")
}
}
-114
View File
@@ -1,114 +0,0 @@
package coding
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/apps/bots"
)
// task.go is coding's WIRE CONTRACT with the bot runtime — the stub behind the
// Runner seam. It says WHAT coding asks the runtime to do (run one coding job)
// and how a progress line is shaped; how the bytes get there is runtime's problem.
//
// This file is the ONE place in clients/coding that knows the runtime exists.
// coding.go (the orchestrator) is pure and never sees it.
//
// The runtime answers a stream: one message per step/log as the job progresses
// (clone → dev exec → commit → push), then a terminal result (or error). Coding
// mirrors every step into the agent session live, so the run is watchable at
// GET /v1/agents/sessions/:id/stream, and returns the terminal outcome.
//
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
// BODY (never a URL, never argv, never a log). That is why the call declares
// Secret — the transport then refuses to carry it over a cleartext hop.
// taskOp addresses the runtime's coding-task operation.
const taskOp = "/v1/coding-tasks"
// credential is the per-org agent git credential the sandbox presents to native
// git. Token is the secret (an sk- key); Username is the basic-auth user label.
// Encoded into the request body only — never logged.
type credential struct {
Username string `json:"username"`
Token string `json:"token"`
}
// taskRequest is the cloud→runtime body for one coding run.
type taskRequest struct {
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
Prompt string `json:"prompt"` // the engineering task
SessionID string `json:"sessionId"` // cloud session id (correlation)
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
Credential credential `json:"credential"` // agent git credential (write-only)
}
// message is the discriminated shape of one streamed line: step/log while the job
// runs, result/error to end it.
type message struct {
Type string `json:"type"` // step | log | result | error
Step string `json:"step,omitempty"`
Message string `json:"message,omitempty"`
Status string `json:"status,omitempty"`
Branch string `json:"branch,omitempty"`
CommitSha string `json:"commitSha,omitempty"`
Diffstat string `json:"diffstat,omitempty"`
Changed bool `json:"changed,omitempty"`
OK bool `json:"ok,omitempty"`
LogTail string `json:"logTail,omitempty"`
}
// runner is the Runner seam over the real runtime. Its fake twin in coding_test.go
// is what the orchestrator is tested against.
type runner struct{}
// Run hands one coding job to the runtime and streams its progress, invoking
// onStep for each line as it arrives, then returns the terminal result. A
// transport failure, or a stream that ends without a terminal message, is an
// error — no partial success is fabricated. org/userID are the tenant context the
// runtime trusts AFTER its own bearer gate.
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
var out RunResult
var terminal bool
err := bots.Stream(ctx, bots.Call{
Op: taskOp,
Org: org,
User: userID,
Body: taskRequest{
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
Credential: credential{Username: req.CredUser, Token: req.CredToken},
},
Secret: true, // the body carries the org's git credential
}, func(msg []byte) {
var m message
if json.Unmarshal(msg, &m) != nil {
return // skip a malformed message rather than abort the whole run
}
switch m.Type {
case "result":
out = RunResult{
Branch: m.Branch, CommitSha: m.CommitSha, Diffstat: m.Diffstat,
Changed: m.Changed, OK: m.OK, LogTail: m.LogTail,
}
terminal = true
case "error":
out = RunResult{OK: false, LogTail: m.LogTail, Error: nonEmpty(m.Message, "coding task failed")}
terminal = true
default: // step | log — mirror live
if onStep != nil {
onStep(Step{Type: m.Type, Step: m.Step, Message: m.Message, Status: m.Status})
}
}
})
if err != nil {
return RunResult{}, fmt.Errorf("coding: run task: %w", err)
}
if !terminal {
return RunResult{}, fmt.Errorf("coding: stream ended without a result")
}
return out, nil
}
-152
View File
@@ -1,152 +0,0 @@
package coding
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// These pin coding's wire contract with the runtime through the REAL transport
// (apps/bots' transport) against a stub server — so the credential-custody and
// fail-closed properties are proven end to end over the seam, not against a fake
// of it.
// ndjsonServer streams the given lines as application/x-ndjson and records the
// request it received, so a test can assert the wire contract (path, headers,
// body) AND that the credential travels only in the body.
func ndjsonServer(t *testing.T, lines []string, capture *http.Request, capBody *[]byte) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if capture != nil {
*capture = *r
}
if capBody != nil {
b, _ := io.ReadAll(r.Body)
*capBody = b
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
for _, ln := range lines {
_, _ = io.WriteString(w, ln+"\n")
}
}))
}
func TestTask_StreamsStepsAndResult(t *testing.T) {
lines := []string{
`{"type":"step","step":"clone","status":"ok"}`,
`{"type":"log","message":"editing handler.go"}`,
`{"type":"step","step":"push","status":"ok"}`,
`{"type":"result","branch":"agent/x","commitSha":"deadbeef","diffstat":"1 file changed","changed":true,"ok":true,"logTail":"done"}`,
}
var gotReq http.Request
var gotBody []byte
srv := ndjsonServer(t, lines, &gotReq, &gotBody)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
t.Setenv("BOT_GATEWAY_TOKEN", "svc-token-xyz")
var steps []Step
res, err := runner{}.Run(context.Background(), "acme", "u-1", RunRequest{
CloneURL: "https://git.test/v1/git/acme/api.git", Branch: "agent/x", Prompt: "fix",
CredUser: "x-access-token", CredToken: "sk-SECRETtoken",
}, func(s Step) { steps = append(steps, s) })
if err != nil {
t.Fatalf("run: %v", err)
}
// Result parsed.
if !res.OK || !res.Changed || res.Branch != "agent/x" || res.CommitSha != "deadbeef" {
t.Fatalf("result wrong: %+v", res)
}
// Steps mirrored (2 steps + 1 log; result/terminal not delivered as a step).
if len(steps) != 3 {
t.Fatalf("want 3 progress steps, got %d: %+v", len(steps), steps)
}
if steps[0].Type != "step" || steps[0].Step != "clone" || steps[1].Type != "log" {
t.Fatalf("step vocabulary wrong: %+v", steps)
}
// Wire contract: POST /v1/coding-tasks, identity + bearer headers set.
if gotReq.Method != http.MethodPost || !strings.HasSuffix(gotReq.URL.Path, "/v1/coding-tasks") {
t.Fatalf("bad request line: %s %s", gotReq.Method, gotReq.URL.Path)
}
if gotReq.Header.Get("X-Org-Id") != "acme" || gotReq.Header.Get("X-User-Id") != "u-1" {
t.Fatalf("identity headers wrong: %v", gotReq.Header)
}
if gotReq.Header.Get("Authorization") != "Bearer svc-token-xyz" {
t.Fatalf("service bearer missing/wrong: %q", gotReq.Header.Get("Authorization"))
}
// The credential travels in the BODY only (never a URL/header).
if strings.Contains(gotReq.URL.RawQuery, "SECRETtoken") || strings.Contains(gotReq.Header.Get("Authorization"), "SECRETtoken") {
t.Fatal("credential leaked onto URL/header")
}
var body taskRequest
if err := json.Unmarshal(gotBody, &body); err != nil {
t.Fatalf("body decode: %v", err)
}
if body.Credential.Token != "sk-SECRETtoken" || body.Credential.Username != "x-access-token" {
t.Fatalf("credential not carried in body: %+v", body.Credential)
}
}
func TestTask_ErrorLine(t *testing.T) {
srv := ndjsonServer(t, []string{`{"type":"error","message":"dev exec failed","logTail":"boom"}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
res, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{}, nil)
if err != nil {
t.Fatalf("a clean error line is a terminal result, not a transport error: %v", err)
}
if res.OK || res.Error != "dev exec failed" || res.LogTail != "boom" {
t.Fatalf("error result wrong: %+v", res)
}
}
func TestTask_Non2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
t.Fatal("a 401 must be an error")
}
}
func TestTask_NoTerminalIsError(t *testing.T) {
srv := ndjsonServer(t, []string{`{"type":"step","step":"clone"}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
t.Fatal("a stream with no result/error line must be an error (no fabricated success)")
}
}
func TestTask_RefusesCleartextByDefault(t *testing.T) {
// The credential-bearing POST must not go cleartext without an explicit mesh
// opt-in: an http target with BOT_GATEWAY_ALLOW_PLAINTEXT unset fails closed and
// never dials.
srv := ndjsonServer(t, []string{`{"type":"result","ok":true}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
CredUser: "x", CredToken: "sk-SECRET",
}, nil)
if err == nil {
t.Fatal("cleartext coding POST must fail closed by default")
}
if strings.Contains(err.Error(), "sk-SECRET") {
t.Fatalf("error must not leak the credential: %v", err)
}
}
+140
View File
@@ -0,0 +1,140 @@
package coding
// What a run may say about itself, and what it may not.
//
// A coding run has two channels back into cloud that are NOT the git protocol:
// the fields the caller sets when starting it, and the fields the sandbox
// reports when it finishes. Both were partly trusted. The sandbox is the thing
// assumed compromised, and the starting caller is only as trustworthy as the
// prompt that reached them — so both are inputs, and both are shaped here.
import (
"context"
"strings"
"testing"
)
// The base branch travels to a `git clone -b <base>` argument on a machine we do
// not own. A value beginning with '-' is not a branch there but a FLAG, and
// `--upload-pack=` / `--config=core.fsmonitor=` each make git run a command of
// the caller's choosing on that machine. Repo and Org were shape-checked; Base
// and Project were not, and Base is the one that reaches an argv.
func TestStartRefusesABaseThatIsNotABranch(t *testing.T) {
for name, base := range map[string]string{
"an upload-pack flag": "--upload-pack=touch /tmp/pwned",
"a config flag": "--config=core.fsmonitor=touch /tmp/pwned",
"a bare dash": "-",
"an option-looking -o": "-o",
"a traversal": "../../etc/passwd",
"a space": "main branch",
"a newline": "main\nrm -rf /",
"a semicolon": "main;id",
"a leading dot": ".hidden",
} {
in := startIn("u", "api", "do a thing")
in.Base = base
if _, err := Start(context.Background(), "acme", in, nil); err == nil {
t.Errorf("%s (%q): accepted; it reaches a git argv on a customer's machine", name, base)
} else if !strings.Contains(err.Error(), "branch name") {
t.Errorf("%s (%q): refused for the wrong reason: %v", name, base, err)
}
}
}
// The control must not refuse ordinary work: a real base, including a nested
// one, has to pass. A branch rule that rejects `release/2.1` gets removed.
func TestStartAcceptsARealBase(t *testing.T) {
for _, base := range []string{"main", "develop", "release/2.1", "v1.2.3", "feature/JIRA-42_thing"} {
if !BaseRE.MatchString(base) {
t.Errorf("%q is an ordinary branch and must be accepted", base)
}
}
}
// Project becomes a git scope and a tracker key, so it is shaped for the same
// reason Repo is: a value carrying a separator addresses another namespace.
func TestStartRefusesAProjectThatIsAPath(t *testing.T) {
for _, project := range []string{"../other", "a/b", ".", "..", "a/../b"} {
in := startIn("u", "api", "do a thing")
in.Project = project
if _, err := Start(context.Background(), "acme", in, nil); err == nil {
t.Errorf("project %q: accepted; it is a path", project)
}
}
}
// THE ATTACK: a compromised sandbox reports that it pushed `main`.
//
// The branch was cloud's to decide — BranchFor(sessionID), named after a session
// the sandbox did not choose. Adopting the sandbox's self-report meant the claim
// flowed into VerifyRef, which only asks whether a ref EXISTS (and main does),
// and out the other side as PR.Open{Head: "main"}: a pull request headed at the
// trunk, filed by us, on behalf of a run that never had permission to write
// there.
func TestACompromisedSandboxCannotRenameItsOwnBranch(t *testing.T) {
sessions := &fakeSessions{id: "sess_abc123def456"}
tracker := &fakePR{ref: PRRef{Identifier: "API-1"}}
verified := map[string]bool{}
d := Dispatcher{
Sessions: sessions,
PR: tracker,
Runner: &fakeRunner{result: RunResult{
OK: true, Changed: true, CommitSha: "deadbeef",
Branch: "main", // the lie
}},
CloneURL: func(context.Context, string, string) string { return "https://git.test/acme/api.git" },
VerifyRef: func(_ context.Context, _, _, branch string) (string, bool) {
verified[branch] = true
return "deadbeef", true
},
}
res := d.Run(context.Background(), Req{
Org: "acme", Repo: "api", Prompt: "do a thing", CredToken: "hgg_x", UserID: "u",
})
want := BranchFor("sess_abc123def456")
if res.Branch != want {
t.Fatalf("the run's branch became %q; it is cloud's to decide and must stay %q", res.Branch, want)
}
if verified["main"] {
t.Fatal("the integrity check was pointed at main by the sandbox's own claim")
}
if len(tracker.inputs) != 1 {
t.Fatalf("want one PR, got %d", len(tracker.inputs))
}
if head := tracker.inputs[0].Head; head != want {
t.Fatalf("A PR WAS FILED HEADED AT %q — the sandbox chose the head", head)
}
t.Logf("the sandbox said %q; the PR is headed at %q", "main", head(tracker))
}
// The same lie on the ROUTED path, where the reporter is a customer's machine
// rather than our sandbox — a strictly less trusted place.
func TestARoutedMachineCannotRenameItsOwnBranch(t *testing.T) {
sessions := &fakeSessions{id: "sess_abc123def456"}
tracker := &fakePR{ref: PRRef{Identifier: "API-2"}}
d := Dispatcher{
Sessions: sessions,
PR: tracker,
VerifyRef: func(context.Context, string, string, string) (string, bool) { return "deadbeef", true },
}
issuedBranch := BranchFor("sess_abc123def456")
d.finalizeRouted(context.Background(),
RoutedRun{Org: "acme", Repo: "api", SessionID: "sess_abc123def456", Branch: issuedBranch, Actor: "u"},
RoutedResult{OK: true, Changed: true, Branch: "main", CommitSha: "deadbeef"})
if len(tracker.inputs) != 1 {
t.Fatalf("want one PR, got %d", len(tracker.inputs))
}
if got := tracker.inputs[0].Head; got != issuedBranch {
t.Fatalf("A ROUTED PR WAS FILED HEADED AT %q, want %q", got, issuedBranch)
}
t.Logf("the machine said %q; the PR is headed at %q", "main", issuedBranch)
}
func head(f *fakePR) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.inputs[0].Head
}
+55
View File
@@ -141,6 +141,61 @@ func describeBilling() {
"/v1/billing/credit, which no browser can reach. Reading an empty balance is an empty "+
"array, not an error.")
// ---- the four reads the billing app's own tabs are built on ----
//
// All four take GetTier's chain, and the middleware that makes them safe is
// PinBillingSubject: it OVERWRITES the subject key each handler filters on
// with the caller's own account.Payer subject, so the ?user and ?userId these
// handlers read can never name anybody else. The prose below says so, because
// a reader who believes those parameters are theirs to choose has misread the
// operation in the direction that matters.
openapi.Describe("/v1/billing/transactions", http.MethodGet,
"List the movements on your own balance, newest first",
"Returns the caller's own ledger movements — every credit and debit against the subject "+
"the usage gate charges — newest first, with a count and the subject they belong to, "+
"so a customer can reconcile a bill against the acts that produced it. Paging is "+
"limit and offset, and the currency can be narrowed.\n\n"+
"The subject is NOT the caller's to choose. The handler filters on a user parameter, "+
"and that parameter is overwritten with the caller's own billing subject before the "+
"handler runs — so naming another subject returns your own rows rather than theirs, "+
"and the read can never disagree with the wallet it describes. An unauthenticated "+
"call is 401 rather than 403, because a browser re-authenticates on the first and "+
"only reports the second. No movements is an empty list, not an error.")
openapi.Describe("/v1/billing/credit-balance", http.MethodGet,
"What is left of your credit, as one number",
"Returns the total credit still available to the caller's own subject — the sum of what "+
"the grants have left, which is the figure the console shows above the usage meter. "+
"It is the balance a metered act draws down, so it answers the one question a "+
"customer asks before spending: how much is there.\n\n"+
"Like every read in this family the subject is pinned to the caller before the "+
"handler runs, so the userId parameter the handler reads can never name another "+
"tenant. For the grants BEHIND this number — each with its original amount and its "+
"expiry — read /v1/billing/credits. A subject with no credit is zero, which is an "+
"answer and not an error.")
openapi.Describe("/v1/billing/accounts", http.MethodGet,
"The billing account you are signed in to",
"Returns the billing accounts visible to the caller. One organisation is exactly one "+
"billing account here, so an authenticated caller sees precisely one: their own. "+
"The list shape is the honest one — it is what a caller with access to several "+
"would receive — rather than a promise that more will ever appear for a token "+
"scoped to a single org.\n\n"+
"The account is derived from the validated org claim and from nothing the caller "+
"sends, so there is no account parameter and a cross-tenant read is not "+
"expressible. An unauthenticated call is 401.")
openapi.Describe("/v1/billing/accounts/:id/members", http.MethodGet,
"Who is on a billing account",
"Returns the members of one billing account. The id must be the caller's OWN account — "+
"the handler compares it against the org resolved from the token and answers 403 "+
"when they differ, which is what guards this route: unlike its siblings it carries "+
"no subject key for the pin to overwrite, so it checks the path segment itself.\n\n"+
"The roster it can answer is currently the requesting user alone. Membership lives "+
"in IAM, not in the ledger, and this operation reports what commerce actually holds "+
"rather than inventing a roster from a source it does not read. An unauthenticated "+
"call is 401.")
openapi.Describe("/v1/billing/payouts", http.MethodGet,
"List your org's payouts, newest first",
"Returns the caller org's payout records ordered by creation time descending, read from "+
+26 -7
View File
@@ -39,6 +39,13 @@ var _ creditledger.CreditLedger = ledger{}
// with (account.Payer, via principal), so a credit and the spend it funds land on one
// wallet by construction. An org whose members share one balance names no subject and
// is byte-for-byte unchanged.
//
// AND Test IS THE THIRD PART OF THAT ADDRESS. finance keeps sandbox money in a
// separate file per org, so which books to write is not a mode this adapter can leave
// unsaid — it was saying "live" for every credit that came through here, which put a
// sandbox tenant's grant in the books the gate spends real inference from. It is the
// input's, unchanged, because the caller is the one that knows whether the charge it
// is crediting was a sandbox charge.
func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string, int64, error) {
fin := finance.Current()
if fin == nil {
@@ -64,22 +71,31 @@ func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string,
Notes: in.Reason,
Tags: tag,
Ref: in.IdempotencyKey,
Test: in.Test,
})
if err != nil {
return "", 0, err
}
// Read back the account that was CREDITED, never the pool — reporting a pool
// balance after crediting a member is how a caller concludes the grant vanished.
bal, berr := fin.Balance(ctx, in.Org, subject, cur, false)
// Read back the account that was CREDITED, never the pool and never the other
// books — reporting a pool balance after crediting a member, or a live balance
// after crediting the sandbox, is how a caller concludes the grant vanished. The
// read repeats the deposit's whole address, one value at a time.
bal, berr := fin.Balance(ctx, in.Org, subject, cur, in.Test)
if berr != nil {
return id, 0, berr
}
return id, bal.Cents(), nil
}
// Balance returns the org pool's available balance in cents for currency — the
// same read the AI gate performs, so GET /v1/billing/balance and the gate agree.
func (ledger) Balance(ctx context.Context, org, currency string) (int64, error) {
// Balance returns the available balance in cents held at (org, subject) in currency,
// from the sandbox books when test — the same read the AI gate performs, so
// GET /v1/billing/balance and the gate agree.
//
// It takes the whole address for the reason Credit writes it: an empty subject is the
// org's pool, and a named one is the member's own wallet. Answering the pool for a
// member's read is answering about a different account, and it answers without
// erroring — which is the shape of a balance bug nobody notices.
func (ledger) Balance(ctx context.Context, org, subject, currency string, test bool) (int64, error) {
fin := finance.Current()
if fin == nil {
return 0, fmt.Errorf("commerce balance: no finance ledger co-resident")
@@ -87,7 +103,10 @@ func (ledger) Balance(ctx context.Context, org, currency string) (int64, error)
if currency == "" {
currency = "usd"
}
bal, err := fin.Balance(ctx, org, org, currency, false)
if subject == "" {
subject = org // pooled org: the slug IS the pool account the gate reads
}
bal, err := fin.Balance(ctx, org, subject, currency, test)
if err != nil {
return 0, err
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
import (
"io"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
accountclient "github.com/hanzoai/cloud/apps/account"
commercebilling "github.com/hanzoai/commerce/api/billing"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
)
// The customer's own ledger reads — transactions, credit balance, and the
// billing account (with its members). billing.hanzo.ai calls all four; none was
// mounted, so all four answered 404 and the Transactions, Credits, Team and
// Settings tabs were permanently empty.
//
// What makes that failure worth a test is that it was INVISIBLE to the obvious
// check. commerce declares these routes itself, on its api.Route() `user` group
// (api/billing/handlers.go), so grepping the module finds them wired and the
// deployment looks merely stale. It is not: the co-resident embed registers on
// the HOST's router and never compiles that table, so a commerce route exists in
// production only if Mount names it. This test asserts the naming, which is the
// thing that was actually missing — a library-side grep cannot.
//
// The assertion is 401-not-404, and the distinction is the whole point. 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.
// ledgerApp mounts the four reads exactly as Mount does — same middleware, same
// order. A test that assembles a different chain proves only that the chain it
// invented behaves.
func ledgerApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{})
app.Get("/v1/billing/transactions",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListTransactions,
)
app.Get("/v1/billing/credit-balance",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.GetCreditBalance,
)
app.Get("/v1/billing/accounts",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListBillingAccounts,
)
app.Get("/v1/billing/accounts/:id/members",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListAccountMembers,
)
return app
}
// TestLedgerReads_MountedAndFailClosed — each read must be reachable and must
// refuse an anonymous caller. 404 is the production bug this replaces: the route
// absent, the gate never reached, the tab empty with nothing to explain it.
func TestLedgerReads_MountedAndFailClosed(t *testing.T) {
app := ledgerApp(t)
for _, path := range []string{
"/v1/billing/transactions",
"/v1/billing/credit-balance",
"/v1/billing/accounts",
"/v1/billing/accounts/acme/members",
} {
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 404 {
t.Fatalf("GET %s answered 404 — the route is not mounted, so the auth gate never ran", path)
}
if resp.StatusCode != 401 {
t.Fatalf("GET %s: want 401 for an anonymous caller, got %d (%s)", path, resp.StatusCode, body)
}
}
}
// TestLedgerReads_ForgedSubjectIsRefused — the two reads that filter on a
// caller-supplied subject key (ListTransactions on ?user, GetCreditBalance on
// ?userId) must not answer a request that names someone else. Unpinned, both
// return that subject's rows out of the org namespace, which is the leak the
// chain exists to close; anonymous, the pin fail-closes before the handler runs,
// so a forged subject buys nothing. Asserted against a hostile request rather
// than a well-formed one, because a well-formed one cannot fail this way.
func TestLedgerReads_ForgedSubjectIsRefused(t *testing.T) {
app := ledgerApp(t)
for _, path := range []string{
"/v1/billing/transactions?user=victim",
"/v1/billing/credit-balance?userId=victim",
} {
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("GET %s: a forged subject must be refused with 401, got %d (%s)",
path, resp.StatusCode, body)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
// ledger_test.go pins what the creditledger ADAPTER translates: commerce names an
// address, and this is the one place that address becomes a finance call.
//
// The address has three parts — (Org, Subject, Test) — and the adapter used to drop
// the third on the floor. `Test` was never passed to Deposit and both balance reads
// hardcoded `false`, so every credit commerce minted through this seam landed in the
// LIVE books whatever the caller meant, and a sandbox tenant's grant became money the
// AI spend gate buys real inference with. finance keeps sandbox money in a separate
// file per org, so this is not a flag being ignored — it is a different ledger.
//
// It asserts against a RECORDING finance client rather than a real one on purpose.
// What changed here is the translation, and a recording seam states the arguments
// exactly; that finance then routes (org, test) to the right file is finance's own
// property and is covered where it lives (apps/finance).
import (
"context"
"testing"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/money"
"github.com/hanzoai/cloud/types"
)
// recorder is a finance client that answers plausibly and remembers exactly what it
// was asked. Balances are held per WHOLE address, so a deposit into one book is not
// visible from a read of the other — the property the adapter has to get right.
type recorder struct {
deposits []types.DepositInput
reads []readArgs
bal map[readArgs]int64
}
// readArgs is one balance read's whole address. It is also the deposits' key, so the
// two can only meet when every component agrees.
type readArgs struct {
org string
subject string
currency string
test bool
}
var _ types.FinanceClient = (*recorder)(nil)
func newRecorder() *recorder { return &recorder{bal: map[readArgs]int64{}} }
func (r *recorder) Deposit(_ context.Context, in types.DepositInput) (string, error) {
r.deposits = append(r.deposits, in)
k := readArgs{in.Org, in.Subject, in.Currency, in.Test}
r.bal[k] += in.Amount.Cents()
return "dep_1", nil
}
func (r *recorder) Balance(_ context.Context, org, subject, currency string, test bool) (money.Amount, error) {
k := readArgs{org, subject, currency, test}
r.reads = append(r.reads, k)
return money.FromCents(r.bal[k]), nil
}
func (r *recorder) RecordUsage(context.Context, types.UsageInput) error { return nil }
func (r *recorder) SumUsageSince(context.Context, string, bool, int64) (int64, error) {
return 0, nil
}
// seam is the adapter under test, bound once. A composite literal cannot start an
// if-statement's expression, and naming it also says what it is: the ONE value
// commerce is handed as its credit ledger.
var seam = ledger{}
// recording publishes the recorder as the process finance client for one test.
func recording(t *testing.T) *recorder {
t.Helper()
r := newRecorder()
prev := finance.Current()
finance.Publish(r)
t.Cleanup(func() { finance.Publish(prev) })
return r
}
// A sandbox credit writes the SANDBOX books, and a live credit the live ones — the
// deposit AND the balance read the caller is answered with.
//
// The read-back matters as much as the write: it is the number the credit door hands
// back to its caller, and reading the live books after crediting the sandbox ones
// reports a balance that has nothing to do with the credit just made. That was the
// state of both call sites.
func TestLedgerAdapter_TestRoutesToTheSandboxBooks(t *testing.T) {
for _, tc := range []struct {
name string
test bool
}{
{"a sandbox credit", true},
{"a live credit", false},
} {
t.Run(tc.name, func(t *testing.T) {
rec := recording(t)
_, cents, err := seam.Credit(context.Background(), creditledger.CreditInput{
Org: "acme",
Subject: "acme/alice",
Currency: "usd",
AmountCents: 2500,
IdempotencyKey: "pay_1",
Test: tc.test,
})
if err != nil {
t.Fatalf("credit: %v", err)
}
if len(rec.deposits) != 1 {
t.Fatalf("deposits=%d, want 1", len(rec.deposits))
}
if got := rec.deposits[0].Test; got != tc.test {
t.Errorf("DepositInput.Test=%v, want %v — the credit went into the wrong books", got, tc.test)
}
if got := rec.deposits[0].Org; got != "acme" {
t.Errorf("DepositInput.Org=%q, want acme", got)
}
if got := rec.deposits[0].Subject; got != "acme/alice" {
t.Errorf("DepositInput.Subject=%q, want acme/alice", got)
}
// The read-back repeats the deposit's WHOLE address.
if len(rec.reads) != 1 {
t.Fatalf("balance reads=%d, want 1", len(rec.reads))
}
want := readArgs{"acme", "acme/alice", "usd", tc.test}
if rec.reads[0] != want {
t.Errorf("balance read %+v, want %+v — the credit was read back from a different account", rec.reads[0], want)
}
if cents != 2500 {
t.Errorf("returned balance=%d, want 2500 — the caller was answered from books it did not credit", cents)
}
})
}
}
// The two books do not see each other. A sandbox credit leaves the live balance at
// zero, which is the whole reason `test` is part of the address and not a flag.
func TestLedgerAdapter_SandboxMoneyIsNotSpendable(t *testing.T) {
recording(t)
if _, _, err := seam.Credit(context.Background(), creditledger.CreditInput{
Org: "acme", Subject: "acme/alice", Currency: "usd",
AmountCents: 9900, IdempotencyKey: "pay_sandbox", Test: true,
}); err != nil {
t.Fatalf("credit: %v", err)
}
live, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", false)
if err != nil {
t.Fatalf("live balance: %v", err)
}
if live != 0 {
t.Fatalf("LIVE balance=%d after a SANDBOX credit, want 0 — sandbox money became spendable", live)
}
sandbox, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", true)
if err != nil {
t.Fatalf("sandbox balance: %v", err)
}
if sandbox != 9900 {
t.Fatalf("sandbox balance=%d, want 9900", sandbox)
}
}
// Balance reads the account it is ASKED for, and an empty subject is the org's pool —
// the same default Credit applies, so the two halves address one wallet.
//
// Its signature used to be (org, currency) with the org passed for the subject too,
// which meant a member-scoped read opened a LEDGER named after the member: a
// different file, holding nothing, answering a funded customer with a confident zero.
func TestLedgerAdapter_BalanceReadsTheAccountItIsAsked(t *testing.T) {
rec := recording(t)
// The pool holds one amount and a member another, in the same org and books.
for _, seed := range []struct {
subject string
cents int64
}{{"acme", 100}, {"acme/alice", 700}} {
if _, _, err := seam.Credit(context.Background(), creditledger.CreditInput{
Org: "acme", Subject: seed.subject, Currency: "usd",
AmountCents: seed.cents, IdempotencyKey: "seed:" + seed.subject,
}); err != nil {
t.Fatalf("seed %s: %v", seed.subject, err)
}
}
rec.reads = nil
pool, err := seam.Balance(context.Background(), "acme", "", "usd", false)
if err != nil {
t.Fatalf("pool balance: %v", err)
}
if pool != 100 {
t.Errorf("pool balance=%d, want 100 — an empty subject must be the org's own account", pool)
}
if got := rec.reads[0]; got.subject != "acme" {
t.Errorf("pool read addressed subject %q, want acme", got.subject)
}
member, err := seam.Balance(context.Background(), "acme", "acme/alice", "usd", false)
if err != nil {
t.Fatalf("member balance: %v", err)
}
if member != 700 {
t.Errorf("member balance=%d, want 700 — the read answered a different account than the credit funded", member)
}
if got := rec.reads[1]; got.org != "acme" || got.subject != "acme/alice" {
t.Errorf("member read addressed (%q,%q), want (acme, acme/alice) — the subject was used as the ledger", got.org, got.subject)
}
}
+65
View File
@@ -795,6 +795,71 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
commercebilling.DeleteSpendAlert,
)
// THE CUSTOMER'S OWN LEDGER — the four reads billing.hanzo.ai's Transactions,
// Credits, Team and Settings tabs call, and which NOTHING in this binary served.
//
// commerce declares them on its api.Route() `user` group (api/billing/handlers.go),
// but that route table is never compiled here: the co-resident embed registers on
// the HOST's router, so a commerce route reaches production only if this file names
// it. The library had the handlers all along — so the symptom read as a stale image
// and was not one. Every unnamed route falls through to the account bridge's
// /v1/billing/* wildcard, whose allowlist does not include these four, so all four
// answered a bare 404 and four tabs of the billing app rendered empty forever.
//
// THE CHAIN IS GetTier's, and each link earns its place on a MONEY READ:
// - IAMTokenRequired resolves the org from the gateway-validated X-User-Id +
// X-Org-Id into Locals("organization") — the namespace every handler reads.
// It FALLS THROUGH when there is no validated principal rather than refusing.
// - PinBillingSubject is therefore both the gate and the IDOR control. It
// fail-closes that fall-through with 401 "sign in to view billing" (401, not
// 403 — a browser re-authenticates on 401 and merely reports 403), and it
// OVERWRITES every billing subject key {user,userId,customerId} with the
// caller's own account.Payer subject. That is load-bearing here and not
// decoration: ListTransactions filters on ?user and GetCreditBalance on
// ?userId, both unpinned client values — an unpinned read returns every
// subject's rows in the org namespace. Because the pin SETS the key rather
// than merely validating it, the handlers' "required parameter" 400 can never
// fire for a real caller, and the subject is exactly the one account.Payer
// debits, so a read can never disagree with the wallet it describes.
// - TokenRequired (no masks) runs AFTER the pin so the trusted S2S reader still
// resolves an org — IAMTokenRequired admits only IAM principals and would
// leave GetOrganization unset for a service token, which panics on a nil
// Locals assertion. With no masks it passes any authenticated principal, so
// the browser path is unchanged.
//
// accounts/:id/members carries no subject key and guards itself (:id must equal
// the resolved org, else 403), but it takes the same chain: one chain for the
// family, and the pin is what turns an anonymous call into 401 instead of a panic.
// None is typed yet — module work, per the module-handler note.
app.Get("/v1/billing/transactions",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListTransactions,
)
app.Get("/v1/billing/credit-balance",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.GetCreditBalance,
)
app.Get("/v1/billing/accounts",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListBillingAccounts,
)
app.Get("/v1/billing/accounts/:id/members",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
accountclient.PinBillingSubject(),
commercemid.TokenRequired(),
commercebilling.ListAccountMembers,
)
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
+8 -5
View File
@@ -112,11 +112,14 @@ package commerce
// in the same one — a sandbox charge can never fund live inference, and a live
// charge is never parked in books no gate reads.
//
// This is why the credit is posted through finance's own Deposit rather than
// through commerce's injected creditledger adapter (ledger.go): CreditInput has no
// test field, so that seam cannot express which books to write, and routing a
// sandbox settlement through it would put unspendable sandbox money in the live
// ledger. Same ledger, same idempotency, one field the seam cannot carry.
// The credit is posted through finance's own Deposit rather than through commerce's
// injected creditledger adapter (ledger.go) because of the ADDRESS, not the books:
// the seam carries the test bit now, but it is commerce's door onto its own credit,
// and the address this file deposits at is (p.ledger, p.subject) — the payer the
// SCREEN resolved from the request's principal, which is a value commerce cannot
// compute for itself. Going through the adapter would mean handing it back the
// answer it exists to ask for. Same ledger, same idempotency, one address resolved
// where the identity is.
import (
"context"
+43 -1
View File
@@ -144,6 +144,7 @@ var (
errIllegalTransition = errors.New("content: illegal transition")
errModuleNotInstalled = errors.New("content: marketing module not installed for org")
errInvalidSource = errors.New("content: invalid source_media")
errPublishBusy = errors.New("content: another publisher holds this item")
)
// ---- typed ops ----
@@ -449,6 +450,40 @@ func Transition(ctx context.Context, org, doctype, name, to, scheduleAt string)
return TransitionResult{}, fmt.Errorf("%w: %q", errUnknownStatus, to)
}
// On the ONE edge that distributes, the item's publish lease covers this WHOLE
// function — read, edge-check, status write and fan-out — not just the fan-out.
//
// The status write below carries the entire document, external_ids included, and
// it cannot carry less: UpdateData replaces the document, so a field left out of
// the map is deleted rather than preserved. The snapshot it writes is read at the
// top of this function, BEFORE any fan-out. Two concurrent transitions to
// published therefore interleave as: both 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 — itself correctly leased, correctly re-reading — finds nothing
// to skip and posts the item a second time. The lease inside Publish cannot see
// this, because the erasure happens outside it. Measured at ~7% of runs before
// this widened, which is a gate that reddens at random rather than a bug anyone
// could reproduce on demand.
//
// Holding it here makes the loser's read happen after the winner's record: it sees
// status=published (a legal no-op edge, CanTransition returns true for from==to),
// re-stamps the same status, and its fan-out skips every channel already on record.
// One post, both callers succeed.
if entersDistribution(to) {
lease, ok, err := framework.AcquireLease(ctx, org, publishLeaseKey(doctype, name), publishLeaseTTL, publishLeaseWait)
if err != nil {
return TransitionResult{}, err
}
if !ok {
// A live publisher held the item for the whole wait window. Refusing is the
// honest answer and the safe one: this call cannot write the document without
// erasing ids that publisher is still recording, so it writes nothing and says
// so. 409, retryable — the caller re-transitions and takes the no-op path.
return TransitionResult{}, errPublishBusy
}
defer func() { _ = lease.Release(ctx) }()
}
doc, err := framework.Get(ctx, org, doctype, name)
if err != nil {
return TransitionResult{}, err
@@ -474,7 +509,9 @@ func Transition(ctx context.Context, org, doctype, name, to, scheduleAt string)
res := TransitionResult{DocType: doctype, Name: name, From: from, To: to}
if entersDistribution(to) {
pr, perr := Publish(ctx, org, PublishInput{DocType: doctype, Name: name, ScheduleAt: scheduleAt})
// publishHeld, not Publish: this call already holds the item's publish lease
// (above), and the lease is not reentrant.
pr, perr := publishHeld(ctx, org, PublishInput{DocType: doctype, Name: name, ScheduleAt: scheduleAt})
if perr != nil {
// Never fatal — the status IS updated; distribution can be retried.
s.Log.Warn("distribution on transition failed (status updated)",
@@ -610,6 +647,11 @@ func opErr(err error) error {
return zip.ErrBadRequest(err.Error())
case errors.Is(err, errIllegalTransition):
return zip.Errorf(http.StatusConflict, "%v", err)
case errors.Is(err, errPublishBusy):
// Same 409 the illegal edge answers with, for the same reason: the request
// conflicts with the item's current state. Nothing was written and nothing was
// posted, so retrying is safe and is the expected response.
return zip.Errorf(http.StatusConflict, "%v", err)
case errors.Is(err, framework.ErrNotFound):
return zip.ErrNotFound("content item not found")
case errors.Is(err, framework.ErrConflict):
+23
View File
@@ -176,6 +176,29 @@ func Publish(ctx context.Context, org string, in PublishInput) (PublishResult, e
}
defer func() { _ = lease.Release(ctx) }()
return publishHeld(ctx, org, in)
}
// publishHeld is the fan-out itself: read the skip-set, post what is missing, record
// what came back. THE CALLER HOLDS the item's publish lease (publishLeaseKey) for the
// whole of it — that is the precondition the idempotency argument above rests on, and
// it is a precondition rather than something this function takes itself because the
// section that must be serialized is LARGER than the fan-out for one caller.
//
// Transition is that caller. Its status write carries the whole document (UpdateData
// replaces it — a field left out of the map is dropped, so external_ids cannot simply
// be omitted), and the snapshot it writes is read BEFORE the fan-out. Leasing only the
// fan-out therefore cannot see the erasure: publisher A records external_ids and
// releases, B's already-stale snapshot writes the skip-set back to empty, and B's
// fan-out — correctly leased, correctly re-reading — finds nothing to skip and posts
// the item a second time. Whoever must serialize the write serializes the fan-out with
// it, on the same key, in one section.
func publishHeld(ctx context.Context, org string, in PublishInput) (PublishResult, error) {
s := mounted
if s == nil {
return PublishResult{}, errNotMounted
}
doc, err := framework.Get(ctx, org, in.DocType, in.Name)
if err != nil {
return PublishResult{}, err
+17 -6
View File
@@ -9,10 +9,14 @@ package crawl
// than one that fails, because nothing upstream can tell.
//
// So: static first, and if what came back is too thin to be the page, ask Hanzo
// Crawl (headless Chromium, ghcr.io/hanzoai/crawl) for the rendered version.
// Escalation is one-way and best-effort — if the browser is absent, slow or
// unhappy, the static Page still stands. That keeps a working crawl working
// while the browser is not deployed, which is the state this ships in.
// Crawl (headless Chromium, ghcr.io/hanzoai/crawl) for the rendered version. It
// runs at crawl.hanzo.svc:11235 — the address browserEndpoint returns — and it
// requires the bearer token below.
//
// Escalation is one-way and best-effort: if the browser is slow or unhappy, the
// static Page still stands. That is a property worth keeping even though the
// service is up, because "the render failed" must never turn a page we already
// have into an error.
import (
"bytes"
@@ -183,8 +187,15 @@ func browse(ctx context.Context, raw string) (*Page, error) {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Token if the deployment has one; the network policy is the real boundary,
// so its absence is not a reason to skip rendering.
// The service REQUIRES this: unauthenticated it answers
// {"detail":"Authentication required"}, so a missing token is not a degraded
// render, it is no render at all. It reaches both pods from KMS as the
// crawl-secrets/CRAWL_API_TOKEN key.
//
// Sent when present rather than demanded up front because the failure is
// already well reported — the request returns a non-2xx, escalate() keeps the
// static Page, and websearch counts the engine blind. One boundary, one
// refusal, read in one place.
if tok := strings.TrimSpace(os.Getenv("CRAWL_API_TOKEN")); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
+8 -6
View File
@@ -3,14 +3,16 @@
// Hanzo Crawl: fetch one URL and return its readable content as markdown,
// in-process, in Go.
//
// It replaces the dial to a separate Crawl4AI deployment. That service was named
// in config but did not exist — crawl.hanzo.svc.cluster.local was NXDOMAIN — so
// every scrape returned {success:false, "no such host"} while the surface in front
// of it answered 200. This package is the same move already made for the SEARCH
// half in clients/websearch/search.go, which replaced a SearXNG pod with in-process
// Go: one fewer non-Go dependency, one fewer thing that can be down, and no network
// The FETCH is in-process Go rather than a dial to a separate service, which is
// the same move already made for the SEARCH half in apps/websearch/search.go:
// one fewer non-Go dependency, one fewer thing that can be down, and no network
// hop for work that is a fetch and a parse.
//
// A browser still lives out of process, because rendering is not a fetch and a
// parse. `crawl` (ghcr.io/hanzoai/crawl, headless Chromium) is deployed at
// crawl.hanzo.svc:11235 and browser.go escalates to it for the pages this file
// cannot read alone.
//
// The engine is three orthogonal steps, each in its own file and each testable
// without the others:
//
+249 -117
View File
@@ -1,17 +1,16 @@
package crawl
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
"github.com/zap-proto/zip"
)
@@ -37,55 +36,59 @@ type crawlRequest struct {
// error handling honest — a 200 means the surface worked, and Success says what it
// found.
type crawlResult struct {
Success bool `json:"success"`
Data *crawlDocument `json:"data,omitempty"`
Error string `json:"error,omitempty"`
// Success is whether the page was fetched and read. FALSE with an Error is a
// complete answer, not a fault — check this before reading Data.
Success bool `json:"success"`
// Data is the page, present exactly when Success.
Data *crawlDocument `json:"data,omitempty"`
// Error says what stopped the fetch: the host was refused, unreachable, or
// served something that is not a document.
Error string `json:"error,omitempty"`
}
// StatusCode is how this answer states the ONE non-2xx it carries as a domain
// body: a request with no url is 400 `{"success":false,"error":"missing url"}`,
// which is what the untyped handler answered and what its callers parse. Every
// other outcome — including a fetch that failed — is 200, because the surface
// worked and Success says what it found.
//
// It is the sanctioned way to spell this (zip typed.go, StatusCoder): the status
// rides the value the handler already returns, so the document publishes 400 with
// THIS schema and a generated client expects the body it will actually get. The
// alternative, returning zip.ErrBadRequest, renders the flat {status,code,error}
// envelope — a different body for a refusal this route has always answered in its
// own shape.
func (r *crawlResult) StatusCode() int {
if r.Success {
return http.StatusOK
}
if r.Error == missingURL {
return http.StatusBadRequest
}
return http.StatusOK
}
// missingURL is the refusal spelled once, because [crawlResult.StatusCode]
// compares against it and the handler produces it.
const missingURL = "missing url"
// crawlDocument is the crawled page.
type crawlDocument struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Markdown string `json:"markdown"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// This route is NOT a typed op, and it cannot become one without moving the wire.
// Two independent facts keep it out, both measured by TestCrawlRefusalIsTheWire:
//
// 1. It is deliberately BODY-TOLERANT, and its refusal carries a DOMAIN body. A
// malformed body and an empty url are the SAME answer here — 400 with
// `{"success":false,"error":"missing url"}`, this package's own shape. zip's
// op.invoke 400s on any unparseable non-empty body before a handler runs, with
// zip's flat HTTPError (`{status,code,error}`) — a different body — and a typed
// op's only way to refuse is to RETURN an error, which takes that same shape.
// So neither branch of today's 400 is expressible.
// 2. The body is read through io.LimitReader(r.Body, 1<<20). A typed op receives
// its DECODED In and never sees the raw length, so the 1 MiB bound would be
// silently dropped — cloud's global zip BodyLimit is far larger, and this
// surface fetches a caller-chosen URL from inside the cluster.
//
// Staying untyped costs exactly three things — the prose, the MCP tool and the CLI
// command zip's registry supplies — and it must not also cost a document that says
// this route takes no body. The declaration below is what buys that back: it is the
// same reflection over the same structs the handler binds, so it cannot drift.
func init() {
openapi.Register("/v1/crawl", http.MethodPost, crawlRequest{}, crawlResult{})
openapi.Describe("/v1/crawl", http.MethodPost,
"Fetch one URL and read it back as markdown",
"Fetches a single URL from inside the cluster and answers with the page's title, its "+
"content rendered to markdown, and whatever metadata the document carried.\n\n"+
"A page that could not be fetched is a NORMAL outcome, not a fault: an unreachable "+
"host, a refused address or a non-document content type all answer 200 with "+
"`success:false` and the reason in `error`. Non-2xx is reserved for a caller "+
"problem — 401 for a bad key, 400 for a missing url, 503 when the surface is "+
"unconfigured — so error handling can trust the status.\n\n"+
"Admission is either a validated principal or the shared service key, presented as "+
"X-API-Key or a Bearer; neither is refused, and an unset key fails closed rather "+
"than opening the fetcher to the private network. Crawled pages are archived under "+
"the scope of the VERIFIED principal, never a scope named in the body; a service "+
"caller has no org and its pages land in the shared corpus. One URL per call, and "+
"the request body is bounded at 1 MiB.")
// URL is the address actually read, after redirects.
URL string `json:"url"`
// Title is the document's title, when it carried one.
Title string `json:"title,omitempty"`
// Markdown is the page's content, extracted and rendered to markdown. This is
// the field to read.
Markdown string `json:"markdown"`
// Metadata is whatever the document said about itself — description, og:*,
// language — plus the response status, the final URL and the content type. It
// is an OPEN key space, so it is carried as raw JSON: `map[string]any`
// publishes `additionalProperties:{"type":"object"}`, which the integer
// `status` inside it refutes, while raw JSON publishes `{}` — "any JSON",
// which is true. The bytes are identical either way (encoding/json writes a
// map's keys sorted, and this IS that marshalling).
Metadata json.RawMessage `json:"metadata,omitempty"`
}
// serviceKey is the shared key a service caller presents. It is deliberately the
@@ -99,6 +102,29 @@ func init() {
// inside this one would put a rename in the path of a fix.
func serviceKey() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_API_KEY")) }
// Go drops comments at compile time, so cmd/zipdoc is the ONLY path from the
// handler's prose to the published document, the SDKs and the MCP tool
// description. Its output is committed; `make zipdoc-check` fails on drift.
//
// Without this directive the package builds, the tests pass, and the typed op
// below publishes a summary with no description — openapi.Complete accepts
// either, so the gap is invisible to every gate and visible in every SDK.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Path is the address this API is called on, spelled once so the registration,
// the admission middleware and the prose cannot disagree.
//
// It used to be registered twice — `g.Post("", serve)` and `g.Post("/", serve)`
// on a Group("/v1/crawl") — and they were the SAME route: zip normalises an empty
// leaf to "/", so both composed to "/v1/crawl/" and the second was dead. The
// consequence was not on the wire (the router is non-strict, so both URLs are
// served either way) but in the ARTIFACTS: op.Path is the identity every
// projection reads, so the published document, the operation id, the MCP tool and
// the URL every generated SDK calls all carried a trailing slash for a path this
// API's callers do not use.
const Path = "/v1/crawl"
// Mount registers /v1/crawl.
//
// The gate mirrors /v1/websearch/search exactly, and it is not optional here. This
@@ -108,6 +134,13 @@ func serviceKey() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_API_KEY
// validated principal (a signed-in user, already authenticated and metered) OR the
// shared service key. Neither ⇒ refused. An unset key 503s rather than defaulting
// open, so a misconfigured deploy fails closed and loudly.
//
// The two halves of that gate now live in two places, and they have to: a typed op
// is also an MCP tool and a CLI command, and tools/call invokes it with no route
// and therefore no middleware. So the KEY is checked in middleware, where a
// request is (it is a header, which a typed op cannot see), and the DECISION is
// made in the handler, where every door reaches it. The middleware only ever adds
// a fact; it never admits by itself.
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("crawl.Mount: nil app")
@@ -122,31 +155,44 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// with no object store keeps crawling and keeps nothing — see Bind.
Bind(deps.VFS)
// The scope is resolved per request at the zip layer, where the verified
// principal lives, and captured in the handler — it cannot be read off the
// net/http request below, and reading it from the BODY would let a caller name
// another tenant's corpus prefix.
serve := func(c *zip.Ctx) error {
s := scope(c)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleScoped(w, r, s) })
if principal.Validated(c) {
return zip.AdaptNetHTTP(h)(c)
}
return zip.AdaptNetHTTP(guard(h))(c)
}
// ONE registration, at the address this API is called on.
// The two request facts a typed op cannot reach, checked where a request is.
//
// It used to be two — `g.Post("", serve)` and `g.Post("/", serve)` on a
// Group("/v1/crawl") — and they were the SAME route: zip normalises an empty
// leaf to "/", so both composed to "/v1/crawl/" and the second was dead. The
// consequence was not on the wire (the router is non-strict, so both URLs are
// served either way) but in the ARTIFACTS: op.Path is the identity every
// projection reads, so the published document, the operation id, the MCP tool
// and the URL every generated SDK calls all carried a trailing slash for a path
// this API's callers do not use. Declaring the whole path here is the fix
// LLM.md prescribes for that class.
app.Post("/v1/crawl", serve)
// cloud.RoutePath, not c.Path(): fiber routes case-insensitively and ignores a
// trailing slash, so the raw spelling is what the CLIENT sent and RoutePath is
// the form THE ROUTER MATCHED. `POST /V1/CRAWL` would otherwise miss a
// lowercase prefix test and reach the fetcher with no credential checked.
app.Use(zip.H(func(c *zip.Ctx) error {
if cloud.RoutePath(c.Path()) != Path {
return c.Continue()
}
if !bounded(c) {
return c.JSON(http.StatusBadRequest, crawlResult{Error: missingURL})
}
if principal.Validated(c) {
return c.Continue()
}
return admitKey(c)
}))
reg := cloud.ZipApp(app)
if reg == nil {
return fmt.Errorf("crawl.Mount: router carries no typed-op registry")
}
// ONE registration, at the address this API is called on. Declaring the whole
// path here — rather than a leaf on a Group — is the fix LLM.md prescribes for
// the trailing-slash class described on [Path].
// Named, not derived. A POST to /v1/crawl derives `create_crawl`, which reads
// as "make a crawl" — a job this surface does not have and cannot start. What
// it does is read ONE page that is already addressed, and a model choosing
// from an `op` enum picks by that name before it reads any description: the
// derived name offered a crawler and the op is a reader. It is the verb over
// the noun, beside search_web and research_web.
zip.Post(reg, Path, fetch,
zip.WithOperationID("read_page"),
zip.WithSummary("Fetch one URL and read it back as markdown"),
// 400 is DECLARED because this op answers it with its OWN body rather than
// with zip's error envelope. See [crawlResult.StatusCode].
zip.WithStatus(http.StatusOK, http.StatusBadRequest))
// No "archive" field: it used to log deps.VFS != nil, which is ALWAYS true —
// deps.VFS is guaranteed non-nil by contract (R-7, so consumers never
@@ -159,73 +205,159 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return nil
}
// guard admits a caller holding the shared service key, presented as either
// maxRequest is what this route will read FROM A CALLER. One URL does not need
// more, and this surface dials a caller-chosen address from inside the cluster.
// (crawl.go's maxBody is the other direction — what is read from a fetched page.)
const maxRequest = 1 << 20
// bounded reports whether a body is one this route will read: at most
// [maxRequest] bytes, and JSON if there is any of it.
//
// Both facts are invisible to a typed op — it receives its DECODED In and never
// sees the raw bytes — so they are asked where the bytes still are. This is not a
// second admission gate: no credential is read here and nothing is admitted, only
// a body that has ALWAYS been refused is refused in the shape its callers parse.
// The untyped handler did both through one io.LimitReader(r.Body, 1<<20) whose
// decode failure WAS this 400; zip's op.invoke would answer its own flat
// {status,code,error} instead, a different body for a refusal that has not
// changed.
//
// It is a PREDICATE and writes nothing. An earlier version answered the refusal
// itself and returned c.Continue() otherwise — which runs the whole rest of the
// chain from inside it, so the handler ran before the caller had been admitted
// and then the middleware continued a second time. A middleware step either
// continues or it does not; a helper that does both is neither.
func bounded(c *zip.Ctx) bool {
b := c.Body()
return len(b) <= maxRequest && (len(b) == 0 || json.Valid(b))
}
// admitKey admits a caller holding the shared service key, presented as either
// X-API-Key or a Bearer. Both are accepted because the two clients that reach this
// surface already differ on that point and neither is wrong; requiring one would
// break a working caller to no benefit.
func guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := serviceKey()
if want == "" {
writeJSON(w, http.StatusServiceUnavailable, crawlResult{Error: "crawl not configured"})
return
}
got := strings.TrimSpace(r.Header.Get("X-API-Key"))
if got == "" {
got = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
}
// Constant-time: a byte-at-a-time comparison leaks the key's prefix to a
// caller willing to time enough requests.
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
writeJSON(w, http.StatusUnauthorized, crawlResult{Error: "invalid api key"})
return
}
next.ServeHTTP(w, r)
})
//
// It writes crawl's OWN refusal body, which is what its callers parse, and it
// records the admission on the CONTEXT rather than letting the request through —
// so the handler makes the one decision and this only ever supplies a fact.
func admitKey(c *zip.Ctx) error {
want := serviceKey()
if want == "" {
return c.JSON(http.StatusServiceUnavailable, crawlResult{Error: "crawl not configured"})
}
got := strings.TrimSpace(c.Header("X-API-Key"))
if got == "" {
got = strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
}
// Constant-time: a byte-at-a-time comparison leaks the key's prefix to a
// caller willing to time enough requests.
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return c.JSON(http.StatusUnauthorized, crawlResult{Error: "invalid api key"})
}
c.SetContext(admit(c.Context()))
return c.Continue()
}
// scope reads the caller's corpus scope from the VERIFIED principal.
// admittedKey names the context slot [admitKey] records the service key in.
// Unexported zero-size type, so only this package can mint or read one.
type admittedKey struct{}
func admit(ctx context.Context) context.Context {
return context.WithValue(ctx, admittedKey{}, true)
}
func isAdmitted(ctx context.Context) bool {
ok, _ := ctx.Value(admittedKey{}).(bool)
return ok
}
// scopeOf is the ONE admission decision, and it never reads the body.
//
// A validated principal is admitted and scoped to its own org and project. A
// caller with no principal is admitted only on the marker [admitKey] leaves, and
// takes the shared corpus, which is the same thing the untyped route did through
// an empty scope. Neither ⇒ refused, so the fetcher is closed on every door
// including the ones with no request behind them: the CLI projection runs an op
// with no request at all, and it lands here.
//
// A service caller (the chat server, holding the shared key) has no user
// principal and therefore no org — its pages land in the shared "_" prefix that
// seg() produces for an empty segment. That is deliberate: a service-wide corpus
// is the honest home for pages fetched on nobody's behalf, and inventing an org
// for it would file them under a tenant that did not ask.
func scope(c *zip.Ctx) Scope {
org, _ := principal.Org(c)
return Scope{Org: org, Project: principal.Project(c)}
//
// It reads the CONTEXT and never the request. All three facts are server-minted
// identity that cloud.Bridge parks in one expression, so holding the raw request
// to re-read them would take back what typing bought for nothing: the org is the
// tenant, the project only narrows within it, and neither is a fact a caller
// supplies.
func scopeOf(ctx context.Context) (Scope, error) {
if principal.ValidatedFrom(ctx) {
org, _ := principal.OrgFrom(ctx)
return Scope{Org: org, Project: principal.ProjectFrom(ctx)}, nil
}
if isAdmitted(ctx) {
return Scope{}, nil
}
return Scope{}, zip.ErrUnauthorized("crawling requires a validated principal or the service key")
}
// handleScoped serves one crawl under a caller scope. The scope selects the corpus
// prefix, so it comes from the VERIFIED principal and never from the body.
func handleScoped(w http.ResponseWriter, r *http.Request, s Scope) {
var req crawlRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
writeJSON(w, http.StatusBadRequest, crawlResult{Error: "missing url"})
return
}
page, err := Read(r.Context(), s, req.URL)
// fetch reads one URL and answers with the page as markdown.
//
// It fetches a single URL from inside the cluster and answers with the address it
// actually landed on, the document's title, its content rendered to MARKDOWN, and
// whatever the page said about itself. One URL per call: batching would make the
// answer a partial-failure envelope every caller then has to unpack.
//
// A PAGE THAT COULD NOT BE FETCHED IS A NORMAL ANSWER, not a fault. An
// unreachable host, a refused address and a content type that is not a document
// all answer 200 with `success:false` and the reason in `error`, because the
// caller sent a well-formed ask and gets a well-formed answer. Non-2xx is reserved
// for a caller problem — 400 with the same body when there is no url, 401 for a
// bad key, 503 when the surface is unconfigured — so error handling can trust the
// status. Check `success` before reading `data`.
//
// Admission is either a validated principal or the shared service key, presented
// as X-API-Key or a Bearer; neither is refused, and an unset key fails closed
// rather than opening the fetcher to the private network. Pages are archived under
// the scope of the VERIFIED principal and NEVER a scope named in the body, so a
// URL already read under that scope is answered from the archive without touching
// the network; a service caller has no org and its pages land in the shared
// corpus.
//
// The URL is caller-supplied and dialled from INSIDE the cluster, which makes this
// a request-forgery primitive by construction. Only http and https are accepted,
// and every address actually dialled must be public unicast — loopback,
// link-local, private and multicast are refused. The check lives in the DIALER
// rather than on the hostname, because resolving a name to validate it and then
// letting the transport resolve it again is a gap DNS rebinding walks straight
// through; redirects re-enter the same dialer.
func fetch(ctx context.Context, in *crawlRequest) (*crawlResult, error) {
s, err := scopeOf(ctx)
if err != nil {
// 200 with Success:false — see the note on Response. The message is the
return nil, err
}
if strings.TrimSpace(in.URL) == "" {
return &crawlResult{Error: missingURL}, nil
}
page, err := Read(ctx, s, in.URL)
if err != nil {
// 200 with Success:false — see the note on crawlResult. The message is the
// error verbatim: a caller debugging a failed crawl needs to know whether the
// host was refused, unreachable, or served the wrong type.
writeJSON(w, http.StatusOK, crawlResult{Error: err.Error()})
return
return &crawlResult{Error: err.Error()}, nil
}
writeJSON(w, http.StatusOK, crawlResult{
meta, err := json.Marshal(page.Metadata)
if err != nil {
return nil, zip.ErrInternal("crawl: the page's metadata will not encode")
}
return &crawlResult{
Success: true,
Data: &crawlDocument{
URL: page.URL,
Title: page.Title,
Markdown: page.Markdown,
Metadata: page.Metadata,
Metadata: meta,
},
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}, nil
}
+116 -31
View File
@@ -1,6 +1,7 @@
package crawl
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -36,18 +37,33 @@ func post(t *testing.T, app *zip.App, path, body string) (int, string) {
return resp.StatusCode, strings.TrimSpace(string(b))
}
// TestCrawlRefusalIsTheWire measures the two facts that keep POST /v1/crawl an
// untyped handler. They are named at the registration in mount.go; this is what
// makes that prose a MEASUREMENT rather than a claim, so the day zip can express
// either one, the conversion is a test edit away instead of a re-derivation.
func TestCrawlRefusalIsTheWire(t *testing.T) {
// TestCrawlWireSurvivedTyping is the conversion's whole obligation.
//
// POST /v1/crawl was a raw handler for two measured reasons, and both were real:
// it is BODY-TOLERANT with a DOMAIN refusal body, and it bounds the request at
// 1 MiB. The cost of that was the whole point of tracker #190 — a raw route is in
// no registry, so this subsystem projected NO MCP tool and the fleet's one way to
// read a web page was unreachable by the agent that needed it.
//
// It is a typed op now, and NEITHER fact moved. They moved HOUSE:
//
// - The 400 with `{"success":false,"error":"missing url"}` is stated by the
// ANSWER, through zip's StatusCoder — the sanctioned way for an op that
// refuses with its own body — so the document publishes 400 with that schema.
// - The 1 MiB bound and the malformed-body tolerance are facts about BYTES, and
// a typed op receives its decoded In. So they are asked in middleware, where
// the bytes still are (mount.go, bounded).
//
// This test is the measurement that says so: the same three inputs, the same
// three answers, byte for byte.
func TestCrawlWireSurvivedTyping(t *testing.T) {
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
app := mount(t)
// (1) BODY-TOLERANT, with a DOMAIN refusal body. An unparseable body and an
// empty url are the SAME answer, and it is this package's shape — not zip's
// flat {status,code,error}, which is the only thing a typed op's returned
// error can become.
// flat {status,code,error}, which is what an op that refused by RETURNING an
// error would send.
for _, body := range []string{`{"url":""}`, `not json at all`, `{`} {
code, out := post(t, app, "/v1/crawl", body)
if code != http.StatusBadRequest {
@@ -58,9 +74,8 @@ func TestCrawlRefusalIsTheWire(t *testing.T) {
}
}
// (2) The 1 MiB io.LimitReader bound. A body past it cannot decode, so it is
// the same 400 — and a typed op, which receives its DECODED In, could never
// see the length that produced it.
// (2) The 1 MiB bound. A body past it is the same 400 — it must never become a
// crawl that runs because the cap was dropped in the conversion.
big := `{"url":"https://example.com","pad":"` + strings.Repeat("x", 1<<20) + `"}`
code, out := post(t, app, "/v1/crawl", big)
if code != http.StatusBadRequest || out != `{"success":false,"error":"missing url"}` {
@@ -68,12 +83,38 @@ func TestCrawlRefusalIsTheWire(t *testing.T) {
}
}
// TestCrawlIsServedAtOneAddressAndPublishedAtIt pins the registration fix. The
// mount used to declare `g.Post("", …)` and `g.Post("/", …)` on a
// Group("/v1/crawl"), which are the SAME route ("/v1/crawl/") — so the second was
// dead and the published path carried a trailing slash the callers do not use.
// Both URLs must keep working (the router is non-strict) AND the document must
// name the one without the slash.
// TestCrawlIsClosedToAnAnonymousCaller is the gate, asked at the door a typed op
// adds rather than at the one it already had.
//
// A tools/call reaches a typed op with NO route and therefore NO middleware, so a
// gate that lived only in middleware would be no gate at all for the MCP and CLI
// projections this conversion exists to create. The decision is in the handler
// (scopeOf) for exactly that reason. Here it is measured from the HTTP side: no
// principal and no key is refused, and the refusal is not a crawl.
func TestCrawlIsClosedToAnAnonymousCaller(t *testing.T) {
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
app := mount(t)
rq := httptest.NewRequest(http.MethodPost, "/v1/crawl", strings.NewReader(`{"url":"https://example.com"}`))
rq.Header.Set("Content-Type", "application/json")
resp, err := app.Test(rq)
if err != nil {
t.Fatalf("POST /v1/crawl: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("an anonymous crawl = %d %s, want 401 — this surface dials a caller-chosen "+
"address from inside the cluster", resp.StatusCode, body)
}
}
// TestCrawlIsServedAtOneAddressAndPublishedAtIt pins the registration. The mount
// used to declare `g.Post("", …)` and `g.Post("/", …)` on a Group("/v1/crawl"),
// which are the SAME route ("/v1/crawl/") — so the second was dead and the
// published path carried a trailing slash the callers do not use. Both URLs must
// keep working (the router is non-strict) AND the document must name the one
// without the slash.
func TestCrawlIsServedAtOneAddressAndPublishedAtIt(t *testing.T) {
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
app := mount(t)
@@ -96,11 +137,58 @@ func TestCrawlIsServedAtOneAddressAndPublishedAtIt(t *testing.T) {
}
}
// TestCrawlDeclaresItsBodies holds the other half of the refusal to account.
// Staying untyped costs the prose, the MCP tool and the CLI command; it must not
// also cost a document that says this route takes no body, or every generated SDK
// offers a crawl with nowhere to put the URL.
func TestCrawlDeclaresItsBodies(t *testing.T) {
// TestCrawlProjectsAsATool is the fact the conversion was FOR.
//
// A raw handler appends nothing to zip's op registry, and that registry is what
// every projection reads — so an untyped /v1/crawl is in no OpenAPI operation, no
// SDK method, no CLI command and no MCP tool. Asserting the handler answers
// correctly says nothing about any of that. This asks the subsystem's OWN MCP
// door, over JSON-RPC, exactly as the fleet's door asks it, and reads the answer.
func TestCrawlProjectsAsATool(t *testing.T) {
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
app := mount(t)
rq := httptest.NewRequest(http.MethodPost, "/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
rq.Header.Set("Content-Type", "application/json")
resp, err := app.Test(rq)
if err != nil {
t.Fatalf("POST /mcp: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var env struct {
Result *struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &env); err != nil || env.Result == nil {
t.Fatalf("POST /mcp did not answer MCP: %d — %.200s", resp.StatusCode, raw)
}
for _, tool := range env.Result.Tools {
if tool.Name != "read_page" {
continue
}
if tool.Description == "" {
t.Fatal("read_page projects with NO description — a tool a model cannot " +
"read is a tool it will not call; run `go generate -run zipdoc ./...`")
}
t.Logf("read_page projects: %.90s…", tool.Description)
return
}
t.Fatalf("read_page is not in this subsystem's tools/list — %.300s", raw)
}
// TestCrawlPublishesItsBodies holds the document to account. It used to assert
// that the UNTYPED route declared its shapes by hand through openapi.Register;
// the typed op declares them by construction, off the same structs the handler
// binds, so this now asserts the stronger fact — that the published operation
// carries a request body, a response AND the prose only a typed op can have.
func TestCrawlPublishesItsBodies(t *testing.T) {
t.Setenv("WEBSEARCH_API_KEY", "test-service-key")
doc, err := openapi.Spec(mount(t), openapi.Info{Title: "crawl", Version: "v1"})
if err != nil {
@@ -114,18 +202,15 @@ func TestCrawlDeclaresItsBodies(t *testing.T) {
if op == nil || op.RequestBody == nil {
t.Fatal("POST /v1/crawl publishes no request body — an SDK would offer a crawl with no URL")
}
// RequestBody/Responses are `any` on Operation: Register builds the closed
// shapes, the typed fold reuses zip's open maps. Assert the closed ones.
rb, ok := op.RequestBody.(*openapi.RequestBody)
if !ok {
t.Fatalf("request body is %T, want the declared *openapi.RequestBody", op.RequestBody)
if op.Responses == nil {
t.Error("POST /v1/crawl publishes no response shape")
}
if _, jsonBody := rb.Content["application/json"]; !jsonBody {
t.Errorf("request body content = %v, want application/json", rb.Content)
if strings.TrimSpace(op.Summary) == "" {
t.Error("POST /v1/crawl publishes no summary")
}
resp, ok := op.Responses.(map[string]*openapi.Response)
if !ok || resp["2XX"] == nil {
t.Errorf("POST /v1/crawl publishes no success body (responses = %#v)", op.Responses)
if strings.TrimSpace(op.Description) == "" {
t.Error("POST /v1/crawl publishes no description — zipdoc lifts it from the handler's " +
"doc comment; run `go generate -run zipdoc ./...` and commit zipdoc_gen.go")
}
}
+22
View File
@@ -0,0 +1,22 @@
// Code generated by zipdoc; DO NOT EDIT.
package crawl
import (
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("POST /v1/crawl", zip.Doc{
Description: "Reads one URL and answers with the page as markdown.\n\nIt fetches a single URL from inside the cluster and answers with the address it\nactually landed on, the document's title, its content rendered to MARKDOWN, and\nwhatever the page said about itself. One URL per call: batching would make the\nanswer a partial-failure envelope every caller then has to unpack.\n\nA PAGE THAT COULD NOT BE FETCHED IS A NORMAL ANSWER, not a fault. An\nunreachable host, a refused address and a content type that is not a document\nall answer 200 with `success:false` and the reason in `error`, because the\ncaller sent a well-formed ask and gets a well-formed answer. Non-2xx is reserved\nfor a caller problem — 400 with the same body when there is no url, 401 for a\nbad key, 503 when the surface is unconfigured — so error handling can trust the\nstatus. Check `success` before reading `data`.\n\nAdmission is either a validated principal or the shared service key, presented\nas X-API-Key or a Bearer; neither is refused, and an unset key fails closed\nrather than opening the fetcher to the private network. Pages are archived under\nthe scope of the VERIFIED principal and NEVER a scope named in the body, so a\nURL already read under that scope is answered from the archive without touching\nthe network; a service caller has no org and its pages land in the shared\ncorpus.\n\nThe URL is caller-supplied and dialled from INSIDE the cluster, which makes this\na request-forgery primitive by construction. Only http and https are accepted,\nand every address actually dialled must be public unicast — loopback,\nlink-local, private and multicast are refused. The check lives in the DIALER\nrather than on the hostname, because resolving a name to validate it and then\nletting the transport resolve it again is a gap DNS rebinding walks straight\nthrough; redirects re-enter the same dialer.",
Fields: map[string]string{
"crawlDocument.markdown": "Markdown is the page's content, extracted and rendered to markdown. This is\nthe field to read.",
"crawlDocument.metadata": "Metadata is whatever the document said about itself — description, og:*,\nlanguage — plus the response status, the final URL and the content type. It\nis an OPEN key space, so it is carried as raw JSON: `map[string]any`\npublishes `additionalProperties:{\"type\":\"object\"}`, which the integer\n`status` inside it refutes, while raw JSON publishes `{}` — \"any JSON\",\nwhich is true. The bytes are identical either way (encoding/json writes a\nmap's keys sorted, and this IS that marshalling).",
"crawlDocument.title": "Title is the document's title, when it carried one.",
"crawlDocument.url": "URL is the address actually read, after redirects.",
"crawlResult.data": "Data is the page, present exactly when Success.",
"crawlResult.error": "Error says what stopped the fetch: the host was refused, unreachable, or\nserved something that is not a document.",
"crawlResult.success": "Success is whether the page was fetched and read. FALSE with an Error is a\ncomplete answer, not a fault — check this before reading Data.",
},
})
}
-71
View File
@@ -2,7 +2,6 @@ package deploy
import (
"context"
"encoding/json"
"testing"
"github.com/hanzoai/cloud"
@@ -170,76 +169,6 @@ func TestSyncStatus(t *testing.T) {
// ── ref parsing ─────────────────────────────────────────────────────────────
func TestParseRef(t *testing.T) {
// The App CR resolves; the core/v1 Service (a child object) resolves distinctly.
if _, gvr, err := parseRef("hanzo.ai:App:hanzo:iam"); err != nil || gvr != k8s.Apps {
t.Errorf("App ref → (%v, %v), want k8s.Apps", gvr, err)
}
if _, gvr, err := parseRef("apps:Deployment:hanzo:iam"); err != nil || gvr != k8s.Deployments {
t.Errorf("Deployment ref → (%v, %v), want k8s.Deployments", gvr, err)
}
if _, _, err := parseRef(":Service:hanzo:iam"); err != nil {
t.Errorf("core Service ref err = %v, want nil", err)
}
// hanzo.ai:Service is not a kind this plane reads — the operator CR is App.
bad := []string{"", "a:b:c", "hanzo.ai:Service:hanzo:iam", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
for _, r := range bad {
if _, _, err := parseRef(r); err == nil {
t.Errorf("parseRef(%q) = nil err, want rejection", r)
}
}
}
// ── belongs-to-app membership ───────────────────────────────────────────────
func TestBelongsToApp(t *testing.T) {
cr := appCR("App", "hanzo", "iam", "u1", "r", "v1.0.0", "Running", 1, 1)
// owned by uid
if !belongsToApp(deployment("hanzo", "x", "d1", "u1", "img:v1", 1, 1), cr, "iam") {
t.Error("ownerRef match should belong")
}
// name == app
if !belongsToApp(deployment("hanzo", "iam", "d1", "other", "img:v1", 1, 1), cr, "iam") {
t.Error("name==app should belong")
}
// label instance
lbl := &unstructured.Unstructured{Object: map[string]any{"metadata": map[string]any{"name": "z", "labels": map[string]any{"app.kubernetes.io/instance": "iam"}}}}
if !belongsToApp(lbl, cr, "iam") {
t.Error("instance label should belong")
}
// unrelated
other := &unstructured.Unstructured{Object: map[string]any{"metadata": map[string]any{"name": "z", "uid": "zz"}}}
if belongsToApp(other, cr, "iam") {
t.Error("unrelated object must NOT belong")
}
}
// ── diff ────────────────────────────────────────────────────────────────────
func TestComputeDiff(t *testing.T) {
// No annotation → source none, not modified.
live := deployment("hanzo", "iam", "d1", "u1", "ghcr.io/hanzoai/iam:v1", 2, 2)
if src, mod, _ := computeDiff(live); src != "none" || mod {
t.Errorf("no-annotation diff = (%q,%v), want (none,false)", src, mod)
}
// Annotation identical to live (minus status/volatile) → not modified.
desired := map[string]any{"apiVersion": "apps/v1", "kind": "Deployment",
"metadata": map[string]any{"name": "iam", "namespace": "hanzo"},
"spec": map[string]any{"replicas": int64(2), "selector": map[string]any{"matchLabels": map[string]any{"app.kubernetes.io/instance": "iam"}}, "template": map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1"}}}}}}
db, _ := json.Marshal(desired)
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db)}, "metadata", "annotations")
if src, mod, _ := computeDiff(live); src != deployDesiredTODO || mod {
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, deployDesiredTODO)
}
// Annotation with a different image → modified.
desired["spec"].(map[string]any)["template"].(map[string]any)["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["image"] = "ghcr.io/hanzoai/iam:v2"
db2, _ := json.Marshal(desired)
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db2)}, "metadata", "annotations")
if _, mod, _ := computeDiff(live); !mod {
t.Error("changed-image diff = not modified, want modified")
}
}
// ── observe mapping ─────────────────────────────────────────────────────────
func TestObserveApplication(t *testing.T) {
-133
View File
@@ -1,133 +0,0 @@
// logs.go — GET /v1/deploy/{name}/logs: the app's current pod logs, streamed from
// the newest running pod via the typed CoreV1 GetLogs subresource. The operator
// labels the workload it renders for an App CR with
// app.kubernetes.io/instance=<name>, so that selects the app's pods; the
// most-recently-started pod is read (the current rollout). Optional ?container=
// selects a container; ?tail= bounds the lines. Never fabricates output — an
// unreachable cluster or absent pod yields an honest 200 with an empty tail + the
// reason, not invented logs.
package deploy
import (
"context"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
logTailDefault = int64(400)
logTailMax = int64(5000)
logMaxBytes = 512 << 10
logReadDeadline = 8 * time.Second
)
// appLogs streams the newest app pod's logs. It resolves the app's namespace, then
// selects pods by the operator's instance label. 200 always (with content or an
// honest empty note) so a dashboard poll never errors on a not-yet-scheduled pod.
func appLogs(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
if err != nil {
return err
}
tail := logTailDefault
if q := strings.TrimSpace(c.Query("tail")); q != "" {
if n, e := strconv.ParseInt(q, 10, 64); e == nil && n > 0 {
if n > logTailMax {
n = logTailMax
}
tail = n
}
}
container := strings.TrimSpace(c.Query("container"))
logs, pod, ok := podLogs(s, c.Context(), ns, "app.kubernetes.io/instance="+name, container, tail)
res := map[string]any{"application": ns + "/" + name, "pod": pod, "container": container, "logs": logs}
if !ok {
res["note"] = "no running pod logs available yet (pod not scheduled, or the cluster/pod is unreachable)"
}
return c.JSON(http.StatusOK, res)
}
// podLogs finds the newest pod matching selector in ns and streams its logs
// (tail-bounded, byte-capped, time-boxed). Returns (logs, podName, ok). ok=false on
// no typed client / no pod / read failure — the caller states the honest fallback.
func podLogs(s *cloud.Service[state], ctx context.Context, ns, selector, container string, tail int64) (string, string, bool) {
if s.State.clientset == nil {
return "", "", false
}
rctx, cancel := context.WithTimeout(ctx, logReadDeadline)
defer cancel()
pods, err := s.State.clientset.CoreV1().Pods(ns).List(rctx, metav1.ListOptions{LabelSelector: selector})
if err != nil || len(pods.Items) == 0 {
return "", "", false
}
pod := newestPod(pods.Items)
if pod == "" {
return "", "", false
}
opts := &corev1.PodLogOptions{TailLines: &tail}
if container != "" {
opts.Container = container
}
stream, err := s.State.clientset.CoreV1().Pods(ns).GetLogs(pod, opts).Stream(rctx)
if err != nil {
return "", pod, false
}
defer stream.Close()
data, err := io.ReadAll(io.LimitReader(stream, int64(logMaxBytes)+1))
if err != nil && len(data) == 0 {
return "", pod, false
}
out := string(data)
if len(out) > logMaxBytes {
out = out[len(out)-logMaxBytes:]
if nl := strings.IndexByte(out, '\n'); nl >= 0 && nl < len(out)-1 {
out = out[nl+1:]
}
out = "… (truncated to the most recent " + strconv.Itoa(logMaxBytes>>10) + " KiB)\n" + out
}
if strings.TrimSpace(out) == "" {
return "", pod, false
}
return out, pod, true
}
// newestPod returns the name of the most-recently-started pod (by startTime, then
// creationTimestamp; ties break on name for stability).
func newestPod(pods []corev1.Pod) string {
sort.Slice(pods, func(i, j int) bool {
ti, tj := podTime(pods[i]), podTime(pods[j])
if ti.Equal(tj) {
return pods[i].Name > pods[j].Name
}
return ti.After(tj)
})
return pods[0].Name
}
func podTime(p corev1.Pod) time.Time {
if p.Status.StartTime != nil {
return p.Status.StartTime.Time
}
return p.CreationTimestamp.Time
}
-183
View File
@@ -1,183 +0,0 @@
// resource.go — GET /v1/deploy/{name}/resource/{ref}: one tree node's live
// manifest plus a desired-vs-live diff.
//
// {ref} is the canonical "group:kind:namespace:name" token the tree emits on each
// node, so the console round-trips it back verbatim. The kind must be in the
// closed registry (kindGVR) and the namespace a platform namespace, and the object
// must belong to {name}'s tree (it IS the CR, or carries an ownerRef/label tying it
// to the app) — so the endpoint can never be steered at an arbitrary cluster
// object. Secrets are not in the registry, so their manifests are never returned.
//
// desiredSource: today "last-applied" (the object's kubectl last-applied-config
// annotation) or "none". When git.hanzo.ai becomes the manifest source of truth
// (RegisterPushBuilder → commit → engine sync), desiredSource becomes "git" with
// the SAME diff shape. P2b replaces the field-strip diff with gitops-engine
// pkg/diff (three-way) for exact ArgoCD parity.
package deploy
import (
"encoding/json"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const lastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration"
// parseRef parses a "group:kind:namespace:name" token into its ResourceRef + GVR.
// Group may be empty (core). The kind must be in the closed registry; anything
// else is refused, so a ref can only address a known operator-owned kind.
func parseRef(ref string) (ResourceRef, schema.GroupVersionResource, error) {
parts := strings.SplitN(ref, ":", 4)
if len(parts) != 4 {
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("ref must be group:kind:namespace:name")
}
group, kind, ns, name := parts[0], parts[1], parts[2], parts[3]
gvr, ok := kindGVR[group+"/"+kind]
if !ok {
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("unsupported resource kind " + group + "/" + kind)
}
if _, ok := nsEnv[ns]; !ok {
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("namespace must be a platform namespace")
}
if !appNameRE.MatchString(name) {
return ResourceRef{}, schema.GroupVersionResource{}, zip.ErrBadRequest("resource name must be a DNS-1123 label")
}
return makeRef(group, gvr.Version, kind, ns, name), gvr, nil
}
// appResource returns the live manifest + diff for one node of {name}'s tree.
func appResource(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
app := reqName(c)
if !appNameRE.MatchString(app) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ref, gvr, perr := parseRef(c.Param("ref"))
if perr != nil {
return perr
}
// Resolve the app CR first (for the membership check + namespace consistency).
crNS, err := resolveNamespace(s, c, app)
if err != nil {
return err
}
if ref.Namespace != crNS {
return zip.ErrNotFound("resource is not in this application's namespace")
}
cr, _, err := getAppCR(s, c.Context(), crNS, app)
if err != nil {
return k8sErr(s, "get", err)
}
live, err := s.State.dyn.Resource(gvr).Namespace(ref.Namespace).Get(c.Context(), ref.Name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return zip.ErrNotFound("resource not found")
}
return k8sErr(s, "get", err)
}
if !belongsToApp(live, cr, app) {
return zip.ErrNotFound("resource is not part of this application")
}
health, hmsg := resourceHealth(live)
desiredSource, modified, desired := computeDiff(live)
return c.JSON(http.StatusOK, map[string]any{
"ref": ref,
"health": health,
"healthMessage": hmsg,
"liveManifest": live.Object,
"desiredSource": desiredSource,
"diff": map[string]any{
"modified": modified,
"desiredManifest": desired,
},
})
}
// belongsToApp reports whether live is part of app's tree: it IS the CR, or it
// carries an ownerRef to the CR, or its name equals app, or a standard app label
// ties it to app. The membership boundary that keeps this endpoint scoped.
func belongsToApp(live, cr *unstructured.Unstructured, app string) bool {
if live.GetUID() == cr.GetUID() && live.GetUID() != "" {
return true
}
if ownedBy(live, string(cr.GetUID())) {
return true
}
if live.GetName() == app {
return true
}
labels := live.GetLabels()
return labels["app.kubernetes.io/instance"] == app || labels["app.kubernetes.io/part-of"] == app
}
// computeDiff derives (desiredSource, modified, desired) for a live object from its
// last-applied-configuration annotation. modified is true when the normalized live
// object differs from the normalized desired (server-set noise stripped from
// both). Absent the annotation, the source is "none" and modified is false (no
// desired to compare) — honest, never a fabricated diff.
func computeDiff(live *unstructured.Unstructured) (source string, modified bool, desired map[string]any) {
raw := live.GetAnnotations()[lastAppliedAnnotation]
if strings.TrimSpace(raw) == "" {
return "none", false, nil
}
var d map[string]any
if err := json.Unmarshal([]byte(raw), &d); err != nil {
return "none", false, nil
}
modified = !jsonEqual(normalizeForDiff(live.Object), normalizeForDiff(d))
return deployDesiredTODO, modified, d
}
// normalizeForDiff strips server-set / volatile fields so a diff reflects only
// intent: status, metadata.managedFields/resourceVersion/uid/generation/
// creationTimestamp, and the last-applied annotation itself.
func normalizeForDiff(in map[string]any) map[string]any {
out := deepCopyMap(in)
delete(out, "status")
if md, ok := out["metadata"].(map[string]any); ok {
// Strip every field the server/operator sets that the last-applied intent
// never carries (ownerReferences included — the operator owns those), so the
// coarse two-way compare reflects intent, not server bookkeeping. The precise
// three-way merge arrives with the gitops-engine diff (P2b).
for _, k := range []string{"managedFields", "resourceVersion", "uid", "generation", "creationTimestamp", "selfLink", "ownerReferences"} {
delete(md, k)
}
if ann, ok := md["annotations"].(map[string]any); ok {
delete(ann, lastAppliedAnnotation)
if len(ann) == 0 {
delete(md, "annotations")
}
}
}
return out
}
// jsonEqual compares two maps by canonical JSON (encoding/json sorts string keys),
// so field order never produces a false diff.
func jsonEqual(a, b map[string]any) bool {
ab, _ := json.Marshal(a)
bb, _ := json.Marshal(b)
return string(ab) == string(bb)
}
func deepCopyMap(in map[string]any) map[string]any {
b, err := json.Marshal(in)
if err != nil {
return map[string]any{}
}
var out map[string]any
_ = json.Unmarshal(b, &out)
return out
}
-26
View File
@@ -15,10 +15,8 @@ package deploy
import (
"context"
"github.com/hanzoai/cloud/apps/k8s"
"net/http"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -49,30 +47,6 @@ type Node struct {
ParentRefs []ResourceRef `json:"parentRefs,omitempty"`
}
// appTree returns the flat node tree for one Application.
func appTree(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
return err
}
name := reqName(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("name must be a DNS-1123 label")
}
ns, err := resolveNamespace(s, c, name)
if err != nil {
return err
}
cr, _, err := getAppCR(s, c.Context(), ns, name)
if err != nil {
return k8sErr(s, "get", err)
}
nodes := buildTree(s, c.Context(), ns, name, cr)
return c.JSON(http.StatusOK, map[string]any{
"application": observeApplication(cr, ns, ""),
"nodes": nodes,
})
}
// depth1GVRs are the operator-owned kinds scanned directly under the Service CR.
var depth1GVRs = []schema.GroupVersionResource{
k8s.Deployments, coreSvcGVR, ingressGVR, hpaGVR, pdbGVR, configMapsGVR,
+9 -4
View File
@@ -439,7 +439,7 @@ func TestLinkedInBuild(t *testing.T) {
}
}
func TestXBuildAndScaffold(t *testing.T) {
func TestXBuild(t *testing.T) {
convs := xBuild([]Conversion{{Standard: EventPurchase, Value: 9, Currency: "USD", User: UserData{Email: "a@b.com", Clicks: map[string]string{"twclid": "tw1"}}}})
if len(convs[0].Identifiers) != 2 || convs[0].Identifiers[0].HashedEmail != sha("a@b.com") {
t.Errorf("identifiers: %+v", convs[0].Identifiers)
@@ -447,16 +447,21 @@ func TestXBuildAndScaffold(t *testing.T) {
if convs[0].Value != "9.00" {
t.Errorf("value = %q", convs[0].Value)
}
// Send is an honest scaffold: it refuses (OAuth1 not wired) rather than faking.
// The four OAuth1 parts ride as ONE composite JSON secret; a non-JSON blob is refused.
if _, err := (xDest{}).Send(context.Background(), Config{"pixelId": "o1"}, "tok",
[]Conversion{{Standard: EventPurchase, User: UserData{Email: "a@b.com"}}}); err == nil {
t.Fatal("x Send must return the honest not-enabled error")
t.Fatal("x Send must reject a non-JSON credential blob")
}
// Incomplete OAuth1 credentials are refused rather than sent unsigned.
if _, err := (xDest{}).Send(context.Background(), Config{"pixelId": "o1"}, `{"consumer_key":"ck"}`,
[]Conversion{{Standard: EventPurchase, User: UserData{Email: "a@b.com"}}}); err == nil {
t.Fatal("x Send must reject incomplete OAuth1 credentials")
}
}
// TestRegistryComplete asserts every platform self-registered with a coherent Spec.
func TestRegistryComplete(t *testing.T) {
want := []string{"ga4", "meta", "tiktok", "linkedin", "x", "reddit", "analytics", "insights"}
want := []string{"ga4", "meta", "tiktok", "linkedin", "x", "reddit", "analytics", "insights", "pinterest", "google-ads"}
m := snapshot()
for _, id := range want {
d, ok := m[id]
+2
View File
@@ -163,6 +163,8 @@ func routes(app cloud.Router, zapp *zip.App, s *cloud.Service[state]) {
g.Post("/:platform", cloud.Handle(s, connect))
zip.Delete(zapp, "/v1/destinations/:platform", o.disconnect)
zip.Post(zapp, "/v1/destinations/:platform/test", o.test)
// GET /v1/tags moved to the projects app — it must be served by the process that
// owns the project store (production runs ~25 single-app processes).
}
// The one untyped route DECLARES what it carries. Its request is the map the
+244
View File
@@ -0,0 +1,244 @@
package destinations
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// googleads.go forwards conversions to Google Ads via the Ads API offline-conversion
// import (uploadClickConversions) — this is GOOGLE ADS proper, not GA4. GA4 (ga4.go) is
// web analytics over the Measurement Protocol; this uploads real conversions against a
// configured conversion action, attributed by gclid and/or enhanced-conversion hashed
// identifiers, so Google Ads bidding optimizes on them.
//
// Config (non-secret): customerId (the Ads account), conversionActionId (the conversion
// action to import against), and an optional loginCustomerId (a manager account).
// Secret: a composite JSON blob {developer_token, client_id, client_secret,
// refresh_token} — the fan-out resolves ONE primary secret per destination, and Google
// Ads needs four OAuth2 app/refresh values, so they ride together. Send exchanges the
// refresh token for a short-lived access token, then uploads.
//
// Only CONVERSION-class events with a Google match key (a gclid, or a hashed email/phone
// for enhanced conversions) are uploaded; a pageview or a keyless event is skipped —
// offline conversion import is for conversions, not page traffic.
const googleadsID = "google-ads"
// googleAdsAPI / googleOAuthURL are the Ads API base and the OAuth2 token endpoint —
// package vars so tests point them at mock servers; never mutated in production.
var (
googleAdsAPI = "https://googleads.googleapis.com/v18"
googleOAuthURL = "https://oauth2.googleapis.com/token"
)
// googleConversionEvents is the conversion-class subset of the taxonomy Google Ads
// accepts as offline conversions. Traffic events (page_view/view_content/search) are not
// conversions and are skipped.
var googleConversionEvents = map[StandardEvent]bool{
EventPurchase: true,
EventLead: true,
EventSignUp: true,
EventStartCheckout: true,
EventAddToCart: true,
EventContact: true,
}
type googleads struct{}
func init() { register(googleads{}) }
func (googleads) ID() string { return googleadsID }
func (googleads) Name() string { return "Google Ads" }
func (googleads) Category() string { return categoryAdvertising }
func (googleads) Spec() Spec {
return Spec{
Fields: []DestinationField{
{Key: "customerId", Label: "Customer ID", Required: true, Example: "1234567890"},
{Key: "conversionActionId", Label: "Conversion Action ID", Required: true, Example: "987654321"},
{Key: "loginCustomerId", Label: "Login Customer ID (manager)", Required: false, Example: "1112223333"},
},
// One composite secret: the OAuth2 app + refresh credentials, as JSON.
Secrets: []string{"oauth2"},
}
}
// googleCreds are the OAuth2 developer/app/refresh credentials, parsed from the single
// composite KMS secret the connect flow seals for Google Ads.
type googleCreds struct {
DeveloperToken string `json:"developer_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RefreshToken string `json:"refresh_token"`
}
func (c googleCreds) complete() bool {
return c.DeveloperToken != "" && c.ClientID != "" && c.ClientSecret != "" && c.RefreshToken != ""
}
type googleUploadBody struct {
Conversions []googleConversion `json:"conversions"`
PartialFailure bool `json:"partialFailure"`
}
type googleConversion struct {
ConversionAction string `json:"conversionAction"`
ConversionDateTime string `json:"conversionDateTime"`
ConversionValue float64 `json:"conversionValue,omitempty"`
CurrencyCode string `json:"currencyCode,omitempty"`
OrderID string `json:"orderId,omitempty"`
Gclid string `json:"gclid,omitempty"`
Gbraid string `json:"gbraid,omitempty"`
Wbraid string `json:"wbraid,omitempty"`
UserIdentifiers []googleUserIdentifier `json:"userIdentifiers,omitempty"`
}
type googleUserIdentifier struct {
HashedEmail string `json:"hashedEmail,omitempty"`
HashedPhoneNumber string `json:"hashedPhoneNumber,omitempty"`
}
// googleadsBuild renders the batch into the uploadClickConversions body. Pure — tests
// assert the conversion-action resource, the filtering (conversion-class + match key),
// hashed identifiers, and the datetime format without a network call. Events that are
// not conversions, or carry no Google match key, are dropped.
func googleadsBuild(cfg Config, batch []Conversion) googleUploadBody {
action := "customers/" + cfg.get("customerId") + "/conversionActions/" + cfg.get("conversionActionId")
out := make([]googleConversion, 0, len(batch))
for _, cv := range batch {
if !googleConversionEvents[cv.Standard] {
continue // not a conversion — Google Ads offline import is not page tracking
}
gclid := cv.User.click("gclid")
ids := googleIdentifiers(cv.User)
if gclid == "" && cv.User.click("gbraid") == "" && cv.User.click("wbraid") == "" && len(ids) == 0 {
continue // no match key Google can attribute — skip rather than send a blind row
}
gc := googleConversion{
ConversionAction: action,
ConversionDateTime: googleTime(cv.Time),
ConversionValue: cv.Value,
OrderID: cv.EventID,
Gclid: gclid,
Gbraid: cv.User.click("gbraid"),
Wbraid: cv.User.click("wbraid"),
UserIdentifiers: ids,
}
if cv.Value > 0 {
gc.CurrencyCode = cv.Currency
}
out = append(out, gc)
}
return googleUploadBody{Conversions: out, PartialFailure: true}
}
// googleIdentifiers builds the enhanced-conversion user identifiers: hashed email and/or
// phone. Google requires each as a separate UserIdentifier. Empty when neither present.
func googleIdentifiers(u UserData) []googleUserIdentifier {
var ids []googleUserIdentifier
if h := hashEmail(u.Email); h != "" {
ids = append(ids, googleUserIdentifier{HashedEmail: h})
}
if h := hashPhone(u.Phone); h != "" {
ids = append(ids, googleUserIdentifier{HashedPhoneNumber: h})
}
return ids
}
// googleTime formats an event time as Google Ads' required "yyyy-mm-dd hh:mm:ss+00:00"
// (a space separator and a colon in the zone offset), in UTC. Zero time clamps to now.
func googleTime(t time.Time) string {
if t.IsZero() {
t = time.Now()
}
return t.UTC().Format("2006-01-02 15:04:05-07:00")
}
// googleAccessToken exchanges the refresh token for a short-lived access token. The
// OAuth2 token endpoint is FORM-encoded (not JSON), so it does not go through postJSON;
// the error is credential-free (endpoint is a constant, never carries the secret).
func googleAccessToken(ctx context.Context, c googleCreds) (string, error) {
form := url.Values{
"client_id": {c.ClientID},
"client_secret": {c.ClientSecret},
"refresh_token": {c.RefreshToken},
"grant_type": {"refresh_token"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleOAuthURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("google-ads: build token request")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := sendHTTP.Do(req)
if err != nil {
return "", fmt.Errorf("google-ads: token request failed")
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxSendRespBody))
if err != nil {
return "", fmt.Errorf("google-ads: read token response")
}
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("google-ads: token http %d: %s", resp.StatusCode, truncate(raw, 256))
}
var tr struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(raw, &tr); err != nil || tr.AccessToken == "" {
return "", fmt.Errorf("google-ads: token response had no access_token")
}
return tr.AccessToken, nil
}
func (d googleads) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
customer := cfg.get("customerId")
if customer == "" {
return Result{}, fmt.Errorf("google-ads: customerId is required")
}
if cfg.get("conversionActionId") == "" {
return Result{}, fmt.Errorf("google-ads: conversionActionId is required")
}
var c googleCreds
if err := json.Unmarshal([]byte(strings.TrimSpace(secret)), &c); err != nil {
return Result{}, fmt.Errorf("google-ads: credentials must be a JSON object {developer_token, client_id, client_secret, refresh_token}")
}
if !c.complete() {
return Result{}, fmt.Errorf("google-ads: OAuth2 credentials are incomplete")
}
body := googleadsBuild(cfg, batch)
if len(body.Conversions) == 0 {
return Result{}, nil // nothing conversion-class with a match key — send nothing
}
token, err := googleAccessToken(ctx, c)
if err != nil {
return Result{}, err
}
headers := map[string]string{
"Authorization": "Bearer " + token,
"developer-token": c.DeveloperToken,
}
// The manager account, when the upload runs under an MCC; else the customer itself.
if login := cfg.get("loginCustomerId"); login != "" {
headers["login-customer-id"] = login
}
endpoint := googleAdsAPI + "/customers/" + customer + ":uploadClickConversions"
var resp struct {
Results []struct {
Gclid string `json:"gclid"`
} `json:"results"`
}
if err := postJSON(ctx, googleadsID, endpoint, headers, body, &resp); err != nil {
return Result{}, err
}
sent := len(resp.Results)
if sent == 0 {
sent = len(body.Conversions)
}
return Result{Sent: sent}, nil
}
+124
View File
@@ -0,0 +1,124 @@
package destinations
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestGoogleAdsBuild(t *testing.T) {
cfg := Config{"customerId": "123", "conversionActionId": "456"}
body := googleadsBuild(cfg, []Conversion{
{Standard: EventPurchase, Name: "order_completed", Value: 49.5, Currency: "USD", EventID: "ord-1",
Time: time.Unix(1700000000, 0),
User: UserData{Email: " Bob@Example.COM ", Phone: "+1 (555) 000-1111", Clicks: map[string]string{"gclid": "G1"}}},
{Standard: EventPageView, Name: "$pageview", User: UserData{Clicks: map[string]string{"gclid": "G2"}}}, // not a conversion → dropped
{Standard: EventLead, Name: "plan_clicked", User: UserData{}}, // conversion but NO match key → dropped
{Standard: EventSignUp, Name: "signup_completed", User: UserData{Email: "c@d.com"}}, // enhanced-only match key
})
if !body.PartialFailure {
t.Error("partialFailure must be true")
}
if len(body.Conversions) != 2 {
t.Fatalf("want 2 conversions (pageview + keyless lead dropped), got %d", len(body.Conversions))
}
c0 := body.Conversions[0]
if c0.ConversionAction != "customers/123/conversionActions/456" {
t.Errorf("conversionAction = %q", c0.ConversionAction)
}
if c0.Gclid != "G1" || c0.OrderID != "ord-1" || c0.ConversionValue != 49.5 || c0.CurrencyCode != "USD" {
t.Errorf("conversion: %+v", c0)
}
// Google's required "yyyy-mm-dd hh:mm:ss+00:00" UTC format.
if c0.ConversionDateTime != "2023-11-14 22:13:20+00:00" {
t.Errorf("conversionDateTime = %q", c0.ConversionDateTime)
}
// Email + phone → two hashed enhanced-conversion identifiers.
if len(c0.UserIdentifiers) != 2 ||
c0.UserIdentifiers[0].HashedEmail != sha("bob@example.com") ||
c0.UserIdentifiers[1].HashedPhoneNumber != sha("15550001111") {
t.Errorf("userIdentifiers: %+v", c0.UserIdentifiers)
}
// The enhanced-only signup carries no gclid but a hashed email.
c1 := body.Conversions[1]
if c1.Gclid != "" || len(c1.UserIdentifiers) != 1 || c1.UserIdentifiers[0].HashedEmail != sha("c@d.com") {
t.Errorf("enhanced-only conversion: %+v", c1)
}
// No raw PII in the marshalled payload.
raw, _ := json.Marshal(body)
if strings.Contains(strings.ToLower(string(raw)), "bob@example.com") {
t.Fatal("raw email leaked into the Google Ads payload")
}
}
func TestGoogleAdsSendEndToEnd(t *testing.T) {
// Token endpoint (form-encoded) → returns a short-lived access token.
var gotGrant string
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotGrant = r.Form.Get("grant_type")
_, _ = w.Write([]byte(`{"access_token":"ya29.tok","expires_in":3600}`))
}))
defer tokenSrv.Close()
// Upload endpoint → captures headers + body.
var gotAuth, gotDevTok, gotLogin, gotPath string
var gotBody googleUploadBody
uploadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotDevTok = r.Header.Get("developer-token")
gotLogin = r.Header.Get("login-customer-id")
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
_, _ = w.Write([]byte(`{"results":[{"gclid":"G1"}]}`))
}))
defer uploadSrv.Close()
ot, ou := googleOAuthURL, googleAdsAPI
googleOAuthURL, googleAdsAPI = tokenSrv.URL, uploadSrv.URL
defer func() { googleOAuthURL, googleAdsAPI = ot, ou }()
creds := `{"developer_token":"DEV","client_id":"CID","client_secret":"CSEC","refresh_token":"RT"}`
res, err := googleads{}.Send(context.Background(),
Config{"customerId": "123", "conversionActionId": "456", "loginCustomerId": "999"}, creds,
[]Conversion{{Standard: EventPurchase, Name: "order_completed", Value: 10, Currency: "USD", EventID: "e1",
User: UserData{Clicks: map[string]string{"gclid": "G1"}}}})
if err != nil {
t.Fatalf("Send: %v", err)
}
if res.Sent != 1 {
t.Fatalf("sent = %d", res.Sent)
}
if gotGrant != "refresh_token" {
t.Errorf("grant_type = %q, want refresh_token", gotGrant)
}
if gotAuth != "Bearer ya29.tok" || gotDevTok != "DEV" || gotLogin != "999" {
t.Errorf("upload headers: auth=%q dev-token=%q login=%q", gotAuth, gotDevTok, gotLogin)
}
if gotPath != "/customers/123:uploadClickConversions" {
t.Errorf("path = %q", gotPath)
}
if len(gotBody.Conversions) != 1 || gotBody.Conversions[0].Gclid != "G1" || !gotBody.PartialFailure {
t.Errorf("body: %+v", gotBody)
}
}
func TestGoogleAdsRequiresConfig(t *testing.T) {
if _, err := (googleads{}).Send(context.Background(), Config{}, "{}", nil); err == nil {
t.Fatal("missing customerId must error")
}
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1"}, "{}", nil); err == nil {
t.Fatal("missing conversionActionId must error")
}
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1", "conversionActionId": "2"}, "not-json", nil); err == nil {
t.Fatal("non-JSON credentials must error")
}
if _, err := (googleads{}).Send(context.Background(), Config{"customerId": "1", "conversionActionId": "2"}, `{"developer_token":"d"}`, nil); err == nil {
t.Fatal("incomplete credentials must error")
}
}
+205
View File
@@ -0,0 +1,205 @@
package destinations
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// pinterest.go forwards conversions to the Pinterest Conversions API v5 (server-side)
// — the server-side complement of the Pinterest tag, sharing the event_id so a browser
// tag event and this server event DEDUPLICATE. Config: adAccountId (the Pinterest Ads
// account the events file under). Secret: access_token — its own token, or the org's
// pinterest_ads OAuth token via the integrations fallback. The token rides the
// Authorization: Bearer header; email/phone/external id are SHA-256 hashed per
// Pinterest's advanced-matching contract; the epik click id and the ip/user-agent ride
// as Pinterest specifies. Same interface as GA4/Meta.
const pinterestID = "pinterest"
// pinterestAPI is the v5 base. A package var so a test points it at a mock server;
// never mutated in production.
var pinterestAPI = "https://api.pinterest.com/v5"
// pinterestEventName maps the normalized taxonomy onto Pinterest's conversion event
// names — a CLOSED enum, unlike Meta's free-form custom names. A StandardEvent absent
// here (EventCustom) forwards as the "custom" enum value: the specific canonical name
// stays on the warehouse row while Pinterest groups it as custom, because the v5 API
// rejects an event_name outside its enum.
var pinterestEventName = map[StandardEvent]string{
EventPageView: "page_visit",
EventViewContent: "view_category",
EventSearch: "search",
EventLead: "lead",
EventSignUp: "signup",
EventStartCheckout: "checkout",
EventAddToCart: "add_to_cart",
EventPurchase: "checkout",
EventContact: "lead",
}
type pinterest struct{}
func init() { register(pinterest{}) }
func (pinterest) ID() string { return pinterestID }
func (pinterest) Name() string { return "Pinterest" }
func (pinterest) Category() string { return categoryAdvertising }
func (pinterest) Spec() Spec {
return Spec{
Fields: []DestinationField{
{Key: "adAccountId", Label: "Ad Account ID", Required: true, Example: "549755885123"},
},
Secrets: []string{"access_token"},
Fallback: "pinterest_ads", // reuse the integrations Pinterest connection's token
}
}
type pinterestBody struct {
Data []pinterestEvent `json:"data"`
}
type pinterestEvent struct {
EventName string `json:"event_name"`
ActionSource string `json:"action_source"`
EventTime int64 `json:"event_time"`
EventID string `json:"event_id,omitempty"`
EventSourceURL string `json:"event_source_url,omitempty"`
UserData map[string]any `json:"user_data"`
CustomData map[string]any `json:"custom_data,omitempty"`
}
// pinterestBuild renders the batch into the Conversions API body. Pure — tests assert
// the event-name mapping, hashed match keys, and dedup event_id without a network call.
func pinterestBuild(batch []Conversion) pinterestBody {
data := make([]pinterestEvent, 0, len(batch))
for _, cv := range batch {
name := pinterestEventName[cv.Standard]
if name == "" {
name = "custom"
}
e := pinterestEvent{
EventName: name,
ActionSource: "web",
EventTime: pinterestTime(cv.Time),
EventID: cv.EventID,
EventSourceURL: cv.URL,
UserData: pinterestUser(cv.User),
}
if cd := pinterestCustomData(cv); len(cd) > 0 {
e.CustomData = cd
}
data = append(data, e)
}
return pinterestBody{Data: data}
}
// pinterestCustomData renders a conversion's commerce fields into Pinterest's
// custom_data. Pinterest wants monetary VALUES as STRINGS (value, item_price); a
// purchase also carries order_id, its native dedup key. Empty when the event carries
// neither value nor items.
func pinterestCustomData(cv Conversion) map[string]any {
cd := map[string]any{}
if cv.Value > 0 {
cd["value"] = strconv.FormatFloat(cv.Value, 'f', -1, 64)
cd["currency"] = cv.Currency
}
if len(cv.Items) > 0 {
ids := make([]string, 0, len(cv.Items))
contents := make([]map[string]any, 0, len(cv.Items))
num := 0
for _, it := range cv.Items {
if it.ID != "" {
ids = append(ids, it.ID)
}
c := map[string]any{}
if it.Quantity > 0 {
c["quantity"] = it.Quantity
num += int(it.Quantity)
} else {
num++
}
if it.Price > 0 {
c["item_price"] = strconv.FormatFloat(it.Price, 'f', -1, 64)
}
contents = append(contents, c)
}
if len(ids) > 0 {
cd["content_ids"] = ids
}
cd["contents"] = contents
cd["num_items"] = num
}
if cv.Standard == EventPurchase && cv.EventID != "" {
cd["order_id"] = cv.EventID
}
return cd
}
// pinterestTime clamps to now when the event carries no timestamp.
func pinterestTime(t time.Time) int64 {
if t.IsZero() {
return time.Now().Unix()
}
return t.Unix()
}
// pinterestUser builds the advanced-matching user_data: hashed email/phone/external id
// (arrays per Pinterest), the epik click id (un-hashed), and the un-hashed network
// signals (ip, user agent). Only present keys are set — Pinterest requires at least one.
func pinterestUser(u UserData) map[string]any {
ud := map[string]any{}
if h := hashEmail(u.Email); h != "" {
ud["em"] = []string{h}
}
if h := hashPhone(u.Phone); h != "" {
ud["ph"] = []string{h}
}
if h := sha256hex(strings.ToLower(strings.TrimSpace(u.ExternalID))); h != "" {
ud["external_id"] = []string{h}
}
if u.IP != "" {
ud["client_ip_address"] = u.IP
}
if u.UserAgent != "" {
ud["client_user_agent"] = u.UserAgent
}
if ck := u.click("epik"); ck != "" {
ud["click_id"] = ck
}
return ud
}
// pinterestResponse is the v5 events success shape: how many events Pinterest received.
type pinterestResponse struct {
NumEventsReceived int `json:"num_events_received"`
NumEventsProcessed int `json:"num_events_processed"`
}
func (d pinterest) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
account := cfg.get("adAccountId")
if account == "" {
return Result{}, fmt.Errorf("pinterest: adAccountId is required")
}
if strings.TrimSpace(secret) == "" {
return Result{}, fmt.Errorf("pinterest: access_token is required")
}
if len(batch) == 0 {
return Result{}, nil
}
body := pinterestBuild(batch)
endpoint := pinterestAPI + "/ad_accounts/" + account + "/events"
headers := map[string]string{"Authorization": "Bearer " + secret}
var resp pinterestResponse
if err := postJSON(ctx, pinterestID, endpoint, headers, body, &resp); err != nil {
return Result{}, err
}
sent := resp.NumEventsReceived
if sent == 0 {
sent = len(batch)
}
return Result{Sent: sent}, nil
}
+139
View File
@@ -0,0 +1,139 @@
package destinations
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestPinterestBuild(t *testing.T) {
body := pinterestBuild([]Conversion{
{
Standard: EventPurchase, Name: "order_completed", Value: 20, Currency: "USD", EventID: "evt-9",
URL: "https://shop.example/checkout", Time: time.Unix(1700000000, 0),
User: UserData{Email: " Bob@Example.COM ", Phone: "+1 (555) 000-1111", ExternalID: "u-42",
IP: "203.0.113.5", UserAgent: "UA", Clicks: map[string]string{"epik": "ep1"}},
},
{Standard: EventCustom, Name: "feature_used", User: UserData{Email: "a@b.com"}},
})
if len(body.Data) != 2 {
t.Fatalf("want 2 events, got %d", len(body.Data))
}
e := body.Data[0]
if e.EventName != "checkout" || e.EventID != "evt-9" || e.ActionSource != "web" {
t.Errorf("event: %+v", e)
}
if e.EventTime != 1700000000 {
t.Errorf("event_time = %d, want 1700000000", e.EventTime)
}
// Email/phone/external id hashed (arrays); the epik click id + ip/ua ride un-hashed.
if em, _ := e.UserData["em"].([]string); len(em) != 1 || em[0] != sha("bob@example.com") {
t.Errorf("em = %v, want %s", e.UserData["em"], sha("bob@example.com"))
}
if ph, _ := e.UserData["ph"].([]string); len(ph) != 1 || ph[0] != sha("15550001111") {
t.Errorf("ph = %v", e.UserData["ph"])
}
if xid, _ := e.UserData["external_id"].([]string); len(xid) != 1 || xid[0] != sha("u-42") {
t.Errorf("external_id = %v", e.UserData["external_id"])
}
if e.UserData["client_ip_address"] != "203.0.113.5" || e.UserData["client_user_agent"] != "UA" {
t.Errorf("network signals: %+v", e.UserData)
}
if e.UserData["click_id"] != "ep1" {
t.Errorf("click_id (epik) = %v, want ep1", e.UserData["click_id"])
}
// Pinterest wants value as a STRING; a purchase carries its native order id.
if e.CustomData["value"] != "20" || e.CustomData["currency"] != "USD" {
t.Errorf("custom_data value/currency: %+v", e.CustomData)
}
if e.CustomData["order_id"] != "evt-9" {
t.Errorf("order_id = %v, want evt-9", e.CustomData["order_id"])
}
// A custom (unmapped) event collapses to Pinterest's "custom" enum value.
if body.Data[1].EventName != "custom" {
t.Errorf("custom event name = %q, want custom", body.Data[1].EventName)
}
// The raw email must NEVER appear in the marshalled payload.
raw, _ := json.Marshal(body)
if strings.Contains(strings.ToLower(string(raw)), "bob@example.com") {
t.Fatal("raw email leaked into the Pinterest payload")
}
}
func TestPinterestEcommerceContents(t *testing.T) {
body := pinterestBuild([]Conversion{{
Standard: EventPurchase, Name: "order_completed", Value: 59.98, Currency: "USD", EventID: "ord-9",
User: UserData{Email: "a@b.com"},
Items: []Item{
{ID: "SKU1", Price: 24.99, Quantity: 2},
{ID: "SKU2", Price: 10.0, Quantity: 1},
},
}})
cd := body.Data[0].CustomData
if cd["value"] != "59.98" || cd["currency"] != "USD" {
t.Errorf("value/currency: %+v", cd)
}
if ids, _ := cd["content_ids"].([]string); len(ids) != 2 || ids[0] != "SKU1" || ids[1] != "SKU2" {
t.Errorf("content_ids: %+v", cd["content_ids"])
}
if cd["num_items"] != 3 {
t.Errorf("num_items = %v, want 3", cd["num_items"])
}
// item_price rides as a string per Pinterest's contents contract.
contents, _ := cd["contents"].([]map[string]any)
if len(contents) != 2 || contents[0]["item_price"] != "24.99" || contents[0]["quantity"] != 2.0 {
t.Errorf("contents: %+v", cd["contents"])
}
}
func TestPinterestSendEndToEnd(t *testing.T) {
var gotAccount, gotAuth string
var gotBody pinterestBody
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") // /ad_accounts/{id}/events
if len(parts) >= 2 {
gotAccount = parts[1]
}
gotAuth = r.Header.Get("Authorization")
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
_, _ = w.Write([]byte(`{"num_events_received":1,"num_events_processed":1}`))
}))
defer srv.Close()
old := pinterestAPI
pinterestAPI = srv.URL
defer func() { pinterestAPI = old }()
res, err := pinterest{}.Send(context.Background(), Config{"adAccountId": "549755885123"}, "pina_tok",
[]Conversion{{Standard: EventLead, Name: "plan_clicked", User: UserData{Email: "x@y.com"}}})
if err != nil {
t.Fatalf("Send: %v", err)
}
if res.Sent != 1 {
t.Fatalf("sent = %d", res.Sent)
}
if gotAccount != "549755885123" {
t.Errorf("account = %q", gotAccount)
}
// The token MUST ride the Authorization header, never the URL.
if gotAuth != "Bearer pina_tok" {
t.Errorf("auth = %q, want Bearer pina_tok", gotAuth)
}
if len(gotBody.Data) != 1 || gotBody.Data[0].EventName != "lead" {
t.Errorf("body: %+v", gotBody)
}
}
func TestPinterestRequiresConfig(t *testing.T) {
if _, err := (pinterest{}).Send(context.Background(), Config{}, "s", nil); err == nil {
t.Fatal("missing adAccountId must error")
}
if _, err := (pinterest{}).Send(context.Background(), Config{"adAccountId": "1"}, "", nil); err == nil {
t.Fatal("missing access_token must error")
}
}
+43 -2
View File
@@ -79,8 +79,8 @@ func tiktokBuild(cfg Config, batch []Conversion) tiktokBody {
if cv.URL != "" {
e.Page = map[string]any{"url": cv.URL}
}
if cv.Value > 0 {
e.Properties = map[string]any{"value": cv.Value, "currency": cv.Currency}
if props := tiktokProperties(cv); len(props) > 0 {
e.Properties = props
}
data = append(data, e)
}
@@ -112,6 +112,47 @@ func tiktokUser(u UserData) map[string]any {
return user
}
// tiktokProperties renders a conversion's commerce fields into TikTok's Events API
// properties: value/currency, and — for an ecommerce event — the native product signals
// (contents[{content_id,content_name,content_category,brand,price,quantity}] +
// content_type) TikTok's Value-Based Optimization reads. Empty when the event carries
// neither value nor items.
func tiktokProperties(cv Conversion) map[string]any {
p := map[string]any{}
if cv.Value > 0 {
p["value"] = cv.Value
p["currency"] = cv.Currency
}
if len(cv.Items) > 0 {
contents := make([]map[string]any, 0, len(cv.Items))
for _, it := range cv.Items {
c := map[string]any{}
if it.ID != "" {
c["content_id"] = it.ID
}
if it.Name != "" {
c["content_name"] = it.Name
}
if it.Category != "" {
c["content_category"] = it.Category
}
if it.Brand != "" {
c["brand"] = it.Brand
}
if it.Price > 0 {
c["price"] = it.Price
}
if it.Quantity > 0 {
c["quantity"] = it.Quantity
}
contents = append(contents, c)
}
p["contents"] = contents
p["content_type"] = "product"
}
return p
}
func (d tiktok) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
if cfg.get("pixelCode") == "" {
return Result{}, fmt.Errorf("tiktok: pixelCode is required")
+34
View File
@@ -0,0 +1,34 @@
package destinations
import "testing"
// TestTikTokEcommerce proves the ecommerce translation: our line items → TikTok's native
// contents[] + content_type, alongside value/currency, for Value-Based Optimization.
func TestTikTokEcommerce(t *testing.T) {
body := tiktokBuild(Config{"pixelCode": "PC1"}, []Conversion{{
Standard: EventPurchase, Name: "order_completed", Value: 59.98, Currency: "USD", EventID: "ord-9",
User: UserData{Email: "a@b.com"},
Items: []Item{
{ID: "SKU1", Name: "Widget", Category: "tools", Brand: "Acme", Price: 24.99, Quantity: 2},
{ID: "SKU2", Price: 10.0, Quantity: 1},
},
}})
p := body.Data[0].Properties
if p["value"] != 59.98 || p["currency"] != "USD" || p["content_type"] != "product" {
t.Errorf("value/currency/content_type: %+v", p)
}
contents, ok := p["contents"].([]map[string]any)
if !ok || len(contents) != 2 {
t.Fatalf("contents = %+v", p["contents"])
}
if contents[0]["content_id"] != "SKU1" || contents[0]["content_name"] != "Widget" ||
contents[0]["content_category"] != "tools" || contents[0]["brand"] != "Acme" ||
contents[0]["price"] != 24.99 || contents[0]["quantity"] != 2.0 {
t.Errorf("content[0]: %+v", contents[0])
}
// A non-commerce event carries no properties block (nothing to over-send).
pv := tiktokBuild(Config{"pixelCode": "PC1"}, []Conversion{{Standard: EventPageView, Name: "$pageview", User: UserData{ExternalID: "v1"}}})
if pv.Data[0].Properties != nil {
t.Errorf("pageview must carry no properties, got %+v", pv.Data[0].Properties)
}
}
+8 -4
View File
@@ -45,9 +45,13 @@ var standardOf = map[string]StandardEvent{
// (pre-warehouse-scrub) properties carry the match keys the User set is lifted from.
func Translate(ev analytics.SinkEvent) Conversion {
return Conversion{
Standard: standardOf[ev.Name], // "" ⇒ custom, forwarded as ev.Name
Name: ev.Name,
EventID: ev.MessageID,
Standard: standardOf[ev.Name], // "" ⇒ custom, forwarded as ev.Name
Name: ev.Name,
// The dedup id: the browser tag's own event_id when it set one (track.js stamps
// it into properties + fires it on the pixel), else the messageId. This is what
// makes a browser pixel event and this server CAPI event DEDUPLICATE — without
// it the two carry different ids and a conversion is double-counted.
EventID: firstNonEmpty(strProp(ev.Properties, "event_id"), ev.MessageID),
Time: ev.Time,
Value: conversionValue(ev),
Currency: conversionCurrency(ev),
@@ -153,7 +157,7 @@ func liftUser(ev analytics.SinkEvent) UserData {
FBP: strProp(p, "fbp", "_fbp"),
}
clicks := map[string]string{}
for _, k := range []string{"fbclid", "gclid", "ttclid", "twclid", "rdt_cid", "li_fat_id", "msclkid"} {
for _, k := range []string{"fbclid", "gclid", "ttclid", "twclid", "rdt_cid", "li_fat_id", "msclkid", "epik"} {
if v := strProp(p, k); v != "" {
clicks[k] = v
}
+150 -17
View File
@@ -2,19 +2,45 @@ package destinations
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// x.go scaffolds X (Twitter) conversions against the shared interface. Config: the
// web-event tag pixelId. The X Ads conversions endpoint authenticates with OAuth
// 1.0a request signing (a consumer key/secret + access token/secret), NOT a bearer
// token — a signer that is deliberately NOT wired here. The translator + payload
// builder are complete, so enabling X is adding the OAuth1 signer to Send, never a
// new interface. Until then Send returns an honest, credential-free error and the
// fan-out skips it.
// x.go forwards conversions to X (Twitter) via the Ads API web-events measurement
// endpoint. Config: the web-event tag pixelId. The X Ads API authenticates with OAuth
// 1.0a USER-context request signing a consumer key/secret PLUS an access token/secret
// — not a single bearer token. resolveSecret hands an adapter exactly one primary
// secret, so X's four OAuth1 parts ride as ONE composite KMS secret: a JSON object
// {consumer_key, consumer_secret, access_token, access_token_secret}. The signer below
// is the only thing X needs beyond the shared interface; the translator + payload
// builder are unchanged. PII match keys are SHA-256 hashed by xBuild; the shared
// event_id dedups against the browser tag.
const xID = "x"
// xAdsAPI is the X Ads API base (version-pinned). A package var so a test points it at
// a mock server; never mutated in production.
var xAdsAPI = "https://ads-api.x.com/12"
// xNonce / xTimestamp are the OAuth1 nonce and timestamp sources — package vars so a
// test pins them for a deterministic signature. Production reads crypto/rand + wall
// clock. xTimestamp is injected (not time.Now directly) so a signed request is
// reproducible under test.
var (
xNonce = func() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) }
xTimestamp = func() int64 { return time.Now().Unix() }
)
type xDest struct{}
func init() { register(xDest{}) }
@@ -28,10 +54,30 @@ func (xDest) Spec() Spec {
Fields: []DestinationField{
{Key: "pixelId", Label: "Pixel / Event Tag ID", Required: true, Example: "o1abc"},
},
Secrets: []string{"access_token"},
// One composite secret: a JSON object carrying the four OAuth1 parts, because
// the fan-out resolves and passes a single primary secret per destination.
Secrets: []string{"oauth1"},
}
}
// xCreds are the OAuth 1.0a user-context credentials, parsed from the single composite
// KMS secret the connect flow seals for X.
type xCreds struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
AccessToken string `json:"access_token"`
AccessSecret string `json:"access_token_secret"`
}
func (c xCreds) complete() bool {
return c.ConsumerKey != "" && c.ConsumerSecret != "" && c.AccessToken != "" && c.AccessSecret != ""
}
// xConvBody wraps the conversions in the Ads-API measurement request body.
type xConvBody struct {
Conversions []xConversion `json:"conversions"`
}
type xConversion struct {
ConversionTime string `json:"conversion_time"`
EventID string `json:"event_id,omitempty"`
@@ -46,9 +92,22 @@ type xIDent struct {
TwClickID string `json:"twclid,omitempty"`
}
// xBuild renders the batch into X's conversions payload. Pure — tests assert the
// hashed identifiers + event-name mapping — so the scaffold carries a real, reviewed
// payload the day the OAuth1 signer lands.
// xNumItems is the total quantity across an event's line items (each item's quantity,
// or 1 when unspecified) — X's number_items. 0 for a non-commerce event.
func xNumItems(items []Item) int {
n := 0
for _, it := range items {
if it.Quantity > 0 {
n += int(it.Quantity)
} else {
n++
}
}
return n
}
// xBuild renders the batch into X's conversions payload. Pure — tests assert the hashed
// identifiers + value formatting without a network call.
func xBuild(batch []Conversion) []xConversion {
out := make([]xConversion, 0, len(batch))
for _, cv := range batch {
@@ -59,7 +118,7 @@ func xBuild(batch []Conversion) []xConversion {
if tw := cv.User.click("twclid"); tw != "" {
ids = append(ids, xIDent{TwClickID: tw})
}
c := xConversion{ConversionTime: redditTime(cv.Time), EventID: cv.EventID, Identifiers: ids}
c := xConversion{ConversionTime: redditTime(cv.Time), EventID: cv.EventID, Identifiers: ids, NumberItems: xNumItems(cv.Items)}
if cv.Value > 0 {
c.Value = fmt.Sprintf("%.2f", cv.Value)
c.PriceCurrency = cv.Currency
@@ -69,11 +128,85 @@ func xBuild(batch []Conversion) []xConversion {
return out
}
func (d xDest) Send(_ context.Context, cfg Config, _ string, _ []Conversion) (Result, error) {
if cfg.get("pixelId") == "" {
// oauthEnc percent-encodes per RFC 3986 (OAuth 1.0a §3.6): the unreserved set stays
// literal, everything else is %-encoded upper-hex. net/url does NOT do this exactly
// (it leaves some sub-delims), so OAuth1 needs its own encoder.
func oauthEnc(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~' {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
// xAuthHeader builds the OAuth 1.0a Authorization header for a request. A JSON body is
// NOT part of the signature base string (only form-encoded params and query params
// are), so the base string is the method, the URL, and the sorted oauth_* params.
func xAuthHeader(method, endpoint string, c xCreds) string {
params := map[string]string{
"oauth_consumer_key": c.ConsumerKey,
"oauth_nonce": xNonce(),
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": strconv.FormatInt(xTimestamp(), 10),
"oauth_token": c.AccessToken,
"oauth_version": "1.0",
}
// Signature base string: METHOD & enc(url) & enc(sorted "k=v" params).
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, oauthEnc(k)+"="+oauthEnc(params[k]))
}
base := strings.ToUpper(method) + "&" + oauthEnc(endpoint) + "&" + oauthEnc(strings.Join(pairs, "&"))
signingKey := oauthEnc(c.ConsumerSecret) + "&" + oauthEnc(c.AccessSecret)
mac := hmac.New(sha1.New, []byte(signingKey))
mac.Write([]byte(base))
params["oauth_signature"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Header: OAuth k="v", … over the same params incl. the signature.
hkeys := make([]string, 0, len(params))
for k := range params {
hkeys = append(hkeys, k)
}
sort.Strings(hkeys)
parts := make([]string, 0, len(hkeys))
for _, k := range hkeys {
parts = append(parts, oauthEnc(k)+`="`+oauthEnc(params[k])+`"`)
}
return "OAuth " + strings.Join(parts, ", ")
}
func (d xDest) Send(ctx context.Context, cfg Config, secret string, batch []Conversion) (Result, error) {
pixel := cfg.get("pixelId")
if pixel == "" {
return Result{}, fmt.Errorf("x: pixelId is required")
}
// The X Ads conversions API requires OAuth 1.0a request signing, which is not
// wired. Honest, credential-free: the fan-out logs and skips.
return Result{}, fmt.Errorf("x: the X Ads conversions API requires OAuth 1.0a app credentials that are not yet enabled")
var c xCreds
if err := json.Unmarshal([]byte(strings.TrimSpace(secret)), &c); err != nil {
return Result{}, fmt.Errorf("x: credentials must be a JSON object {consumer_key, consumer_secret, access_token, access_token_secret}")
}
if !c.complete() {
return Result{}, fmt.Errorf("x: OAuth1 credentials are incomplete")
}
if len(batch) == 0 {
return Result{}, nil
}
endpoint := xAdsAPI + "/measurement/conversions/" + pixel
body := xConvBody{Conversions: xBuild(batch)}
headers := map[string]string{"Authorization": xAuthHeader(http.MethodPost, endpoint, c)}
if err := postJSON(ctx, xID, endpoint, headers, body, nil); err != nil {
return Result{}, err
}
return Result{Sent: len(batch)}, nil
}
+136
View File
@@ -0,0 +1,136 @@
package destinations
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// xStubOAuth pins the nonce + timestamp for a deterministic signature, restoring both
// on the returned cleanup.
func xStubOAuth(nonce string, ts int64) func() {
on, ot := xNonce, xTimestamp
xNonce = func() string { return nonce }
xTimestamp = func() int64 { return ts }
return func() { xNonce = on; xTimestamp = ot }
}
// xSigFromHeader extracts and percent-decodes the oauth_signature from an OAuth header.
func xSigFromHeader(hdr string) string {
const mark = `oauth_signature="`
i := strings.Index(hdr, mark)
if i < 0 {
return ""
}
rest := hdr[i+len(mark):]
j := strings.Index(rest, `"`)
if j < 0 {
return ""
}
dec, err := url.PathUnescape(rest[:j])
if err != nil {
return ""
}
return dec
}
// TestXOAuthSignature cross-checks the OAuth 1.0a signer: it independently reconstructs
// the signature base string + signing key and recomputes the HMAC-SHA1, so a bug in the
// base-string assembly (param ordering, separators, a double-encode) fails here rather
// than silently producing a 401 in production.
func TestXOAuthSignature(t *testing.T) {
defer xStubOAuth("nonceABC", 1700000000)()
endpoint := "https://ads-api.x.com/12/measurement/conversions/o1abc"
c := xCreds{ConsumerKey: "CK", ConsumerSecret: "CS", AccessToken: "AT", AccessSecret: "ATS"}
hdr := xAuthHeader(http.MethodPost, endpoint, c)
// The six oauth_* params in sorted order, "k=v" joined by & — every value here is
// already in the RFC-3986 unreserved set, so it encodes to itself.
pstr := strings.Join([]string{
"oauth_consumer_key=CK",
"oauth_nonce=nonceABC",
"oauth_signature_method=HMAC-SHA1",
"oauth_timestamp=1700000000",
"oauth_token=AT",
"oauth_version=1.0",
}, "&")
base := "POST&" + oauthEnc(endpoint) + "&" + oauthEnc(pstr)
mac := hmac.New(sha1.New, []byte("CS&ATS"))
mac.Write([]byte(base))
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if got := xSigFromHeader(hdr); got != want {
t.Fatalf("oauth_signature = %q, want %q\nheader: %s", got, want, hdr)
}
for _, must := range []string{
"OAuth ", `oauth_consumer_key="CK"`, `oauth_nonce="nonceABC"`,
`oauth_signature_method="HMAC-SHA1"`, `oauth_token="AT"`, `oauth_version="1.0"`,
} {
if !strings.Contains(hdr, must) {
t.Errorf("header missing %q: %s", must, hdr)
}
}
}
func TestXSendEndToEnd(t *testing.T) {
defer xStubOAuth("fixednonce123", 1700000000)()
var gotAuth, gotPath string
var gotBody xConvBody
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
old := xAdsAPI
xAdsAPI = srv.URL
defer func() { xAdsAPI = old }()
creds := `{"consumer_key":"CK","consumer_secret":"CS","access_token":"AT","access_token_secret":"ATS"}`
res, err := xDest{}.Send(context.Background(), Config{"pixelId": "o1abc"}, creds,
[]Conversion{{Standard: EventPurchase, Name: "order_completed", Value: 9, Currency: "USD", EventID: "e1",
User: UserData{Email: "a@b.com", Clicks: map[string]string{"twclid": "tw1"}}}})
if err != nil {
t.Fatalf("Send: %v", err)
}
if res.Sent != 1 {
t.Fatalf("sent = %d", res.Sent)
}
if gotPath != "/measurement/conversions/o1abc" {
t.Errorf("path = %q, want /measurement/conversions/o1abc", gotPath)
}
// The request is OAuth1-signed with our pinned nonce/timestamp and a signature.
if !strings.HasPrefix(gotAuth, "OAuth ") ||
!strings.Contains(gotAuth, `oauth_nonce="fixednonce123"`) ||
!strings.Contains(gotAuth, `oauth_timestamp="1700000000"`) ||
!strings.Contains(gotAuth, "oauth_signature=") {
t.Errorf("oauth header malformed: %s", gotAuth)
}
if len(gotBody.Conversions) != 1 || gotBody.Conversions[0].EventID != "e1" || gotBody.Conversions[0].Value != "9.00" {
t.Errorf("body: %+v", gotBody)
}
}
func TestXNumItems(t *testing.T) {
convs := xBuild([]Conversion{{Standard: EventPurchase, Value: 30, Currency: "USD",
User: UserData{Email: "a@b.com"},
Items: []Item{{ID: "s1", Quantity: 2}, {ID: "s2", Quantity: 1}}}})
if convs[0].NumberItems != 3 {
t.Errorf("number_items = %d, want 3 (2+1)", convs[0].NumberItems)
}
// No items ⇒ number_items omitted (0).
none := xBuild([]Conversion{{Standard: EventLead, User: UserData{Email: "a@b.com"}}})
if none[0].NumberItems != 0 {
t.Errorf("no items ⇒ number_items 0, got %d", none[0].NumberItems)
}
}
+98 -17
View File
@@ -73,6 +73,18 @@ import (
"github.com/zap-proto/zip"
)
// zipdoc lifts the doc comment off each typed op and its In/Out fields into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by
// `make -C apps/exec describe` and by the Dockerfile before every build.
//
// Without this directive the package builds, tests pass, and the ONE typed op here
// publishes a summary with no description: openapi.Complete accepts either, so the
// gap is invisible to every gate and visible in every SDK. POST /v1/exec shipped
// exactly that way.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Path is where the code interpreter answers. It lives HERE, in the app that serves
// it, because three consumers once disagreed about it in production and nothing
// could see the disagreement.
@@ -119,14 +131,30 @@ var langs = map[string]struct{ file, run string }{
// sends: lang/code/args from the model's tool call, files/session_id/user_id from
// the host's injection, runtime_session_hint from the stateful-session path.
type CodeRun struct {
// Lang selects the toolchain, and with it the filename the code is written to
// and the line that runs it: py, js, ts, bash, r, php, go, rs, c, cpp, java, d,
// f90. Anything else is refused rather than guessed at — a run in the wrong
// language fails somewhere deep in a compiler, which reads as an outage.
Lang string `json:"lang" validate:"required"`
// Code is the WHOLE program, not a fragment: it is written to a single file and
// that file is what runs, so a compiled language needs its entry point and an
// interpreted one runs top to bottom.
Code string `json:"code" validate:"required"`
// Args become the PROGRAM's argv, never the compiler's. For the compiled
// languages the toolchain builds first and these are passed to the binary it
// produced.
Args []string `json:"args,omitempty"`
// Files are inputs the host already put in some session. Each names the session
// its bytes live in, which is usually — and ideally — the session this run wants.
Files []CodeFile `json:"files,omitempty"`
SessionID string `json:"session_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Files []CodeFile `json:"files,omitempty"`
// SessionID continues an EXISTING sandbox, which is what makes runs stateful:
// the same filesystem, so one run's output file is the next run's input. Empty
// leases a fresh sandbox and the id it got comes back on the result.
SessionID string `json:"session_id,omitempty"`
// UserID attributes the run inside the caller's org. It is a label, never a
// tenant: the org is resolved from the validated principal and a value here
// cannot widen what the run may reach.
UserID string `json:"user_id,omitempty"`
// RuntimeSessionHint is the stateful-session hint. It is carried so a client
// that sends it is not silently misread, and it selects nothing here: every
// session in this implementation is already a warm sandbox, so there is no
@@ -146,10 +174,18 @@ type CodeRun struct {
// skipped by the "not available" note as well — so the CSV was invisible and nothing
// said so. Answering with `storage_session_id` keeps the reply on the agents shape.
type CodeFile struct {
ID string `json:"id"`
Name string `json:"name"`
// ID is the file's path RELATIVE to its session's artifact directory, which is
// also how it is fetched: GET /v1/download/{session}/{id}.
ID string `json:"id"`
// Name is the display name. On an ANSWER it carries the `{session}/{id}`
// identifier whole, because the client matches on that prefix.
Name string `json:"name"`
// StorageSessionID names the session holding the bytes, and is the spelling the
// answer always uses.
StorageSessionID string `json:"storage_session_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
// SessionID is the other accepted spelling of the same fact on the way IN. Both
// are read; whichever is set wins.
SessionID string `json:"session_id,omitempty"`
}
// Session is the session a file's bytes live in, whichever name the caller used.
@@ -160,10 +196,19 @@ func (f CodeFile) Session() string { return firstNonEmpty(f.StorageSessionID, f.
// "the code threw" and "the interpreter is down" are different facts and the caller
// renders them differently.
type CodeResult struct {
SessionID string `json:"session_id"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Files []CodeFile `json:"files,omitempty"`
// SessionID is the sandbox this run used — the one that was passed in, or the
// fresh one that was leased. Pass it to the next run to keep the filesystem.
SessionID string `json:"session_id"`
// Stdout is what the program wrote to standard output.
Stdout string `json:"stdout"`
// Stderr is what the program wrote to standard error, INCLUDING a compiler's
// diagnostics and the trace of a program that exited non-zero. Its presence is
// not a failed call.
Stderr string `json:"stderr"`
// Files are what this run CREATED OR CHANGED, decided by mtime against a marker
// taken before the program started — so it is the run's output, not a listing of
// the directory. Fetch each from GET /v1/download/{session}/{id}.
Files []CodeFile `json:"files,omitempty"`
}
// listing is one row of GET /v1/files/{sid}. The client matches on `name` having
@@ -189,13 +234,35 @@ type uploadedFile struct {
// ---- the composition -------------------------------------------------------
// run is the typed op: resolve the tenant, then the interpreter.
// run executes a program in a throwaway sandbox and answers with what it printed
// and what it left behind.
//
// The resolution is what makes THIS door safe. A typed op is also an MCP tool and
// an op-plane op; MCP's tools/call invokes it directly, with no route and therefore
// no middleware, so nothing there could have checked a credential. tenantOf refuses
// a context that carries neither a validated principal nor exec's own admission
// marker, so those doors fail closed without a second gate to keep in step.
// `lang` names one of the thirteen the sandbox image carries — py, js, ts, bash, r,
// php, go, rs, c, cpp, java, d, f90 — and `code` is the whole program, not a
// fragment: a compiled language is compiled and then run, an interpreted one is
// interpreted, and `args` becomes the program's own argv either way. Nothing is
// installed for you; the image is the environment.
//
// A PROGRAM THAT FAILS IS A SUCCESSFUL CALL. A non-zero exit answers 200 with the
// diagnostics on `stderr`, because "the code threw" and "the interpreter is down"
// are different facts a caller renders differently. Only the second is an error
// status.
//
// Runs are stateful through `session_id`. Omit it and the run gets a fresh sandbox
// whose id comes back on the answer; pass that id again and the next run sees the
// same filesystem, so a program can write a file one call and read it the next.
// `files` names bytes already uploaded to a session (POST /v1/upload), copied in
// before the program starts. `files` on the ANSWER is what the program created or
// changed, by comparison against a marker taken at start — so it is the run's real
// output, not a listing of the directory — and each is fetched from
// GET /v1/download/{session}/{name}.
//
// The tenant is the caller's, never the body's, at every door. A typed op is also
// an MCP tool and an op-plane op; MCP's tools/call invokes it directly, with no
// route and therefore no middleware, so nothing there could have checked a
// credential. tenantOf refuses a context carrying neither a validated principal nor
// exec's own admission marker, so those doors fail closed without a second gate to
// keep in step.
func run(ctx context.Context, in *CodeRun) (*CodeResult, error) {
org, err := tenantOf(ctx)
if err != nil {
@@ -662,7 +729,21 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return c.Continue()
}))
zip.Post[CodeRun, CodeResult](app, Path, run,
// Registered on the *zip.App rather than on the cloud.Router, and that is what
// makes the prose reach the document. zipdoc resolves a typed op's path
// STATICALLY, and a `cloud.Router` parameter is an interface it cannot follow to
// a prefix — so it refuses to lift, the op publishes a summary and no
// description, and openapi.Complete accepts that because either one satisfies
// it. The subsystem scope adds no prefix here (every path below is absolute), so
// this is the same registration, spelled where the generator can read it.
//
// It is only the ROUTES that move: the credential middleware above stays on the
// scoped router, where the prefix guard applies to it.
reg := cloud.ZipApp(app)
if reg == nil {
return fmt.Errorf("exec.Mount: router carries no typed-op registry")
}
zip.Post[CodeRun, CodeResult](reg, Path, run,
zip.WithSummary("Run a code snippet in a sandboxed interpreter"))
app.Post(Path+"/programmatic", programmatic)
app.Post("/v1/upload", upload)

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