feat(ingest): write AI classification columns on the ingest path - #452
Open
JeremyFunk wants to merge 3 commits into
Open
feat(ingest): write AI classification columns on the ingest path#452JeremyFunk wants to merge 3 commits into
JeremyFunk wants to merge 3 commits into
Conversation
Migration 0015 adds five trailing columns to `traces` — vendor slug, session-key state, session-key hash, rules version, rollup hour — plus a `set(0)` skip index on the vendor and a token bloom filter on `ScopeName`, which the vendor rules match by prefix. Every column carries a DEFAULT, so the ALTER is metadata-only and rows written before the classifier existed still read back: `AiRulesVersion = 0` means "never examined", distinguishable from an examined-and-non-AI row. Nothing here materializes an index or column, and nothing mutates parts — the 30-day TTL retires the unindexed ones on its own. `requiredForIngest: true`, unlike the last two migrations: the gateway's INSERT now names all five columns, so a BYO-ClickHouse cluster that has not applied 0015 would reject every direct insert. Gating on it is the designed fallback — such an org resolves `clickhouse_ready = false` and routes to the managed pipeline until its schema syncs. The five columns declare snake_case JSONPaths rather than identity ones. That distinction is load-bearing and now also asserted: the insert-mapping generator drops a column that has a DEFAULT *and* an identity path, on the assumption the warehouse computes it. These are emitted on every span, so they must not match that shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The row builder now classifies each span and stamps the five columns. Inputs are built once per accepted payload, not per span: the migration-window flag is read once and the batch receive time is captured once, so every span in one payload clamps against the same instant. `AiRollupHour` is written unconditionally, flag on or off. It is the rollup's partition key and the span timestamp is attacker- and replay-controlled, so it is clamped at write time to `[receive - 7d, receive + 1d]`. Clamping in the view instead would need `now()`, which a later partition rebuild re-evaluates and which would silently relocate rows across hours. On the attribute-mapping path the classifier reads a first-occurrence-wins view of the wire attributes rather than the row's stored Map, which keeps last-wins canonicalization. The two rules only disagree on a span carrying a duplicate rule-key, and the verdict must not depend on whether the org happens to have mapping rules configured. Observability is batch-level, never per span — a span per classification on this path is what the self-observability rule forbids. The accept span carries whether the flag was on and how many spans were examined; `ingest_ai_spans_examined_total` is labeled by signal only, exactly like `native_rows`, so the two series are directly comparable and any divergence is a bug. Also here: - An adversarial fixture module driving `encode_traces` end to end, with a reproducibility check and a branch-coverage check over the written rows. - A ClickHouse E2E pinning `AiSessionKeyHash` to `cityHash64`. Without it a divergence returns zero rows and puts a permanent discontinuity in a 400-day-TTL sketch, with nothing else failing — so CI runs it, and the ClickHouse job's path filter now also watches the CityHash port. - A schema probe asserting the live `traces` columns against the generated schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local schema v5: the same five defaulted columns and two skip indexes on `traces`, no new objects. The v4 -> v5 module and a frozen v5 DDL snapshot keep an existing local store readable after the generated current schema advances, and the manifest gate now checks that snapshot's identity the way it already checks v1 through v4. The local OTLP encoder stamps the same five fields, so a local store and the hosted warehouse hold the same shape for the same span. Asserted as a column and index delta against the frozen v4 manifest rather than a whole-manifest snapshot, so a stray table or a rewritten column cannot ride along on this version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack position: 2 of 3. Base:
ai2/01-ai-classifier. Followed byai2/03-vendors-rollup. Review 01 first — it adds the classifier this PR calls.Wires the classifier into the row writer and gives its verdict somewhere to live:
five columns on
traces, in ClickHouse, in Tinybird, and in the local store.Rationale for the overall design lives in the write-side plan (§2–§4 and the
migration appendix); this description covers what the diff does and how it is
verified.
What lands
Storage — ClickHouse migration
0016, mirrored on the Tinybird datasource andin local schema v6:
AiVendorLowCardinality(String)''— not classified as AIAiSessionKeyStateUInt80— never examinedAiSessionKeyHashUInt640AiRulesVersionUInt320— row predates classificationAiRollupHourDateTime('UTC')toDateTime(0)Plus
idx_ai_vendor(set(0), unbounded because the vendor domain is a closedallowlist and a capped
set()degrades to always-match) andidx_scope_name(
tokenbf_v1, token-level lookups for scope prefixes).Every column carries a constant DEFAULT, which is what makes the ALTER
metadata-only: no part is rewritten, and pre-classifier rows read the defaults.
There is deliberately no
MATERIALIZE INDEX— a whole-table mutation ontracesis the expensive mistake, and the 30-day TTL retires unindexed parts on its own.
0016 is
requiredForIngest. The gateway's INSERT names all five columns, so aBYO cluster that has not applied it resolves
clickhouse_ready = falseand routesto the managed pipeline until its schema syncs —
clickHouseSchemaVersionbecomes"16". That fallback is the designed behavior, not an incident.(Numbering note:
mainshipped its own migration0015— the minutely serviceoverview — and its own local schema v5 while this stack was open, so the AI
columns are
0016/ local v6. The two numbering schemes are unrelated: theClickHouse migration number and the local-store schema version happen to move
together here only by coincidence.)
Write path — classification runs in
encode_traces, with the classifiercontext hoisted once per
ResourceSpansand once perScopeSpanson every path,including the attribute-remapping one: the contexts borrow the resource and the
scope, which mapping rules never touch, and only the rewritten attribute list is
per span. It runs after attribute remapping, so an org that remaps a custom key
onto a rule key classifies by the shape the row actually stores.
Flag —
INGEST_AI_CLASSIFICATION_ENABLED, default off, read once per batch andnever per span. It exists to ramp the migration window and is deleted once
classification is unconditional in production. There is no full-clock-hour
condition and no ordering against MV creation.
AiRollupHouris written on every span, flag on or off. It istoStartOfHour(span start)when the start time is within[receive − 7d, receive + 1d], elsetoStartOfHour(receive time). Client timestamps are attacker-and replay-controlled and this column becomes a partition key downstream, so an
unclamped value means unbounded partition creation and rows whose TTL never fires.
Clamping at write time is the only deterministic option: a view-side clamp needs
now(), which a later partition rebuild re-evaluates.Observability is batch-level, not per span — per-span classification spans on
this hot path are what the self-observability rule forbids. The accept span records
maple.ingest.ai.enabledandmaple.ingest.ai.spans_examined;ingest_ai_spans_examined_totalis labeled by signal exactly likenative_rows,so any divergence between the two series is a bug (a code path building rows
without classifying, or a partially-flagged fleet).
Verification
cargo testinapps/ingest: 143 lib + 70 binary, 0 failures, plus one#[ignore]d fixture-regeneration helper.bun typecheckgreen;@maple/domain487 passed,@maple/cli435 passed;bun run clickhouse:schema:check(generator, insert-mapping, lint and theappend-only local-schema gate) green against
origin/main.ai_adversarial_fixtures.rs+fixtures/adversarial/adversarial-spans.jsonl) — hand-built hostile spans driventhrough the real row writer: typed and valueless
AnyValues, present-but-emptyvalues, duplicate keys, near-miss key spellings, astral-plane and NUL-bearing
UTF-8, 64 KiB values that spill out of the inline attribute path, one span
carrying six vendors' evidence at once, and the full session-state ladder per
vendor. Per span it asserts the four columns the row writer emitted equal a
direct classifier call, and that the hash equals
city_hash64over the winningkey — so writer and classifier cannot drift apart silently. Reproducibility and
branch coverage are asserted, which is what makes it a golden. It lives here
rather than in 01 because it drives
encode_traces, which does not exist untilthis PR.
ai_classification_flag_reaches_the_clickhouse_rowdrives a request through key resolution and
AppConfigto the NDJSON bodyClickHouse actually receives.
format matches ClickHouse's
DateTime('UTC')wire shape.cityHash64on a real ClickHouse equalscity_hash64inRust, over the adversarial fixture's raw winning session-key bytes, exercising
the ≤32-byte and >64-byte length bands where CityHash 1.1 diverges from the 1.0.2
variant ClickHouse vendors. Passed against a local server (4 tests). It lives in
packages/domain, so every other ClickHouse CI step skipped it — this PR adds astep that runs it with
--filter=@maple/domain, and widens the job's path filterto the CityHash port, the one input it otherwise could not see change.
that can prove it applied is the schema: column types, index types and
granularities asserted against a real server, plus a row naming none of the new
columns to prove the defaults read back. Runs inside the existing warehouse E2E
suite (4 tests, passed).
Notes for review
attr_mapkeeps last-wins for duplicate keys — the historical JSON-objectbehavior, for every key. The classifier's contract is first-occurrence-wins, and
it holds on every path: the remapped path builds a parallel first-wins view
(
attr_map_first_wins, gated on an exact length comparison —attr_maponly evercollapses duplicates, so equal lengths mean the two rules agree and the common
path allocates nothing), and a two-way regression test pins that an unrelated
mapping rule cannot change a span's verdict. That is a determinism rule for
matching, not a storage rule. Residual caveat, documented at the call site: a
future retro-fit re-reading written rows would see the last duplicate where the
live classifier used the first.
insert-mapping generator drops a column that has both a DEFAULT and an identity
path, assuming the gateway never emits it — these are emitted on every span, so
they must not match that shape.
datasources.contract.test.tsnow applies thegenerator's actual rule rather than its previous DEFAULT-only approximation.
AiRollupHouris written for real through a port of the Rust clamp, with onereceive time per batch so two spans in the same request cannot land on different
anchors. The port is a hand-translation of
rollup_hour_secs+format_datetime_secs, soencode.test.tsnow asserts the same boundaries theRust test does, at one-second resolution: exactly −7 d and exactly +1 d stay in
window, one second past either edge clamps to the receive hour, a zero or
unparseable timestamp clamps too, and the rendering is
DateTime('UTC')ratherthan
DateTime64(9). Without those, the existing key-set test proved the fieldwas present, not that its value was right — and the value is a partition key.
copy of it. The two were previously duplicated under a "change these together"
comment, which meant retuning
idx_scope_namein 0016 alone left every migratedlocal store on the old index with nothing failing: v5 → v6's
verifycomparesagainst the frozen v6 manifest, which records an index by name and not by its
parameters. One definition, shared the same way
SERVICE_AI_VENDORS_HOURLY_SELECT_SQLalready is. The registry test still asserts v6 as a column/index delta against the
frozen v5 manifest, so a stray table cannot ride along on the version bump.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.