12 Commits
Author SHA1 Message Date
zeekay 4679327ddc Sentinel reads the errors /v1/event lands, and takes none itself
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
The event plane folded every occurrence into event.fact, and the Sentry
module still read and wrote event.error. Its reads now select the error
signal of event.fact, mapping issue, env, origin and the severity band.

Errors enter through /v1/event only. The Sentry envelope and store
routes, DSN keys and their rotation leave the module; the consumer of
event.error files each fact through Ingest(org, product), which keeps
the issue lifecycle and returns the issues a batch created.
2026-09-30 01:15:34 -07:00
zeekay f0ad9f2321 refactor: use, not mount — one verb for composition
cloud renamed its composition verb to zip own word, and this module publishes
the other half of that seam.
2026-08-29 14:08:33 -07:00
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 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 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
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
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 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 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 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
zeekayandHanzo Dev c4acee269a telemetry: the five error-read routes answer as typed ops
discover, logs, traces, traces/{id} and stats reached traffic only through the
delegation wildcard, so they were in no document: no SDK method, no command, no
agent tool, no reference page for a customer's own error telemetry. They are
typed ops now — real In, real Out, and the prose zipdoc lifts out of the handler
doc comments — which is the only construct that reaches all of those projections.

OURS, all five: pkg/apiserver/o11yapiserver/sentry.go, pkg/modules/sentry and
pkg/types/sentrytypes were added whole by 9fc67566af, so no upstream resync cost
is being spent here. Nothing in this unit was refused.

THE WIRE DOES NOT MOVE, and not by assertion. The ops do not re-implement the
reads: each hands the call to the SAME runtime handler the wildcard delegates to,
so identity resolution, the org gate, the role check, the audit record, the
timeout and the success envelope stay exactly where they were and run in the
order they always did. What is new is the TYPE. telemetry_test.go proves it the
only way that counts — it takes the bytes the runtime wrote and the bytes the op
sent, for an event with every field populated, and demands they are the same 782
bytes; likewise for traces, stats, one trace (whose map-shaped answer pins the
field ORDER of the Out) and discover, whose body the runtime must receive field
for field. Paths, methods and the 200 are pinned the same way, and the other
eleven routes of the face are pinned still reaching the runtime through the
wildcard, with the five typed paths taking precedence over it whatever order the
host mounted them in.

Identity is PROPAGATED, never minted: the gateway's assertion travels on as the
same headers it arrived on. A context with no request behind it carries none, so
the runtime's gate refuses it — the honest answer rather than an identity
invented at this hop.

TWO DISPATCHES, ONE IMPLEMENTATION. The mux registrations stay: the standalone
server has no native router to register an op on, the composed binary has no mux
tree of its own, and both end in the same handler. Deleting either half drops one
of the two deployments, so o11yapiserver/sentry.go now says that where a reader
of it will find it.

DELTAS A CALLER CAN SEE, stated rather than buried. A REFUSAL now carries the
platform's error shape ({status, code, error}) rather than the runtime's
({status, error:{code, message}}); the status code and the reason are both
preserved, the envelope around them is the one every typed op answers with.
Response headers other than the status are not relayed, and the relayed hop
carries the caller's identity, not their socket.

Schema names are qualified — O11yEvent, O11yTrace, O11yTable and their siblings —
because these types enter a fleet-wide document where one name may have exactly
one shape.

GATE, all executed: go build ./... green; go test ./... green (exit 0);
go generate -run zipdoc ./ regenerated zipdoc_gen.go, all five described.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-31 18:00:04 -07:00