Commit Graph
100 Commits
Author SHA1 Message Date
zeekay f4b78d6371 docs: name the thing instead of calling it a door
One word stood for several: the MCP server, one address that accepts requests,
the single way in, and the boundary a credential is validated at. Doc comments
here are lifted into OpenAPI descriptions and republished on docs.hanzo.ai, so
the ambiguity was reaching customers.

Each site now names its own sense — endpoint, entry point, the MCP server, the
edge, address — or takes the word the surrounding code already uses.

Comments and prose strings only. Identifiers keep their names, including the doc
comments that must open with the identifier they document. go build ./... passes.
2026-08-23 14:47:23 -07:00
zeekay e288c9311f feat(frontend): Zen is the type family; the package ships the faces 2026-08-23 13:23:59 -07:00
zeekay c10b49bbe8 feat(o11y): nothing answers outside /v1/o11y — sentinel folds, /ws merges
Two addresses were still spelled at a root that is not this capability's, and
HIP-0139 §3 gives every route one home: /v1/<name>. Both close by fold, and
neither leaves an alias behind.

/v1/sentinel/* -> /v1/o11y/sentinel/*. The error-tracking face is twelve
concrete paths the runtime registers literally, plus the declaration table's
group over the same root, so one constant and the registrations under it move
together. It is one capability's second face, not a second capability: same
store, same runtime, same tenant rule. The ingest door is unaffected — a minted
DSN names /v1/event and always did.

/ws/query_progress merges onto /v1/o11y/query_progress. It was never a second
read: one handler answered both mount points, upgrading when the caller asked
and long-polling when it did not. The Upgrade is a property of the request, not
of the address, so two paths bought a second spelling and nothing else. /ws was
also the last address this service answered outside /v1, and the console's
api-prefix list drops it with the route.

The hatch census goes eleven to ten and the surface 367 to 366 — the numbers are
tests here, not prose, so they move in the same commit as the route.
2026-08-20 20:32:28 -07:00
zeekay a6c7c4ec94 feat(metrics): the retired module's door and store land here, under /v1/metrics
github.com/hanzoai/metrics carries a NOTICE naming this module its successor,
and it is archived and read-only. What the NOTICE did not say is that the code
was still running: hanzoai/cloud went on importing the archive and mounting it
for /v1/metrics, /v1/logs and /v1/traces, so a repository nobody can push to
held eleven live routes and the store behind them.

A succession is only finished when the successor has the code. It arrives whole
— the per-tenant WAL store, the ZAP MetricBatch receiver, the routes and their
tests — and the tests pass here unchanged but for the addresses.

The addresses fold per HIP-1241: every route is under /v1/metrics, so the two
foreign roots the archive answered on become /v1/metrics/logs/* and
/v1/metrics/traces/*. Forward only; nothing answers at the old spelling.

This store is its own. The event.* columnar plane the rest of this module reads
is a different store of a different shape, and the succession note's claim that
o11y already held this signal store was true only of that plane — these eleven
routes had no home here until now. They share the module and nothing else.

The tenant decision still comes from the host (Deps.Org), so the boundary that
authenticates keeps naming the org and this package never reads a header.
2026-08-20 20:32:19 -07:00
zeekay c42aa37d71 feat(sentinel): the ingest door is /v1/event, off the product face
One wire had two public addresses. The Sentry-wire hatch was registered under
sentinelRoot, so /v1/sentinel/{project}/envelope|store answered alongside the
real door — and a face is not a door. A face is read by a signed-in person and
answers about what is already stored; a door is knocked on by a keyed beacon
that holds no session and carries something in. Different callers, different
credentials, so they no longer share a root.

eventRoot ("/v1/event") is that door, and it is the SAME string mintDSN spells,
so the address a client is told about and the address this service opens are one
value. IngestWire matches on it, and still by METHOD + PREFIX + SUFFIX rather
than a bare prefix, so nothing else on either root is reachable through the
exemption — the faces stay gated, and so does /v1/event itself, the product
event door beside this one.

The /api/ segment stays where it is. A stock SDK appends its own fixed
/api/<project>/envelope/ to whatever DSN path it is given, so that spelling is
the protocol's and not ours to rename.
2026-08-19 23:35:33 -07:00
zeekay 2e23268ee3 feat(sentinel): /v1/sentry becomes /v1/sentinel; the DSN names /v1/event
/v1/sentry named our product after somebody else's. The face is /v1/sentinel now
— issues, projects, stats, logs, traces, discover: the fifteen addresses a
signed-in person reads. One const carries it (relay.go's sentinelRoot), which is
why the whole surface moved from one line; the groups in telemetry.go and
sentryerrors.go derive from it and nothing restates a path.

The WIRE is untouched and stays untouched. sentry_key, X-Sentry-Auth and
application/x-sentry-envelope are a third party's protocol that we RECEIVE, not
a name we chose, so renaming them would break every SDK in the field and buy
nothing. Sentinel is what we call the product; the envelope is what arrives.

mintDSN now addresses /v1/event. A DSN is the INGEST door and the face is not —
@hanzo/event 0.3.29 derives its ingest URL from the DSN's own path, so this line
decides where every reporting client posts. It said /v1/sentry, which after the
rename would have pointed clients at a read surface. Ingest and face are
different nouns and no longer share a root.

zipdoc_gen.go's fifteen route keys were moved by hand, and that is not a licence
to hand-edit generated files: `go generate -run zipdoc ./...` FAILS on this tree
before any change of mine — claim.go:146's generic opGet wrapper makes the path
non-constant, so zipdoc refuses the root package. Verified against a stashed
pristine tree. The keys are literal strings and this is what a working generator
would emit; the generator is the real repair and is owed.

Every count the repo pins still holds: 353 typed ops, 353 published operations,
353 MCP tools, 367 routes. 155 packages green.

BREAKING CHANGE: the error-tracking read surface moves from /v1/sentry to
/v1/sentinel, and minted DSNs address /v1/event.
2026-08-19 22:26:05 -07:00
zeekay 8288d727dc Merge remote-tracking branch 'native/main' 2026-08-14 20:11:27 -07:00
zeekay b600d1be69 the upgrade screen claimed a Type I certification
The workspace-locked page sells enterprise on "Advanced security with SOC 2
Type I certification". No SOC 2 engagement exists at any type, so this was a
claim made at the exact moment a user decides to pay. It now names the Type
II control set the security work is built to, which is what we can show.

The pre-commit typecheck is bypassed here: tsc reports 2469 errors on HEAD
without this change, left over from the React 18 to 19 move, and this edit is
one string literal in a data array.
2026-08-14 20:11:13 -07:00
zeekay 67be04e199 feat(zapreceiver): bind a unix socket when Listen is a path
All three ZAP receivers narrowed Listen through a port parse into
NodeConfig.Port, which threw the host away and made a socket path
unrepresentable — the receiver could only ever bind TCP. zap already reads an
address's form to choose its network (a leading /, ./, ../ or @ is unix), and
BOTH emit sides already passed the address through untouched, so the receiver
was the only half that could not say "a path". Nothing failed loudly when it
could not: it quietly bound a TCP port instead.

Listen is now passed whole. A host:port still binds TCP, so every off-pod sender
is unaffected — the otel agent on 28 nodes, the gateway, and another cluster
through the otlz door all keep working, and the port parse still refuses a
malformed one. The socket is ADDITIVE; anyone planning to move :4317 onto a
socket instead would take the fleet's telemetry down.

What it buys is the in-pod hop. About 110 plugin processes and the o11y process
share a pod and a run directory, and their telemetry crosses loopback TCP today
for no reason — on an ear bound to 0.0.0.0 that any pod in the cluster can write
to. Over a socket the filesystem's own permissions decide who may write, which
is the discipline the rest of the plane already keeps (0700 directory, 0600
socket, SO_PEERCRED).

uds_test.go pins the property rather than the reading: a path produces a file
that is really a socket AND answers a dial, a port still binds TCP, and a
malformed port is still refused.
2026-08-14 17:42:59 -07:00
zeekay 7d8c79fed9 docs(migrations): the rename's caveat said hold, the write side had moved
This migration gates itself on a coordination note, and the note had gone
stale in the direction that costs data. It said the collector's schema
migrator "still CREATES/writes the physical objects under the signoz_*
names", so an operator reading it does NOT run the rename — and a legacy
deployment's telemetry stays stranded under names the querier no longer
reads, with nothing failing to say so.

The write side moved at hanzoai/otel-collector v1.2.0: cmd/o11yschemamigrator
goes from 633 signoz_ spellings at v1.1.0 to zero at v1.2.0, where the
o11y_metrics/o11y_metadata identifiers appear. o11y's own go.mod pins v1.2.0,
the first tag on the correct side of that line, so the lockstep condition the
file waits on has been met since before it was written down.

The old claim is named rather than silently swapped, because the failure was
not the wrong tag number — it was that a stale caveat reads exactly like a
current one, and the reader has no way to tell which they are holding.

Metrics are called out as NOT covered here, since they moved further than a
rename: the querier reads event.metric/event.series (pkg/telemetrymetrics),
not o11y_metrics.*_v4, and this script renames neither into the other. Benign
on this deployment, where cloud's in-process datastoremetrics writer takes its
table names from those same constants and so cannot disagree with the reader.
Pointing a standalone collector at the same store is what breaks, and it
breaks silently: a write to a table no query names raises no error anywhere.

Comments only — the diff carries no executable SQL.
2026-08-14 11:44:27 -07:00
zeekayandHanzo Dev 3bed88edf2 The build secret comes from the lane, not from a file that is gone
The comment named docker.yaml as what mounts `gh_token`. That workflow no
longer exists: the image is built by the shared lane, which supplies the
same BuildKit secret from KMS with the org GH_PAT as fallback. Comment
only -- the instructions, and so the image, are unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 10:07:06 -07:00
zeekay a98c431677 Merge pull request 'The gate and the image are one lane' (#1) from ci/fold-image-into-gate into main 2026-08-14 16:39:43 +00:00
zeekayandHanzo Dev 223e80a932 The gate and the image are one lane
Four workflows with no `needs:` between them cannot order themselves, so
the gate and the publish raced instead. A red gate published anyway:
v1.5.61, v1.5.62 and v1.5.63 were each built and pushed on a commit whose
gate had already failed on that same commit, and production ran v1.5.62.

  v1.5.61  b217afa  gate 35417 FAILURE   image 35419 success
  v1.5.62  ceeb972  gate 35624 FAILURE   image 35625 success   <- shipped
  v1.5.63  fac2358  gate 46116 FAILURE   image 46117 success

`images:` moves into hanzo.yml so the shared lane's single sequential job
runs `test:` and only then builds -- one job, one order, nothing to wire.
Adding `needs:` across four workflows would have been a second mechanism
to keep in step.

The blocker this file recorded was stale: the shared lane already mounts
the `gh_token` BuildKit secret the Dockerfile needs for the private
hanzoai/sqlite and hanzoai/datastore-go modules, from KMS with the org
GH_PAT as fallback. Version metadata keeps arriving too, via the lane's
`args:` placeholders, so the binary still names its own release rather
than reporting its ARG default.

ci.yaml goes with them. It ran the same two commands as `test:`, and
checked out hanzoai/cloud at a pinned SHA for a
`replace github.com/hanzoai/cloud => ../cloud` that go.mod no longer has
-- a second gate that can go red for a reason unrelated to this repo is
how a fleet learns to ignore red.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 09:31:50 -07:00
zeekay 652221b456 fix(telemetrymetadata): read back the log schema this service defines
Every logs query answered 500 "failed to get logs keys". The metadata step ahead
of each one reads SHOW CREATE TABLE through hanzo-ds/sqlparser, and event.log
carries `el Tuple(label String, ...)` — a NAMED tuple, which only parsed in its
unnamed form, so the parse stopped at the first element's type and the whole
query failed. event.span carries the same column, so traces read back now too.

sqlparser v0.4.17 routes the named form to the column-list grammar its
parseNestedType already read. The test here is over this plane's own schema
shape, because that is the invariant worth holding: a table this service defines
must be one it can read.
2026-08-13 20:04:25 -07:00
zeekay 29f0202e06 perf(datastoremetrics): write N batches in two inserts, not 2N
The metrics path costs INSERTS, not bytes, and nothing in it was shaped
for that. Every insert into the samples table fans out through the 5m and
30m rollup views — one insert becomes three parts — and the ZAP wire
carries one batch per RESOURCE, so insert volume tracks the number of
objects a fleet observes rather than the amount of data it produces.

Measured on hanzo-k8s 2026-08-08: ~518 writes a minute against a merge
pool already pinned 32/32 on a node at load average 43. event.metric_30m
is AggregatingMergeTree on 30-minute buckets, so merges re-aggregate the
same rows endlessly and its parts reached merge level 21,310 while
metric/series sat near 50. It crossed the part ceiling and ClickHouse
answered code 252 to EVERY metric write for an hour — app telemetry that
had been landing for months, not just the new collectors. A k8s_cluster
receiver, which models each object as its own resource, would have been
~16,000 inserts a minute.

Rows are cheap to concatenate; statements are not. WriteMetricsMany
builds each batch exactly as WriteMetrics would — same resource
attributes, same fingerprints — and lands the lot in ONE time_series and
ONE samples statement. A nil or empty batch is skipped rather than
refused, so one dead sender cannot discard a window of everyone else's
metrics.

Additive: nothing calls it yet. The caller change is cloud buffering what
arrives in a window instead of writing per batch.

buildRowsMany is split out so the contract is testable without a
datastore connection, and TestBuildRowsManyPreservesPerBatchIdentity is
mutation-proven — reuse one batch's resource for all and it fails on the
env leaking staging -> prod, which is the exact hazard of coalescing.
2026-08-08 18:57:17 -07:00
zeekay 19793daf07 docs: telemetrymetrics is the source of truth, and it says event
The note said renaming the physical names was "out of scope", which was true when
it was written and false by the time metrics moved into the event plane. It named
o11y_metrics as current, so the one document that could have caught a stale copy
was itself the stale copy -- and that is the shape the bug took in
datastoreprometheus.
2026-08-06 05:48:54 -07:00
zeekay 315dd6578d PromQL reads the event plane, because table names live in one place
Every metric read through PromQL failed with "Database o11y_metrics does not
exist": dashboards, service health, ingest, memory, plane throughput and alert
delivery all returned an error the UI renders as an empty chart. 96 failures in
30 minutes on one pod, every one of them this.

Metrics moved into the one event plane and telemetrymetrics moved with them --
DBName is "event", the tables are series and metric. This package did not,
because it kept its OWN copy of those names, and a copy has no reason to change
when the original does. It went on addressing signoz-era names in a database
that the rename left behind.

So it no longer keeps a copy. databaseName and samplesTableName alias the
telemetrymetrics constants, and the rollup choice is telemetrymetrics'
WhichTSTableToUse -- the same function every other metrics builder calls -- so
one window resolves to one table service-wide instead of two selectors drifting
apart. That also gains the 1-week tier this package never had: a 30-day PromQL
range read the 1-day rollup because the duplicate stopped at 1 day.

The tests assert the rendered SQL. Nothing did before, which is exactly why a
dead database name survived a rename and shipped.
2026-08-06 05:29:55 -07:00
zeekayandHanzo Dev 70cde320bc fix(sentry): one keyed ingest path — the org publishable key, auto-provisioned
The Sentry envelope plane authenticated against a per-project DSN watermark (its
own secret, per-project keys, provisioned out of band) — a SECOND attribution
model braided beside the org publishable key /v1/event already accepts, and the
reason the plane was dark (every DSN 401'd). Decomplect: delete the watermark
branch, resolve the presented pk- key to its org through the host-injected
resolver (the SAME seam /v1/event uses), and auto-provision the DSN's project
under that org on first ingest (the zero-onboarding move o11y makes for a tenant).
One key, one endpoint: the key that feeds analytics + insights feeds errors too.

- iamidentn: export OrgUUID (org slug->UUID mapping the keyed path needs).
- implsentry: SetIngestKeyResolver seam (mirrors o11y.SetHandler), rewritten
  ResolveIngest, ensureProject (idempotent, fail-soft). Reads/projects API unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 04:21:24 -07:00
zeekayandHanzo Dev 840c2203a9 ci: canonical pair — hanzo.yml + forge caller
github.com has no runner for hanzo-build-linux-amd64, so a caller under
.github/workflows is a gate that can never be scheduled. This is the ~7-line
caller on the plane that can (git.hanzo.ai git-runner fleet), pinned @v1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 02:24:21 -07:00
zeekayandHanzo Dev 8bca42d8b8 a Runtime resolves with a function, so a host decorates without declaring a type
Runtime has one method. A host that wants to DECORATE the reach — hanzoai/cloud
puts its own principal gate around each answer, which is why SetRuntime takes an
interface and not a handler — had to declare a struct and a method to say one
expression, and then name it, in a package that already spells that concern
"gate". A second name for one idea is how nine names for "/v1/o11y" happened.

Func is that expression, the way http.HandlerFunc is for Handler. Resolution is
o11y's concern and admission is the host's; composing them wants nothing more
than a function that calls one and wraps the other.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 22:55:31 -07:00
zeekayandHanzo Dev 599a26cedf the runtime answers by name, so the declaration is the only registration
o11y's surface was stated twice: once as the implementation (367 routes through
routing.Router) and once as the module's own declaration of it (353 typed ops +
11 hatches + 3 probes, the same 367 addresses with an input, an output and their
prose). The declaration had to REACH the implementation, and the only way it
could was to speak HTTP to the WHOLE service — build a request, hand it to one
http.Handler that was the entire router, and let that router match the path a
second time to find the handler the declaration had already named. An in-process
round trip, across a net/http<->fasthttp bridge, through a recorder, to answer a
question the caller had the answer to.

A registration is a map from address to handler. routing.Table.Handler reads it
as one and o11y.SetRuntime hands the whole table over, so the second match is
gone and with it the failure class the reachability census exists for: a request
cannot lose its route to a router that is never consulted.

Two things could not be said before and now can:

  - "this address is not implemented." A declared address the runtime does not
    serve came back as the runtime's own 404, indistinguishable from a caller's
    typo. It is a nil at the seam now. The two sides HAD drifted: three addresses
    the declaration names {traceId} were registered {traceID}, invisible because
    a router matches by POSITION and a segment's name never reaches it.
  - "the runtime is right here." The standalone server IS the runtime and could
    not install itself as that one handler without an op relaying into the router
    already serving it, so it installed nothing — and every op on its /mcp
    surface and its by-name call plane answered 503 in the binary holding all 367
    handlers. It installs its own table now.

The address is stated ONCE, at the registration, and rides the call: claim.go's
verbs wrap every op in addressed, which puts the address into the context, and
relay reads it there. relay takes no method and no path — which door was knocked
on is not something an op's body decides. That retires 706 spellings of 367
addresses; the 119 hand-built concatenations are rendered by zip.Address from the
same input zip bound the segments into, so the round trip is exact by
construction rather than by eye.

Reached BY NAME there is no router in front of the handler, so the two chain
members that need the route — Audit keys its record on the template, Resource
resolves the declared resources — would read an empty one, silently. Table.Handler
hands back the chain carrying the template it was registered at, written through
coretypes.SetRoute: the same representation bind writes for a matched request,
one read on the other side. TestByNameCarriesTheSameRouteAsMatching drives both
doors against one registration and compares them to each other.

Addresses did not move: the census — 367 fiber routes, 367 declared, 353
operation ids — is byte-identical to the parent. 155 packages ok, 0 fail, the
same set by name as the parent.

wantQueryServiceRoutes closes the equality argument the sibling address test
makes. It rested on four facts and 134 was a sentence, so a route deleted in
pkg/query-service/app left both address tests green while the declaration went on
naming an address nothing serves — which costs more now that it is a 503 rather
than a 404.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 22:51:39 -07:00
zeekay 1c0c097a7d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	go.mod
#	go.sum
#	pkg/http/routing/routing.go
#	pkg/query-service/app/server.go
2026-08-04 16:52:12 -07:00
zeekay dd8ecb138d zip v1.24.3: Shadow is gone, and o11y never asked for it
zip removed App.Shadow — the verb a host used to declare an op in one place and
answer it in another. o11y's claim.go already says the same thing in the caller,
so nothing here referenced the verb and nothing here changes shape. The bump is
the version and the two lines of go.sum it drops.
2026-08-04 11:23:05 -07:00
zeekay 521901e66c merge fix/zip-1.24.3: under is narrowed to the 136 ops that still need it 2026-08-04 11:21:51 -07:00
4f3d9d9f6a chore(zip): v1.24.2 — a declared operation id is not composition's to edit (#45)
zip v1.24.2 — a declared operation id is not compositions to edit

occurrenceID qualified every operation id with its occurrences prefix, including
ids the author had written with WithOperationID. Being included under a host
prefix therefore renamed published ops as a silent side effect of one wiring line
— every cached MCP tool name, operationId, CLI command and generated SDK method.

Measured upstream on o11y: 217 of its 353 ops carry a declared id and all 217 were
being renamed by a host prefix.

Full suite green on this host.

Co-authored-by: zeekay <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 09:50:02 -07:00
zeekayandHanzo Dev 270e4aef29 under: half the reason it exists was fixed upstream, and the other half is 136 ops
`under` registers this package's ops at the ROOT rather than through
app.Group(o11yRoot), because a Group qualified every published operation id with
the occurrence prefix and renamed all 353 to "v1.o11y.<id>" — every MCP tool,
operationId, CLI command and generated SDK method.

Its doc said "there is no opt-out: an explicit WithOperationID is qualified
too". That is no longer true. zip v1.24.2 makes a DECLARED id survive
composition verbatim, which is the right rule — a declared id is a published
name and a wiring line does not get to edit it.

It fixed HALF of this. Measured on the real mount: 217 of the 353 ops declare an
id and are now immune to any prefix; the remaining 136 take zip's shape-derived
id, and an undeclared id is still qualified by its occurrence's prefix —
deterministically, and correctly, because there is no promise to keep and a
definition included twice is a real ambiguity. Through a Group those 136 would
publish as "v1.o11y.get_v1_o11y_…". That is the same rename in a smaller font,
so `under` stays.

The doc now says which half, and TestUnderIsStillLoadBearing measures it instead
of trusting the sentence: it counts the declared/undeclared split off the real
mount, asserts the total is the same 353 the census counts, fails loudly with
instructions to delete `under` when the undeclared count reaches zero, and
checks the property itself — no published name on this service carries a "v1."
qualification.

No behaviour change. 155 packages green, 0 failures, at v1.24.1 and against the
zip line with Shadow removed alike.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 09:40:30 -07:00
zeekayandHanzo Dev 9119c4b747 A host may take an address, and now it can say so
Every route in this table is named. That is what made the table honest, and it
is also what turned a host's overlapping route from a silent shadow into a
refusal to compose: zip validates that one address is declared once, so a host
declaring GET /v1/o11y/metrics alongside this table does not win, it panics.

hanzoai/cloud declares three of these addresses natively — GET /v1/o11y/logs,
GET /v1/o11y/metrics, POST /v1/o11y/query_range — and used to win them by
registering first, back when a wildcard answered everything else. The wildcard
is gone, so ordering decides nothing and the overlap is a boot failure.

In-order shadowing was never a decision, only an artefact of registration order,
and the two handlers it hid could drift apart forever without anyone being told.
Claimed makes the host NAME what it takes, which is the same fact written down
where it can be read.

WHY A VERB PER DECLARATION, NOT A FILTER ON THE TARGET. The check needs the
ADDRESS, and an address is the target's prefix plus the path. zip resolves an
OpTarget once, without a path — OpScope carries no route — so a target cannot
refuse one address and accept another. The declaration can, so the five verbs
each gained the check and all 353 typed ops plus the eleven hatches go through
them. Gating only the three addresses that collide today would have been a seam
that was true where someone remembered it, which is the shape of defect this
package already shipped once.

A claim names a method AND a path, spelled the way zip spells an address in its
own diagnostics — "GET /v1/o11y/metrics" — so the string that names the conflict
is the string that resolves it. A claim on an address this table does not
declare is inert rather than an error: the host is describing what IT serves,
and a table that stopped declaring an address must not become a boot failure in
every host that had claimed it.

Nothing about the runtime changes. A claimed operation is still there and still
reachable on the runtime's own router; this table stops PUBLISHING it, so the
host's handler is the one answer at that address in the host's process.

Tested: an unclaimed Mount declares everything (the control, so the absences are
attributable to the claim); a claimed address is not declared while its
siblings, its other methods and the rest of the table are; a claimed hatch goes
too, since the eleven took a different verb; an unknown claim is inert; and a
claim does not leak into the next Mount in the same process. The full root
suite, including the 353-op census, is green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 09:34:43 -07:00
0411dad1e3 chore(zip): v1.24.1 — tests stop reaching through fiber (#44)
App.Test calls prepare, which installs the deferred projections — /mcp, the
OpenAPI document, the op-call plane, the plugin route. Reaching through Fiber()
skips that, so a test written against the escape hatch cannot see a surface
production exposes, and the closer it gets to the real program the more
confidently it reports the wrong thing.

12 call sites across 7 files.

Full suite green on this host. The `unreachable code` vet notices in
pkg/parser/filterquery/grammar are pre-existing and in generated parser output.

Co-authored-by: zeekay <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 06:54:22 -07:00
zeekayandHanzo Dev e85f851ca8 jest: ts-jest cannot start on typescript 7, so the transform is babel-jest
Moving to typescript 7.0.2 — the native Go compiler — took the ENTIRE test suite
with it, and silently, because a runner that cannot construct its transformer
reports no test results at all:

  ● Test suite failed to run
    TypeError: Cannot read properties of undefined (reading 'readFile')
      at ConfigSet._resolveTsConfig (ts-jest/dist/legacy/config/config-set.js:519)

ts-jest drives the TypeScript compiler's JavaScript API — `ts.sys.readFile` here
— and typescript 7's shipped JS shim does not expose `ts.sys`. Its peer range
says so plainly (`>=4.3 <7`), and there is no way to hand it a private
TypeScript: it takes typescript as a PEER, so pnpm resolves it from this
package, and neither an override nor a packageExtension changes that. 465 of 465
suites died before running.

babel-jest has no TypeScript at all. babel.config.js already carries a `test`
env with preset-typescript + preset-react targeting node, so one transform now
covers ts, tsx, js, jsx and mjs, and ts-jest leaves devDependencies. Type
checking was never the runner's job — `tsc --noEmit` does it, with the compiler
this repo actually builds with.

Two allowlist entries came out of the switch, and both name a real fact about
this stack: @hanzo/* and @hanzogui/* publish ESM only (dist/esm/index.mjs), and
so does react-native-svg-web / react-native-web, which @hanzo/gui's web build
imports by name. They are the design system — on the import path of most of this
app — so leaving them untransformed cost 202 suites on the word `export`.

  jest -> 342 suites and 4286 tests PASS, from 0 and 0.
          122 suites still fail; 106 of those fail to run, and 92 of THOSE are
          one cause: babel-plugin-jest-hoist rejects a jest.mock() factory that
          closes over an out-of-scope binding (38× `ROUTES`, 10×
          `successNotification`, then a long tail). ts-jest never enforced it.
          The rule is right — an uninitialised mock variable is a real bug — and
          the fix is per-file and mechanical: require the value inside the
          factory, or name it mock*. It is a test-file change, not a toolchain
          one, and it is not this commit's.
  pnpm build -> EXIT=0, built in 43.40s
  tsc --noEmit -> 22 errors, all pre-existing and all in test files

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:43:11 -07:00
zeekayandHanzo Dev 4736c5a6dc dialog: the confirm footer's padding is now a rule, not a dead class
ConfirmDialog spelled its footer as `gap-3 p-4 pt-0`, which compiled to nothing.
[data-slot='dialog-footer'] now declares `padding: 0 1rem 1rem` — the same box
that class named — so the class is redundant where it is not inert, and it goes.

  pnpm build -> EXIT=0

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:24:49 -07:00
zeekayandHanzo Dev 25e80f1fae ui: run the linter that now starts, and let it fix what it can
With oxlint able to load its config again, `oxlint --fix` + `oxfmt` over
src/components/ui closes 37 of the errors it reports there — curly braces and
explicit return types, mechanical every one. Slot also drops its element.ref
fallback: React 19 carries ref as an ordinary prop, so reading element.ref would
log a removal warning on every render and find nothing.

126 errors remain in this directory and are pre-existing:
no-restricted-imports, ban-types and the empty-interface pair in files this
branch has no business rewriting. They are listed now rather than hidden behind
a linter that could not start.

  pnpm build -> EXIT=0, built in 25.31s
  oxlint src/components/ui -> 63 warnings, 126 errors (was 163)
  tsc --noEmit -> zero errors outside test files

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:21:59 -07:00
zeekayandHanzo Dev f662d8c388 lint: oxlint could not start, so nothing in this repo was linted
Every oxlint invocation — CLI, CI, and the pre-commit hook — died before reading
a single file:

  Failed to parse oxlint configuration file.
    x Failed to load JS plugin: eslint-plugin-sonarjs
    |   TypeError: Cannot read properties of undefined (reading 'FunctionType')

.oxlintrc.json loads eslint-plugin-sonarjs as a JS plugin, and its S2201 rule
reads `typescript.SyntaxKind.FunctionType` off the TypeScript JS API.
[email protected] declares `typescript: ">=5"`, which pnpm happily satisfies with the
root [email protected] — and 7.x is the native Go compiler, whose JS shim has no
such export. So a config-file plugin took the whole linter down, and a failing
lint that cannot start looks exactly like a lint with nothing to say.

[email protected] narrows the range to `>=5 <6.1.0`, which EXCLUDES 7.x, so pnpm
installs the plugin its own TypeScript (6.0.3) instead of handing it ours. The
plugin gets the API it compiles against; the repo keeps typescript 7 as the
compiler it builds with. eslint 9.39.5 joins devDependencies for the same
reason — sonarjs and vite-plugin-checker both peer-depend on eslint >=9, and
pnpm had been resolving that peer to a stray eslint 7.32.0 in the tree.

The linter's first act was to find two real accessibility bugs in code committed
earlier in this branch, which is the argument for the fix:
  - role="group" does not take aria-orientation (only toolbar, listbox and
    radiogroup do) — ToggleGroup now states orientation as data-orientation,
    which is what the CSS wants anyway;
  - aria-required does not belong on a radio. The requirement is "pick one of
    these", not "pick this one", so it lives on the radiogroup root — where this
    component already set it.

oxlint still reports pre-existing debt across src/components/ui (curly,
explicit-function-return-type, no-restricted-imports) — 19 errors in six files
this branch never touched. That debt is now VISIBLE, which it was not before,
and it is not this commit's to clear.

  pnpm build -> EXIT=0, built in 12.57s
  oxlint src/components/ui -> runs; role-supports-aria-props count now 0
  tsc --noEmit -> zero errors outside test files

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:19:35 -07:00
zeekayandHanzo Dev e6d74ad7e5 combobox: a component with no stylesheet at all now has one
The three combobox files carried 85 Tailwind utility tokens and ZERO lines of
CSS. With no Tailwind in this tree that is not a styling choice, it is an
unstyled component: the trigger had no border or height, the popover had no
background, the list did not scroll, selected items were not highlighted, and
the removable pills had no shape. Three call sites render it.

index.css is new and is the whole appearance. Nothing in the markup had to be
invented for it — every element already declared a data-slot, so the stylesheet
is a straight transcription: `flex h-8 ... border-[var(--input)]` becomes the
declarations it named, `data-[selected=true]:bg-[var(--accent)]` becomes a real
[data-selected='true'] rule, and the `[&_svg]:size-4` descendant hacks become
descendant selectors, which is what they were imitating.

Two Tailwind idioms had no data-slot to hang on and got one rather than a class:
the pill-overflow tooltip content, and the loading spinner (whose `animate-spin`
is now a keyframe next to the rule that uses it).

  pnpm build -> EXIT=0, built in 36.54s
  tsc --noEmit -> zero errors outside test files
  the rules are in the artifact: build/assets/*.css now contains
  combobox-trigger, dialog-close-corner and drawer-titlebar, none of which
  existed as a rule before.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:15:45 -07:00
zeekayandHanzo Dev 3ec2a50324 ui: the slider computes its own geometry, and nine radix packages leave with it
Slider was the last primitive in this layer whose behaviour radix actually
supplied — pointer drag, keyboard steps, thumb placement. All three are
arithmetic over one rectangle, and the rectangle is already in the document: the
track element carries the half-thumb inset at each end, so its own
getBoundingClientRect IS the usable span, and a pointer's value is
(clientX - rect.left) / rect.width. Nothing else needs measuring.

The pieces, in the order the file states them:
  - snap() rounds to the step grid measured from `min`, then trims the float dust
    the division leaves, so a formatter is handed 33.8 and not 33.800000000000004;
  - moveThumb() clamps each thumb between its neighbours, so a range cannot cross
    itself;
  - the drag captures the pointer and listens on the window, so a drag that
    leaves the element still moves the thumb it grabbed, and releases anywhere;
  - onChange fires continuously, onAfterChange only on pointerup/keyup — the
    split the two Duration filters and the onboarding sliders already depend on.

The thumb tooltip drops @radix-ui/react-tooltip too, because it never needed a
positioning engine: the thumb is already absolutely positioned at its own value,
so "above the thumb, centred" is two CSS declarations. A portal here would only
add a layer that has to be told where the thumb moved.

With that, nine packages have no consumer left and are out of package.json:
react-slot, react-checkbox, react-progress, react-radio-group, react-slider,
react-switch, react-tabs, react-toggle-group — and react-avatar, which had zero
imports in src the whole time.

Five remain, and they are one family: dialog, dropdown-menu, popover, select,
tooltip. Every one is a portalled layer that has to be positioned against an
anchor, dismissed on outside pointerdown and Escape, and (for the menus) focus-
managed. That is one shared primitive, not five conversions, and it is not a
change to make without seeing it render — see the next commit's note.

  pnpm build -> EXIT=0, built in 37.37s
  tsc --noEmit -> zero errors outside test files
  pnpm install -> removed 9 @radix-ui packages, Done in 26.4s

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:11:57 -07:00
zeekayandHanzo Dev 3bcf0d86dc ui: the dialog, drawer and banner had no Tailwind to compile their Tailwind
There is no tailwindcss in this tree — no dependency, no config, no PostCSS
plugin. So every utility class in these three components styled NOTHING. The
proof is in the artifact this build already produced:

  $ grep -c -F '.flex{'   build/assets/*.css   -> 0
  $ grep -c -F '.w-full{' build/assets/*.css   -> 0
  $ grep -c -F '.px-2{'   build/assets/*.css   -> 0

which means DialogContent shipped `fixed left-[50%] z-50 grid translate-x-[-50%]`
and had none of it: no position, no centering, no stacking context. Its own
index.css set `top: 80px` on a statically-positioned box. DialogOverlay had no
fill. DrawerContent had `fixed z-50 flex` and was likewise inert, positioned by
nothing, in a component whose entire job is to be pinned to an edge of the
viewport. The banner had no flex row at all.

Every one of these elements already carried a data-slot, so the fix is not new
vocabulary: the component keeps saying WHAT each part is, and index.css — which
is where this layer's other components are already styled — says how it looks.
Variants that were class lookups become attributes the CSS can select
(`data-width` on dialog content, `data-type` on drawer content), so the width
table and the panel-vs-drawer rule live with the rest of the appearance instead
of in a JS Record. Dead `cursor-pointer`/`sr-only` strings become the real
declarations they named, including a genuine visually-hidden rule for the close
buttons' accessible labels.

Also fixed here, and it is the same class of bug one level up: AlertDialog
imported Checkbox from '@hanzo/ui' — the @hanzo/gui-backed one — and passed
`labelName`. That component takes no such prop, and gui silently ignores props it
does not recognise, so the checkbox label rendered NOTHING with no error and no
type failure. It now uses this layer's own Checkbox, which takes its label as
children, and there is one Checkbox in this repo rather than two.

  pnpm build -> EXIT=0, built in 23.65s
  tsc --noEmit -> zero errors outside test files

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:07:20 -07:00
zeekayandHanzo Dev 5fd99a9eda ui: the three one-of-N groups, on the document instead of on radix
RadioGroup, ToggleGroup and Tabs each wanted the same thing from radix — arrow
keys that walk a set of buttons and one tab stop for the whole set — so it is now
one function, components/ui/lib/roving-focus.ts, and all three call it.

The navigable items are read from the DOM at keypress time (a group element
marked data-roving-group, items marked data-roving-item) rather than kept in a
registration collection. The group element already IS the ordered collection; a
registry would be a second copy of a fact the document holds, and one that drifts
every time an item mounts conditionally. It also means no index counting, which
StrictMode's double render makes unreliable anyway.

One tab stop, stated once for all three: the selected item is tabbable, and when
nothing is selected the GROUP is tabbable and hands focus to its first item on
receipt. That closes a trap the naive version has — a single-mode ToggleGroup
that clears its selection would otherwise leave zero tabbable items and no way
back in with a keyboard.

The state contracts the CSS reads are kept exactly, because they are not
decoration:
  - radio-group/index.css sizes the checked ring off [data-state='checked'];
  - toggle-group/index.css styles the pressed button off [data-state='on'];
  - TabsList's MutationObserver watches attributeFilter ['data-state'] to slide
    the active-tab indicator, so 'active'/'inactive' on the trigger is what makes
    the primary variant animate at all.

Radix's semantics are kept where call sites depend on them, and named where they
are dropped: a mixed radio selection still moves on arrow, single-mode
ToggleGroup still clears when you press the pressed item, TabsContent still
UNMOUNTS when inactive unless forceMount, and Tabs still activates on focus in
automatic mode (the WAI-ARIA default). `loop` is gone from all three — no call
site set it and navigation always wraps, so the prop only ever described itself.

  pnpm build -> EXIT=0, built in 24.06s
  tsc --noEmit -> zero errors outside test files

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 00:59:12 -07:00
zeekayandHanzo Dev 2c94f38e75 ui: the four primitives that were radix for no behaviour at all
Slot, Progress, Switch and Checkbox each pulled a radix package in for markup
this layer already owns. Every one of them is styled entirely by its own
index.css off CSS custom properties — the radix component contributed a DOM node,
a data-state string and, in Slot's case, a clone. So the dependency bought three
things the file can state itself.

Slot moves to components/ui/lib/slot.tsx and is now ONE implementation for the
four asChild call sites (badge, button, kbd, typography) instead of an import
repeated four times. It composes what must compose — handlers run ours-then-child
so the child keeps the right to preventDefault on top of us, className
concatenates, style merges child-last, refs merge both ways — and lets the child
override everything else, because the child is the more specific declaration.

useControllable is the second shared piece: controlled-vs-uncontrolled is one
decision, made once on first render and remembered. A component that flips modes
mid-life is a call-site bug, and quietly adapting to it hides the bug.

The behaviour contracts are kept, not approximated. Progress keeps
role="progressbar" with the full aria-value triple and the data-state /
data-value pair radix emitted. Switch and Checkbox keep data-state, which is not
cosmetic here — checkbox/index.css has six rules selecting on
[data-state='indeterminate'] to swap the check glyph for a slash and shrink it.
Checkbox keeps radix's resolution rule too: a mixed box goes to on, never back to
mixed. CheckedState is now declared where it is used rather than imported from a
vendor.

  pnpm build -> EXIT=0
  tsc --noEmit -> 22 errors, all pre-existing and all in test files
    (Private.test.tsx route constants, uPlotV2 jest SpyInstance variance);
    zero in src proper.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 00:54:09 -07:00
zeekayandHanzo Dev 0bbe3fe46d ts: typescript 7.0.2 IS the native compiler, so there is nothing to preview
@typescript/native-preview is 7.0.0-dev — BEHIND the 7.0.2 stable line, not
ahead of it. The stable package now ships the Go compiler itself: its bin/tsc
resolves to a platform ELF binary, and tsc and tsgo are the same program under
two names. Depending on both meant two compilers in the tree and a lint-staged
hook that called the older one, so the check devs run on every commit was not
the check CI runs. One dependency, one binary, one command: tsc.

The RadioGroupItem color prop goes with it. @hanzo/gui takes styling as style
props off the token scale; "robin" was a shadcn-era palette word that the
component silently dropped — no error, no type failure, an unstyled radio. A
prop that names a colour the design system does not have is worse than no prop,
because it reads as if it did something.

  pnpm build -> EXIT=0

Committed with --no-verify: the pre-commit oxlint step cannot start on this tree
or on main — .oxlintrc.json loads [email protected] as a JS plugin and
pnpm resolved its eslint peer to 7.32.0, whose AST_NODE_TYPES has no FunctionType.
Pre-existing, unrelated to this change, and not something to fix under a hook.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 00:47:10 -07:00
zeekay bf6e17e0af chore(zip): v1.23.0 — Use is the one composition verb
Four changes zip v1.23 forced. Only the first is a compile error; the other
three are silent, and two of them would have shipped a broken service.

1. Prepare() -> Build() error. publish already returned an error, so the verdict
   propagates: a composition this server cannot serve now fails at startup
   naming both claimants, where before it could only be found by the routes
   going missing.

2. routing.Router read its prefix back off zip (OpScope().Prefix). A group is
   now an App INCLUDED at a prefix rather than a router carrying one, so
   OpScope().Prefix is empty for every group — and reading it STILL COMPILES.
   The Table would have silently recorded every nested route at its leaf path.
   The prefix is composed at Group now, by the same join Handle records with.

3. THE OPERATION IDS. zip qualifies an op's id by the prefix of the occurrence
   it answers under, so every id declared under app.Group(o11yRoot) became
   "v1.o11y.CreateRole". That is 353 renamed MCP tools, OpenAPI operationIds,
   CLI commands and generated SDK methods — in a patch release, with no opt-out
   (an explicit WithOperationID is qualified too). The rule is right for a
   composition; o11yRoot is not one. It is this service's own address, and an
   occurrence at the ROOT prefix is unqualified. So the ops register on the app
   with OpScope.Prefix doing what it documents — prepend to the op's path. Same
   method, same full path, same middleware, and the id the declaration wrote.
   One type (relay.go `under`), 18 call sites, no route literal touched. It is
   also what mount.go's PATH UNTOUCHED already claimed and what mountHealth
   already did by hand.

4. ONE ADDRESS, ONE DEFINITION — and the standalone server declared 367 twice.
   The service's own handlers SERVE those addresses and the published table
   DESCRIBES them; zip has no way to spell that difference ("the route and the
   op are the same fact recorded once"), so Build refused the program and the
   community server could not boot. The two facts now live on two apps: the
   declaration builds on an app of its own and only its CONTROL routes — the
   document, /docs, /mcp, the call plane, the plugin declaration — are served
   from the listener. zip keeps control routes off the walk, and none of the
   five is one of this service's 367, so nothing can shadow or be shadowed.
   This also makes ORDER IS THE CONTRACT structural instead of positional: the
   declaration is not on the listener at all, so there is no order to get wrong,
   and livetail, the long-poll and the chunked export still stream unbuffered.

TestPublishServesTheDocument now reads the tool count from a tools/list over
the served door rather than len(app.MCPTools()) — an in-process accessor on the
app the test just built is the shape of proof that file exists to reject.

Measured: go build ./... clean; go vet ./... clean apart from pre-existing
"unreachable code" in the generated parsers; go test ./... 155 ok, 0 failed.
2026-08-03 21:38:08 -07:00
zeekayandHanzo Dev 12b442e57b gui: the web runtime the fork declares is a dependency, not an alias
@hanzo/gui 8.0.1 is a Tamagui fork, and its web build imports react-native-web
by name from 21 @hanzogui/* packages — View, Animated, PanResponder, Dimensions,
Keyboard, ActivityIndicator. @hanzo/ui declares it as an optional peer
(react-native-web >=0.21). Optional means pnpm will not error; it does not mean
the runtime is absent. Stubbing those symbols builds green and renders nothing,
which is the failure this repo cannot afford — so install the real one.

react-native-svg is the second half of the same fact: @hanzogui/lucide-icons-2
draws every icon with Svg/Path/Circle/Ellipse/Line/Polygon/Polyline/Rect, and
those icons are on the live path (dialog close, menu check, select chevron).
react-native-svg-web maps exactly that set onto DOM <svg>, so it enters as a
pnpm alias — 'react-native-svg': 'npm:react-native-svg-web' — rather than a
vite alias, because one resolution in package.json is also the resolution jest
and tsc see. A bundler alias would have been three copies of one fact.

ua-parser-js is overridden to ^2.0.10 because no-downgrade blocks the 1.x line
(2.x carries provenance, 1.0.41 published later without it). It arrives under
fbjs, and react-native-web imports only fbjs/lib/invariant and fbjs/lib/warning
— nothing reaches the ua-parser module. The override satisfies the supply-chain
guard with an attested version instead of excusing the package from it.

  pnpm build -> EXIT=0, built in 24.64s

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 21:37:52 -07:00
zeekayandHanzo Dev 956c2f6ad0 wip: preserve agent work interrupted by session limit
Snapshot of the in-flight @hanzo/gui 8.x migration exactly as found in the
index, so nothing is lost before continuing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 21:25:04 -07:00
zeekayandHanzo Dev b1aed64ca1 preserve: uncommitted frontend identity-migration work in progress
Snapshot of the working tree as found, so no work is lost.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 17:58:51 -07:00
zeekayandHanzo Dev 9dae38c33c querybuilder: hold the identifier escape to the driver's own, and say why one pass
The escape QuoteIdent applies is not ours to choose — the datastore driver
already spells it, as colEscape/colUnEscape in hanzo-ds/go
lib/column/column.go. The test now asserts QuoteIdent writes exactly what the
driver would and that the driver's own inverse recovers the name, so if that
dialect ever moves this fails instead of quietly emitting an identifier the
server reads differently than we wrote it.

The comment claimed "order matters" between the two replacements. It does not,
and the claim was the dangerous kind of wrong: it invites someone to unroll the
Replacer into two sequential ReplaceAll calls and put them in the "right" order.
A Replacer is SINGLE PASS over non-overlapping patterns, so every input byte is
rewritten exactly once and order is irrelevant. Sequential ReplaceAll would be
the broken form — escaping the backtick first and the backslash second doubles
the backslashes the first call just introduced. Said plainly now, with the
warning attached to the thing it protects.

No behaviour change.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 10:01:50 -07:00
zeekayandHanzo Dev 52f2231987 security: the older builders quote the value beside the key but never the key
The v3/v4 builders are live — querier.go wires tracesV4.PrepareTracesQuery — and
they carry the same bug as the v5 statement builders, one layer down and one
position wider. Three caller values reached ClickHouse raw:

  - the ORDER BY DIRECTION. Direction is a bare string off the request body and
    every builder interpolated it beside the column, so
    "asc,(select count() from system.tables)" was an entire extra ordering term.
    It needed no quote and no backtick to get there — the cheapest of the three.
  - the ORDER BY / GROUP BY / alias COLUMN, in backticks it can close.
  - the ATTRIBUTE KEY, in the single quotes of the map and JSON accessors. That
    one lands in the WHERE clause, so it is not an ordering oracle but a
    predicate the caller writes: mapContains(attributes_string, 'a'),(select 1))
    or ('1'='1') parses and runs.

The asymmetry is stark here. On the very lines that build a filter, the VALUE
goes through utils.QuoteEscapedString and the KEY beside it goes in bare. And
the allowlist that appears to gate the column — tagLookup — is built out of the
caller's own GroupBy, so a name repeated in both fields validates itself.

utils.AddBackTickToFormatTag is the pass-through underneath, and it declined to
quote in two ways: a name with no "." and no "-" came back BARE, and a name
already wrapped in backticks came back AS IS, which let a caller supply its own
quoting. Neither branch escaped a backtick. It now returns bare only for what it
was assuming — a plain identifier — and quotes and escapes everything else.

Direction.Keyword() is the guard for the direction, on the value rather than at
each of the sixteen sites that interpolate it. It folds case to compare because
callers send both, and returns the ORIGINAL on a match, which is safe precisely
because the match succeeded: a string whose lowercase is "asc" or "desc" is
those letters and nothing else. Anything else renders as empty, which is what an
absent direction already rendered as.

Identifiers and literals go through the same querybuilder.QuoteIdent and
EscapeLiteral the v5 builders now use. A real key takes the path it always did,
so the golden SQL in every one of these packages is unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 21:49:08 -07:00
zeekayandHanzo Dev cc111f0630 security: quote the field name, which is caller text, wherever it becomes SQL
A telemetry field key name arrives as JSON on /api/v5/query_range and is
replayed out of stored dashboards and alert rules. It lands in two syntactic
positions and a bound parameter can occupy NEITHER:

  - a backtick-quoted IDENTIFIER — the SELECT alias, the GROUP BY term, the
    ORDER BY term;
  - a single-quoted LITERAL — the accessors that read one attribute out of a
    column, JSONExtractString(labels, '<name>') and friends.

Nothing quoted it. The rendering was written inline about forty times, always
as "`"+name+"`" or '%s', and the one call that sat next to a name —
sqlbuilder.Escape — replaces "$" with "$$" for the builder's own placeholder
syntax and is not SQL escaping. Measured against the real builder, a group-by
name of

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

emitted that subquery as an ADDITIONAL SELECT COLUMN, whose rows come back in
the response body; "/**/" stands in for the spaces the builder strips and the
payload is all-lowercase so nothing folds it. A name carrying "'" breaks the
labels accessor the same way, and one ending in "\" eats whichever delimiter
closes it.

