Skip to content

Sense 14.2 — Typed Signals

Sense 14.2 · In bloom · Last touched 2026-08-20

  • last_verified: 2026-05-25
  • supersedes: sense_14_2_typed_signals.md

Synced from docs/design/sense_14_2.md in the engine repo — that’s the source; this page is a build-time mirror.

A signal already has a type. Today the type is implicit — buried in dbt SQL, smuggled through impact_unit: CHF strings, exposed via a magic money_at_risk column. Sense 14.2 makes the type declared. The compiler stops trusting prose and starts checking the math.

Status: proposed (rewrite of April doc) Authors: the owner + Claude (rewrite, 2026-05-20) Depends on: Sense 14 (The Signal — shipped) Unlocks: Sense 13 (Canvas), UoM Phase 3 (drug-strength arithmetic), lens declarativity (retire today’s {#if showMoneyAtRisk} guards) Reference: the_jinflow.md — the five-name model.


The April doc (sense_14_2_typed_signals.md, preserved as the conceptual archive) was written before:

  1. Phase 0 shipped (2026-05-08, engine 3a004f2c + pack 5415a32). scripts/ucum.py carries a curated 25-unit subset + compound parser. signalcheck.py validates outputs: and window: blocks. signal_price_stability in numetrix carries the first typed outputs end-to-end (coefficient_of_variation: %, mean_price: CHF, observation_count: {observations}) plus an explicit P12M window. Foundation is in.
  2. The Atelier shipped. B-tier authoring through JinDesk is proven. The writer pattern, the ajv schema mirror, the cross-ref walker — all extend cleanly to typed outputs.
  3. Sense 15 shipped. Observations + Explanations + Contributing Factors carry anchors to evidence. Typed evidence makes those anchors mean something the engine can check, not just narrate.
  4. The diamond replaced the pyramid. Signals are the bottom of the Analysis name; theses + verdicts ascend from them. Typed outputs flow upward, and the diamond’s walking toward each other framing depends on the two sides recognising each other’s evidence shape.
  5. The economic-lens sweep happened (2026-05-20). A 14-file imperative-guard pattern ({#if showMoneyAtRisk}) shipped to cope with hrcentral not thinking in money. The lens-declarativity memo (memory/project_lens_declarativity.md) explicitly names Typed Signals as the way to retire those guards.
  6. User priority signal (2026-05-08). Currencies were flagged as the urgent piece: “currencies will play a prominent role, soon.” Phase 3 (currency machinery) jumps the queue accordingly — see phase plan below.

What the April doc got right — and the rewrite preserves:

  • Standards-grounded. UCUM, ISO 4217, ISO 8601, ISO 80000. No invented vocabulary.
  • Five base types. quantity / currency / time / category / flag. No more, no less.
  • Contextual unit registry. FTE / bed-day / case / weighted case as tenant-scoped definitions, anchored to Subject Matter.
  • Currency conversion as provenance. Not silent arithmetic.
  • Opt-in migration. Existing signals keep working as implicit flag until annotated.

What changes:

  • Decisive on the nine April open questions. Each gets a recommendation; the doc isn’t a list of open items.
  • The Canvas connection. Typed Signals are the prerequisite for declarative pages. This is the reason to do 14.2 before 13.
  • The lens-declarativity payoff is now spelled out. Yesterday’s guards have a named retirement path: availableUnits derived from the signal catalog, pages iterate instead of branching.
  • Phase plan refined. Four phases instead of three, with Phase 0 being a deliberate no-op-friendly foundation — the schema accepts typed YAML without acting on it before any signal is annotated.
  • Migration safety baked in. Every step keeps existing signals working unchanged.

Sense 14.2 doesn’t add a type system. It makes the type system that already exists declared rather than implicit.

Today (implicit)After 14.2 (declared)
impact_unit: CHF as a free stringcurrency: CHF (ISO 4217 validated)
money_at_risk as a magic columnOutput exposure: { type: currency } named explicitly
time_bucket as VARCHARtime typed with ISO 8601 form (point / interval / duration)
Quantity-vs-currency SUM in a thesis = silent nonsenseCompile error
”Trailing 12 months” prose in SQLwindow: { duration: P12M } declarative
UI guards on hand-managed show_money_at_risk flagUI iterates availableUnits derived from the catalog

The compiler stops trusting prose and starts checking math.

Three consequences fall out.

Yesterday’s 14-file imperative sweep retires. The engine queries the signal catalog for “does this tenant have any signals with kind: currency?” and renders columns accordingly. New units (headcount, days, FTE) drop in without code changes. display.economic_lens survives but flips from “show toggle” to “suppress override” — “keep the data hidden even though it exists”, useful for public dashboards or board-only cuts.

2. Aggregation correctness becomes structural

Section titled “2. Aggregation correctness becomes structural”

A thesis that aggregates one signal carrying unit: CHF and another carrying unit: kg fails to compile. Today it builds fine and produces a meaningless sum. This is the bug class that hides in production because the number looks plausible. Compile-time catch > runtime audit.

The Canvas YAML can declare “I need a time axis and a quantity axis” and the engine matches against the typed catalog. Without 14.2, every Canvas panel needs the same imperative branching that yesterday’s sweep cleaned up. With 14.2, Canvas can be type-checked at authoring time — a panel’s compatibility with a tenant’s data is verifiable before the page renders.


Four international standards cover everything jinflow signals will ever measure. No invented vocabulary.

StandardGovernsExamples
UCUMUnits of measure, compound unitskg, m, d, %, {items}, CHF/{item}
ISO 4217Currency codes (subset of UCUM)CHF, EUR, USD
ISO 8601Time points, intervals, durations2026-04, P12M, 2025-04/2026-04
ISO 80000Quantity kind taxonomymass, length, time, currency, dimensionless

UCUM is the machine-readable encoding for units. Designed for software systems to do dimensional analysis. ISO 4217 currency codes are valid UCUM atoms — CHF is a UCUM unit, CHF/kg is a compound UCUM unit (price per kilogram). The type system treats money and physical quantities uniformly.

ISO 80000 is the conceptual layer above UCUM — quantity kinds are the abstract dimensions that units measure. Two values can be aggregated (SUM, AVG) only if they share the same quantity kind. You can convert kg to g (same kind, different unit). You cannot add kg to CHF (different kinds).


Five base types. (See April archive for full examples and DuckDB storage notes; condensed here.)

A numeric value with a UCUM unit and an inferred or declared kind.

margin_per_unit:
type: quantity
unit: CHF/{item} # UCUM code
kind: currency # ISO 80000; usually inferred from unit
range: [-50, 50] # expected domain (for rendering + severity)
polarity: higher_better

Aggregation: SUM / AVG / MIN / MAX within same kind+unit. Cross-unit within same kind needs explicit conversion. Cross-kind is a compile error.

A monetary amount with an ISO 4217 currency code and a reference date.

exposure:
type: currency
currency: CHF
ref_date: latest # or explicit ISO 8601 date
polarity: closer_to_zero

SUM/AVG/MIN/MAX only within the same currency code. Cross-currency requires an explicit conversion step that becomes a traceable Ledger node (Sense 18).

money_at_risk is no longer a magic column — it becomes a backward-compatible alias for the first currency-typed output of a signal, if any. New signals declare their currency outputs by name.

A temporal value grounded in ISO 8601.

observation_window:
type: time
form: interval # point | interval | duration
granularity: P1M

Replaces the informal time_bucket VARCHAR. Signals declare their windows declaratively:

window:
duration: P12M # how far back
granularity: P1M # bucket size
anchor: latest # latest | fixed(date) | delivery

The compiler generates the SQL WHERE clause; the Ledger narrates the window in prose.

An enumerated value with a defined option set. When ordered: true, the values list defines the ordering — enabling “worst-of” aggregation in perspectives (if any material is non_compliant, the entity-level status is non_compliant).

compliance_status:
type: category
values: [compliant, partial, non_compliant]
ordered: true

Boolean — the degenerate case. Every existing signal is implicitly flag type today (the finding exists or doesn’t). This is the migration baseline — existing signals keep working unchanged.

A signal declares its outputs — typed values that compile to real columns in signal_findings__<signal_id> with an out_ prefix.

Framework columns survive intact: finding_id, tenant_id, signal_id, severity, score, evidence. They are unchanged by Sense 14.2 — typed outputs are additive structured data alongside them. money_at_risk stays as a backward-compat alias.


Contextual units — where 14.2 meets Subject Matter

Section titled “Contextual units — where 14.2 meets Subject Matter”

Not all units are universal. Some depend on tenant-specific rules:

UnitDefinition depends on…
FTEEmployment rules (42 h/wk RMC, 35 h/wk France, 40 h/wk Swiss academia)
bed-dayClinical counting rules (ICU vs general ward, day-case included?)
caseAdministrative definition (DRG case vs clinical episode)
weighted caseWeight table version (CMI 2025 vs CMI 2026)
standard costMethodology + base year
supply unitMaterial master (pack size, dispensing vs billing unit)

These are contextual units — meaningful only with their definition attached. “2.4 FTE” is useless without knowing what 1 FTE means at this tenant.

Each tenant declares a small units.yml (or block in tenant.yml) mapping contextual units to their base-unit equivalents:

units:
FTE:
base_unit: h/wk # UCUM base
factor: 42 # 1 FTE = 42 h/wk at this tenant
source: smebit_rmc_fte_definition # governance link → Subject Matter
bed_day:
base_unit: d
factor: 1
qualifiers: ["excludes day-case admissions"]
source: smebit_rmc_bed_day_rules

The registry is tenant-scoped, not pack-scoped. The same pack serves tenants with different FTE definitions.

The source field is where the type system meets institutional knowledge. It points at a Subject Matter Statement that governs who decided this definition, when, and why. This is the place Sense 14, Sense 14.2, and the Atelier converge.

The Ledger (Sense 18 — TBD) will narrate contextual units with their definition inline:

“2.4 FTE (= 100.8 hours/week, per RMC employment rules — 1 FTE = 42 hours/week, defined by HR department, 2024-01-15)”

Changing the FTE definition is a traceable assumption in “what could change this number” — exactly the kind of narrative-grade provenance the Ledger is meant to produce.


The signal compiler gains dimensional analysis. Two validators run in lock-step:

  • Engine sidescripts/signalcheck.py and scripts/signalcompile.py validate UCUM codes, infer kinds, check aggregation compatibility, generate typed columns.
  • Atelier side$lib/atelier/signal_validator.ts (ajv schema mirror) catches the same issues live in the editor before the user hits Save.

Both must agree on the type system, same way they do today for the structural validator. Drift between the two is the bug class to guard against.

The five checks the compiler gains:

  1. Unit validation — every unit field must be a valid UCUM code from the curated subset (see Phase plan). Reject unit: francs. Accept unit: CHF.
  2. Kind inference / disambiguationkg → mass (unambiguous, inferred). {count} → ambiguous, explicit kind: required. Compiler logs every inference.
  3. Aggregation compatibility — perspectives that SUM/AVG across signals must have compatible kind. SUM of kg + CHF → compile error.
  4. Currency consistency — within a signal, all currency outputs must declare the currency code. Cross-currency signals are legal but must declare a conversion reference.
  5. Temporal consistency — if a signal declares a window, its time-typed outputs must be compatible with the window’s granularity.

Lens declarativity — the retire-the-guards payoff

Section titled “Lens declarativity — the retire-the-guards payoff”

Today’s pattern (post-yesterday’s sweep, across 14 files):

const showMoneyAtRisk = $derived(
($pageStore.data as any).economicLens?.showMoneyAtRisk !== false,
);
{#if showMoneyAtRisk}
<td><MoneyValue value={f.money_at_risk} /></td>
{/if}

Plus a display.economic_lens.show_money_at_risk: false config block in every pack that doesn’t think in money.

Post-14.2, layout.server.ts computes once per tenant:

availableUnits: Set<UnitKind> = union of {
output.kind for output in signal_registry across tenant
}

Pages iterate:

{#each availableUnits as unit}
<th>{labelFor(unit)}</th>
{/each}
{#each row.typed_outputs.filter(o => o.kind === unit) as out}
<td><TypedValue value={out} /></td>
{/each}

display.economic_lens survives — but as a suppression overlay only. It says “the tenant’s data carries CHF but don’t show it on this surface” (public dashboards, board-only cuts, sensitive slices). Default behaviour: render every kind the data carries.

The 14 imperative guards retire one by one as each page migrates.


Six phases. Phase 0 is shipped. Phase 3 (currencies) jumps the queue per user priority — it depends on Phase 1 alone, not on the type-aware perspectives of Phase 2.

Phase 0 — Foundation ✅ SHIPPED 2026-05-08

Section titled “Phase 0 — Foundation ✅ SHIPPED 2026-05-08”

Engine 3a004f2c + pack 5415a32. No user-facing change — the schema accepts typed YAML without acting on it yet.

  • scripts/ucum.py (~140 LOC): curated 25-atom subset + compound parser (X/Y where X and Y are atomic, e.g. CHF/{item}, h/wk, {items}/mo).
  • signalcheck.py validators for outputs: and window: blocks. Validates UCUM codes, ISO 4217 currency codes, ISO 80000 quantity kinds, ISO 8601 durations.
  • signal_price_stability in numetrix becomes the first signal with typed outputs end-to-end:
    outputs:
    coefficient_of_variation: { kind: dimensionless, unit: "%" }
    mean_price: { kind: currency, unit: CHF }
    observation_count: { kind: dimensionless, unit: "{observations}" }
    window:
    duration: P12M
  • 47 unchanged signals + 1 typed signal all validate clean. 58/58 tests pass.

Phase 1 — Compiler emission (engine) ✅ SHIPPED 2026-05-22

Section titled “Phase 1 — Compiler emission (engine) ✅ SHIPPED 2026-05-22”

Make typed outputs do something. Today they validate but produce no SQL columns.

  • signalcompile.py emits typed columns when outputs: is declared. Column naming: out_<output_name>, with companion sidecars for type metadata (out_<n>_type, out_<n>_unit, out_<n>_currency, out_<n>_kind, out_<n>_form, out_<n>_granularity, out_<n>_ordered). Wired into _apply_signal_extensions via _typed_outputs_emit.
  • ✅ JinDesk renders typed columns when present; falls back to the legacy money_at_risk column when no outputs: declared. $lib/utils/typedOutputs.ts discovers them from row sidecars; TypedOutputValue.svelte formats per type (Intl currency, UCUM-aware quantity, flag, category).
  • ✅ UCUM percent convention pinned: a value labeled % is the percent number (5 means 5 %), not a fraction. Signal SQL owns the multiplication; the formatter just appends the symbol.
  • ✅ Perspectives compile with UNION ALL BY NAME so source signals with differing out_* shapes coexist without column-count errors.
  • ✅ Second pack end-to-end: signal_overload_risk in hrcentral declares three typed outputs (total_scope_pct, assignment_count, exceeds_threshold_by) and renders them in vai’s KLS.

Exit criterion met: signal_price_stability renders its mean_price (currency CHF), coefficient_of_variation (percent), and observation_count (observations) as distinct typed columns. signal_overload_risk in hrcentral proves the path across packs.

Moved to Phase 2 (see below):

  • window: block → SQL WHERE clause. The honest path requires deciding what anchor: latest means against current_date vs max(observation_date), choosing which field to filter on (the YAML carries scope.time.field but hand-written SQL uses arbitrary column names), and finding a clean injection point for type: hand_written signals where the compiler reads SQL verbatim. Migration target; the declaration stays validated metadata in the meantime.
  • Atelier signal editor for outputs: + window: structured form. Authoring through Atelier is a UX layer on top of the compiler surface; lands once the compiler has both pieces in place.

Phase 2 — Type-aware perspectives + declarative window (engine)

Section titled “Phase 2 — Type-aware perspectives + declarative window (engine)”

Compile-time checks start biting, and the window: declaration finally drives SQL.

  • Perspective compiler validates aggregation methods against output types. Cross-kind sums become errors.
  • ✅ Thesis evidence chains validate type compatibility. Two checks:
    • Dimension consistency for primary evidence. When a thesis cites multiple primary signals, they must share impact.dimension. Financial-vs-operational primary mix → validation error pointing at the offending pair. Supporting / context / counter roles get a pass — they contribute weight, not the kind of claim. (Default dimension: financial.)
    • Interpretation references match outputs. A template referencing {out_total_scope_pct} requires every primary signal to declare total_scope_pct in its outputs: block. Catches the silent-blank rendering at validate time. Pre-existing thesischeck bug fixed in the same pass: known_signal_ids now includes perspectives, so evidence chains can legitimately cite them without false “no corresponding YAML” errors.
  • ✅ Severity bound to a typed output via severity_from: { output, rules }. Wrapper-level SELECT * REPLACE(<case> AS severity) swaps the inner signal’s hand-rolled severity for a CASE built against the named typed output. Validator confirms the referenced output exists. Legacy severity_rules: keeps working unchanged when no severity_from: is declared.
  • window: block generates the SQL WHERE clause for compiler-driven signal types. Decisions pinned 2026-05-22:
    • anchor: latest = max(<field>) from the source, not current_date. The KLS is a snapshot; a query against it should produce the same findings regardless of when you run. anchor: now is the explicit opt-in for the wall-clock variant.
    • field: declared on the window block, not inherited from scope.time.field. Event-time and entity-time are different things; conflating them is what makes price_stability look consistent while the SQL filters a different column. The window author names the column they want filtered.
    • Hand-written signals: window: stays metadata. Authors of type: hand_written own the WHERE. Migration off hand_written into a declarative windowed-aggregation type is a separate piece of work, not Phase 2.
    • granularity: stays descriptive in Phase 2. Overlap with scope.time.bucket (raw/month/week/quarter) is real but not blocking; unification deferred to a later phase where ISO 8601 can subsume the bucket enum cleanly.

Exit criterion: a deliberately invalid signal+thesis pair (SUM(kg) + SUM(CHF), say) fails to compile with a clear error pointing at the offending fields. A compiler-driven signal declaring window: { duration: P12M, anchor: latest } emits the corresponding WHERE clause without hand-rolled SQL.

Phase 3 — Currency machinery (engine + pipeline)

Section titled “Phase 3 — Currency machinery (engine + pipeline)”

Sub-phased for incremental shipping:

  • 3.0 ✅ shipped 2026-05-24 — Cascade foundation: resolver + engine fallback for currency.anchor + per-tenant _{tenant}.analytical_defaults bake. No consumer yet; lays the groundwork for the keys that follow. See jinflow/config.py, bake_analytical_defaults.
  • 3.1 ✅ shipped 2026-05-25 — Compile-time currency safety net: signalcheck.py rejects perspectives whose source signals collectively declare currency-typed outputs in multiple ISO 4217 currencies, unless they declare convert_to: { currency: <ISO>, anchor: <key> }. Pure validation; no runtime change. Catches the silent cross-currency aggregation bug class before 3.2 ships any conversion machinery.
  • 3.2.a ✅ shipped 2026-05-26 — Currency conversion infrastructure: gold_exchange_rates table shape (currency_from, currency_to, ref_date, rate, rate_source), convert_currency() macro with nearest-prior ASOF lookup, testpack ships a fixture seed + macro for tests + as reference pattern. Returns NULL on missing rate (soft fail); 3.2.b adds compile-time coverage checks. Real packs declare exchange rates as reference_data: entries in their pipeline.yml per extractor_discipline.md (SHA-256-pinned, publisher + license required).
  • 3.2.b ✅ shipped 2026-05-27 — Compiler integration: perspective compiler emits source_findings_raw (UNION) + source_findings (conversion CTE applying convert_currency() to every currency-typed output declared across source signals). aggregate_outputs: references currency outputs by their plain name; the compiler auto-remaps to the converted column when convert_to: is active. V1 supports anchor: transaction_date (maps to time_bucket); other anchors raise a clear “not yet supported in compilation” error at compile time. Backward compatible — perspectives without convert_to: produce byte-identical SQL to before.
  • 3.2.c ✅ shipped 2026-05-27 — Coverage guarantee for missing rates: generate_signal_yml now adds a not_null test (with severity: error) for every converted column when the perspective declares convert_to:. The where: clause restricts the assertion to rows where the source amount was non-null — so a NULL converted value caused by a missing exchange rate now fails the build (dbt build / dbt test), instead of degrading silently. The test description carries the conversion provenance for human readers. End of Phase 3’s conversion arc.
  • The conversion anchor (transaction_date vs reporting_date vs period_end) is the pilot key for the new analytical_defaults: namespace under Sense 43 — The Cascade. Cascade: L0 engine fallback (transaction_date) → L1 pack default → L2 tenant override → L3 perspective declaration → L4 Lab-only URL override. The chosen value bakes into the KLS’s _jinflow_analytical_defaults table — every snapshot is self-describing about its conversion posture.
  • The Ledger (Sense 18 — when it lands) narrates: “EUR 300 → CHF 279 at rate 0.93 (SNB, 2026-04-17, source: reference_data/exchange_rates_snb_2026, anchor: transaction_date per pack default).”

Exit criterion: an Interlogic-style pack with mixed EUR/USD/GBP shipments builds correctly. The conversion rates are traceable to their source, and the rate becomes an explicit assumption in “what could change this number.” The anchor choice is declared in AFS, not buried in code.

Implementation spec, 2026-06-01. Code to follow as a separate commit. Per the spec-before-code rule in CLAUDE.md, this spec is the contract.

Eighteen files in explorer/src/ (the original Sense 14.2 draft said 14; actual is 18 as of 2026-06-01) carry imperative guards on two booleans, showMoneyAtRisk and showQtyAtRisk. The pattern repeats:

const showMoneyAtRisk = $derived(
($pageStore.data as any).economicLens?.showMoneyAtRisk !== false,
);
{#if showMoneyAtRisk}<option value="money">Sort: exposure</option>{/if}
{#if showMoneyAtRisk && signal.money_at_risk > 0}
<MoneyValue value={signal.money_at_risk} />
{/if}

The booleans collapse a per-tenant question (“does this tenant’s data carry currency-typed observations worth showing?”) into hardcoded UI branches that name money_at_risk and qty_at_risk explicitly. A new pack carrying a different unit (days, incidents, headcount) can’t surface it without a Svelte edit. The whole Form-Follows-Data conviction (CLAUDE.md, Sense 13) is broken at this surface.

availableUnits is computed once per tenant at layout-load time, derived from the signal registry baked into the KLS. Pages iterate over it. display.economic_lens flips from a presence- gate (“show money_at_risk”) to a suppression-override (“hide currency-typed units even though data carries them”) — useful for public dashboards and board-only cuts.

// New type in `explorer/src/lib/types.ts` (or a new
// `availableUnits.ts` module).
interface AvailableUnit {
/** The column name the unit lives on. V1 keys are
* 'money_at_risk' and 'qty_at_risk' (legacy contract columns);
* future packs that ship typed outputs add their column names. */
key: string;
/** Quantity kind — drives formatting choice on the consumer side. */
type: 'currency' | 'quantity' | 'time' | 'flag' | 'category';
/** The unit's value column. For legacy keys equals `key`; for
* typed outputs may differ (e.g. `out_<name>`). V1: equals key. */
column: string;
/** Sort dropdown label (i18n key, not a string literal). */
sort_label_key: string;
/** Stat block label (i18n key). */
stat_label_key: string;
/** Locale-aware formatter selector — pages pass `unit.type` to
* the existing `format(value, type)` helper. */
}
// Layout server returns:
availableUnits: AvailableUnit[]
async function computeAvailableUnits(tenant: string): Promise<AvailableUnit[]> {
// Step 1: query the signal registry baked into the KLS.
// _<tenant>.signal_registry already exists; we read which signal
// columns carry non-null values across the tenant's findings.
const present = await query<{ has_money: boolean; has_qty: boolean }>(
`SELECT
COUNT(*) FILTER (WHERE money_at_risk IS NOT NULL AND money_at_risk > 0) > 0 AS has_money,
COUNT(*) FILTER (WHERE qty_at_risk IS NOT NULL AND qty_at_risk > 0) > 0 AS has_qty
FROM "${schema}".signal_findings`,
);
// Step 2: assemble candidate units.
const candidates: AvailableUnit[] = [];
if (present[0]?.has_money) {
candidates.push({
key: 'money_at_risk',
type: 'currency',
column: 'money_at_risk',
sort_label_key: 'lens.sort.exposure',
stat_label_key: 'lens.stat.exposure',
});
}
if (present[0]?.has_qty) {
candidates.push({
key: 'qty_at_risk',
type: 'quantity',
column: 'qty_at_risk',
sort_label_key: 'lens.sort.volume',
stat_label_key: 'lens.stat.volume',
});
}
// Future: walk typed outputs from the signal registry and add
// one entry per declared `out_<name>` column. Out of scope V1.
// Step 3: apply economic_lens suppression. The flag flipped from
// "show this unit" to "suppress this unit even though it's
// present." Both old keys recognised for backward compat.
const lens = extractEconomicLens(config);
return candidates.filter(u => {
if (u.key === 'money_at_risk' && lens.showMoneyAtRisk === false) return false;
if (u.key === 'qty_at_risk' && lens.showQtyAtRisk === false) return false;
return true;
});
}

The query runs once per page load against the existing signal_findings table — no schema change, no extra bake step. The shape adds a single field to the layout-data object.

The flip from “show” to “suppress” is semantically a rename without a behavioural change for existing tenants:

  • Today: show_money_at_risk: false → boolean → all {#if showMoneyAtRisk} blocks evaluate false → UI hides.
  • After: show_money_at_risk: false → filtered out of availableUnits → no iteration of that unit → UI hides.

Same end-state. Tenants who don’t change their config see the same JinDesk. Tenants who currently rely on show_money_at_risk: true (default) see the same JinDesk. Zero migration burden for existing packs.

For tenants who want a unit suppressed even when data carries it (the new use case — public dashboards), the existing show_money_at_risk: false keeps working. A future config extension could add display.economic_lens.suppress: [<key>] as a more general syntax, but V1 doesn’t need it.

The shape of the migration is the same across the 18 files:

Before:

const showMoneyAtRisk = $derived(
($pageStore.data as any).economicLens?.showMoneyAtRisk !== false,
);
const showQtyAtRisk = $derived(/* … */);
{#if showMoneyAtRisk}<option value="money">Sort: exposure</option>{/if}
{#if showMoneyAtRisk && signal.money_at_risk > 0}
<MoneyValue value={signal.money_at_risk} />
{/if}

After:

const availableUnits = $derived(
($pageStore.data as any).availableUnits as AvailableUnit[] ?? [],
);
const moneyUnit = $derived(availableUnits.find(u => u.key === 'money_at_risk'));
{#each availableUnits as unit (unit.key)}
<option value={unit.key}>{$t(unit.sort_label_key)}</option>
{/each}
{#if moneyUnit && signal.money_at_risk > 0}
<MoneyValue value={signal.money_at_risk} />
{/if}

Where the page has hardcoded “money_at_risk” semantics that don’t generalize (e.g. a MoneyValue component that knows about CHF), the V1 migration keeps the named lookup (availableUnits.find(u => u.key === 'money_at_risk')). The hardcoded shape stays — but the guard becomes data-driven. Future packs that add new units add new lookups in the same shape.

Eighteen files, three categories:

CategoryFilesOrder
Foundation — adds availableUnits to layout data+layout.server.tsFirst, alone
Listing pages — sort dropdowns + per-row statssignals/+page.svelte, theses/+page.svelte, verdicts/+page.svelte, perspectives/+page.svelte, findings/+page.svelteSecond batch
Detail pages — single-entity statssignals/[signal_id]/+page.svelte, theses/[thesis_id]/+page.svelte, theses/[thesis_id]/present/+page.svelte, verdicts/[verdict_id]/+page.svelte, perspectives/[perspective_id]/+page.svelte, findings/[signal_id]/+page.svelte, findings/[signal_id]/[finding_id]/+page.svelteThird batch
Adjacent surfaces — dashboards, compare, builder, instruments+page.svelte (tenant home), compare/+page.svelte, signal-builder/+page.svelte, instruments/+page.svelteFourth batch
Doc artifactrelease-notes.mdOne-line update mentioning the migration

Total: 18 files. Foundation is independent; the three page batches can land in one commit each, or all together. The shape per file is identical — the loop over availableUnits replaces the boolean guard, the named lookup keeps the hardcoded MoneyValue path working.

For numetrix tenants today, the layout server returns:

availableUnits: [
{ key: 'money_at_risk', type: 'currency', column: 'money_at_risk',
sort_label_key: 'lens.sort.exposure', stat_label_key: 'lens.stat.exposure' },
{ key: 'qty_at_risk', type: 'quantity', column: 'qty_at_risk',
sort_label_key: 'lens.sort.volume', stat_label_key: 'lens.stat.volume' },
]

For hrcentral.vai today (signal registry has no money_at_risk findings):

availableUnits: [
{ key: 'qty_at_risk', type: 'quantity', column: 'qty_at_risk',
sort_label_key: 'lens.sort.volume', stat_label_key: 'lens.stat.volume' },
]

For vai with display.economic_lens.show_qty_at_risk: false:

availableUnits: [] // both currency-absent AND quantity-suppressed

The UI handles availableUnits: [] gracefully — no sort options, no stat blocks, falling back to whatever sort the page picks as default (findings count, name, modified date).

lens:
sort:
exposure: "Sort: exposure" # de: "Sortieren: Risiko" / fr: "Trier: exposition"
volume: "Sort: volume" # de: "Sortieren: Menge" / fr: "Trier: volume"
stat:
exposure: "Exposure"
volume: "Volume"

Four keys per locale, three locales (de/en/fr — it where hrcentral declares it). Lands as part of the layout-server commit.

Test surfaceWhere
extractAvailableUnits returns the expected shape for a tenant with both columns presenttests/test_lens_declarativity_layout.ts (or extend an existing layout spec)
Empty array when both suppressed via economic_lenssame
Single-unit case (vai)same
Page renders sort options driven by availableUnits (Playworks smoke)playworks/authored/lens-declarativity.spec.ts (new)
Page renders no money stats when availableUnits is emptysame

Tests cover the data-shape and at least one rendering surface end-to-end. Per-file Playwright regression for all 18 is overkill — the patterns are uniform, the shared helper is what changes.

FileChange
explorer/src/lib/types.ts (or new availableUnits.ts)New AvailableUnit interface.
explorer/src/routes/[tenant]/+layout.server.tsAdd extractAvailableUnits(tenant, config) async helper. Compose into the layout-data object alongside the other extractors. Keep economicLens field for backward compat (one release of overlap).
17 *.svelte pagesReplace showMoneyAtRisk / showQtyAtRisk derived bindings with availableUnits consumption per the migration pattern. Hardcoded MoneyValue/QtyValue paths keep their named lookups.
explorer/src/lib/data/release-notes.mdOne-line note about the migration.
explorer/src/lib/i18n/<locale>.json (de/en/fr/it)Add 4 new keys per locale.
tests/... + playworks/...New tests per the table above.
  • A new pack ships with incidents as a unit. Its signals carry out_incident_count as a quantity-typed output. After Phase 4, the Phase-5 typed-output walker adds an entry to availableUnits with key: 'incident_count', type: 'quantity'. Pages iterate; the new column renders. Zero Svelte edits.
  • A board-only JinDesk view suppresses currency without touching the data — display.economic_lens.show_money_at_risk: false filters the unit out.
  • Per-identity unit visibility (per the Sense 43 Outlook section) lands the day the resolver grows identity-axis support. The availableUnits filter pipeline already supports a suppress set; identity overlays inject into that set.
  • Walking typed out_<name> outputs from the signal registry. V1 hardcodes money_at_risk and qty_at_risk as the candidate set. Future packs that need new units extend the candidate list via a config block or a registry query — design when forced.
  • The general display.economic_lens.suppress: [<key>] config syntax. V1 keeps show_money_at_risk / show_qty_at_risk as the only suppression knobs. New units will need a syntax; that’s the same trigger as walking typed outputs.
  • Lab/Salon/Studio panel migration. The pages this Phase touches are room-agnostic surfaces (listings, details, dashboards). Panel-internal lens declarativity is its own concern under Sense 13.1 and lands separately.

Exit criterion: a new pack with a new unit (days, incidents, whatever) renders correctly in JinDesk without any Svelte edits, given that the unit is registered in the candidate-set extension that follows Phase 4.

Phase 5 — Temporal precision + Ledger integration

Section titled “Phase 5 — Temporal precision + Ledger integration”

The narrative-grade payoff. Two threads ship in V1; one stays parked.

The original Phase 5 sketch said “Ledger (Sense 18 — TBD) narrates units…”. Sense 18 (The Ledger) shipped its V1 substrate in early June 2026: surfaces V1 lets notes declare surfaces: [notebook, ledger]; the report engine renders metric / narrative / table / chart cells; the first trace (nb_ledger_net_exposure.yaml) is live in pack + rmc. So Phase 5 thread 2 is now extension of the existing trace surface, not construction of a new one.

Phase 2 also already wired window: into 5 of 13 signal types (balance, mandatory_item, distribution_outlier, duplicate, ratio). Phase 5 thread 1 extends it to the three remaining types where “all data ever” is the wrong default.

Thread 1 — window: SQL across three more signal types

Section titled “Thread 1 — window: SQL across three more signal types”

Five of the eight un-wired types stay un-wired by design; three get the same _window_where_fragment injection Phase 2 introduced.

Wire window: into:

  • trend — the canonical temporal signal. Computing rolling metrics or differences against “all data ever” is broken by construction. Window declaration becomes mandatory in the authoring guide once this lands (validator can warn).
  • temporal_sequence — same reasoning: “were events ordered correctly in the last N months” is the operationally useful question. Without windowing the signal compares historical noise to today’s pipeline.
  • reconciliation — the typical reconciliation case (usage-vs-billing totals, declared-vs-actual quantities) is time-bound. Wire it.

Leave un-wired (explicit non-decisions):

TypeWhy no window
silver_auditPoint-in-time snapshot of Silver validity. “What’s broken now”, not “what’s been broken”. Add later if a historical-audit driver shows up.
entity_filterFilters entities by attribute. Orthogonal to time.
enrichmentMetadata-adding signal. Time has no semantic place.
hand_writtenPhase 2 decision: author owns the SQL, including any time scoping. window: stays metadata.
perspectiveAggregates source-signal findings. The window applies upstream; the perspective itself doesn’t filter time.

Result: 8 of 13 signal types use declarative window:; 5 stay out by design.

Thread 2 — Signal-attached provenance on metric cells

Section titled “Thread 2 — Signal-attached provenance on metric cells”

The exit-criterion sentence — “Margin: CHF −5.80 per item, computed over the P12M ending 2026-04-30” — needs three pieces of ground truth: the unit, the temporal window, the signal that produced the number. All three live on the signal in the registry. The trace cell just needs to point at the signal.

Schema extension. The metric cell gains one optional field:

- id: margin
type: metric
label: { en: "Margin per item" }
signal_id: signal_margin_pressure # ← new in Phase 5
query: >
SELECT margin_chf
FROM signal_findings__signal_margin_pressure
ORDER BY observation_date DESC
LIMIT 1

Renderer behaviour. When signal_id is set, the cell renderer looks up the signal in signal_registry at bake time and pulls three pieces of metadata:

  1. outputs[0].unit — the unit declaration (UCUM symbol, ISO 4217 currency, etc.). Renders as the suffix on the value.
  2. window block — the temporal window, formatted in ISO 8601 duration prose with the anchor date resolved at bake time.
  3. The signal’s signal_id + version — for the audit trail.

These render as an attribution line below the metric value:

Margin per item
CHF −5.80
↳ computed over P12M ending 2026-04-30 (signal_margin_pressure v2)

The narrative cells surrounding the metric stay free-form prose — authors compose context, summary, caveats. Provenance lives where the headline number lives, not interpolated across the page.

Validator. notecheck.py gates that when signal_id is present, it resolves to an entry in the registry. Unknown signal slugs fail at compile time, not at render time on the user’s browser.

Coordination with Sense 43 — The Cascade. The metric value’s display behaviour is not derivable from the signal alone. The cascade owns it.

When the signal output is currency-typed and the tenant’s cascade declares currency.anchor (default transaction_date, optional month_end / valuation_date), the provenance line MUST quote which anchor was used — otherwise “CHF −5.80” looks identical whether it’s the spot rate on the transaction date or a month-end FX. The information is already baked: Sense 43’s in-flight bake_analytical_defaults step writes _{tenant}.analytical_defaults as a queryable table; the cell sidecar (Phase 5 thread 2 above) reads from there alongside the signal-registry lookup. No duplicated resolution.

For display preferences (precision, locale formatting) the existing path (display: block of the cascade, baked into the same KLS) is already what the renderer consults. Phase 5 doesn’t touch it.

The sister-conversation surface for this coordination lives at sister14_2_sister43_conversations.md.

Why not interpolation tokens or a new cell type. Earlier sketches proposed {{signal.unit}} interpolation in narrative cells (too implicit — authors forget; readers can’t tell from YAML whether prose is grounded) or a sibling metric_attribution cell type (too much surface area — splits the authoring model for a single attribute). The signal_id field on the existing metric cell is the cheapest path to honest provenance.

Thread 3 — Contextual unit registry temporal columns (deferred)

Section titled “Thread 3 — Contextual unit registry temporal columns (deferred)”

valid_from / valid_until on registry entries (April Q9) stays parked until a real historical-analysis case forces it. Adding two-dimensional registry semantics with zero callers inverts the spec-before-code rule.

Trigger to revive: any tenant’s first signal that compares historical periods with units that changed over the analysis horizon (e.g. currency redenomination, kg→g switchover, FTE definition revision). At that point thread 3 becomes its own phase, not a Phase 5 hangover.

PathChange
scripts/signalcompile.pyInsert _window_where_fragment call in compile_trend, compile_temporal_sequence, compile_reconciliation. Pattern matches the five existing call sites; only the source-ref naming changes.
scripts/signalcheck.pyOptional: warn (not error) when trend or temporal_sequence ship without window: — the bug-prevention nudge. Reconciliation can decide per case.
scripts/notecheck.pyValidate that metric cells with signal_id: reference a slug present in signal_registry.
scripts/notecompile.pyWhen baking cells_json, resolve signal_id{unit, window, version} sidecar so the renderer doesn’t need to round-trip to signal_registry at view time.
explorer/src/lib/components/notebook/MetricCell.svelte (or equivalent)Render the attribution line when sidecar present. Format unit per Phase 1 conventions (Intl currency, UCUM symbol).
tests/test_phase5_window.py (new)Round-trip cases for trend / temporal_sequence / reconciliation.
tests/test_phase5_metric_provenance.py (new)Notecheck cases for unknown signal_id, registry-resolved sidecar.

No JinDesk i18n keys needed — the attribution line uses the unit’s own locale-aware formatting; the connecting prose (“computed over”) is short enough to ship in de/en/fr at compile time.

Exit criterion: an Observation’s Validation panel can quote “Margin: CHF −5.80 per item, computed over the P12M ending 2026-04-30 (signal_margin_pressure v2)” with every term standards-grounded and Ledger-traceable. Implementation contract above is the realisation. Code follows once this spec is reviewed.


The April doc ended with nine open items. Each gets a recommendation here.

Curated subset, expandable. Settled in Phase 0: 25 atomic units (CHF, EUR, USD, GBP, kg, g, mg, t, m, cm, mm, km, s, min, h, d, wk, mo, a, %, plus the {annotation} mechanism for dimensionless quantities and a simple X/Y compound parser). Catches typos better (francs is rejected). New units land in scripts/ucum.py::_ATOMIC_UNITS as packs need them — the validator’s job is to catch typos, not enumerate the universe.

Declarative only. The compiler validates the form of a compound unit (CHF/{item} parses correctly as a UCUM expression) but doesn’t compute dimensional algebra (CHF/{item} × {items} = CHF). That’s a Phase 5+ wish; today’s correctness gain comes from same-kind aggregation rules, not algebra.

Inferred when unambiguous, explicit required when ambiguous. The compiler emits the inference into the log so authors see what was inferred.

  • kgmass (one-mapping, inferred)
  • CHFcurrency (inferred)
  • %dimensionless (inferred)
  • {count} → ambiguous, explicit kind: required
  • {items}/mo → rate, infer kind: dimensionless but warn

Option A (derived from range position) as default for quantity/currency with a declared range; explicit otherwise. Existing signals untouched. New typed signals default to range-derived. Manual override always allowed via score_strategy: explicit.

Defer the conversion machinery to Phase 4. Stub the currency type with single-currency-per-signal in Phase 1. When Interlogic (or another pack) hits the multi-currency case, implement Phase 4 machinery then. Don’t build pre-emptively.

Explicit declaration at perspective level; compile error on incompatibility. No inference. A perspective aggregating signals with different windows must declare which window applies (window: intersection, window: P12M, etc.) or the compile fails.

Keep forever as alias for the first currency-typed output. Cheap. Avoids breaking existing analytical pipelines. Emit a deprecation warning only when both money_at_risk and a typed output coexist in the same signal (drift signal).

Scan + require. Compiler errors when a signal uses a non-UCUM unit that isn’t in the tenant’s registry. Pack ships a curated default registry (FTE, bed-day, case, weighted case) — tenants override as needed. Statement-anchoring of registry entries is required in Phase 2, recommended in Phase 1.

Phase 1: one active definition per unit. Add valid_from / valid_until on registry entries in Phase 2 once a real historical-analysis case forces it.


  • Specify the engine’s UCUM library implementation (Python pint? custom? bare-bones regex+lookup? — engineering choice, doesn’t block design)
  • Resolve Sense 18 (the Ledger) — typed outputs feed the Ledger but the Ledger has its own design surface
  • Cover cross-pack semantics — is numetrix’s CHF the same CHF as millesime’s? Trivially yes today, but worth re-examining when multi-pack tenants exist
  • Pre-commit to a specific migration tool — Phase 1 is opt-in, so there’s no migration script needed; tenants annotate signals at their own pace

Every signal carries a base type (quantity / currency / time / category / flag), and that type is grounded in a standard — UCUM for units, ISO 4217 for currency, ISO 8601 for time. jin signal check enforces the type contract at compile time; JinDesk renders the typed value (with the right unit, currency symbol, or formatted timestamp) on findings + perspective scores. Type machinery is invisible in the YAML you write, visible in the output.

  • In the app: any signal detail page on the demo shows typed findings — currency on signal_project_funding_gap, counts on signal_dormant_employee, time-bucketed values on the trend variants
  • CLI: jin signal check validates the type contract per signal · jin signal compile carries type metadata into the generated SQL

Status: Phases 0–2 shipped (base types, UCUM grounding, currency formatting). Phase 3 (cross-dimension conversion — vials of substance billed in mg) is in flight; see the Phase plan section above.


  • Sense 14 (The Signal) — the foundation. 14.2 makes implicit types explicit; the conceptual model is unchanged.
  • Sense 15 (Observation / Explanation / Contributing Factor) — Contributing Factors anchor to typed evidence. The same cf_or_paper_handoff_skip can cite signal_missing_billing_handoff (out_exposure: currency CHF); the Observation’s Validation narrative can quote the currency-typed value precisely instead of reaching into a magic money_at_risk column.
  • Sense 13 (The Canvas) — Sense 14.2 unlocks Sense 13. Canvas panels declare type requirements; the engine matches against the typed catalog. Without 14.2, Canvas inherits today’s imperative- guard problem from day one.
  • Sense 18 (The Ledger) — Sense 14.2 is the substrate the Ledger narrates. “Margin: CHF −5.80 per item, computed over the P12M ending 2026-04-30 (latest delivery).” Every term is standards-grounded.
  • UoM Phase 3 (drug strength) — the parked project becomes a Phase 2 follow-on once 14.2 ships. billing.quantity gets unit-annotated; manual_uoms declares strength_per_unit; cross-dimension conversion (vials of substance → mg administered) uses the unit registry.

Sense 14.2 doesn’t add a type system to signals; it makes the type system that already exists declared rather than implicit, with five base types grounded in UCUM / ISO 4217 / ISO 8601 / ISO 80000, unlocking lens declarativity, aggregation correctness, and the Canvas (Sense 13) all at once.


Numerical neighbors:Sense 14: The Signal · Sense 14.2 Phase 5 — Typed Entity Aggregates

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