Sense 22 — The Legend: Implementation
Sense 22 · Folded in · Under question · Last touched 2026-08-20
Folded into
Sense sense-22-the-legend.
- last_verified: 2026-07-27
Synced from
docs/design/sense_22_legend_implementation.mdin the engine repo — that’s the source; this page is a build-time mirror.
The HOW that sits next to Sense 22’s WHAT.
Status: proposed
Date: 2026-04-28
Concept doc: sense_22_the_legend.md
Spike: <live>/numetrix/rmc/afs/entities/article_price_spread.yaml (2026-04-28)
Author: the owner + Claude (Santa Pola sprint, week of 2026-04-27)
Purpose
Section titled “Purpose”Sense 22 declared that classified values must travel with their meaning. This doc names the schema, the compile-time integration, the registry projection, and JinDesk surfaces required to make that happen.
It also retires two ambiguities that the Sense doc allowed and the spike exposed: an alternate signal-block shape that didn’t generalize, and an implicit assumption that Legends would inherit at runtime.
The spike validated that the Sense’s basic shape sits right. This doc formalizes that shape and decides the load-bearing edge cases.
Schema
Section titled “Schema”Placement
Section titled “Placement”A classifications: block lives at the top level of the YAML that
owns the column.
| Artifact | YAML location |
|---|---|
| Dimension column | Entity YAML — <afs>/entities/<entity>.yaml |
| Signal severity / category | Signal YAML — <afs>/signals/<id>.yaml |
| Action status | Notebook YAML or tenant-level <afs>/legends/action_legend.yaml |
| Verdict confidence | Verdict YAML — <afs>/verdicts/<id>.yaml |
| Subject Matter category | Pack-level legend file in <afs>/contracts/ |
Top-level, not nested under columns:, because a Legend is a
description of a value-space — richer than per-column display hints.
The existing columns.formats and columns.tooltips blocks remain
where they are; they describe column rendering. Legends describe column
meaning.
Block shape
Section titled “Block shape”classifications: <column_or_field_name>: # required: the column the Legend describes description: # optional but encouraged en: "<one-sentence summary>" de: "..." fr: "..." thresholds: # optional; numeric or string <name>: <value> values: # required; one entry per declared value <value_name>: rule: "<plain-language test>" # required; references thresholds by name icon: "<grapheme>" # optional but encouraged color: "<hex or token>" # optional but encouraged description: # required; what the value means en: "..." de: "..." fr: "..." action: # required; what the reader should do en: "..." de: "..." fr: "..."Field-by-field
Section titled “Field-by-field”-
description— one-sentence summary of what this Legend classifies. Multilingual. Optional but recommended; surfaces as the column-header tooltip’s preamble. -
thresholds— named parameters of the rules. Numeric or string. Declared once at the Legend level, referenced by name from individual rules. The names are part of the schema: the same name appears in the Legend’srules, in dbt vars (Phase 3, see Threshold–SQL Coupling), and in any UI that reveals the threshold to the user. Naming convention:snake_case, with a unit suffix where applicable (absolute_stub_chf,min_money_at_risk_eur,lookback_days). -
values— required. A dict mapping each declared value of the classified column to its Legend entry. Order matters for display only; ordering of value rendering follows YAML key order. A column may have values not enumerated in the Legend — the schema does not require completeness — but the compiler emits a warning (not an error) when it encounters classified rows whose value is absent from the Legend. -
Per-value
rule— single sentence in plain language describing the test that fires the value. Referencesthresholdsby name. Not a substitute for the SQL CASE WHEN that produces the value — the rule is documentation, not implementation. See Threshold–SQL Coupling below for how the two stay in sync. -
Per-value
icon— a single grapheme (emoji, dingbat, arrow, domain-specific glyph). Used in column-cell rendering, filter chips, and tooltip summaries. Optional but encouraged. -
Per-value
color— hex or palette-token. Used wherever the value appears as a badge or chip. Optional but encouraged. See Open Question on palette governance. -
Per-value
description— multilingual prose explaining what the label means in human terms. Surfaces in hover-on-value tooltips. -
Per-value
action— multilingual recommended response. “What does the reader do when they see this?” Surfaces in the same hover.
Fallthrough values are first-class
Section titled “Fallthrough values are first-class”A Legend may include a value like unclassified that the SQL emits as
a diagnostic safety-net for rows that don’t match any other branch.
The schema blesses this pattern: such values are declared like any
other, with rule: "Diagnostic fallthrough — does not match any other pattern". Their presence is a feature, not an inconsistency.
Multilingual fields
Section titled “Multilingual fields”All prose fields (description, action, optionally rule) follow
the existing en/de/fr triple convention used elsewhere in jinflow YAML.
A field may be a string (single language, treated as en) or a dict
of locale-keyed strings. The compiler normalizes both forms to dict at
bake time.
What the schema explicitly does not include
Section titled “What the schema explicitly does not include”-
Status enums for the Legend itself. A Legend doesn’t carry
proposed | accepted | deprecated. Its lifecycle follows its artifact’s git history. -
Sense 22’s alternate signal-block shape. The Sense doc’s §“2. Signal classifications” example used
rules:as a top-level dict separate fromvalues::classifications:severity:rules:high: "money_at_risk >= high_money_at_risk_chf"medium: "..."This shape is retired. The single canonical shape is
values: { <name>: { rule, description, action, icon, color } }— rule lives next to its value. Sense 22 should be cleaned up to match during the next docs pass.
Threshold–SQL coupling
Section titled “Threshold–SQL coupling”The load-bearing decision: when the same number (absolute_stub_chf = 0.10) appears in both the Legend (as thresholds.absolute_stub_chf)
and the SQL (as {{ var('absolute_stub_chf') }}), who is the source of
truth?
Decision: the Legend is the source of truth
Section titled “Decision: the Legend is the source of truth”The Legend declares the threshold. The compiler emits a dbt vars
file at build time. The dbt run reads that file via --vars or
dbt_project.yml vars: injection. The SQL never carries threshold
literals.
How it works in practice
Section titled “How it works in practice”-
The compiler walks all entity/signal/verdict YAMLs in the AFS, collects every
thresholds:block, and emits a single resolved vars file at<afs>/build/dbt/legend_vars.yml:# auto-generated by entitycompile — do not editabsolute_stub_chf: 0.10substantial_movements: 10high_money_at_risk_chf: 100000 -
The dbt build picks up the file via standard
vars:injection (jin makealready passes--varsfor tenant context; this becomes one more piece of that bundle). -
SQL refers to thresholds by name only:
casewhen b.max_price < {{ var('absolute_stub_chf') }} then 'sentinel_tail'... -
If two Legends declare the same threshold name with different values, the compiler emits a fatal error. Threshold names are a tenant-flat namespace.
Why this direction, not the other
Section titled “Why this direction, not the other”- The Legend describes the meaning; the SQL is the implementation. Meaning should not depend on implementation. If the SQL is rewritten in a different engine tomorrow, the Legend (and its threshold) is the durable record.
- A label without its threshold is meaningless.
mis_channeledfires becauseabsolute_stub_chf = 0.10. The threshold is part of the label’s identity, not a tunable parameter discovered separately. - Drift detection becomes free. If the SQL hard-codes a number that doesn’t match the Legend, validation can catch it. If both are derived from the Legend, they cannot drift.
Drift between rule text and SQL CASE WHEN
Section titled “Drift between rule text and SQL CASE WHEN”The rule field is plain-language documentation; the SQL is the actual
classifier. They can drift. Two mitigations:
- Phase 3 compiler check (cheap): require that every value declared
in
values:actually appears in the SQL ofdbt_model. Grep for the string. Warn on mismatch. Catches “I added an unclassified branch but forgot to add a Legend entry,” and “I removed a value but left it in the Legend.” - Phase 5 (richer): a
last_validated_at: <iso-date>field per Legend, set when a human signs off that the rule text matches the SQL. JinDesk can mark Legends as stale if too long has passed. Out of scope for v1.
Compiler integration
Section titled “Compiler integration”Files touched
Section titled “Files touched”scripts/entitycompile.py— extend to parse the newclassifications:block. Validate the schema (required keys, multilingual normalization, value-name uniqueness, threshold-name uniqueness across the AFS). Emit the resolved Legend into the entity registry.scripts/signalcompile.py— extend identically for signal-level Legends (severity / category).scripts/verdictcompile.py— extend identically for verdict confidence buckets.scripts/smebitcompile.py— extend identically for Subject Matter category. (Tool namesmebitcompile.pypreserved as the internal slug per the SMEbit → Subject Matter display-only rename.)scripts/legendcompile.py(NEW) — emitslegend_vars.yml. Reads everyclassifications:block in the AFS, resolves threshold names, fails on collisions, writes the unified vars file. Runs before dbt.
Compile order
Section titled “Compile order”extract → bronze → silver → legendcompile → dbt → signalcompile → verdictcompile → smebitcompile → entitycompile → bake metadatalegendcompile runs first because dbt needs legend_vars.yml before
the SQL build. Entity/signal/verdict/smebit compilers run after dbt
because they bake the resolved Legends into the registry tables, which
are themselves dbt models.
Validation rules
Section titled “Validation rules”The compiler errors (not warns) on:
- Threshold name appearing in two places with different values.
- A value declared in
values:whose name appears in no SQL CASE WHEN of the referenceddbt_model. (Phase 3.5; defer if it slows iteration.) - A multilingual field with an unknown locale key.
- A
rule:that references an undeclaredthresholds:name.
The compiler warns (not errors) on:
- A SQL value that has no entry in
values:. (The SQL is broader than the Legend; classify all paths.) - A
descriptionoractionmissing one ofen,de,fr.
Registry projection
Section titled “Registry projection”What the registry stores
Section titled “What the registry stores”The existing entity_registry table already carries an entity_config
column — a JSON blob with display config keyed by entity. Extend the
JSON to include a classifications key:
{ "display": { "...": "..." }, "classifications": { "spread_signature": { "description": { "en": "...", "de": "...", "fr": "..." }, "thresholds": { "substantial_movements": 10, "absolute_stub_chf": 0.10 }, "values": { "mis_channeled": { "icon": "↔", "color": "#dc2626", "rule": "...", "description": { "en": "...", "de": "...", "fr": "..." }, "action": { "en": "...", "de": "...", "fr": "..." } } } } }}Same pattern for signal_registry, verdict_registry, smebit_registry.
Why nest under entity_config rather than a new column
Section titled “Why nest under entity_config rather than a new column”Three reasons:
- No schema migration on the registry tables — the JSON shape grows.
- Single round-trip from JinDesk — one
SELECT entity_configreads display config and Legend together. - Cascade-ready for the future — when the config engine grows multiple inheritance pathways (cf. user note 2026-04-28), Legends can ride those pathways without a parallel infrastructure.
JinDesk surfaces
Section titled “JinDesk surfaces”JinDesk reads entity_config.classifications.<col> and renders
five surfaces. None of them require schema changes beyond the registry
extension above; they’re rendering work.
1. Column-header tooltip (Phase 4)
Section titled “1. Column-header tooltip (Phase 4)”When a column is in classifications, the column-header hover shows:
the Legend’s description, the threshold list, and a compact table of
values with icon + name + one-line description.
This replaces the Phase 1 prose tooltip when present. Until Phase 4 ships, the prose tooltip and the structured Legend coexist (the spike keeps both side by side in the YAML).
2. Per-cell value hover (Phase 4)
Section titled “2. Per-cell value hover (Phase 4)”Hovering on a specific cell value shows just that one entry’s
description + action. The icon and color render in-cell where
the value is displayed.
3. “About these classifications” panel (Phase 5)
Section titled “3. “About these classifications” panel (Phase 5)”A small expandable panel below the dimension table. Renders the full Legend as a structured table: rules, thresholds, descriptions, actions. A one-stop reference for the column.
4. Filter chips (Phase 5)
Section titled “4. Filter chips (Phase 5)”Clicking a Legend entry filters the table to that class. The chip displays the icon + color from the Legend.
5. Search-grammar autocomplete (Phase 6)
Section titled “5. Search-grammar autocomplete (Phase 6)”Typing !spread_signature: in the search bar offers the declared
values as autocompletes. The grammar engine reads
entity_config.classifications per entity.
6. Severity badges across JinDesk (Phase 5+)
Section titled “6. Severity badges across JinDesk (Phase 5+)”Existing severity rendering stops hard-coding palettes. The badge for
severity = 'high' reads its color, icon, and tooltip from the signal’s
own Legend. Same for status chips, confidence indicators, etc.
Cascade and ownership (the Pack-out-of-reach reaffirmation)
Section titled “Cascade and ownership (the Pack-out-of-reach reaffirmation)”This section repeats Sense 20’s fourth invariant in the Legend’s specific language, because the temptation to introduce runtime cascade is real and we already caught ourselves on it once.
Tenant Legends carry full content, always. Reading any tenant entity/signal YAML tells you everything needed to render its Legend. No fall-through to pack defaults. No inheritance chain.
Pack Legends are not consulted at runtime. The pack supplies content
into a tenant via copy at alignment (an afs update event). After
the copy, the tenant owns the content. Edit it freely; the pack is
invisible until the next alignment event.
Threshold values are tenant-specific by default. rmc’s
absolute_stub_chf = 0.10 is rmc’s truth, written into rmc’s Legend.
A new tenant copying this Legend at init time gets 0.10 baked in;
they can edit it without consulting any pack.
Promotion travels the full Legend. When a column promotes from tenant→pack via The Seam, its Legend rides along — with tenant identity stripped per Sense 20’s pack-blindness invariant. The pack’s copy then becomes the seed for future tenants’ init.
Phase ordering (refined)
Section titled “Phase ordering (refined)”| Phase | Scope | Status |
|---|---|---|
| 1 | Cheap-now: prose tooltip on column header. | Shipped 2026-04-27 (rmc spread_signature) |
| 2 | Schema spike: structured classifications: block in one tenant YAML alongside the prose tooltip. No compiler change. | Shipped 2026-04-28 (rmc article_price_spread.yaml) |
| 2.5 | This implementation doc. Codifies what the spike taught. | In flight 2026-04-28 |
| 3 | legendcompile.py. Threshold vars file. Compiler validation. Registry projection (JSON extension on entity_config). | Pending |
| 4 | JinDesk reads registry’s classifications block. Column-header tooltip + per-cell hover render structured form. Phase 1 prose tooltip removed. | Pending |
| 5 | Filter chips, “About these classifications” panel, severity-badge color/icon read from Legends, signal/verdict/smebit Legends adopted. | Pending |
| 6 | Search-grammar autocomplete from declared values. | Pending |
Spike learnings codified
Section titled “Spike learnings codified”What the Phase 2 spike on spread_signature taught us, now fixed in
this doc:
-
Top-level placement is correct.
classifications:is a peer ofcolumns:, not nested. Confirmed by the spike feeling natural in rmc’s entity YAML. -
The single canonical shape is
values: { <name>: { ... } }. Sense 22’s alternaterules:block is retired. (Sense 22 doc itself should be cleaned up.) -
Fallthrough values are first-class. rmc’s
unclassifiedvalue is real and useful. The schema blesses this pattern. -
Threshold–SQL coupling: Legend wins. Compiler emits dbt vars from Legend thresholds; SQL never carries literals. Decision recorded above with rationale.
-
Pack-out-of-reach is the cascade rule. Tenant Legends carry full content; alignment is a copy event. Codified here, in Sense 20 (as the fourth invariant), and in the
feedback_pack_out_of_reach.mdmemory. -
Co-existence with Phase 1 tooltip is acceptable during transition. The prose tooltip stays in the YAML until the compiler reads
classifications:and JinDesk renders the structured form. Phase 4 retires the prose tooltip; until then, both shapes coexist in the same file with a comment header explaining why.
How this manifests in jinflow
Section titled “How this manifests in jinflow”Currently implementing. Status: implementing. The in-flight work is described in the roadmap / phases above; the canonical map of running surfaces (URLs, CLI verbs, source pointers) will land here once the first phase ships.
Open questions
Section titled “Open questions”-
Color palette governance. Should
color:accept any hex value, or only tokens from a curated engine palette (e.g.palette: severity-high)? Lean: a small token set declared at engine level; Legends pick by name; raw hex permitted as escape hatch with a linter warning. -
Multilingual rule text. This doc treats
rule:as English-only plain-language documentation. Should it be multilingual likedescriptionandaction? Lean: yes for v1, since translation cost is low and JinDesk surfaces the rule in tooltips. -
Tenant-defined value spaces (
dominant_channel = LOG/PHA,material_class = CONSUMABLE/MEDICATION). The schema as defined here serves engine-defined enums and pack-shaped enums. Tenant-only enums need a slightly different profile: no pack defaults, no cross- tenant naming alignment. Defer to a follow-up:tenant_legends.mdaddresses Family 6 of Sense 22 with its own conventions. -
Dynamic thresholds (e.g. severity bucket boundaries computed as percentiles of tenant data). v1 supports static thresholds only. v2 may add
computed_threshold:blocks pointing at SQL. Out of scope here. -
Legend versioning at the threshold level. When a threshold changes from 1.00 to 0.10, do we record the prior value? Git is the answer for now; a
last_changed: <iso-date>per threshold may come later if the Ledger (Sense 18) needs it. -
Where does
legend_vars.ymllive? Proposal:<afs>/build/dbt/legend_vars.yml, regenerated everyjin make. Build-tree, not committed. -
Cross-Legend threshold sharing. Two Legends might genuinely want to share the same threshold (
min_money_at_risk_chfacross multiple signals). Today the compiler errors on duplicate names. Should we support a top-levelshared_thresholds:namespace? Defer; address when the second instance arrives.
Connections
Section titled “Connections”- Sense 20 (The Seam) — the cascade-vs-copy decision, and the Pack-out-of-reach invariant, rest on Sense 20’s vocabulary.
- Sense 18 (The Ledger) — Legends are the substrate the Ledger reads when narrating how a label was assigned. The Ledger’s computational provenance and the Legend’s declarative provenance meet at the registry.
- Sense 21 (The Heartbeat) — Beat 5 (notify) needs interpretable labels. “Severity went from low to high” is meaningful only when both labels carry their meaning. Legends are what make the heartbeat speak.
- Sense 14 (The Signal) — signals declare
severity,category,polarity. The Legend formalizes what each value means. config_engine.md— the runtime cascade for operational scaffolding. Distinguished from Legends here, which are semantic content and follow the copy-at-alignment rule.
What this doc deliberately does not say
Section titled “What this doc deliberately does not say”- Specific SQL transformations in
legendcompile.py. Implementation detail; the function signature and validation rules are above; the body is for the PR. - Migration of existing classifications. Severity, status, confidence, category — they exist today as bare strings. Their retrofit to Legends is a Phase 5 effort with its own ordering.
- Engine-level palette specification. Open Question 1 punts on it intentionally; addressed when we have the second Legend live.
- Performance. The registry JSON grows; loading cost is sub- millisecond per entity. Revisit if it ever becomes one.
Closing
Section titled “Closing”The spike taught us the schema sits right. This doc fixes the schema into a contract, names the compiler’s job, projects the Legend into the registry JinDesk already reads, and enumerates the surfaces that follow.
Step 3 (the actual code) should now be small, obvious, and reversible — the way it always should be when the design has done its work.
Numerical neighbors: ← Sense 21: The Heartbeat — keeping the system in rhythm · Sense 22: The Legend — labels that explain themselves →