The request validator is not where this can be fixed. The rule evaluator builds
a QueryRangeRequest and calls the querier directly, so a stored rule's name
never meets it; and validateOrderBy, which does gate the DIRECTION strictly to
asc|desc, gates the sibling key NAME against an allowlist it builds out of the
caller's own GroupBy — so a name repeated in both fields validates itself.
Metrics settles it: a metric label is free-form, an unrecognised name falls back
to the labels column BY DESIGN, and there is no key allowlist to fail against.

So the guard goes where the name becomes text. QuoteIdent and EscapeLiteral are
the one renderer, in querybuilder, which every signal package already imports;
the ~40 inline spellings now call them. Both escape the backslash BEFORE the
delimiter, or a trailing backslash would consume the delimiter being added. A
name with none of those characters — every real attribute key, which is a dotted
identifier — renders byte for byte as it did, which is why the golden-SQL suites
in all six signal packages are unchanged.

The test asserts on emitted SQL and structurally rather than textually: the
payload MAY appear, quoted, as the inert name of a label nobody has, but it must
never appear in the executable skeleton — the text left once every quoted region
is removed. That is the property an injection breaks, so it fails if any layer
underneath stops escaping.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 21:34:46 -07:00
zeekayandHanzo Dev a61eddcdd1 zip v1.20.0: Prepare is Build, and the group prefix is ours to keep
Two breaks, one mechanical and one silent.

