Skip to content

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.md in 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)


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.


A classifications: block lives at the top level of the YAML that owns the column.

ArtifactYAML location
Dimension columnEntity YAML — <afs>/entities/<entity>.yaml
Signal severity / categorySignal YAML — <afs>/signals/<id>.yaml
Action statusNotebook YAML or tenant-level <afs>/legends/action_legend.yaml
Verdict confidenceVerdict YAML — <afs>/verdicts/<id>.yaml
Subject Matter categoryPack-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.

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: "..."
  • 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’s rules, 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. References thresholds by 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.

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.

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 from values::

    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.


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.

  1. 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 edit
    absolute_stub_chf: 0.10
    substantial_movements: 10
    high_money_at_risk_chf: 100000
  2. The dbt build picks up the file via standard vars: injection (jin make already passes --vars for tenant context; this becomes one more piece of that bundle).

  3. SQL refers to thresholds by name only:

    case
    when b.max_price < {{ var('absolute_stub_chf') }} then 'sentinel_tail'
    ...
  4. If two Legends declare the same threshold name with different values, the compiler emits a fatal error. Threshold names are a tenant-flat namespace.

  • 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_channeled fires because absolute_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.

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 of dbt_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.

  • scripts/entitycompile.py — extend to parse the new classifications: 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 name smebitcompile.py preserved as the internal slug per the SMEbit → Subject Matter display-only rename.)
  • scripts/legendcompile.py (NEW) — emits legend_vars.yml. Reads every classifications: block in the AFS, resolves threshold names, fails on collisions, writes the unified vars file. Runs before dbt.
extract → bronze → silver → legendcompile → dbt → signalcompile →
verdictcompile → smebitcompile → entitycompile → bake metadata

legendcompile 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.

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 referenced dbt_model. (Phase 3.5; defer if it slows iteration.)
  • A multilingual field with an unknown locale key.
  • A rule: that references an undeclared thresholds: 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 description or action missing one of en, de, fr.

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_config reads 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 reads entity_config.classifications.<col> and renders five surfaces. None of them require schema changes beyond the registry extension above; they’re rendering work.

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).

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.

Clicking a Legend entry filters the table to that class. The chip displays the icon + color from the Legend.

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.


PhaseScopeStatus
1Cheap-now: prose tooltip on column header.Shipped 2026-04-27 (rmc spread_signature)
2Schema 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.5This implementation doc. Codifies what the spike taught.In flight 2026-04-28
3legendcompile.py. Threshold vars file. Compiler validation. Registry projection (JSON extension on entity_config).Pending
4JinDesk reads registry’s classifications block. Column-header tooltip + per-cell hover render structured form. Phase 1 prose tooltip removed.Pending
5Filter chips, “About these classifications” panel, severity-badge color/icon read from Legends, signal/verdict/smebit Legends adopted.Pending
6Search-grammar autocomplete from declared values.Pending

What the Phase 2 spike on spread_signature taught us, now fixed in this doc:

  1. Top-level placement is correct. classifications: is a peer of columns:, not nested. Confirmed by the spike feeling natural in rmc’s entity YAML.

  2. The single canonical shape is values: { <name>: { ... } }. Sense 22’s alternate rules: block is retired. (Sense 22 doc itself should be cleaned up.)

  3. Fallthrough values are first-class. rmc’s unclassified value is real and useful. The schema blesses this pattern.

  4. Threshold–SQL coupling: Legend wins. Compiler emits dbt vars from Legend thresholds; SQL never carries literals. Decision recorded above with rationale.

  5. 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.md memory.

  6. 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.


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.


  1. 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.

  2. Multilingual rule text. This doc treats rule: as English-only plain-language documentation. Should it be multilingual like description and action? Lean: yes for v1, since translation cost is low and JinDesk surfaces the rule in tooltips.

  3. 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.md addresses Family 6 of Sense 22 with its own conventions.

  4. 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.

  5. 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.

  6. Where does legend_vars.yml live? Proposal: <afs>/build/dbt/legend_vars.yml, regenerated every jin make. Build-tree, not committed.

  7. Cross-Legend threshold sharing. Two Legends might genuinely want to share the same threshold (min_money_at_risk_chf across multiple signals). Today the compiler errors on duplicate names. Should we support a top-level shared_thresholds: namespace? Defer; address when the second instance arrives.


  • 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.

  • 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.

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

jazzisnow jinflow is a jazzisnow product
v0.64.7 · built 2026-09-20 19:48 UTC