Skip to documentation

Module references

Crucible

Crucible is the measurement system: golden sets of questions with atomic assertions, graded relevance judgments over retrieved evidence, evaluation runs scored against both, and drift snapshots that notice when the corpus underneath moves. It turns "this feels better" into a number, and then into a signed delta with a confidence interval and a p-value, so a configuration change can be accepted or rejected on evidence. Its public API surface is deliberately small and read-mostly: read one run's aggregated metrics, compare two runs with paired significance, and append a relevance judgment.

Golden items, qrels, and a config feed a run; per-result rows roll up into the metrics endpoint and the paired comparison. Drift is a separate, deterministic sweep over documents and entities that raises deduped alerts.

Use it standalone

Adopting Crucible alone means one package subpath and one API key. Nothing here imports another capability module, and none of the three endpoints requires a document, graph, connector, or agent to be configured — they read rows that already exist.

  1. Mint a key with the right scope

    Reads (getEvalRunMetrics, compareEvalRuns) require kb:read; the routes also accept a legacy runs:read key. Authoring a qrel requires kb:write. For a delegated user key (eli_uk_) the owner must additionally still hold the evals:read capability for the reads and evals:write for the write — the route passes those to the guard, and a revoked capability returns 403 insufficient_capability.
  2. Install one subpath

    @eli-ai/client/quality for headless use, or @eli-ai/react/quality for the components. The React subpath pulls in @eli-ai/react/provider and @eli-ai/react/hooks from the same package and nothing else.
  3. Call it

    Every method takes an optional per-call workspaceId and AbortSignal. Responses are plain JSON contract types — no envelope to unwrap.
installationbash
npm install @eli-ai/client @eli-ai/contracts
# React surfaces only:
npm install @eli-ai/react react
the whole module, headlessts
import { createEliTransport } from "@eli-ai/client/core";
import { createQualityClient } from "@eli-ai/client/quality";

const quality = createQualityClient(
  createEliTransport({
    baseUrl: "https://eli.ai",
    apiKey: () => process.env.ELI_API_KEY!,
    workspaceId: process.env.ELI_WORKSPACE_ID,
  }),
);

// One run's aggregate.
const metrics = await quality.getEvalRunMetrics(runId);
console.log(metrics.status, metrics.resultsCounted, metrics.abstention.abstentionRate);

// Is the candidate config actually better than the baseline?
const compare = await quality.compareEvalRuns({ a: baselineRunId, b: candidateRunId, metric: "ndcgAt10" });
if (compare.significant) {
  console.log("delta", compare.deltaMean, "CI", compare.ci, "p", compare.pValue);
}

// Append a human relevance judgment (0–3) for one golden item / chunk pair.
await quality.authorQrel({ queryId: goldenItemId, chunkId, grade: 3 });

What Crucible alone does not give you over HTTP