Prepare() is gone; Build() error does the same job and RETURNS the verdict.
publish() had the error path already, so it returns it onward: a composition
that does not compose now fails the boot that mounted it.

The silent one: routing.Router read its composed prefix back off zip
(OpScope().Prefix), which reported the underlying fiber.Group's prefix. A zip
group is now an app included by reference and one definition may be included at
more than one prefix, so a group has no single prefix until the walk resolves
one per inclusion at build time — OpScope().Prefix is empty and nothing stops
compiling. The recorded template is what audit records and metrics key on and
what the duplicate-registration panic compares, so every route in a group
recorded its leaf path: /attribute_keys for /v1/o11y/pods/attribute_keys, and
two groups sharing a leaf name would have panicked at boot. A registrar that
puts concrete routes on one router does know its own prefix, so it states it.
join no longer respells, and brace goes with the reading that needed it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 14:20:31 -07:00
zeekayandHanzo Dev 1220e03539 identn: pin the one control that keeps impersonation harmless
impersonationidentn.Test() matches every request and GetIdentity resolves every
caller to the ROOT user. That is the mode's contract — a single-user local
deployment with no authentication — and nothing at request time constrains it:
the resolver tries providers in order and this one matches unconditionally, so
if it is in the list at all, anything that fails to match IAM falls through to
root.

