HIP-0129: Eval — A Score Over Model Output
Abstract
hanzoai/eval is the third plane of the Hanzo AI surface and its own subsystem
repo. Three planes, orthogonal:
| Plane | Question | Direction | Owns |
|---|---|---|---|
| o11y | what happened | passive, derived from events | spans, logs, metrics — read only |
| ai | do the thing | inference | models, completions, embeddings |
| eval | was it good | judgment | dataset, judge, rubric, experiment, run, score, queue |
eval consumes o11y and calls ai. Neither depends on eval, and neither may ever. That single-direction edge is the whole justification: judgment is a different concern from observation and from inference, so it is a different plane, with its own repo, its own store, its own routes, and its own name. Repo hygiene is not the argument — an acyclic dependency graph is.
The plane exists today as a cloud subsystem, not yet as its own repo:
apps/eval in hanzoai/cloud serves /v1/eval/* (the manifest row is
manifest/apps.go:408, the group apps/eval/eval.go:207); o11y serves a
write endpoint, POST /v1/o11y/llm/annotation
(pkg/apiserver/o11yapiserver/llmobs.go), inside a read plane; apps/o11y
serves a second, unrelated annotation-queue entity at /v1/o11y/reviews on
its own SQLite file (annotation_store.go, pool o11y_annotations). One
resource, three owners, so none of them kept it in sight of the others —
which is why the console's queue module and the span plane's annotation write
name the same idea at two addresses, and neither side is wrong. This HIP does not patch that
mismatch. It moves the resource to the plane that owns it, after which the mismatch
is not expressible.
Motivation
A score is not an observation. An observation is a fact the system emitted about
itself; a score is a verdict a judge — a model or a human — pronounced about that
fact. Storing verdicts in the observation plane forces that plane to accept writes,
and a plane that accepts writes is no longer derivable from events. That is how
o11y grew a POST, a mutable llm_scores table with a DELETE, and a free-text
queue column naming a queue entity that lives in a different repo.
Judgment also costs money — a judge run is inference — so the plane that issues verdicts must be the plane that is billed, on the one payer path, or the cost lands under the wrong owner. And judgment must be observable by the plane it observes: an eval run that emits no spans is a black box asking to be trusted.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119.
§1 The plane, and the direction of the edge
hanzoai/eval MUST depend on o11y and ai only through their public surfaces, and
o11y and ai MUST NOT depend on eval — not by import, not by module requirement, not
by a table one of them reads. A build of either that resolves hanzoai/eval in
go list -deps is a defect, not a convenience.
Consequences, stated so they are not re-argued:
- Anything eval takes from o11y or ai is a read. eval MUST NOT write to a table
another plane owns — in particular
hanzo.cloud_usage, which is ai's metering warehouse (HIP-0106) and never a second observation store. - Anything that only aggregates what already happened is observability, not
judgment, and MUST NOT live on this plane.
GET /v1/eval/metrics— which readshanzo.cloud_usageand the o11y span index and writes nothing — is an o11y board mis-homed on eval; it moves to o11y and does not reappear under/v1/eval. It is still served today (apps/eval/metrics.go, in the published subset), so the move is owed, not done. - eval is the only plane that stores verdicts. There is exactly one score store.
§2 What eval owns
| Resource | It is | Store |
|---|---|---|
| dataset | a named set of inputs, with items | metastore |
| item (of a dataset) | one input, identified within its dataset | metastore |
| judge | a named evaluation method — kind: model (LLM-as-judge) or kind: code | metastore |
| rubric | the definition a verdict is produced against and validated against: numeric with bounds, categorical with a label set, or boolean | metastore |
| experiment | a named comparison that groups runs | metastore |
| run | one execution of a judge over a dataset, with its per-item results | metastore (rollup) + OLAP (items) |
| score | one verdict — value, rubric, source (model | code | human), optional comment | OLAP, append-only |
| queue | a queue of items awaiting human judgment, with items and assignment | metastore |
Two renames, both from first principles, both narrowing a name to what it is:
evaluator→ judge. It judges.ai/object/eval_judge.goalready calls the typeJudgeRubricand the entry pointRunJudge; the resource takes the name the code already uses.score-config→ rubric. It is the criteria and the admissible range of a verdict, which is what a rubric is, and it is one word rather than two.
One deletion. There is no annotation resource. What POST /api/annotation
wrote was a verdict with an author and no rubric — a score, missing its definition.
A human's output on this plane is a score with source: human, validated
against the same rubric a model judge is validated against. The queue is what routes
work to the human; the score is what the human produces. Deleting the noun is what
makes the three-way seam unrepresentable rather than merely fixed.
§3 Route surface — one prefix, /v1/eval, singular throughout
What is served today is the plural surface, and any reader of the table
below should know the distance: /v1/eval/{datasets, datasets/{name}, datasets/{name}/items, evaluators, rubrics, runs, scores, traces, metrics} —
nine paths, in plugin/eval/openapi.json, with the resource still named
evaluators rather than judge and no queue routes at all. The table below
is the target grammar this HIP proposes; until the migration lands, the
capability's name and address are eval (manifest/apps.go:408).
Every eval endpoint MUST live under /v1/eval and MUST follow the resource-name
grammar (HIP-0119 §2): the resource is named once, in the singular, and the HTTP
method carries collection-versus-item. POST /v1/eval/dataset creates one;
GET /v1/eval/dataset lists them; GET /v1/eval/dataset/{name} reads one.
Nesting rule: a child is nested only when its identifier is scoped by its parent
(a dataset item's id means nothing without its dataset). A resource with its own
id, unique within the org, is flat and filtered by query parameter. Compound
segments (dataset-items, score-configs, annotation-queues) are the shape this
rule replaces, and MUST NOT appear.
| Method | Path | Notes |
|---|---|---|
| POST · GET | /v1/eval/dataset | create · list |
| GET · DELETE | /v1/eval/dataset/{name} | read (with item count) · delete (with its items, one tx) |
| POST · GET | /v1/eval/dataset/{name}/item | create · list |
| GET · PATCH · DELETE | /v1/eval/dataset/{name}/item/{id} | an item MUST NOT change dataset |
| POST · GET | /v1/eval/judge | create · list |
| GET · PATCH | /v1/eval/judge/{name} | |
| POST · GET | /v1/eval/rubric | create · list |
| GET · PATCH | /v1/eval/rubric/{name} | |
| POST · GET | /v1/eval/experiment | create · list |
| GET | /v1/eval/experiment/{id} | |
| POST · GET | /v1/eval/run | start a run · list, ?experiment= ?dataset= |
| GET | /v1/eval/run/{id} | rollup |
| GET | /v1/eval/run/{id}/item | per-item results |
| POST · GET | /v1/eval/score | record a verdict · list, ?run= ?item= ?trace= |
| GET | /v1/eval/score/{id} | |
| POST · GET | /v1/eval/queue | create · list |
| GET · PATCH · DELETE | /v1/eval/queue/{id} | |
| POST · GET | /v1/eval/queue/{id}/item | enqueue · list |
| GET · PATCH | /v1/eval/queue/{id}/item/{itemId} | |
| PUT · DELETE | /v1/eval/queue/{id}/item/{itemId}/assignment | claim · release |
| GET | /v1/eval/health | eval's own probe, §5 |
A score MUST NOT be updated or deleted. A verdict is the record of a judgment that
was made; a retraction is a new score, not an edit of the old one. The OLAP
engine enforces this by construction, and no route may work around it — which is
precisely why DELETE /v1/o11y/llm/score/{id} does not survive the move.
Static sub-routes MUST be registered before their {param} siblings, so a real
dataset name can never shadow a collection route.
§4 Annotation moves off o11y
The following are removed, not aliased:
- o11y:
GET/POST /v1/o11y/llm/annotation(pkg/apiserver/o11yapiserver/llmobs.go) and thellm_annotationstable. - o11y:
GET/POST /v1/o11y/llm/scores,GET/DELETE /v1/o11y/llm/score/{id}andllm_scores. Score is a verdict; verdicts live on one plane, and it is not the read plane. - cloud:
apps/o11y/annotation_queues.go, its eight/v1/o11y/reviews*routes and itso11y_annotationsmetastore (apps/o11y/annotation_store.go).
o11y keeps exactly what it is: the passive span, log and metric views
(/v1/o11y/observations, /traces, /sessions, /users), which read and never
write. A POST in a read plane is the defect; deleting it is the fix. Because the
queue and the verdict now have one owner under one prefix, the console's
/v1/o11y/reviews call against a server registering /v1/o11y/llm/annotation
cannot be written at all — there is no second spelling left to disagree with. A
correct boundary does not need two sides kept in sync; it makes the disagreement
unrepresentable.
§5 The subsystem contract
eval is a Hanzo service (HIP-0119) and a cloud subsystem (HIP-0106). It MUST be:
ZAP-native. Routes are registered on *zip.App (HIP-0122); transport is HTTP +
ZAP (HIP-0114/0120). No gRPC — not on the wire, not in the module graph. No /api/
prefix anywhere, per HIP-0119 §2/§9.
Mounted as data, with no dependency cycle. hanzoai/eval exports
Mount(app *zip.App, deps eval.Deps) error and
Shutdown(ctx context.Context) error, where eval.Deps is a small set of
interfaces eval declares itself — logger, data directory, durability, an
inference seam, a datastore seam, a meter seam. hanzoai/eval MUST NOT import
hanzoai/cloud. This is deliberate, and it is not how o11y does it: o11y's Mount
takes cloud.Deps, so o11y/go.mod requires hanzoai/cloud and carries
replace … => ../cloud, which makes the pinned version and the CI checkout ref one
fact in two places. A new plane MUST NOT inherit that tax.
Cloud names eval in exactly two places: a thin adapter in cloud/clients/eval that
fills eval.Deps from cloud.Deps, and one line in apps.Wire():
{Name: "eval", Mount: eval.Mount, Shutdown: eval.Shutdown, OwnsHealth: true},
Slice position IS mount order (cloud.MountSpec has no order field); MountAll
registers Shutdown via app.OnShutdown, which zip drains LIFO after in-flight
requests. Wiring Shutdown is REQUIRED: today's eval.Shutdown exists and is never
wired, so the metastore handle is not closed on SIGTERM. OwnsHealth: true
suppresses the generic always-ok route; GET /v1/eval/health MUST return 200 when
the metastore is open and report OLAP availability in its body. It MUST NOT return
503 merely because the OLAP store is absent — that degradation is per-route (§9).
Org-keyed and fail-closed. Org is the only tenant key, on every read and every
write. Every handler MUST resolve the tenant first, via principal.Validated(c)
then principal.Org(c), and MUST 403 when either fails. A handler MUST NOT read
X-Org-Id (or any identity header) directly: SanitizeIdentity strips and re-mints
those headers, but it restores a client-supplied X-Org-Id on the bearer-less path
for data scoping — so an org without a validated principal is forgeable, and only
the Validated gate closes it.
The org string MUST be used verbatim: never lowercased, never trimmed, never
folded. Normalizing collapses distinct owners into one storage bucket and puts the
authorization check and the storage key on different values. Metastore tables MUST
carry a NOT NULL org column with a composite (org, id) primary key — a global id
primary key is a cross-tenant existence oracle. OLAP tables MUST order by org
first, so a tenant read is a prefix scan. principal.ProjectScope(c) narrows within
an org; it never widens. principal.IsSuperAdmin(c) MAY drop the org predicate only
on an explicitly fleet-wide read, and §3 defines none.
Metered on the one payer path. Judge inference is inference: it MUST be issued
through the ai seam carrying the caller's identity, so ai's own path meters it
into hanzo.cloud_usage exactly once. eval MUST NOT write cloud_usage, MUST NOT
construct a second metering.Client, and MUST NOT re-derive who pays. Data scope
reads principal.Org; money reads principal.HomeOrg / principal.WalletOf, whose
answer comes from hanzoai/account.Payer and nowhere else — the gate and the debit
must address the same wallet, a split that has already shipped wrong twice.
Where eval charges for its own resources, it uses the one in-handler sequence:
fee := cloud.ResourceFeeCents("EVAL", kind)
_, projectValidated := principal.ValidatedProject(c)
if err := bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
// … do the work …
bill.Meter(principal.HomeOrg(c), project, kind, fee, c.RequestID(), cloud.ClientIP(c))
bill is the *cloud.ResourceMeter that cloud.NewBase(deps, "eval") supplies,
with provider eval. A run MUST be gated before the first judge call and MUST
NOT write a score when the gate denies.
What is priced today: the surface itself is free — the plugin declares
Price: cloud.Free (plugin/eval/main.go:21) and ResourceFeeCents("EVAL", …)
is unset, so every eval resource costs nothing in those words. The only money
on this plane is the judge's inference, metered by ai on the caller's
identity as above. The capability publishes no events on the bus, so a
customer's webhooks receive nothing from it. Its stage is ga (HIP-0139
§8): the manifest row declares no stage. It forks, embeds or mirrors
no OSS upstream — its OLAP tables ride the shared datastore client
another capability owns (apps/eval/telemetry.go), which is that
capability's upstream to declare, not this one's.
Observable by the plane it observes. eval MUST emit spans from the global
tracer provider (otel.Tracer("hanzo-eval")), parented from c.Context() — cloud
installs the one provider before mounting subsystems and calls
aiobject.AdoptHostTracerProvider() so ai's gen_ai spans share it. eval MUST NOT
construct a provider or an exporter of its own.
Today apps/eval constructs no tracer and emits no span of its own (there is
no otel import in the package), so what a customer can read back under
/v1/o11y from this capability is the request span every route already gets
and nothing more. The mandate below is the unimplemented half of this section.
Run, item and judge spans MUST carry gen_ai.hanzo.org_id set to the caller's org —
o11y's llmobs views hard-filter on it, and a span without it is invisible. eval adds
eval.run.id, eval.dataset, eval.item.id, eval.judge, eval.rubric,
eval.score.name, eval.score.value, eval.score.source. Cost attributes
(_o11y.gen_ai.*) belong to ai and MUST NOT be re-emitted by eval — one cost
vocabulary, emitted by the plane that knows the price.
§6 Storage
Two stores, one seam each.
Metastore — encrypted SQLite (cek) under DataDir, opened through
cloud.OrgDB / cloud.NewOrgStore with cloud.WithDurable so it survives a rolling
deploy on the HA plane (HIP-0107). It holds dataset, item, judge, rubric,
experiment, run rollup, queue, queue item and assignment. Natural keys are unique
within an org ((org, name)), never globally. Today's implementation is
one shared file with a NOT NULL org column as the trust boundary
(apps/eval/store.go), not a file per org — the per-org-file question is
named in Open questions and this paragraph is its target state.
OLAP — scores and per-item run results, over the shared datastore client
(aiobject.DatastoreEnabled, DatastoreExec, DatastoreQuery). eval MUST NOT open
its own datastore connection: the pool, the retries, and the KMS-injected
credentials live in exactly one place. Tables are append-only, partitioned by month,
ordered by org first; every predicate is a bound parameter, never interpolated; and
every list route is bounded by a server-side limit.
SQL identifiers are not URL paths. The grammar of §3 governs the route surface; the
existing table names (hanzo.eval_traces, hanzo.eval_scores) are data at rest and
are NOT renamed by this HIP — renaming them buys nothing and costs a migration.
§7 Bounds instead of a queue
eval bounds work rather than queueing it: a per-org concurrent-run cap that returns
429 immediately (runs are never queued — the only queue on this plane is the human
one), a wall-clock run deadline, a per-run item cap, and content and comment size
caps. Fail fast and say so; a silent backlog is a worse answer than a refusal.
§8 Judge
A judge is kind: model or kind: code.
A model judge MUST validate its verdict against the named rubric before the
score is recorded, and MUST fail the item when the reply cannot be parsed against
that rubric. It MUST NOT default an unparseable verdict to a value — a fabricated
zero is worse than a recorded failure. Untrusted content (the item, the model's
output) MUST be delimiter-fenced and never concatenated into the instruction.
ai/object/eval_judge.go (JudgeRubric, RunJudge, parseVerdict) is the
implementation of record; eval MUST NOT ship a second, weaker judge parser beside
it, which is what apps/eval/runner.go is today.
A code judge is a deterministic function of (input, output, expected) returning
a value admissible under its rubric. Where it executes is an open question.
§9 Degraded, honestly
With no OLAP store configured, eval MUST serve dataset, item, judge, rubric,
experiment and queue from the metastore, MUST return 503 from the score and run
routes, and MUST report the degradation on /v1/eval/health. It MUST NOT fabricate
an empty result that reads as a real one.
§10 Forbidden
- A dependency edge from o11y or ai to eval.
- A write endpoint in the o11y plane; a second score store; a mutable or deletable score.
- An
annotationresource, or any second spelling of the queue. - A plural, compound, or
/api/-prefixed path segment; a second prefix aliasing/v1/eval. - Reading an identity header directly; an org normalized before it is used as a key; a global-id primary key on a tenant table.
- A second billing path, a second
metering.Client, a write tocloud_usage, or a fee charged to any org but the payer's. - A tracer provider, an exporter, or a datastore connection of eval's own.
- An aggregate-only dashboard on this plane.
Migration
One release; the old spellings are removed, not aliased — two ways to say one thing is the defect this HIP exists to remove.
- Stand up
hanzoai/evalwithMount/Shutdownovereval.Deps(nocloudimport), portingapps/evaland its ~40 tests, which are the real specification of the current behavior. - Add the adapter and the one
apps.Wire()line withShutdownwired; deleteapps/eval. - Move the annotation queue to
/v1/eval/queue; deleteapps/o11y/annotation_queues.go, theo11y_annotationsstore, o11y's/v1/o11y/llm/annotationand/v1/o11y/llm/score*routes,llm_annotationsandllm_scores. - Move the metrics board to o11y;
/v1/eval/metricsis never created. - Repoint the console:
evals/*→eval/*in its API modules, its proxy allow list, and the tests that pin the literal paths. - Resolve
clients/admin's direct read ofhanzo.eval_tracesandhanzo.eval_scores(see Open questions) before eval's tables are touched.
Acceptance tests
A conformant implementation passes all of these. (1) and (4) are where this breaks silently, so they are not optional.
- Cross-tenant isolation. For every route in §3: a validated principal of org B
presenting an id created by org A gets
404/403and never200, on reads, writes and deletes alike. A list issued by B never contains a row of A's. - Fail-closed tenancy. Every route, called with
X-Org-Id: victimand no bearer, returns403— the header alone never scopes anything. - Org verbatim. Orgs differing only by case or surrounding whitespace are distinct tenants and never share a row, a file or a natural key.
- A judge run bills the correct org. A run issued by a principal of org A whose
home org is A produces exactly one
cloud_usageattribution per judge call, addressed toaccount.Payerfor A; when the effective org differs from the home org, the debit follows the home org and the data follows the effective one. eval itself writes zerocloud_usagerows. - Gate precedes work. A spend-capped org gets
402fromPOST /v1/eval/runbefore any judge call is issued, and no score, run rollup or item row is written. - Score immutability. No route mutates or deletes a score; a retraction is a new row and the original remains readable.
- Rubric fail-closed. A score violating its rubric's type, bounds or label set is rejected; an unparseable model verdict fails the item and records no value.
- Assignment is exclusive. Two annotators claiming one queue item: one
200, one409; releasing returns it to the pool. - Degraded honesty. With no OLAP store: score and run routes
503; dataset, item, judge, rubric, experiment and queue200;/v1/eval/health200, reporting the degradation. - Bounds hold. Exceeding the per-org concurrent-run cap returns
429immediately and enqueues nothing; the run deadline terminates a run and records it as failed. - Teardown. SIGTERM closes the metastore handle exactly once, via the wired
Shutdown. - Grammar. A table-driven assertion over the registered route list: no plural
segment, no compound segment, no
/api/, no prefix but/v1/eval. - Acyclic.
go list -depsfor o11y and for ai contains nohanzoai/eval. - Span shape. A run emits spans carrying
gen_ai.hanzo.org_idequal to the caller's org, parented to the request span, and emits no_o11y.gen_ai.*attribute.
Open questions
Named, not hidden. Each needs a human call before the corresponding code is written.
- Where a code judge executes. In-process pure expression, the extension runtime (HIP-0105/0116), or a sandbox — and whether it may make network calls at all. This is an arbitrary-code-execution decision and is deliberately unresolved here.
- Standalone or embedded. Whether
hanzoai/evalalso ships a binary, an image and aServiceCR (o11y's shape, with the two listeners of HIP-0119 §1), or exists only embedded in cloud. This HIP specifies the embedded contract; the standalone one is additive. - The existing rows.
llm_scoresandllm_annotations— migrate into eval's score store, or drop? Migration099'sDown()is a no-op, so whichever is chosen is one-way. clients/admin's direct read ofhanzo.eval_tracesandhanzo.eval_scoresfor the SuperAdmin fleet board: does admin keep a documented warehouse read (eval owns the schema, admin reads it with no interface between them), or does eval expose a fleet-scoped read the board calls?- Ordering with the console. Whether the metrics board lands in o11y before or after the eval cut; the console reads it today either way.
- Per-org file or shared file. eval's metastore is one shared
evals.dbwith aWHERE org = ?;cloud.OrgDB/NewOrgStorewould make the isolation a file boundary. Stronger, and a data migration. - Is project authoritative? Whether the project axis hard-enforces
(
ValidatedProject) or stays advisory on eval reads. - What eval charges for.
ResourceFeeCents("EVAL", …)is unset, so everything but the judge's inference is free today. Whether a run, a stored dataset or a queue seat is billable — and whether eval gets its own spend-cap service axis or inherits ai's — is a pricing decision, not an engineering one.
Security Considerations
What an attacker gets from the wrong implementation: a score store that can be edited is the ability to rewrite verdicts after the fact — an agent judged dangerous re-judged safe with no record — which is why a score is append-only and a retraction is a new row (§3). A judge that concatenates untrusted item content into its instruction is a prompt-injection path from evaluated data into the evaluator (§8). A tenancy defect here discloses another org's datasets and verdicts and, worse, lets one org's runs bill another's wallet — the acceptance tests (1), (2) and (4) are the checks that hold those three closed.
References
- HIP-0106 Unified Cloud Binary · HIP-0111 IAM
- HIP-0114 ZAP Transport · HIP-0119 Service Conventions · HIP-0120 gRPC Elimination
- HIP-0122 zip — ZAP-Native Application Server · HIP-0132 One Telemetry Plane
- HIP-0105/0116 Extension Runtime
- HIP-0139 Capability
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.