The public /api/v1 surface has no operation that creates a golden item, launches an eval run, diffs two runs by assertion flip, captures a drift snapshot, or lists alerts. Those live on the session-authenticated workspace BFF under /api/w/{workspaceId}/evals/*, /api/w/{workspaceId}/golden/*, and /api/w/{workspaceId}/evals/drift, which are guarded by workspace membership and capabilities (for example evals:execute) rather than a bearer key. A standalone adopter reads metrics, compares runs, and appends qrels over the API; runs are produced in the eli.ai workspace or by the scheduled runner.

React components

Four components are exported from @eli-ai/react/quality, together with the EliQualityLabels, EliQualityProps, and EliPolicyDecisionsProps types. They are client components (the file carries "use client") and each builds only a quality client from the provider's transport. Markup uses scoped eli-* classes; import @eli-ai/react/styles.css for the shipped styling, or leave it out and write your own.

EliProvider takes a transport (or an aggregate client, whose transport is retained) plus an optional workspaceId that the components pass as a per-call override. It has no API-key prop by design.

Keys do not belong in the browser

These are browser components, so the transport they receive must not carry an eli_sk_ key. Inject a fetch that calls a same-origin proxy which adds the credential server-side — see the SDK guide for the full reasoning.

EliEvalMetrics

The focused Crucible surface. Given a run id it fetches GET /api/v1/evals/runs/{id}/metrics once and renders: a status badge (succeeded positive, failed critical, anything else warning), a resultsCounted/samplesN samples counted line, a KPI grid with a meter per value, abstention badges, and — when the run stored selective-prediction pairs — an inline SVG risk–coverage curve with one point per answered result.

The KPI grid shows exactly seven values, each rendered as a rounded percentage or when null: accuracy, groundedness, citation precision, citation recall and retrieval hit rate from quality, plus recallAtK and ndcgAt10 from retrieval. The API also returns precisionAtK, mrr, hitAtK, the RAGAS block and the error-class histogram — this component does not draw those, so read them from the endpoint or a hook if you need them.

EliEvalMetrics props

runIdrequiredstringEval run id to load. It is part of the query key, so changing it refetches.
classNamestringAppended to the root element's eli-root class.
titlestringSurface heading. Default: "Evaluation metrics".
descriptionstringSub-heading. Default: "Retrieval, answer quality, and abstention signals for one evaluation run."
loadingLabelstringShown while loading. Default: "Loading evaluation metrics…".
errorLabelstringShown on failure, next to a retry button that refetches. Default: "Evaluation metrics could not be loaded."
onError(error: unknown) => voidCalled with the thrown error when the fetch rejects.
emptyLabelstringAccepted because the props type spreads the shared surface props, but this component never reads it.
EliEvalMetrics with provider wiringtsx
"use client";

import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliEvalMetrics } from "@eli-ai/react/quality";
import "@eli-ai/react/styles.css";

// sameOriginProxyFetch hits your own route, which adds the Bearer key.
const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function RunReport({ runId, workspaceId }: { runId: string; workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliEvalMetrics runId={runId} onError={(error) => console.error(error)} />
    </EliProvider>
  );
}

EliQuality

The composite dashboard. It renders three sections in one surface: an answer-policy table (verdict, question, surface, confidence as a rounded percentage) with a total-decisions badge, an evaluation section whose form takes a run id and loads that run's metrics on submit, and a semantic-cache KPI grid. Policy decisions, policy stats and cache stats load on mount; the evaluation panel is idle until you submit, and the submit button is disabled while the input is blank or a request is in flight.

Two of those three sections do not read Crucible endpoints — see How it composes below. A single kb:read key covers all three.

EliQuality props

classNamestringAppended to the root element's eli-root class.
titlestringSurface heading; falls back to labels.title ("Quality controls").
descriptionstringSub-heading; falls back to labels.description.
labelsPartial<EliQualityLabels>Overrides for the nine built-in strings: title, description, policy, evaluations, cache, runId, loadRun, noDecisions, noEvaluation.
initialEvalRunIdstringPrefills the run-id input. Default: "". It does not auto-submit — the metrics call still needs the button.
policyLimitnumberlimit sent to the policy-decisions list, and part of that query's key. Default: 8.
loadingLabelstringDefault: "Loading quality controls…".
errorLabelstringShown when any of the three mount-time queries fails; the retry button refetches all three. Default: "Quality controls could not be loaded."
onError(error: unknown) => voidPassed to all three queries and to the evaluation mutation.
onEvalLoaded(metrics: EvalRunMetrics) => voidFired after a successful metrics load, with the parsed EvalRunMetrics.
onPolicyDecisionSelect(decisionId: string) => voidFired when a verdict badge in the policy table is clicked.
EliQuality with provider wiringtsx
"use client";

import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliQuality } from "@eli-ai/react/quality";
import "@eli-ai/react/styles.css";

const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function QualityConsole({ workspaceId, runId }: { workspaceId: string; runId?: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliQuality
        initialEvalRunId={runId ?? ""}
        policyLimit={12}
        labels={{ evaluations: "Eval runs" }}
        onEvalLoaded={(metrics) => console.log(metrics.runId, metrics.quality?.groundedness)}
      />
    </EliProvider>
  );
}

EliPolicyDecisions

A standalone table of answer-policy decisions — verdict badge, question, surface, and a formatted createdAt — with optional surface and verdict filters. It reads the append-only decision log, not evals; it is exported from this subpath because it shares the quality client. Verdict badges are tone-mapped: answer and proceed positive, abstain and abstain_with_pointers critical, everything else warning.

EliPolicyDecisions props

classNamestringAppended to the root element's eli-root class.
titlestringDefault: "Answer policy".
descriptionstringDefault: "Review answer, caveat, clarification, and abstention decisions."
limitnumberPage size sent as ?limit. Default: 20.
surface"chat" | "structured" | "agent"Optional ?surface filter; omitted from the request when undefined.
verdict"proceed" | "clarify" | "abstain_with_pointers" | "answer" | "answer_with_caveat" | "abstain"Optional ?verdict filter; omitted when undefined.
loadingLabelstringDefault: "Loading policy decisions…".
errorLabelstringDefault: "Policy decisions could not be loaded."
emptyLabelstringRendered when the page has zero items. Default: "No policy decisions match these filters."
onError(error: unknown) => voidCalled with the thrown error when the fetch rejects.
onDecisionSelect(decisionId: string) => voidFired when a verdict badge is clicked.
EliPolicyDecisions with provider wiringtsx
"use client";

import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliPolicyDecisions } from "@eli-ai/react/quality";
import "@eli-ai/react/styles.css";

const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function Abstentions({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliPolicyDecisions
        verdict="abstain"
        surface="chat"
        limit={50}
        onDecisionSelect={(decisionId) => console.log(decisionId)}
      />
    </EliProvider>
  );
}

EliCacheStats

A four-tile KPI grid for semantic-cache health: hit rate (with a meter, or when null), active entries with the embedding-backed count beneath, invalidated entries with the expired count beneath, and the last hit timestamp with the current policy mode (off · shadow · enforce) beneath. It reads GET /api/v1/cache/stats, which is the answer path's cache, not an eval surface.

EliCacheStats props

classNamestringAppended to the root element's eli-root class.
titlestringDefault: "Semantic cache".
descriptionstringDefault: "Reuse, invalidation, and expiry health for grounded answers."
loadingLabelstringDefault: "Loading cache statistics…".
errorLabelstringDefault: "Cache statistics could not be loaded." Rendered with a retry button.
onError(error: unknown) => voidCalled with the thrown error when the fetch rejects.
emptyLabelstringAccepted by the shared surface props type but never read by this component.
EliCacheStats with provider wiringtsx
"use client";

import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliCacheStats } from "@eli-ai/react/quality";
import "@eli-ai/react/styles.css";

const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function CachePanel({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliCacheStats />
    </EliProvider>
  );
}

Prefer your own markup?

The same subpath's data access is reachable without the components: build a client with createQualityClient(transport) inside useEliQuery / useEliMutation from @eli-ai/react/hooks. That is exactly what these components do internally, so the fetch, abort and error semantics are identical.

HTTP API

Three operations. All are workspace-scoped through the bearer key plus an optional X-Workspace-Id header, and every failure is the shared { error, message } envelope — body-validation failures add an issues array. A run that belongs to another workspace is a 404, not a 403.

GET/api/v1/evals/runs/{id}/metricskb:read (runs:read accepted) · evals:read

Aggregates one run's per-result rows to the run level. The path parameter `id` is the eval run id. `retrieval` is null unless at least one result carried a retrieval metric; `ragas` is null unless a result carried a RAGAS metric; `quality` is null when the run has no overall run_metrics row. Individual metric fields are null when nothing contributed to that mean. 404 when no run with that id exists in this workspace.

Example requestbash
curl -sS "https://eli.ai/api/v1/evals/runs/$RUN_ID/metrics" \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID"
Responsejson
{
  "runId": "01KZRUNA00000000000000000",
  "status": "succeeded",
  "samplesN": 3,
  "resultsCounted": 42,
  "retrieval": {
    "recallAtK": 0.74,
    "precisionAtK": 0.44,
    "ndcgAt10": 0.71,
    "mrr": 0.79,
    "hitAtK": 0.93
  },
  "ragas": {
    "faithfulness": 0.91,
    "responseRelevancy": 0.84,
    "contextPrecision": 0.66,
    "contextRecall": 0.72,
    "noiseSensitivity": 0.08
  },
  "abstention": {
    "total": 42,
    "abstained": 5,
    "answered": 37,
    "abstentionRate": 0.119,
    "meanTruthfulness": 0.62,
    "aurc": 0.074,
    "riskCoverage": [
      {
        "threshold": 0.91,
        "coverage": 0.5,
        "risk": 0.048
      },
      {
        "threshold": 0.62,
        "coverage": 1,
        "risk": 0.143
      }
    ]
  },
  "errorClasses": {
    "correct": 31,
    "retrieval_miss": 4,
    "incomplete": 3,
    "citation_error": 2,
    "hallucination": 2
  },
  "quality": {
    "accuracy": 0.81,
    "groundedness": 0.9,
    "citationPrecision": 0.88,
    "citationRecall": 0.84,
    "retrievalHitRate": 0.93,
    "nItems": 42
  }
}

Field names above are the response contract; the numbers are illustrative. Three details are worth internalising before you build on it:

  • status is the run's own status — running, succeeded, failed, or degraded. Metrics are served for a run that is still running; they are simply partial.
  • abstention.meanTruthfulness is on the CRAG signed scale (perfect 1, acceptable 0.5, missing/abstained 0, incorrect −1), so it is not a percentage and can be negative. aurc is the mean risk over the swept operating points — lower is better — and is null when the run stored no selective-prediction pairs, in which case riskCoverage is an empty array.
  • errorClasses is a histogram keyed by the deterministic error taxonomy the runner assigns per result: retrieval_miss, hallucination, citation_error, stale, incomplete, conflict_mishandled, correct. Results with no error class contribute nothing.
GET/api/v1/evals/runs/comparekb:read (runs:read accepted) · evals:read

Pairs run B against run A by golden item id (per-item mean across that item's samples) and reports the mean delta b−a with a 95% percentile-bootstrap CI and a two-sided sign-flip permutation p-value. `significant` is a two-gate verdict: the CI excludes 0 AND p < 0.05. Both resamplers are seeded, so the same two runs always produce the same numbers. 400 when a or b is missing or metric is unknown; 404 when either run is absent from this workspace.

Query parameters

arequiredstringBaseline run id.
brequiredstringComparison run id.
metricstringOne of truthfulness (default), retrievalHitRate, recallAtK, precisionAtK, ndcgAt10, mrr, hitAtK. Anything else is a 400.
Example requestbash
curl -sS "https://eli.ai/api/v1/evals/runs/compare?a=$BASELINE&b=$CANDIDATE&metric=ndcgAt10" \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID"
Responsejson
{
  "a": { "runId": "01JB7YB0S9K2N4V6ZQ0X1M3T8D", "n": 40 },
  "b": { "runId": "01JB8QF4H1P7R2S5TC9WZ6K0YE", "n": 40 },
  "metric": "ndcgAt10",
  "pairedN": 38,
  "meanA": 0.641,
  "meanB": 0.723,
  "deltaMean": 0.082,
  "ci": { "low": 0.021, "high": 0.144, "level": 0.95 },
  "pValue": 0.011,
  "significant": true,
  "method": "paired-percentile-bootstrap+sign-flip-permutation"
}

pairedN is the number that matters

a.n and b.n count the items each run scored on that metric; pairedN counts the intersection, and only the intersection is tested. Items present in one run but not the other are silently dropped. With fewer than two pairs, ci and pValue are null and significant is false — an honest "not enough evidence", not a failure.
POST/api/v1/qrelskb:write · evals:write

Appends a TREC-style graded-relevance judgment for a (golden item, chunk) pair. The table is append-only: a human override inserts a new row rather than mutating a machine judgment, and the metrics reader resolves precedence at read time. A duplicate within the same (source, judgeModel) lane is a no-op that returns the existing row with 200; a newly appended row returns 201.

Request body

queryIdrequiredstringGolden item id the judgment is for. Minimum length 1.
chunkIdrequiredstringRetrieved chunk id being graded. For a doc-level judgment the convention used internally is chunkId = docId.
graderequiredintegerTREC graded relevance, integer 0–3.
docIdstringOwning document id. Optional, but the metrics reader keys on docId when present.
source"llm" | "human" | "provenance"Provenance of the judgment. Defaults to "human".
judgeModelstringJudge model id — machine sources only. Part of the dedupe key.
promptVersionstringJudge prompt version — machine sources only.
Example requestbash
curl -sS -L -X POST "https://eli.ai/api/v1/qrels" \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"queryId":"01JB6M0T2Q8V3XN7ZR1C5F9WKD","chunkId":"01JB6M12H4A0B8D6E2G4J7L9NP","grade":3}'
Responsejson
{
  "id": "01JB9T5C7E1G3J5L7N9Q1S3U5W",
  "workspaceId": "org_7Yh2Kq",
  "queryId": "01JB6M0T2Q8V3XN7ZR1C5F9WKD",
  "chunkId": "01JB6M12H4A0B8D6E2G4J7L9NP",
  "docId": null,
  "grade": 3,
  "source": "human",
  "judgeModel": null,
  "promptVersion": null,
  "createdAt": "2026-07-31T14:02:11.184Z"
}

Grades collapse per document at read time with the precedence human > provenance > llm; within the winning source the maximum grade wins. A successful append also writes a qrel.author row to the audit log, targeting the queryId.

Data elements

Every table below carries workspace_id and a *_tenant_isolation row-level-security policy asserting workspace_id = current workspace for both USING and WITH CHECK, so every read and write in the module runs inside a workspace transaction under FORCE RLS. There is no cross-workspace read path.

TableWhat one row isCrucibleColumns that matter to a consumer
golden_itemsOne evaluation question, revisioned so a later edit cannot silently change what a historical run was graded against.writesquestion · difficulty(easy|medium|hard) · status(draft|active|archived) · revision · answerable · questionType · popularity · dynamism · notes
golden_assertionsOne atomic claim a correct answer must or should support.writesitemId · text · kind(must|should) · weight · conceptId (null = plain text assertion)
golden_citationsA document anchor the answer is required to cite — or forbidden from citing.writesitemId · docId · snippet · mode(required|forbidden)
golden_item_tagsOne namespaced tag on an item; the axis metrics are stratified along.writesprimary key (workspaceId, itemId, tag) · tag e.g. bu:finance, topic:invoicing
golden_conceptsAn item-level entity anchor: which concepts a correct answer must, may, or must not touch.writesitemId · entityId · relevance(required|acceptable|irrelevant)
qrelsOne append-only graded-relevance judgment for a (golden item, chunk) pair. Written by POST /api/v1/qrels.reads + writesqueryId · chunkId · docId · grade 0–3 · source(llm|human|provenance) · judgeModel · promptVersion · unique(ws, queryId, chunkId, source, judgeModel)
eval_configsA named run configuration — which model slots, prompt versions and retrieval parameters a run applies.writesname · modelSlots · promptVersions · retrievalParams · params · judgeModelId · judgePromptVersion
eval_runsOne execution of a golden set. Read by both eval endpoints to resolve the run and its status.reads + writesconfigId · goldenSetHash · status(running|succeeded|failed|degraded) · samplesN · trigger(manual|scheduled) · kbUnstable · costEstimate/costActual · targetSnapshotId · judgeSnapshotId · startedAt/finishedAt
eval_resultsOne item × sample outcome. The source rows both endpoints aggregate.reads + writesrunId · itemId · sampleIdx · answerText · citations · retrievedDocIds · evidenceSnapshot · deterministic · retrievalHitRate · abstained · truthfulness[-1,1] · errorClass · retrievalMetrics · status(ok|error|timeout) · latencyMs · costUsd
judgmentsOne judge verdict on one assertion of one result, with an optional human override that wins.writesresultId · assertionId · verdict(supported|contradicted|not_found) · confidence · judgeModelId · flaggedForReview · humanVerdict · humanNote
run_metricsRolled-up quality for a run, overall or per tag. The tag = NULL row is what the metrics endpoint returns as `quality`.reads + writesrunId · tag (null = overall) · accuracy · groundedness · citationPrecision · citationRecall · retrievalHitRate · nItems
eval_feedbackAn append-only reclassification or correction note against a run, or one of its results.writesrunId · resultId (nullable, no FK) · errorClass · note · correctedBy
drift_snapshotsA point-in-time corpus fingerprint used to detect movement between sweeps.writesentityCounts (per type) · orphanRate · docChurn · docCount · capturedAt
alertsOne raised condition, deduped while open so a repeating sweep bumps rather than duplicates.writeskind e.g. drift.kb · kb.stale · eval.regression · severity(info|warning|critical) · message · meta · dedupeKey · acknowledgedAt · resolvedAt
audit_logThe shared workspace audit trail. Authoring a qrel appends one `qrel.author` event.writesevent · target (the queryId) · actorId · meta { chunkId, grade, source }

Two schema decisions shape what a consumer can rely on. Qrel keys are plain text, not foreign keys queryId and chunkId carry no referential constraint, so judgments survive golden-item edits and chunk churn from re-ingestion. And a run pins what it was graded against: goldenSetHash plus the targetSnapshotId / judgeSnapshotId foreign keys into immutable configuration snapshots mean a historical comparison stays meaningful after the set and the models have both moved on.

How it composes

Crucible is usable on its own for reading and judging, but it measures something — and that something is produced by other modules. Here is the honest split.

  • Lens — needed to produce runs. An eval run routes each golden item through the real retrieve-and-answer pipeline; that is where retrievalMetrics, abstained and truthfulness come from. Without an answering pipeline there is nothing to score. Reading metrics and comparing runs still work perfectly on runs that already exist.
  • Warrant — powers two of EliQuality's three sections. EliQuality and EliPolicyDecisions read GET /api/v1/policy/decisions and GET /api/v1/policy/stats — the append-only answer-policy decision log written by the chat, structured and agent pipelines. Without the policy engine those panels render their empty state; the evaluation section is unaffected. Same kb:read scope, different system.
  • Atlas — unlocks concept-anchored scoring. golden_concepts.entityId and golden_assertions.conceptId reference graph entities, and the drift snapshot's entity counts and orphan rate are computed from entities and their mentions. With no graph, leave conceptId null — assertions stay plain text and everything else still scores.
  • Intake — gives citations and drift something to point at. Golden citations anchor to document ids, and source: "provenance"qrels are derived from an item's required citations (graded 3, keyed at document granularity). Drift's docChurn and docCount are counts of ingested documents. Human and LLM qrels need none of this.
  • Ports — how the three operations are published. getEvalRunMetrics, compareEvalRuns and authorQrel are catalogued under the quality capability, with kb:read / kb:write as the canonical scopes and runs:read retained as an accepted read scope on the two GETs.
  • Lineage — complementary, not required. Crucible writes its own audit event for qrel authoring and keeps its own append-only trails (qrels, eval_feedback, judgment overrides). Nothing in the module reads a lineage surface.

Conduit has no direct relationship with this module: nothing in Crucible's code reads a data connector or a live query. If an evaluated answer happens to include live data, that arrives through the answer pipeline, and Crucible sees only the resulting text and citations.

For the conceptual model behind the scores, see the evaluation framework and Evals & drift; for authoring golden items and launching runs in the workspace UI, see Golden sets & eval runs and Retrieval metrics.

Extending Crucible

A plugin can contribute a run metric with evalMetrics. The host namespaces the key to plugin.<id>.<metric> and writes it into the additive run_metrics.plugin_metrics jsonb bag, so a contributed number can never collide with or overwrite a first-party metric, and a regression comparison can always tell the two apart. Crucible emits eval.run.completed, drift.detected and alert.raised. See the extension model for the contract and the trust boundary, and Build a plugin for a worked example.