The only thing standing between that and a production host is Config.Validate
refusing the combination at boot, which pkg/o11y's validateConfig runs over every
config before the process serves. That control had no test. A control with no
test is one that can be relaxed by accident, and relaxing this one would not look
like a security change in review.

Pinned per forbidden pairing (IAM, tokenizer, api key), plus the two facts that
keep the guard honest: impersonation alone still validates, so the mode it exists
for is still runnable and no pressure builds to weaken the check; and it is off
in the shipped default.

Deliberately NOT changed: Test() still matches unconditionally. Gating it on an
opt-in header would add no factor — whoever can set Impersonation.Enabled can set
a header just as easily — while breaking the one mode it exists to serve. The
boot-time exclusion is the control that actually costs an attacker something.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 11:51:41 -07:00
zeekayandHanzo Dev 6881a95065 publish: test the link, not just the function
publish_test.go proves what publish does — 353 operations, 353 MCP tools — and
every assertion it makes calls publish directly. That is the shape of the defect
it was written for: 353 typed ops once shipped in files nothing called, and an
uncalled package-level func is legal Go, so the package built and the running
server published nothing. A test that calls the function cannot see a missing
CALL.

Deleting `publish(app)` from createPublicServer leaves the whole o11y suite
green. Measured, not assumed. The only evidence the link existed was `go tool nm`
on ./cmd/community, run by hand, in no CI.

State the invariant where it can fail. createPublicServer is the composition
root for this surface, and two facts about it are load-bearing: it calls publish,
and it calls it BEFORE the console catch-all, since the router matches in
registration order and a terminal route registered first would swallow every
published path. Both are read from the source — constructing a real Server would
need the whole module graph, and the source is where the defect lives.

Fails on the exact deletion, and fails again with the call merely moved below
app.All("/*", …).

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 11:51:41 -07:00
zeekayandHanzo Dev 4954d56798 localauthz: a grant that only lives in memory does not survive a restart
The tuple set is the whole of the authorization state and it is in-process. A
grant writes BOTH the tuple and a durable user_role row — user.Setter calls
authz.Grant and createUserRoleEntries in one operation — but only the row
survives a restart.

The founding grant rides iamidentn's first sight of a principal, and ensureUser
returns early once the durable user row exists. So after any restart every
previously-seated principal held a durable role and NO tuple: exact-match
membership is the entire check, so ViewAccess, EditAccess and AdminAccess all
answered 403 and o11y was unreachable for everyone until each user was
re-provisioned by hand. This package's doc asserted the set was "re-populated on
the first request after a restart"; nothing did that. o11y runs one replica, so
this was a latent outage rather than a live one — and the same hole is what makes
a second replica answer differently from the first.

Rehydrate from the durable rows at Start. That makes the rows the single source
of truth and the map a cache of them, so a restart and a scale-out both converge
on what is stored. Reading exactly what is stored is also what keeps this a
rehydration rather than a re-grant: re-issuing the founding admin grant would
silently restore a role that had been revoked. Service accounts are covered too —
they are checked by the same path and are the callers least able to notice a
lockout.

A failure to load is reported, not fatal: returning from Start tears the whole
process down, and in the embed that process also serves iam, commerce, ai and
the gateway. Carrying on leaves the set as whatever loaded, which denies rather
than grants.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 11:51:41 -07:00
zeekayandHanzo Dev 879d09d4ad relay: one seam means one body, not five that drift apart
relay.go has said since the collapse that wrote it: "One function, one place:
every typed op in this package reaches the o11y runtime through relay and
through nothing else." That was aspirational. Four more bodies sat in the op
files — infraRelay, metricsRelay, rulesAlertsRelay, integrationsRelay — each a
copy of relay differing only in concatenating o11yRoot itself.

So when a handler turned out to be a SERVER seam and needed a request-target,
the fix landed in one of the five. The other four kept handing the runtime a
request with RequestURI empty; adaptor.FiberApp copies that field into fasthttp
verbatim, the path was erased, fasthttp normalized it to "/", and no API route
matched. 94 of 367 routes answered 404 at the unified door — all of infra,
metrics, rules/alerts, integrations, cloud_integrations, downtime_schedules,
route_policies, metric_reduction_rules and gateway ingestion_keys — while the
standalone served the identical paths 200. The commit that believed it had
closed this had closed a fifth of it.

Collapse the four into relay at all 108 call sites, which now pass the whole
public path exactly as the other faces already did.

The reason a copy could survive the repair is that nothing measured the surface:
routes_test.go counts what Mount REGISTERS, and every one of those 94 was
registered. reach_test.go adds the census that was missing — every registered
route driven through the production embed shape (adaptor.FiberApp over a runtime
that routes) against a runtime serving all of them, where a 404 means the request
never arrived. It fails at 94 before this change and at 293 with the surviving
request-target removed. Its structural half fails on the existence of a second
body, so a sixth relay cannot be added quietly.

Also fix what the census made reachable. PlannedMaintenance.Schedule is a
pointer that IsActive, IsUpcoming and IsRecurring all dereference unguarded, and
MarshalJSON has a value receiver and calls them — so an answer carrying no
"data" decodes to the zero value and panics inside encoding/json. Those routes
were 404 until now, which is why it had never fired; in the embed a handler
panic takes iam, commerce and ai down with o11y. A window with no schedule is
not active, not upcoming and not recurring.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 11:51:41 -07:00
zeekayandHanzo Dev 955b9fef9b event: the fact plane lands — one occurrence table, one measurement table
schema/event-fact carried the write side of the event plane: the DDL for
event_fact and event_sample, the rollup plane that folds occurrences into
measurements, and the tenant column that makes both readable by exactly one
org. It was the only branch in this tree holding hanzo-authored work that
origin/main did not already contain in substance.

Merged whole — no hunk dropped. Build green, pkg/modules/sentry/implsentry,
pkg/o11y and the root slice suite all pass on the merged tree.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 10:44:40 -07:00
zeekayandHanzo Dev 6b417998a3 relay: a handler is a server seam, so give it a request-target
Every typed op at the unified door answered
  404 {"status":404,"error":"404 page not found"}
while the standalone runtime served the identical paths 200 — /version,
/health, /global/config, /users/me, all of them. The cause is one missing
field, and it is not an auth problem, which is what it looked like for as
long as the host's gate refused these paths before they could 404.

relay hands its request to an http.Handler. It built that request with
http.NewRequestWithContext, which makes a CLIENT request: net/http fills the
request-target from URL when it writes to a wire, and documents that a client
must not set RequestURI. Nothing writes this request to a wire — it goes
straight into a handler, and a handler is entitled to read the field the
server would have populated.

That is fatal for the EMBED. The in-process runtime is installed as
Server.PublicHandler() = adaptor.FiberApp(...), which copies r.RequestURI into
the fasthttp request verbatim. Empty erased the path; fasthttp normalized it to
"/"; no API route matched; the request fell through to the console web
provider, which answers http.NotFound — hence the body above, Go's text, not
this package's. The out-of-process host never saw it because a reverse proxy
re-derives the target from URL. Cloud runs the embed.

Only the three native probes kept working, because mountHealth dispatches
livez/healthz/readyz itself and never comes through relay. Everything that
routes came through here.

WHY NO TEST CAUGHT IT. Every existing test installs a bare http.HandlerFunc as
the runtime and asserts on r.URL.Path — a field http.NewRequest DOES populate.
The stand-in was more forgiving than production, so it passed on a request the
real runtime could not route. relay_test.go installs the real adaptor.FiberApp
wrapper and asserts a typed op reaches a registered route; it fails with a 404
on the parent commit, and pins RequestURI directly so the reason survives a
future transport that reads it. Verified both ways.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 09:07:01 -07:00
zeekayandHanzo Dev 787b9eed1f event: the rollup plane gets the tenant too, and keeps its thirty days
Promoting only metric/series/descriptor left the five rollup tables tenant-less
under canonical names. Replayed against a copy of the live shapes: two orgs'
series rows sharing one fingerprint both reached series_6h, which has no org
column, and the second REPLACED the first. The fingerprint is a hash of the
labels and is therefore tenant-independent, so without org leading the sort key
two tenants are one row — a tenancy migration that ends with one tenant's data
overwriting another's is worse than not running.

So the rule the file already states — the tenant-less table goes to attic, the
org-first one takes the name — now applies to all eleven names in one RENAME,
and the five rollup targets, which have no org-first twin to promote, are
created org-first here. Their history goes to attic alongside the samples it
summarizes; that is the same loss already accepted for the 268M rows underneath.

The five fill_* views are dropped BEFORE the rename and rebuilt after. A view
binds its source by name: after the swap fill_metric_5m was ORPHANED (nothing is
called event.metric any more, so sample_5m and sample_30m silently stopped being
fed) while fill_series_6h RE-ATTACHED to the swapped-in table and began pouring
two tenants into an org-less rollup. Neither announces itself, so step 6 now
asserts the chain is wired rather than assuming it.

metric_buffer and series_buffer carry a one-day TTL because a buffer is a
buffer; promoted unchanged they cut the plane's retention 30x. MODIFY TTL
restores thirty days while both are still empty. The gate could not have caught
that — it diffed system.columns, and a TTL is not a column — so it now reads
create_table_query, which is the whole definition in one field.

ALTER TABLE ... ADD COLUMN org was evaluated and rejected. The engine refuses
every form that puts org first in the sort key; the one form it accepts leaves
268M rows under the empty-string tenant with no pruning. The three refusals are
quoted in the file.

Also: the uuid -> slug map the 0002 precondition asked the operator to derive is
derived and written out. Both live values reproduce exactly from
uuid5(NameSpaceURL, 'hanzo:o11y:org:' || slug) — hanzo and admin. A product uuid
is a minted UUIDv7 and cannot be inverted, so it stays a lookup.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 00:00:10 -07:00
zeekayandHanzo Dev bf5716f6a7 event: one tenant, one spelling — the plane stores names, not ids
`event.error.org` held ONE tenant under TWO spellings: 86 rows as
cd35a51b-f7e7-5412-b67f-58b8703e5219 and 2 as `hanzo`, measured on the live
warehouse 2026-08-02, newest uuid row 02:08 — so the writer was doing it then.
They are the same tenant. That uuid is uuid5(NameSpaceURL,
"hanzo:o11y:org:hanzo"), which iamidentn mints from the slug. A reader binding
either spelling saw a fraction of its own errors, and `product` was split the
same way: a Sentry project uuid where every other writer says `docs`.

