Files
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

115 lines
5.0 KiB
Go

package implsentry
import (
"context"
"sync"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/modules/organization"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// THE PLANE'S VOCABULARY, said once.
//
// `event.error` is a slice of the ONE event plane, and the plane names a tenant by its
// IAM organization SLUG and a surface by its PRODUCT — the strings every other writer
// on it emits (`hanzo`, `docs`) and every reader binds. This module works in UUIDs,
// because that is what the HTTP surface and the project rows are keyed on. The two
// vocabularies meet HERE and nowhere else.
//
// They used not to meet at all: Insert wrote `orgID.String()` straight into `org`, so
// ONE tenant appeared on the plane as two tenants — 86 rows under
// cd35a51b-f7e7-5412-b67f-58b8703e5219 and 2 under `hanzo`, which is the UUIDv5 of
// exactly that slug. A reader binding either spelling saw a fraction of its own
// errors, and the plane's own tenant assertion (0002, step 5) rejects the column.
//
// WHY THE SLUG AND NOT THE UUID:
//
// - The uuid is DERIVED FROM the slug. `iamidentn.toUUID` is
// uuid5(NameSpaceURL, "hanzo:o11y:org:"+slug), so the slug is the primal value and
// the uuid a projection of it. Storing the projection loses the original and makes
// every reader re-derive; storing the primal value is total.
// - Every other writer and reader on the plane already says slug: cloud stamps the
// validated IAM owner into `org`, cloud's lenses bind `org = ?` with it, insights
// routes on it, llmobs filters on it. A second spelling is not a second opinion,
// it is a partition.
// - The plane's DDL says so: "`org` is THE tenant: the IAM organization slug,
// stamped server-side from the validated principal, first in every sort key in
// this namespace. Never a UUID, never an integer, never translated through a
// lookup table."
// - `org` is LowCardinality(String) and leads every sort key here. A uuid defeats
// both the dictionary and the property that a row says whose it is.
//
// The same argument decides `product`: a Sentry project IS a surface — `docs`, `app`,
// `chat` — so its slug is the product name, and the plane already holds those exact
// values from cloud. A project's slug is assigned at creation and there is no update
// path for it (sentry.Module has Create/Get/List/Rotate/Delete and no Update), so it
// is stable enough to key rows on.
//
// A CONSEQUENCE WORTH SAYING OUT LOUD: because cloud writes `product = 'docs'` for the
// same org, a Sentry project slugged `docs` now reads cloud's errors for that surface
// as well as its own. That is the one table doing what it is for — same tenant, same
// surface, same kind of fact — and it cannot cross a tenant, because `org` leads every
// predicate. It is a UNION, not a leak. If a project ever needs to see only what its
// own DSN captured, that is a narrower on how the fact ARRIVED, and the honest place
// for it is a column that says so, not a second spelling of the surface.
// Scope resolves the identifiers a caller works in to the names the plane stores. It
// is the ONE place a uuid becomes a name; everything above it speaks uuid and
// everything below it speaks the plane.
type Scope func(ctx context.Context, orgID, projectID valuer.UUID) (org, product string, err error)
// NewScope reads the names from the records that own them — the org row and the
// project row — and memoizes them.
//
// Memoizing is sound because neither name changes: an org's is written once by
// provisioning and a project's once by Create. It matters because Scope is on the
// ingest path, where the alternative is two row reads per batch.
func NewScope(orgs organization.Getter, projects sentrytypes.ProjectStore) Scope {
var known sync.Map // valuer.UUID -> string, for both kinds; ids are globally unique
name := func(id valuer.UUID, read func() (string, error)) (string, error) {
if cached, ok := known.Load(id); ok {
return cached.(string), nil
}
got, err := read()
if err != nil {
return "", err
}
// An empty name is NOT cached and NOT returned: naming is the tenant
// boundary, so an unnamed id must fail the operation rather than write a
// row nobody owns.
if got == "" {
return "", errors.Newf(errors.TypeNotFound, sentrytypes.ErrCodeSentryNotFound,
"no name on the event plane for %s", id.String())
}
known.Store(id, got)
return got, nil
}
return func(ctx context.Context, orgID, projectID valuer.UUID) (string, string, error) {
org, err := name(orgID, func() (string, error) {
row, err := orgs.Get(ctx, orgID)
if err != nil {
return "", err
}
return row.Name, nil
})
if err != nil {
return "", "", err
}
product, err := name(projectID, func() (string, error) {
row, err := projects.Get(ctx, orgID, projectID)
if err != nil {
return "", err
}
return row.Slug, nil
})
if err != nil {
return "", "", err
}
return org, product, nil
}
}