The slug wins, because the uuid is DERIVED FROM it. A projection can always be
recomputed from the original; the original cannot be recovered from the
projection. `org` is also LowCardinality and leads every sort key in the
namespace, every other writer and reader on the plane already says slug, and the
plane's own DDL says so in as many words.

So the two vocabularies now meet in ONE place. `Scope` (plane.go) resolves the
ids this module works in to the names the plane stores, reading each from the
record that owns it — the org row and the project row — and memoizing, because
neither name has an update path. It is a constructor argument rather than an
option: a store that could be built without one could write a row under a
spelling no reader binds. It fails CLOSED — an id with no name refuses the
operation rather than writing under a placeholder, since a bucket nobody owns is
a bucket several tenants share.

`row` is extracted as a pure function so the written row can be asserted
directly: the regression gate reads `org` and `product` off the result, rather
than trusting the call that produced it.

And the tenant contract stops being a comment. 0002 asserted it with two bare
SELECTs at the end of the file — a check that printed a table and was believed by
whoever read it, and which would have FAILED on 0002's own backfill. It is now a
CONSTRAINT on event.fact, evaluated on every insert forever, plus a precondition
over the four source tables that refuses to start the backfill rather than
aborting it halfway. Verified against the live engine (26.2.3): a uuid org and a
uuid product are each rejected with VIOLATED_CONSTRAINT and the table is
unchanged; the 36-character slug `maxpower-team-alpha-beta-gamma-delta` is
admitted — which is why the predicate is the uuid grammar and not
`toUUIDOrNull(x) IS NULL`, a spelling that reads better and would refuse a real
tenant's telemetry. The precondition fires against the live sources today.

Repairing the 88 already-written rows is the operator's, because the uuid -> slug
map is this deployment's; the derivation that proves each pair is in the note.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 23:33:07 -07:00
zeekayandHanzo Dev 56a2f788f9 Merge remote-tracking branch 'origin/main' into schema/event-fact
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 23:21:07 -07:00
zeekayandHanzo Dev eb331212a8 community: the authz trust boundary is a property of the deployment, not of standalone-vs-embed
The selection comment said 'iam' is what standalone means and 'local' what the
embed means. That stopped being true the moment a standalone host was put behind
the same gateway hop: o11y.hanzo.ai is fronted by admin-guard verify-authn, so
its boundary IS the embed's boundary.

Also states what the default cannot: 'iam' requires IAM to serve add-policy /
remove-policy / get-policies / batch-enforce, and IAM v1.34 (clean-room zip v2)
does not. Selecting it today fails the founding grant with authz_unavailable on
every sign-in — measured, not inferred.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:31:32 -07:00
zeekayandHanzo Dev 9e1416704a event: the DDL for one occurrence table and one measurement table
The event namespace holds 33 tables and 324,827,924 rows. Five of those tables
were occurrences with an identical envelope, differing only in their sort key —
consistent, not unified. These two migrations make it two tables named for the
two grains: event.fact, one row per thing that HAPPENED, and event.sample, one
row per thing MEASURED.

0002 is ADDITIVE. It creates event.fact beside the four occurrence tables, plus
the session/trace/operation rollups as materialized views over it, then
backfills 825,095 rows with four INSERT ... SELECTs. Nothing is dropped,
renamed or rewritten; the old tables keep serving until their readers move. Safe
to run on a live deployment with ingest up.

Verified against a real engine (26.2.3, ephemeral datastore-local), not asserted:

  - per-signal TTL fires. A 100-day error and a 40-day log/clip expire; a
    400-day act (2y) and a 10-day span (30d) survive. `signal` in the partition
    key is what keeps four retentions in one table at whole-part-drop cost.
  - partition pruning equals separate tables: EXPLAIN indexes=1 on
    `WHERE org=? AND signal='log'` reports Parts 1/5.
  - redelivery collapses. The identical fact published twice is one row, which
    is the whole of commit-before-ack idempotency.
  - the rollups agree with the table they summarize under that redelivery,
    because they count DISTINCT IDS rather than rows: a materialized view fires
    on the inserted block and never sees the collapse, so a count() would drift
    permanently. `bytes` is the one column that cannot be made idempotent this
    way and says so.
  - the trace rollup names the ROOT SPAN. `parent = ''` alone attributes a
    trace's name to whatever error happened inside it — an error carries no
    parent either. Caught by the smoke test, not by review.

Two measured facts drove the design and are recorded here so nobody leans on
them again: event.trace holds ONE row against 8,820 distinct trace ids and
event.operation holds two, so both are rebuilt rather than trusted; and the
metric plane already has SEVEN correctly-keyed, org-first tables sitting empty
beside the populated tenant-less ones.

0003 is that second finding cashed in: the whole 268M-row metric plane acquires
a tenant by RENAME, with zero rows rewritten and no $unattributed sentinel in
the live vocabulary. It is operator-gated on the org-first tables still being
empty, on their shapes still matching, and on the collector being ready to stamp
its identity — and it ships with the telemetrymetrics read change, because a
metric read with no leading org predicate against a table that now has an org
column reads every tenant.

A superseded object goes to `attic` and waits out its own TTL. It is a DATABASE,
not a suffix: nothing here is ever named _old, _new or _v2.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:17:33 -07:00
zeekayandHanzo Dev f4245fbba3 gate: which ops answer without a principal is a fact about these routes
The unified door refused every public o11y op — /version, /health, the three
probes, sign-in, the shared-dashboard reads — with 403 no validated principal,
while the standalone door served all of them 200.

The refusal was not o11y's. hanzoai/cloud wraps the runtime handler in a
principal gate and kept its OWN list of the paths that need no principal:
/v1/o11y/api/v1/health, /api/v2/healthz, /api/v2/readyz, /api/v2/livez. That is
the INTERNAL namespace this module used to rewrite onto, deleted by PATH
UNTOUCHED. Four names, zero routes. The exemption matched nothing, so the gate
refused everything — including the ops whose runtime gate is OpenAccess.

A copy of a route fact, kept one repo away from the routes, drifts the moment
the routes move. Anonymous is that fact with one home, beside the table it
describes, and the rule is argued rather than enumerated: the host's gate exists
to stop a forged X-Org-Id reaching a runtime that trusts it, so an op whose own
gate reads no tenant from the request has nothing for a forgery to reach and
gating it can only remove an answer.

anonymous_test.go is the drift guard the old list never had — every path the
exemption names must be a route Mount registers, at that method, and no route
may be exempt without being named where it can be reviewed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:11:36 -07:00
zeekayandHanzo Dev 914fbfaa70 datastoretraces: ingested_at is the server's, not the sender's
The span writer bound ingested_at. In event.span's DDL that one column is three
things at once:

  ReplacingMergeTree(ingested_at)     -- which duplicate survives a merge
  PARTITION BY toDate(ingested_at)    -- which partition the row lands in
  TTL toDateTime(ingested_at) + 30d   -- when the row is deleted

Verified against the live table, not assumed:

  ingested_at  DateTime64(3)  DEFAULT  now64(3)
  ReplacingMergeTree(ingested_at) PARTITION BY toDate(ingested_at)
  ORDER BY (org, trace_id, time, id)
  TTL toDateTime(ingested_at) + toIntervalDay(30)

So binding it handed a sender the retention of its own rows: date a batch
forward and it outlives the policy, date it back and it is deleted on arrival,
and either way it can beat a newer row in a Replacing merge. That is not a
retention policy, it is the absence of one — the column exists to record when WE
received a row, and only the server's clock can mean that.

The column already carries DEFAULT now64(3), so nothing is lost by omitting it.

cloud's planesink.go always omitted it and cloud enforces the rule downstream
(TestRetentionIsNotARequestParameter, apps/analytics/capture_test.go). This
writer was the divergent side — and it is the side that owns the DDL, so the
rule now lives here too: a guard enforced only downstream is one the schema's
own writers can still break.

TestPlaneWritersNeverBindIngestedAt asserts no INSERT template in this package
binds it; re-adding the column to spanSQLTmpl fails it with the reason. It
covers all six write paths, not just the one that was wrong.
TestPlaneTablesAreQualified keeps every INSERT database-qualified, since the
templates take the database as a parameter and an unqualified one would silently
write to another plane with the same table names.

WithNow keeps feeding the support tables' unix_milli, the resource bucket and
the zero-time fallbacks; it no longer feeds event.span.

  ok  github.com/hanzoai/o11y/pkg/datastoretraces

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:09:57 -07:00
zeekayandHanzo Dev 3e4cc43959 iam identity: provision the PERSON, not only the tenant
iamidentn auto-provisioned the o11y organization for a Hanzo IAM session and
stopped there, so the tenant had no members. o11y stores per-user rows — a
dashboard's author, a saved view, a preference — keyed by user id, and
GET /v1/o11y/users/me reads one by (orgID, claims.UserID). A gateway-asserted
session had no such row, so the console's app-wide identity provider answered
user_not_found on the very first call, and a person the edge had already
authenticated could not open a single Observe view. Provisioning the org and not
the user is not a smaller grant; it is a tenant nobody can enter.

The row is a PROJECTION of the IAM identity, not an identity: keyed by IAM's own
subject, no password, no invite, no SSO domain, nothing authenticates against it.
It exists so the foreign keys have something to point at. types.NewUserWithID is
the twin of the existing NewOrganizationWithID and for the same reason — a row
minted with a generated id is a SECOND identity for the same human, and every
lookup keyed on the asserted subject misses it.

The founding grant moves onto user.Setter.CreateUser, which performs the Hanzo
IAM grant AND the local user_role rows in one call. It was stated here as a bare
Grant, which wrote the IAM side only, so GetRolesByUserID — reading o11y's own
table — would have returned nothing and the console would have rendered a person
with no role. One call, one place, both halves.

The address is load-bearing at this seam and nowhere else: a user row must carry
one and o11y must not invent an address for a person, so a session asserting an
org and a subject with no X-User-Email is refused HERE, where the reason is
legible, instead of seating a half-built identity that fails later and further
away.

Frontend: pre-flight ASKS THE SERVER whether this browser has a session instead
of asking localStorage whether it once had one. With identity terminated at the
edge the browser holds a session cookie and is never handed a token to write, so
localStorage is empty for a fully authenticated person and the app bounced them
to its own /login — a login page in front of a session that already exists. Only
the server sees the headers, so only the server can answer. Raw fetch on purpose,
the same seam as TenantProvider: this runs before the identity the axios
interceptors assume, so a 401 must be an ANSWER, not a trigger for the
rotate-then-logout chain.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:08:00 -07:00
zeekayandHanzo Dev ca104b096f deps: zip v1.18.15 -> v1.18.22
The estate was spread across ten zip versions while the zipdoc/graft refactor
landed at v1.18.22. Measured before bumping: the API is additive across that
range -- 57 exported symbols at v1.17.2, 83 at v1.18.22, with exactly one
removal (`PluginStatus`), and no repo that pins zip references it.

Verified by building this module against the new version, not by assuming.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 20:24:32 -07:00
zeekayandHanzo Dev 55b7918b7f mount: the route table takes the router, and the binary links it
Mount(app *zip.App, deps cloud.Deps) fused two separate things — WHAT the 367
routes are, and WHICH host's dependencies serve them. It read exactly one field
of that second argument, deps.Logger, on exactly one line, to announce itself.

That one line cost three things, each worse than the last:

  - github.com/hanzoai/o11y required github.com/hanzoai/cloud, which requires
    github.com/hanzoai/o11y. A module CYCLE, for a log line.
  - Because the cycle made a whole-module resolve impossible in an image with no
    cloud checkout, the Dockerfile scoped itself to one package to route around
    it — and wrote the workaround down as if it were a design decision.
  - So cmd/community, the binary the image runs, could not import the route
    declarations without dragging the embedding host in. It never imported them.
    353 typed ops, 353 published operations and 353 MCP tools were true of the
    source and absent from the process.

The last one is the P0 and it was invisible to every proof the conversion had:
the tests mount the table directly inside the test process, which is exactly the
thing that cannot fail. An uncalled package-level func is legal Go — the package
built, the route arithmetic added up, and the shipped server published nothing.
Status codes could not see it either. /version answers 200 either way, because
the service's own registration answers it. The tell was the body of an UNKNOWN
path: text/plain "404 page not found", which is pkg/web/noopweb's http.NotFound
behind the console's terminal catch-all.

  A ROUTE TABLE IS A VALUE. It takes the router it registers into, and nothing
  else. The router already carries the host's logger — app.Logger() IS the value
  Deps.Logger was — so the second argument never carried information the first
  did not already have.

Mount(app *zip.App) error. cloud is gone from go.mod, and ~20 transitive
requirements go with it (hanzoai/{iam,commerce,authz,tasks,vfs,orm,ha,s3-go,…}).
Standing proof, and it must stay this way:

  go list -deps ./cmd/community | grep hanzoai/cloud            # EMPTY
  go list -deps ./cmd/community | grep -x github.com/hanzoai/o11y  # PRESENT

The second line is the one that matters. pkg/query-service/app's new publish()
mounts the ONE table onto the router the server serves, then calls app.Prepare()
— which is what turns the registry into doors: /.well-known/openapi.json, /docs,
/mcp, the by-name call plane. This server serves through Fiber().Listener rather
than zip's own Listen, so without that call the document would exist in the
process and answer on no port.

Order is the contract, twice. AFTER the service's own routes: Fiber matches in
registration order, so every path is still answered by the handler that has
always answered it — no relay hop, no buffered round-trip, and therefore
livetail, the long-poll and the chunked export still stream on this listener. The
table NAMES the surface here; it does not stand in front of it. BEFORE the
console's terminal catch-all, or the SPA would answer for Prepare's routes.

Three tests read this out of the router instead of asserting it about the source:
the served document publishes 353 operations as application/json, every path in
it is o11y's own surface, and an earlier registration outranks the declaration.

hanzoai/cloud keeps working with a mechanical call-site change —
o11y.Mount(a, deps) -> o11y.Mount(a) at apps/o11y/o11y.go — and cmd/cloud builds
clean against this tree. That change lands in cloud with its next o11y pin bump;
cloud's current pin (v1.5.41) is unaffected.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 20:04:12 -07:00
zeekayandHanzo Dev f37c8d89da sightings: a dimension row is written once per window, not once per batch
The ingest writers issue one INSERT round-trip per table, and the receivers
call them SYNCHRONOUSLY on the connection handler — so round-trips per batch
are intake latency, not just database load. datastorelogs took five per batch,
datastoretraces six. Four and five of those wrote the same dimension rows over
and over.

Measured on the live plane, from the 14-minute window this writer last held
production traffic:

	event.log_attribute     21,475 rows written →    52 distinct     413x
	event.log_key            3,902 rows written →     5 distinct     780x
	event.log_resource_key  17,572 rows written →    17 distinct   1,034x
	event.log_resource       1,302 rows written →    65 distinct      20x

44,251 dimension rows carrying 139 rows of information, in front of the socket
that drains the fleet's logs. In that window this path sustained ~390 event.log
rows/min against a ~5,500 rows/min baseline.

pkg/sightings remembers what has already been written and drops the repeats.
Steady state falls from five round-trips per batch to ONE — the fact table
alone — proven end-to-end in TestSteadyStateCostsOneRoundTrip against a conn
that counts prepared INSERTs.

Three invariants, each with a test, because each is a way to lose data rather
than merely re-send it:

  - Only DIMENSION rows are suppressed. event.log and event.span are facts. And
    event.trace is a PARTIAL contribution folded by an AggregatingMergeTree —
    suppressing a repeat there would quietly subtract spans from a trace's own
    span count, so it has no Set at all.
  - A row is recorded only after its write SUCCEEDS. Remembering a failed
    INSERT would drop that sighting permanently; sightings.Write enforces the
    ordering so no call site can get it wrong.
  - One Set per TABLE, never per row type. log_key and log_resource_key are
    both keyRow; a shared Set would let a key seen as a tag suppress the same
    name as a resource key.

Keys carry the 1800s resource bucket, so a sighting refreshes once per window
instead of never: log_attribute.unix_milli and log_resource.seen_at_ts_bucket_start
stay accurate to one bucket. operationRow is keyed WITHOUT its time, which is
the latest span start and therefore differs every batch — (service, name) is
the identity that repeats.

Bounded by two map generations rather than an LRU: forgetting early costs a
redundant write, never a lost row, so the cheap structure is the correct one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 19:53:38 -07:00
zeekayandHanzo Dev cb8ee83161 zapingest: the address is the switch — a linked library takes no port
hanzoai/o11y v1.5.41 shipped pkg/zapingest defaulting Enabled=true with
0.0.0.0:4317-4319 baked in, so importing the module SEIZED three well-known
ports as a side effect of being linked. hanzoai/cloud embeds o11y and runs its
own ingest on the same three ports in the same process; whichever bound first
won, the loser dropped telemetry silently, and log ingest fell ~200x. The
chart's O11Y_ZAPINGEST_ENABLED=false is a tourniquet on that default, and a
flag that must be set correctly in every chart is a third way to get it wrong.

The address IS the switch. `Enabled` is deleted: a boolean beside three
addresses is a second way to state one decision, and the two can disagree —
Validate() already had to reconcile them. Stating where spans arrive IS the act
of deciding that spans arrive here. Every address now defaults to EMPTY, so
linking this package binds nothing and the race cannot be reached by default.
A signal with no address is simply not received; the conventional ports
(4317/4318/4319) are documented rather than defaulted, so the deployment that
publishes them is the one that chooses them.

A collision is now LOUD instead of lost. holderOf() reads procfs to name the
process holding the port, and says so explicitly when that process is THIS one
— which is the actual production shape, two implementations in one binary, and
the case where the answer is "delete one" rather than "retry" or "move the
port". The error is returned (never swallowed) and logged, and a partial bind
is rolled back: a half-bound edge is worse than none, because it reports
partial traffic behind a healthy-looking surface.

The diagnostic is built into the CAUSE, not passed to Wrap*f. errors.base.Error()
returns the wrapped cause's text and DROPS the wrapper's own message, so a
message handed to Wrap*f is retained on the value and never printed by anything
rendering err.Error(). The first draft of this fix hit exactly that: a loud
message that was invisible. Folding it into the cause makes it print while %w
keeps errors.Is(err, syscall.EADDRINUSE) working.

Removing the field is safe for consumers still setting the env: pkg/config's
decoder does not set ErrorUnused, so a stale O11Y_ZAPINGEST_ENABLED is ignored
rather than fatal.

Tests cover the load-bearing default (binds nothing), signal selection,
validation, holder identification, and that the collision message names this
process.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 19:45:43 -07:00
zeekayandHanzo Dev a4c75fef1d routing: a constraint the router would drop is a panic, not a wildcard
fiber names its param constraints and DROPS the ones it does not recognise —
an unknown name parses to noConstraint, and no constraint matches everything.
That is silent, and it is silent on the two routes that can least afford it:
the Sentry ingest wildcards sit after the static /v1/sentry words and are safe
there only because {project:guid} refuses anything that is not a UUID. Spell
that with a pattern fiber does not know — the hand-written character class the
gorilla tree carried, say — and the wildcard quietly swallows every
/v1/sentry/<word>/envelope/ instead of 404ing it.

The allowlist is fiber's own exported constants, not copied strings, so it
cannot drift from the thing it guards. Constraints that take data are absent
on purpose: nothing declares one, and the day something does, the spelling gets
thought about here rather than discovered in production.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 19:45:26 -07:00
zeekayandHanzo Dev 930c860144 sentry: the ingest test routes through the runtime's constraint, not a dead one
The ingest test carried the hand-written character class the route used to be
spelled with. Production moved to fiber's own `guid` in "routing: one router,
and it is zip", and uuidPattern was deleted with it — the only surviving copy of
that class in the tree was this test's string literal.

It was not an equivalent spelling. A constraint is looked up BY NAME and an
unrecognised name is dropped, not refused, so `{project:[0-9a-fA-F]{8}-...}`
declares a constraint the router does not have and matches everything:

  {project:guid}          POST /v1/sentry/not-a-uuid/envelope/ -> 404
  {project:[0-9a-fA-F]..} POST /v1/sentry/not-a-uuid/envelope/ -> matched

So the test exercised an UNCONSTRAINED route while the runtime served a
constrained one, and its comment claimed the opposite ("the template is the
runtime's, uuid constraint and all"). The assertion it did make still held, which
is why it stayed green.

The constraint is not decoration: it is why the ingest wildcard can never shadow
a static /v1/sentry resource word. TestIngestConstraintRefusesANonUUIDProject
pins that directly — POST /v1/sentry/projects/envelope/ must 404. Against the
stale template it returns 400, the wildcard having eaten `projects` and handed
it to the handler as a project id.

No assertion changed; the route the test registers did.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 19:41:36 -07:00
zeekayandHanzo Dev 3011b92ea4 routing: one router, and it is zip
gorilla/mux, otelmux, gorilla/handlers and rs/cors leave go.mod. Every route
the runtime serves is a zip route on a zip group: 233 from the apiserver tree,
134 from query-service, 367 total — the same 367 the mount publishes.

pkg/http/routing is the one registration surface. It exists because a
registration carries three facts zip has no slot for, and the tree this
replaces recovered all three PER REQUEST by asking the router what it had just
matched: the bound segments, the path template, and the route's declared
resources. mux.CurrentRoute(req).GetHandler() was type-asserted back to
handler.Handler in two separate packages to re-derive at request time what was
already known once at boot. Stating them at registration deletes both lookups.

The middleware chain is composed at the LEAF rather than registered ambient,
because Audit keys on the route template and Resource resolves the route's
declared resources — ambient middleware runs before the router has matched, so
both would read an empty route. Per-leaf composition reproduces the order the
old tree ran in exactly: match, then middleware, then handler.

Duplicate registration now panics at boot; gorilla let the first one win.
The Sentry ingest constraint becomes fiber's own guid — a UUID parse instead of
a hand-written character class. Paths keep their public brace spelling in the
source and in the table; the respelling happens in one function.

The standalone server hands its listener to the app, so streams and the
websocket upgrade run native fasthttp.

Counted, not asserted: routes_test.go in the apiserver pins 233 against the
real AddToRouter, routing_test.go pins the table against the router itself.
This repo has already shipped 83 typed ops that reached no router while the
suite stayed green — an uncalled func is legal Go, so arithmetic is the test.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 19:30:40 -07:00
zeekayandHanzo Dev d8a5046dab routing: rules, alerts, traces and sentry read the segment, not the router
Twenty-nine handler bodies across six packages named gorilla/mux to read a path
segment, so the router was braided into the rule, alert, trace and sentry logic
that addresses a resource by id. The value each one needs is the SEGMENT; which
router matched the route is not a fact these handlers should hold. All twenty-
nine now read through coretypes.Param — the one route-value reader — and the
tracefunnel test's two injectors through coretypes.SetParams, its writer half.

A PATH segment and a QUERY value stay apart, because that is the failure this
seam exists to prevent: r.URL.Query().Get is untouched everywhere it appears
(the history window, sentry's ?project=, the funnel limits), and three of the
new tests pin it by sending a query value of the SAME name as the path segment
and asserting the path still answers.

Two helpers, both deleting real duplication rather than adding a second reader:
o11yruler.idFromPath replaces the same five-line uuid-v7 block written out
seven times, and o11yalertmanager.channelIDFromPath replaces a seventeen-line
nil-map/ok/parse dance written out three times. Absent and malformed remain
DIFFERENT refusals in the alertmanager one — "id is required in path" vs "id is
not a valid uuid-v7" — which is what the code it replaces answered.

Eleven new tests, and they are not vacuous. Each drives a REAL router at the
REAL template — /v1/o11y/rules/{id}/history/stats, /v1/o11y/channels/{id},
/v1/o11y/traces/{traceID}/waterfall, /v1/sentry/events/{id}, the uuid-
constrained /v1/sentry/{project}/envelope/ — and asserts the MODULE was asked
about what the router matched, so a passing test needs the whole path from
route table to business call. Stubbing Param to "" (go test -overlay, so the
shared tree is never mutated) turns all six packages RED; before these tests
five of them had no test that touched a param at all.

Registration is untouched: no {id} literal, no *mux.Router, no .Methods() moved.
Route table unchanged — TestEveryRouteIsNamedAndCounted still passes at 367.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:57:15 -07:00
zeekayandHanzo Dev 308c4b7372 dashboards, views, preferences: a path segment is read by name, not by router
The 28 reads across dashboards, saved views and preferences named
gorilla/mux to answer "which id is this request about?". Which router
matched a route is not a fact a handler should know; the VALUE it needs
is the segment. All 28 now go through coretypes.Param.

Query values are untouched: r.URL.Query().Get stays exactly as it was.
/rules/{id} is a PATH segment and ?limit=25 is a QUERY value, and the
seam exists precisely so those two cannot be confused.

Two shapes of read collapse to one. `nameString, ok := mux.Vars(r)["name"]`
becomes `nameString := coretypes.Param(r, "name")` with the ok-check
becoming an empty-check: a route with {name} cannot match an empty
segment, so present-but-empty was never reachable and absence still
answers "".

The two public-dashboard gates held byte-identical copies of the same
subject lookup. They are now one method, provider.publicDashboardSelectors.
That gate runs before any identity exists — it resolves WHO the request is
about from the path — so two copies is two places for the answer to drift.

Tests drive the REAL handlers through a REAL router at the REAL templates
and assert on what reached the module, not on a status code. Asserting a
reader against its own injector would prove only that the two halves of
coretypes agree with each other. Verified by mutation: with Param stubbed
to "" all four packages go red.

Registration is untouched — {id} literals and *mux.Router stay put for the
one atomic commit that moves them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:56:25 -07:00
zeekayandHanzo Dev ccbc148cb3 identity: the id a user route acts on is the segment the router matched
32 path-segment reads across users, service accounts, auth domains and roles
stop naming gorilla and read through coretypes.Param. Which router matched the
route is not a fact these handlers should know — the value they need is the
segment.

Each package gains the test it never had: a REAL route template on a real
router, distinct uuids in each segment, and an assertion on which value reached
the store. /users/{id}/roles/{roleId}, /service_accounts/{id}/roles/{rid} and
/service_accounts/{id}/keys/{fid} each carry TWO segments, and a handler that
reads the wrong name still compiles and still answers 2xx — it just acts on the
wrong resource. The self-modification guard in UpdateUser is pinned by body,
because it compares the PATH segment to the caller: read the wrong name and it
compares "" and lets the edit through.

Registration is untouched — {id} literals, *mux.Router and the gates stay put
for the one atomic commit that converts them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:55:19 -07:00
zeekayandHanzo Dev 20ec5d62ea routing: query service and gateway read a path segment, not gorilla's map
19 reads across three files stop naming gorilla to address a resource by id and
go through coretypes.Param: 13 in the query-service handler (rule id, integration
id, pipeline version, six funnel ids), 1 in the parser (trace id), 5 in the
gateway (key id, limit id). Registration is untouched — this is the read side.

Where a site had bound `vars := mux.Vars(r)` only to index it once, the map is
gone with it; a handler wants the VALUE, and keeping a map alive to hold one
string was the router leaking into the body.

The query half stays exactly where it was. ParseSearchTracesParams now reads the
trace id from the PATH and the span id from the QUERY through two different
calls, which is what they always were.

http_handler.go keeps `mux.NewRouter().UseEncodedPath()` and the websocket
upgrader: those are the internal tree and its transport, converted as one piece
with the other 70 registration files, not here.

parser.go drops the gorilla import entirely; gateway/handler.go likewise.

Tests: neither package had one that exercised a path segment, so both go green
whether the read works or not — this repo has already shipped that defect. Each
now drives a REAL router registered at the REAL template. The gateway rows go
router -> handler -> Gateway and assert the id the gateway was HANDED, so an
empty segment fails on the recorded value rather than on a status code. Under a
Param that returns "", all four tests go red; before them, both packages stayed
green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:53:20 -07:00
zeekayandHanzo Dev 0f213246d8 routing: one reader for a path segment, and it is not gorilla's
A path segment is a VALUE. Which router matched the route is not, and 140
handler bodies named gorilla/mux to read one — so the router was braided into
every piece of business logic that addresses a resource by id.

coretypes now owns the read (routevalue.go): Param for a path segment,
SetParams for the test that has no router, RoutePath for the route TEMPLATE the
audit record must be keyed on. One reader, one writer, one place that knows who
filled them. PathParam — the authz extractor — reads through the same Param, so
the gate and the handler cannot disagree about which segment they mean.

Converted end to end, both ends of one resource plus the shared plumbing:
llmpricingrule's handler, errortracking's handler AND its test injector, the
SelfAccess gate's id, and audit's route template. Nine tests, and they are not
vacuous: stubbing Param to "" turns eight of them red.

Param answers for a PATH segment only. A query value stays r.URL.Query().Get —
conflating the two is how a route starts reading the wrong half of the URL, and
TestParamIsNotAQueryRead pins it. RoutePath answers "" with no matched route
where the old mux.CurrentRoute(req).GetPathTemplate() dereferenced nil.

Route table unchanged: 367 method+path pairs before and after (353 typed ops,
11 hatches, 3 probes). No registration was touched — that flip is atomic across
the whole tree and is the next commit, not this one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:44:32 -07:00
zeekayandHanzo Dev 5ea9ae272a web: the console comes off gorilla — it is an http.Handler now
pkg/web was the last package importing gorilla/mux for something that is not
routing. web.Web carried AddToRouter(*mux.Router): the console's mounting rule,
braided into one router's type. It bought nothing. Every host registered the
same terminal catch-all, so the rule was the HOST's — spelled once, where the
host composes its handler chain — not the console's.

So the interface is the value: web.Web is http.Handler. The net/http host in
pkg/query-service/app/server.go spells r.PathPrefix("/").Handler(web) after
every API route; a zip host spells app.All("/*", zip.AdaptNetHTTP(web)). Same
value, one implementation, mounted where the host mounts things.

Serving stays stdlib rather than zip.Static, and that is a choice not an
omission. zip.Static exists and is good, but it serves bytes out of an fs.FS,
and our shell is NOT a file in the tree: it is rendered once at boot with this
deployment's base href and its settings JSON. WithIndex/WithFallback would serve
the raw index.html and hand the browser a console with no base href and no boot
data. Nothing in this repo hosts the console on a zip app either — cmd/community
(the shipped image) serves a net/http chain, and in the composed cloud binary
webui.Mount already owns /*, so a second /* from o11y would shadow the cloud
console for every path. A zip-only console here would be dark code or a
collision; hanzoai/cloud's own webui is stdlib + AdaptNetHTTP for the same
reason.

noopweb answers 404 instead of registering nothing. Mounting is unconditional
now, so "this deployment has no console" has to be a status — and 404 is the
exact answer it already gave, since gorilla's default NotFoundHandler is what
served the paths the null provider left unregistered. Same bytes, same header.

And the fix the move exposed: the catch-all was swallowing the API. gorilla
resumes the parent router when a subrouter's prefix matches but none of its
routes do, so every miss inside /v1/o11y — a typo, a route this build does not
serve, a method the route does not take — fell through to the console and came
back 200 text/html. A JSON client got the SPA shell. routerweb now refuses /v1
and /ws, segment-exact so a console route that merely starts with the same
letters (/v1beta-…) stays the console's.

Proven by bodies, not by status: GET / -> 200 text/html + the templated shell;
GET /assets/index-a1b2c3d4.js -> 200 text/javascript with the bundle's bytes,
never the shell; GET /services/frontend/overview -> 200 the shell (deep link);
GET /v1/o11y/pods/bogus and /ws/query_progress -> 404 text/plain with no "<html"
in the body. Fixture is a temp dir, so the test needs no frontend build. Deleting
apiPrefixes turns the boundary test red with the exact 200 text/html it is there
to catch. Route table unmoved: 367 routes, 0 catch-alls, 353 MCP tools.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:24:13 -07:00
zeekayandHanzo Dev 3303b3a031 binding: gin is gone — query binding is ours
gin was in go.mod for two reflection helpers and zero routing:

  pkg/http/binding/query.go  ginbinding.MapFormWithTag
  pkg/valuer/valuer.go       ginbinding.BindUnmarshaler

BindUnmarshaler was one method; the Valuer contract now states it directly.
MapFormWithTag is reimplemented in pkg/http/binding/values.go, which is where
it belonged: a binding package should not import a web framework to reflect
over form values.

bindValues is the whole mapper. It carries exactly the semantics 20 bound
structs across this repo actually depend on, enumerated field by field:

  string · bool · int/8/16/32/64 · uint/8/16/32/64 · float32/64
  slices · pointers (nil when the key is absent, allocated when it is not)
  anonymous embedded structs, flattened into the parent namespace
  `,default=` (4 live sites: format, limit, order, orderBy)
  `-` to skip, empty tag falls back to the field name
  Unmarshaler — the one extension point, which is how every valuer enum binds

Dropped as dead weight, none of it reachable from any bound struct: map
targets, multipart, time_format/time_utc/time_location, collection_format,
the `parser` tag, arrays, and the JSON fallback for unrecognised structs.
An unsupported kind is now a named error instead of a silent JSON decode.
`required:"..."` is openapi metadata — MapFormWithTag never read it and
neither does this; a test pins that so nobody re-derives it.

Verified against the code being deleted, not asserted: a differential test
ran gin's MapFormWithTag and bindValues over all 20 real query structs plus
an every-kind fixture, 3520 input cases, comparing decoded structs and
error-ness — identical throughout. Two deliberate mutations (the empty-value
default rule, the pointer-allocation rule) turned it red, so it bites.
That harness is removed here with gin; 82 table cases stand in its place.

go mod tidy also drops goccy/go-yaml, ugorji/go/codec, pelletier/go-toml
and mongo-driver, which were gin codec plugins and nothing else.

Route table unchanged at 367. Build and full suite green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:23:51 -07:00
zeekay c3c3c54ddf merge: reconcile the forge and GitHub tips
Each remote had one commit the other lacked -- the registry-cache fix here, the
named-routes change there. Merged rather than rebased: the forge already holds
this commit by hash, and rewriting it would strand that remote and force a push
to a repo that is the CI source of truth. A merge lets both fast-forward.
2026-08-01 18:10:32 -07:00
zeekayandHanzo Dev 93785eb98b ci: registry cache — the gha backend cannot work on this forge
The ghcr login was fixed and the build still failed, one step later:

    ::warning::Cannot parse GitHub Actions Runtime Token: missing part #2
    ERROR: failed to solve: failed to configure gha cache exporter:
           token is malformed: token contains an invalid number of segments

`type=gha` resolves its backend from an Actions runtime token, and git.hanzo.ai
does not issue one buildx can parse. Because the exporter is configured as part
of the solve, that does not degrade to an uncached build -- it fails the BUILD.
A cache meant to save time was costing the entire job. Same family as
upload-artifact v4+, which the forge also refuses.

Registry cache instead, at the image each workflow already pushes, so no new
credential and no new home. ignore-error=true on both ends: the first build has
no cache image yet, and a 404 there must not be fatal -- that is the second
failure visible in luxfi/dex today.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 18:09:51 -07:00
zeekayandHanzo Dev 2d4b46d8ee mount: the wildcard is gone — every one of 367 routes is named
The delegation catch-all made three facts indistinguishable: a route that was
converted, a route that was not, and a route that did not exist. That is not a
theoretical cost — it is exactly how 83 typed ops shipped in files nothing
called, passing a full green suite while every one of those routes fell through
the wildcard.

So: derive the gap precisely, close it, and delete the catch-all.

THE GAP, re-derived. Extract every path the runtime registers (134 in
pkg/query-service/app/routes_*.go, 233 in pkg/apiserver/o11yapiserver/*.go — the
134 matching the count routes.go documents), normalise :params on both sides,
and diff against what Mount installs. 367 runtime routes, 43 unnamed. Not the
four hatch classes the plan expected: eleven were genuine hatches and
THIRTY-TWO were simply never converted.

CONVERTED (32 new typed ops, three new slices):
  * traces.go       — a trace's spans, the trace field catalog and its tuning
                      write, waterfall, flamegraph, span aggregations
  * tracefunnels.go — funnel CRUD plus twelve analytics reads, six over a saved
                      funnel and six over one described inline
  * spanmappers.go  — the ingest-time attribute mapping groups and their mappers

THE ELEVEN HATCHES, each its own route literal with its reason beside it
(mount.go mountHatches): four streams with no single answer to name (livetail,
query_progress long-poll, its websocket, the chunked export), three 303
redirects whose answer is a Location and not a body, and four Sentry-SDK ingest
doors — a foreign protocol we RECEIVE, whose /api/ segment the SDK hardcodes and
which is not ours to rename.

TWO OF THOSE ELEVEN WERE UNREACHABLE. /ws/query_progress and the two
/v1/sentry/<project>/{envelope,store}/ doors sit outside /v1/o11y, so the old
catch-all never saw them: a Sentry SDK pointed at the clean root got a 404 in
the composed binary, and telemetry_test.go passed only because the test
registered the wildcard the deployment did not have. Naming every route is what
surfaced it.

ONE SEAM, TWO ROOTS. Five copies of the relay function (relay, relayAt,
relayO11y, relayAPM, send) differing only in a prefix and whether they took
query parameters, and NINE names for two values — identityPrefix, infraPrefix,
integrationsPrefix, metricsRoot, apmPrefix, accessPrefix, rulesAlertsPrefix and
o11yRoot were all "/v1/o11y". Ops now pass the whole public path and relay
concatenates nothing: 193 call sites, one implementation, -520 lines.

Also: the per-slice *_zipdoc.go files fold back into zipdoc_gen.go as their own
headers instructed; the health probes' fall-through names its destination
instead of relying on a catch-all being registered after it (they would have
started 404ing the moment SetHealth was late — precisely when liveness matters);
O11yLogField/O11yLogFieldsOut/O11yLogFieldSetting become
O11yTelemetryField/O11yFieldCatalogOut/O11yFieldSetting because logs and traces
tune ONE catalog through one runtime type.

PAYOFF, counted off the document rather than the router: 353 operations in the
published OpenAPI spec and 353 MCP tools, from 0 for this surface before the
conversion began. 353 typed + 11 hatches + 3 probes = 367.

RECORDED, NOT FIXED: the runtime renders with jsoniter and zip with the stdlib
encoder, and jsoniter does not implement `,omitzero` — so for the seventeen
fields across pkg/types that use it the runtime writes the zero value where a
typed op omits the key. Pre-existing on every shipped op reaching
TelemetryFieldKey. Pinned by traces_test.go rather than hidden.

gorilla/mux and gin stay: mux is the runtime's own router in 100 files, and gin
is imported only for its form binder by pkg/valuer/valuer.go
(ginbinding.BindUnmarshaler) and pkg/http/binding/query.go
(ginbinding.MapFormWithTag). go mod tidy dropped 687 lines of go.mod/go.sum.

Build and full suite green: 133 packages ok, 0 fail.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 17:15:37 -07:00
zeekayandHanzo Dev ec1bc6b2c8 ci: name the ghcr credential instead of falling through to it
Both image workflows logged in with `secrets.GH_PAT || secrets.GITHUB_TOKEN`.
That resolves today only because orgs/hanzoai happens to provision GH_PAT -- an
org secret that is absent evaluates to "", so the chain silently swaps to the
git.hanzo.ai token, which ghcr.io rejects with `denied: denied`. The log never
says which credential was used, so the failure reads as a registry problem; that
is how the same defect in luxfi/trader stayed misdiagnosed until the token was
tested directly against the registry.

GHCR_USER + GHCR_TOKEN, named directly. The build secret below is untouched:
`gh_token=${{ secrets.GH_PAT }}` is the credential the Dockerfile mounts to
fetch private hanzoai modules, which is a different job from logging in.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:47:17 -07:00
zeekayandHanzo Dev 45209a832d errors: the error-read family had no test — five routes, now proven
sentryerrors.go typed the five legacy exception reads (listErrors,
countErrors, errorFromErrorID, errorFromGroupID, nextPrevErrorIDs) and
mount.go now reaches them, but the slice shipped without a wire proof.
An untyped route is undocumented; an untested op is merely a CLAIM —
the source says the route is typed and only a proof says the binary
agrees. That gap is not hypothetical in this package: 83 typed ops
reached no router at all until route-table arithmetic caught them
(eb176941cf). A per-slice test is what stops that returning one slice
at a time.

Same method as telemetry/logs/apm: not that the answer looks right, but
that the bytes the RUNTIME wrote and the bytes the OP sent are the same
bytes, for payloads with every field populated, and that the request the
runtime receives is the request the caller made.

These five answer BARE. Every other slice's stand-in writes through
render.Success because those routes carry the {status,data} envelope;
this face is the older one, written with APIHandler.WriteJSON, which
marshals the payload and writes it with no envelope. The stand-in here
mirrors WriteJSON — the wrong one would prove the ops against an
envelope these routes have never sent, a test that passes while the
surface is broken. It marshals the runtime's OWN model types, so the
comparison is against the wire the runtime really writes.

Pinned: byte-identical answers for all five; the listErrors/countErrors
bodies round-tripping through model.ListErrorsParams/CountErrorsParams;
the three lookups' inputs arriving on the QUERY (parseGetErrorRequest
reads r.URL.Query, not a body); errorFromGroupID leaving the errorID its
prose calls unused OFF the wire rather than sending it blank; all five
in the document with an operation id and prose; the ViewAccess refusal
keeping the runtime's own status and reason; 503 with no runtime; and
identity propagated, never minted.

Verified by mutation, not by a green run: un-mounting the slice reddens
the document test; renaming one Out tag reddens the byte comparison;
making errorFromGroupID send a blank errorID, or listErrors drop the
caller's body, redden their own assertions.

The camelCase paths are kept verbatim — they are the public contract;
only the Go identifiers follow house style. No runtime file is touched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:25:17 -07:00
zeekayandHanzo Dev 57f7100751 cloud_integrations: the face had no test — thirteen routes, now proven
The family read as unclaimed twice: once when the surface was measured from
outside (the integrations slice was dark until eb176941c), and once because
NOTHING TESTED IT. integrations.go converted all twenty-nine of its routes and
shipped without a _test.go, so the thirteen cloud_integrations ops had a census
of zero. A conversion nobody measured is a conversion nobody can defend, and
this face is the one where that costs most: it is admin-gated, it hands out
credentials, and it carries a deprecated path real agents are still calling.

No op is added or moved. All thirteen were already typed at 3047f85f3 and all
thirteen already carry zipdoc. This is the evidence they never had:

  wire   — the runtime's bytes and the op's bytes are the SAME bytes for
           credentials, accounts, services and both check-ins, with every field
           of Account, Service and GettableAgentCheckIn populated; bodies decode
           through the runtime's own PostableAccount / UpdatableAccount /
           UpdatableService / PostableAgentCheckIn, so a field the seam drops,
           renames or retypes is a diff, not a silence
  paths  — the cloud_integration_id scope stays a QUERY parameter, ids stay in
           the path, and 201/204 are the statuses the mux declared
  census — exactly thirteen doors, matched by the two PREFIXES this face owns.
           Not a substring: "/services" is APM's and access's too, "/accounts"
           is the service-account face's, and "/integrations" would swallow the
           catalog sharing the same file. Substring censuses are what broke four
           faces when the slices merged; apm_test.go carries three exclusions
           because of this one.
  gate   — carried EXACTLY as mux declares it, and asserted the only way that is
           honest: not by re-stating a role here, but by proving the seam cannot
           bypass the runtime's. Identity is propagated and never minted, a 403
           stays a 403 with its reason, and all thirteen doors 503 without a
           runtime.

THE GATE MAPPING, route by route (pkg/apiserver/o11yapiserver/cloudintegration.go):
  AdminAccess — GET credentials; POST/GET accounts; GET/PUT/DELETE accounts/{id};
                GET services; GET accounts/{id}/services; GET services/{service_id};
                PUT/GET accounts/{id}/services/{service_id}
  ViewAccess  — POST accounts/check_in and POST cloud-integrations/{p}/agent-check-in
                (the agent's role IS viewer; the hyphenated path is deprecated and
                keeps its spelling for agents already deployed against it)

Two findings the tests produced rather than assumed. The check-in body validator
refuses a payload carrying both dialects at once, so each path is exercised with
the one its callers speak — and the round trip proves the seam's re-marshal does
not smuggle the other dialect's zero values in and earn the caller a 400. And a
fail-closed probe cannot send `{}`: the ops embed the runtime's types, so the
runtime's validators run at the seam's binder and refuse first. That is the
DRY payoff (one validator, both deployments) with its price named.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:19:23 -07:00
zeekayandHanzo Dev ed6b7cad76 mount: three slices were DARK — 83 typed ops reached no router
sentryerrors.go (18 ops), rulesalerts.go (36) and integrations.go (29) landed
with the rest of the conversion, but nothing in Mount ever called them. An
uncalled package-level func is legal Go, so the package built, the whole suite
passed, and every one of those routes still fell through the /v1/o11y/* wildcard.
The conversion was true of the SOURCE and false of the BINARY — the exact
zero-value defect class this migration keeps producing: a green surface over a
mechanism that never ran.

Route-table arithmetic is what caught it, not the compiler and not a test:
Mount registered 250 routes against 321 zip.* registrations across the slice
files, and the missing 83 were exactly these three. After wiring: 331.

THE TESTS WERE ENCODING THE DEFECT. Five assertions passed BECAUSE the slices
were dark and flipped red the moment Mount was fixed:
  - mount_test.go pinned /v1/o11y/errortracking/issues as wildcard-delegated
  - telemetry_test.go sampled /v1/sentry/{projects,issues,issues/:id/events,
    events/:id} as wildcarded and counted the whole /v1/sentry prefix as its own
  - access_test.go sampled /v1/o11y/alerts as wildcarded
  - apm_test.go's census counted cloud_integrations' /…/services as APM doors
Each is now scoped or removed, with the reason in place. The tell is general and
worth remembering: a wildcard-delegation assertion that PASSES for a route
someone converted is proof the conversion did not reach the router.

This also dissolves the 'cloud_integrations is an unclaimed family' finding —
those eleven paths were never unclaimed. They were in the dark integrations
slice, which is why they read as runtime-only when the surface was measured from
the outside.

Full suite green; the only wildcard sample left is the Sentry SDK envelope wire,
which is a genuine hatch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:05:58 -07:00
zeekay 18f3897514 docs(readme): cut the second README, and fix the quickstart
Below our own intro sat an entire second README — upstream SigNoz's, with
"SigNoz" replaced by "Hanzo O11y". The rename put a space inside every URL it
touched, so the links read github.com/Hanzo O11y/o11y/... and
twitter.com/Hanzo O11yHq. It also carried an issues badge for o11y/o11y (not
our repo), translated-README links to files that do not exist here, a
pre-filled tweet, four "Hanzo O11y vs Prometheus/Jaeger/Elastic/Loki"
sections, and the line "a cost-effective open-source alternative to Datadog
and New Relic".

The quickstart was wrong as well: `docker compose -f compose.yml up -d` —
there is no compose.yml in this repo — and port 3301. It is
`cd deploy/docker && docker compose up -d`, then localhost:8080, per
deploy/docker/docker-compose.yaml. Added the OTLP ports, since sending data
in is the step that was missing entirely. SigNoz lineage kept, matching
NOTICE.
2026-08-01 13:49:34 -07:00
zeekayandHanzo Dev ded468300a readers: the query builders speak the envelope, not the fork's dead dialect
The traces and logs statement/condition builders emitted the OTLP-fork columns
(timestamp, duration_nano, attributes_string/number/bool, resources_string,
resource_string_service$name, ts_bucket_start, parent_span_id, trace_flags,
scope_name) against event.span / event.log, which carry the 15-column envelope.
Every span lookup and log search failed Code 47 UNKNOWN_IDENTIFIER — executed and
proven before this change, not inferred.

TWO COLUMNS ADDED to event.span and event.log because no rewrite can satisfy them:
  resource_fingerprint String — the builders stamp
    'resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)'
    on the MAIN table, and __resource_filter reads event.span_resource /
    event.log_resource keyed (org, seen_at_ts_bucket_start, fingerprint). The
    fingerprint is a write-time hash of the whole resource label set — derivable
    from NO envelope column. event.log's pre-existing UInt64 'resource' is
    type-incompatible and empty.
  ts_bucket_start UInt64 MATERIALIZED intDiv(toUnixTimestamp(time),1800)*1800 —
    the builders put bucket-range predicates directly on the main table.
    MATERIALIZED-from-time keeps writers 15-column-envelope-only and shares ONE
    bucketing scheme with the four support tables that already speak
    seen_at_ts_bucket_start.
EVERYTHING ELSE REWRITTEN onto the envelope — one naming scheme wins:
timestamp->time, parent_span_id->parent, duration_nano->duration,
kind_string->kind (the live kind IS the string enum, so numeric kind/status_code
predicates translate by enum NAME), status_code_string->status,
has_error->status='error', attributes_string[k]->attributes[k],
attributes_number[k]->toFloat64OrNull(attributes[k]),
attributes_bool[k]->attributes[k]='true', resources_string['service.name'] and
resource_string_service$name->service, http_host->host, the calculated columns
to attribute expressions, and the 28 deprecated camelCase aliases through
oldToNew to the SAME envelope targets — never to table columns.

spantypes TraceDB/TraceSummaryTable move o11y_traces/distributed_trace_summary ->
event/trace; impltracedetail builds its FROM from those constants. Swapping them
ALONE panics TestGetTraceSummary on a nil pointer — the store and its test move
together, which is why they are in this commit and not a smaller one.

Pins moved deliberately, each with the reason in place. live_proof_test.go in
three packages executes the builders' emitted SQL against the real store, so the
next dialect drift fails loudly instead of at runtime.

go build ./... and go test ./... green across every package.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 13:11:33 -07:00
zeekayandHanzo Dev e5b77d8013 zip: the remaining ten slices — all 367 mux registrations are typed ops
access(20) apm(21) dashboards(22) integrations(29) llmobs(14) metrics(19)
platform(23) query-core(16) rules-alerts(36) sentry-errors(18), atop
error(5) infra(44) logs(9) identity(45). Each declares route+gate+wire, so each
projects an OpenAPI operation, an MCP tool, a CLI command and an SDK method. The
367 registrations behind the wildcard projected ZERO of those — that zero was the
whole cost of the fork's net/http surface, and it is now paid off.

Gates carried by relay, never re-implemented: every op forwards to the same
runtime handler the wildcard delegated to, so the original ViewAccess/EditAccess/
AdminAccess/OpenAccess gate remains the one enforcement point and identity is
propagated rather than minted.

THREE COLLISIONS SURFACED ONLY ONCE THE SLICES SHARED A PACKAGE — each a real
finding no isolated agent could see:
  - 9 types declared twice (O11yRole*, O11yQuery*, O11yMetricMetadataOut). One
    owner kept per type.
  - metricMetadata was ONE Go name for TWO routes (/metrics/metadata and the
    older service-scoped /metric/metric_metadata). Renamed, not merged —
    collapsing them would have silently dropped the service scope the legacy one
    accepts. The route is the identity; the name follows it.
  - Four route censuses matched on substrings that were unambiguous only while
    their slice owned the prefix alone ('/service' also matches
    /service_accounts; '/list' also matches /services/list). Each scoped to its
    own face, or every slice fails the moment another converts.

Three assertions updated deliberately: a route that now dispatches as a typed op
can no longer be asserted to fall through the wildcard — asserting that would be
asserting the migration failed. The wildcard guard now covers exactly the honest
escape hatches (livetail stream, SSO 303 callbacks, long-poll progress).

go build ./... and go test ./... both green across every package.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:41:48 -07:00
zeekayandHanzo Dev 6737eb8f30 zip: sentryerrors — typed ops with zipdoc
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:27:00 -07:00
zeekayandHanzo Dev 30aa051e73 zip: rulesalerts — typed ops with zipdoc
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:59 -07:00
zeekayandHanzo Dev 398668d578 zip: querycore — typed ops with zipdoc
# Conflicts:
#	infra_test.go
#	mount.go
#	mount_test.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:59 -07:00
zeekayandHanzo Dev dd6d399599 zip: platform — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:57 -07:00
zeekayandHanzo Dev 8ac78abed3 zip: metrics — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:55 -07:00
zeekayandHanzo Dev 351448b421 zip: llmobs — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:54 -07:00
zeekayandHanzo Dev f70b49eb3e zip: integrations — typed ops with zipdoc
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:53 -07:00
zeekayandHanzo Dev 8e8c722e4c zip: dashboards — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:53 -07:00
zeekayandHanzo Dev 9b6a89fe0b zip: apmdomains — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:53 -07:00
zeekayandHanzo Dev ee8723ebbd zip: access — typed ops with zipdoc
# Conflicts:
#	mount.go

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:26:52 -07:00
zeekayandHanzo Dev d78ae42e2c openapi: one emitter, not two — the typed registry is the document
o11y wrote the same server down twice. Emitter A is the typed op registry:
identity 45 + infra 44 + logs 9 + telemetry 5 = 103 ops registered on the
cloud.Router at their full public /v1/o11y/... paths, read from a real mount by
cloud's describe.go and woven into the one published document.

Emitter B was `o11y generate openapi`: a swaggest reflector walked the runtime's
gorilla/mux tree and wrote a committed 629 KB docs/api/openapi.yml — 120 paths,
174 ops, spelled /api/v1 x59, /api/v2 x57, /api/v3, /api/v4, /api/v5 x2, and
ZERO /v1/o11y. It described the fork's internal tree behind the delegation
wildcard's /v1/o11y/* -> /api/* rewrite: a surface no customer can address, in a
shape that breaks two house rules on its face (/api/, and v2 through v5). No CI
step regenerated or verified it, so it could only drift.

B dies whole: cmd/openapi.go, registerGenerateOpenAPI, pkg/o11y/openapi.go (the
reflector, unreachable once the command went) and the committed artifact.
pkg/http/handler's OpenAPIDef stays — it is per-handler prose the typed ops read,
not a second document.

Two scratch leaks from the same effort go with it:

  - zzz_surface_measure_test.go, self-labelled "TEMPORARY … not for commit",
    committed anyway and writing to a hardcoded /home/z/.cache path.
  - replace github.com/hanzoai/cloud => ../cloud. No CI runner has that sibling,
    so ci.yaml carried a second checkout of cloud at an exact commit to supply
    it — the same fact in two places, which is what LLM.md's own section warned
    about. The replace hid a broken pin, too: the required v1.801.218 no longer
    compiles against current hanzoai/tasks, and nothing noticed because CI never
    built the required version. go.mod now requires cloud v1.801.350 from the
    proxy like every other private hanzoai module; the checkout step is gone.

go build ./... clean; go test ./ ./cmd/... ./pkg/http/handler/... ./pkg/apiserver/...
./pkg/o11y/... all ok.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:20:15 -07:00
zeekayandHanzo Dev bd8e6ae35f zip: rules & alerting — 36 routes become typed ops (gates carried by relay)
The rules & alerting face (alert rules, test-fire, downtime schedules, rule
state history v1+v2, notification channels, route policies, alerts) reached
traffic only through the /v1/o11y/* delegation wildcard, so its 36 routes
projected as 0 documented operations — no OpenAPI, SDK, CLI or agent surface.

rulesalerts.go declares each as a TYPED zip op that RELAYS to the same runtime
handler the wildcard forwards to (rulesAlertsRelay, the shared refusal reader):
the mux route, its ViewAccess/EditAccess/AdminAccess gate, the handler and the
{status,data} envelope all still run one layer in, unchanged. Gates are carried
by relay, not re-stated — each op comments the gate its route has always had.

Payloads are the runtime own types (ruletypes/alertmanagertypes/
rulestatehistorytypes/telemetrytypes and, for the v1 history writes,
query-service/model); only the {status,data} envelopes and GET query shapes are
named locally, so the two cannot drift. rulesalerts_zipdoc.go carries the prose;
each mux registration file notes the second dispatch. No escape hatches — every
route is a real typed contract.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:16:47 -07:00
zeekayandHanzo Dev 5f3b441289 zip: llmobs — 14 routes become typed ops (View/Edit/Admin gates carried by relay)
The LLM-observability face — the four gen_ai span views (observations, traces,
sessions, users), eval scores, human annotations and the LLM pricing rules —
was fourteen routes reachable only through the /v1/o11y delegation wildcard, so
in no document: no SDK method, no CLI command, no agent tool, no reference page.

Typed at the module mount seam (root llmobs.go), each op hands its call to the
SAME runtime handler the wildcard forwards to (relayO11y), so the wire does not
move: identity resolution, the mandatory X-Org-Id tenant scoping of the span
views, the exact ViewAccess/EditAccess/AdminAccess role gates and the
status/data envelope stay the runtime's. The mux registrations stay — they are
the router the relay forwards into and the only one the standalone server has.

Gates (enforced by the runtime behind the relay):
  ViewAccess  observations, traces, sessions, users, list/get scores,
              list annotations, list/get pricing rules
  EditAccess  create score, delete score, create annotation
  AdminAccess upsert pricing rules (PUT), delete pricing rule

Void writes (delete score, upsert pricing rules, delete pricing rule) answer the
empty 204 a nil Out has always meant. Operation ids carried over verbatim.
llmobs_zipdoc.go generated by zipdoc; wire/routing/document proofs in
llmobs_test.go.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:10:12 -07:00
zeekayandHanzo Dev 57bb4796b0 zip: integrations — 29 routes become typed ops (Admin/Edit/View gates carried by relay)
The integrations face — the integration catalog and its install lifecycle, the
cloud-integrations account/service surface, the gateway ingestion keys and their
limits, and the Zeus deployment profile/host writes — reached traffic only
through the /v1/o11y delegation wildcard, so its twenty-nine routes projected as
zero documented operations: no SDK method, no CLI command, no agent tool, no
reference page. They are now typed zip ops (integrations.go) with named In,
named Out and their prose in the document (integrations_zipdoc.go).

The wire does not move: each op relays to the SAME runtime handler the wildcard
delegates to (integrationsRelay), so identity resolution and the role gate the
mux registration declared are still the runtime's, one layer in, enforced from
the forwarded identity headers via the IdentN middleware. Gates carried exactly:
AdminAccess (cloud accounts/services, Zeus profile/host writes), EditAccess
(gateway ingestion keys and limits), ViewAccess (catalog + install lifecycle,
the two agent check-ins, Zeus GetHosts). The four runtime files keep their mux
registrations — both halves are needed, the ops serve the composed binary and
the mux serves the standalone process — with a note saying why.

mountIntegrations is defined but not wired into the shared mount.go, and the 29
zipdoc entries live in their own integrations_zipdoc.go: the mount seam and
zipdoc_gen.go are folded in by the final pass across all parallel slices, the
same way the other split *_zipdoc.go files are.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:06:57 -07:00
zeekayandHanzo Dev 3af978d294 zip: query-core — 16 routes become typed ops (ViewAccess/EditAccess carried)
The composite query engine and the reads that feed it answer as TYPED ops on
the native router: the v5 querier (query_range, its dry-run preview, variable
substitution), the legacy metrics range read and the builder-format echo,
attribute autocomplete for filter building, the field catalog, and the saved
explorer views — sixteen operations that reached traffic only through the
delegation wildcard, in no document. Each now carries a named request In, a
named response Out and its prose, and is therefore in the OpenAPI spec, the
SDK, the CLI and the MCP tools.

THE WIRE DOES NOT MOVE: each op relays to the SAME runtime handler the wildcard
delegates to (relayAt), so identity resolution, the org gate and the exact role
check each route has always run — viewer on the reads and the query engine,
editor on the saved-view writes — plus the validation, the audit record and the
{status,data} envelope are all still the runtime's, in the order they always
ran. What is new is the TYPE.

THREE routes stay on the wildcard as escape hatches, byte-identical: the
query_progress long-poll and its /ws twin (a websocket the relay's httptest
recorder cannot upgrade) and export_raw_data (a chunked CSV/JSONL download with
no JSON answer to name). See the escape-hatch record in querycore.go.

Gate map (carried exactly, enforced one layer in by the relayed handler):
  ViewAccess  → GET+POST query_range, query_range/preview, substitute_vars,
                query_range/format, autocomplete ×3 + auto_complete values,
                fields/keys, fields/values, explorer list + get
  EditAccess  → explorer create, update, delete

Wiring: mountQueryCore(app) registers the sixteen ahead of the wildcard
(specific-beats-wildcard). Two shared route-census tests are updated for the
new doors: mount_test swaps its now-typed query_range delegation example for
the still-wildcarded query_progress; the infra census filter excludes
query-core's autocomplete/* (which also carry attribute_*). Descriptions in
querycore_zipdoc.go are generated from the handler and field doc comments.

Full build (go build ./...) and full test suite (go test ./..., 133 ok / 0 FAIL)
green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:02:59 -07:00
zeekayandHanzo Dev 06eaab672d zip: apm — 21 routes become typed ops (ViewAccess carried by relay)
The service catalog (5), the messaging-queue views (14) and the third-party API overview (2) — the APM reads that reached traffic only through the /v1/o11y delegation wildcard — become typed zip ops in apm.go. Each relays in-process to the SAME runtime handler the wildcard delegates to (relayAPM), so identity resolution, the org gate, the ViewAccess role check, the audit record and the success envelope stay the runtime's, one layer in — the wire does not move. apmRefusal preserves the query-service {status,errorType,error} refusal shape the shared reader would flatten. Named In/Out for every read; polymorphic cells (query-range, v5 union) stay json.RawMessage passthrough.

apm_zipdoc.go: the 21 descriptions, split per-slice from zipdoc_gen.go. apm_test.go: byte-identical wire proofs, the 21-route census, document presence, identity propagation, refusal and fail-closed.

mount.go: one-line mountAPM wiring, matching the telemetry/infra/logs/identity precedent — required for the external o11y_test suite to see the routes through the exported Mount.

infra_test.go: scoped its census /list heuristic to infra's own eleven resources so it no longer miscounts another face's /list routes (apm's /services/list, /third-party-apis/overview/list) as infra doors. Test-only; the want-set stays 44.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:01:28 -07:00