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.mdin 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: CHFstrings, exposed via a magicmoney_at_riskcolumn. 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.
What’s changed since April
Section titled “What’s changed since April”The April doc (sense_14_2_typed_signals.md, preserved as the
conceptual archive) was written before:
- Phase 0 shipped (2026-05-08, engine
3a004f2c+ pack5415a32).scripts/ucum.pycarries a curated 25-unit subset + compound parser.signalcheck.pyvalidatesoutputs:andwindow:blocks.signal_price_stabilityin numetrix carries the first typed outputs end-to-end (coefficient_of_variation: %,mean_price: CHF,observation_count: {observations}) plus an explicitP12Mwindow. Foundation is in. - 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.
- 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.
- 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.
- 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. - 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
flaguntil 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:
availableUnitsderived 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.
The structural claim
Section titled “The structural claim”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 string | currency: CHF (ISO 4217 validated) |
money_at_risk as a magic column | Output exposure: { type: currency } named explicitly |
time_bucket as VARCHAR | time typed with ISO 8601 form (point / interval / duration) |
| Quantity-vs-currency SUM in a thesis = silent nonsense | Compile error |
| ”Trailing 12 months” prose in SQL | window: { duration: P12M } declarative |
UI guards on hand-managed show_money_at_risk flag | UI iterates availableUnits derived from the catalog |
The compiler stops trusting prose and starts checking math.
Three consequences fall out.
1. Lens declarativity is unlocked
Section titled “1. Lens declarativity is unlocked”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.
3. Canvas (Sense 13) becomes buildable
Section titled “3. Canvas (Sense 13) becomes buildable”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.
The standards
Section titled “The standards”Four international standards cover everything jinflow signals will ever measure. No invented vocabulary.
| Standard | Governs | Examples |
|---|---|---|
| UCUM | Units of measure, compound units | kg, m, d, %, {items}, CHF/{item} |
| ISO 4217 | Currency codes (subset of UCUM) | CHF, EUR, USD |
| ISO 8601 | Time points, intervals, durations | 2026-04, P12M, 2025-04/2026-04 |
| ISO 80000 | Quantity kind taxonomy | mass, 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).
The type system
Section titled “The type system”Five base types. (See April archive for full examples and DuckDB storage notes; condensed here.)
quantity
Section titled “quantity”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_betterAggregation: SUM / AVG / MIN / MAX within same kind+unit. Cross-unit within same kind needs explicit conversion. Cross-kind is a compile error.
currency
Section titled “currency”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_zeroSUM/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: P1MReplaces 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) | deliveryThe compiler generates the SQL WHERE clause; the Ledger narrates the window in prose.
category
Section titled “category”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: trueBoolean — 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.
Outputs vs framework columns
Section titled “Outputs vs framework columns”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:
| Unit | Definition depends on… |
|---|---|
| FTE | Employment rules (42 h/wk RMC, 35 h/wk France, 40 h/wk Swiss academia) |
| bed-day | Clinical counting rules (ICU vs general ward, day-case included?) |
| case | Administrative definition (DRG case vs clinical episode) |
| weighted case | Weight table version (CMI 2025 vs CMI 2026) |
| standard cost | Methodology + base year |
| supply unit | Material 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_rulesThe 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.
Compile-time validation
Section titled “Compile-time validation”The signal compiler gains dimensional analysis. Two validators run in lock-step:
- Engine side —
scripts/signalcheck.pyandscripts/signalcompile.pyvalidate 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:
- Unit validation — every
unitfield must be a valid UCUM code from the curated subset (see Phase plan). Rejectunit: francs. Acceptunit: CHF. - Kind inference / disambiguation —
kg→ mass (unambiguous, inferred).{count}→ ambiguous, explicitkind:required. Compiler logs every inference. - Aggregation compatibility — perspectives that SUM/AVG across
signals must have compatible kind. SUM of
kg+CHF→ compile error. - Currency consistency — within a signal, all
currencyoutputs must declare the currency code. Cross-currency signals are legal but must declare a conversion reference. - Temporal consistency — if a signal declares a
window, itstime-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.
Phase plan
Section titled “Phase plan”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/Ywhere X and Y are atomic, e.g.CHF/{item},h/wk,{items}/mo).signalcheck.pyvalidators foroutputs:andwindow:blocks. Validates UCUM codes, ISO 4217 currency codes, ISO 80000 quantity kinds, ISO 8601 durations.signal_price_stabilityin 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.pyemits typed columns whenoutputs: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_extensionsvia_typed_outputs_emit. - ✅ JinDesk renders typed columns when present; falls back to the
legacy
money_at_riskcolumn when nooutputs:declared.$lib/utils/typedOutputs.tsdiscovers them from row sidecars;TypedOutputValue.svelteformats 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 NAMEso source signals with differingout_*shapes coexist without column-count errors. - ✅ Second pack end-to-end:
signal_overload_riskin 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 whatanchor: latestmeans againstcurrent_datevsmax(observation_date), choosing which field to filter on (the YAML carriesscope.time.fieldbut hand-written SQL uses arbitrary column names), and finding a clean injection point fortype: hand_writtensignals 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 declaretotal_scope_pctin itsoutputs:block. Catches the silent-blank rendering at validate time. Pre-existing thesischeck bug fixed in the same pass:known_signal_idsnow includes perspectives, so evidence chains can legitimately cite them without false “no corresponding YAML” errors.
- Dimension consistency for primary evidence. When a thesis cites
multiple primary signals, they must share
- ✅ Severity bound to a typed output via
severity_from: { output, rules }. Wrapper-levelSELECT * 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. Legacyseverity_rules:keeps working unchanged when noseverity_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, notcurrent_date. The KLS is a snapshot; a query against it should produce the same findings regardless of when you run.anchor: nowis the explicit opt-in for the wall-clock variant.field:declared on the window block, not inherited fromscope.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 oftype: hand_writtenown 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 withscope.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_defaultsbake. No consumer yet; lays the groundwork for the keys that follow. Seejinflow/config.py,bake_analytical_defaults. - 3.1 ✅ shipped 2026-05-25 — Compile-time currency safety net:
signalcheck.pyrejects perspectives whose source signals collectively declare currency-typed outputs in multiple ISO 4217 currencies, unless they declareconvert_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_ratestable 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 asreference_data:entries in theirpipeline.ymlperextractor_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 applyingconvert_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 whenconvert_to:is active. V1 supportsanchor: transaction_date(maps totime_bucket); other anchors raise a clear “not yet supported in compilation” error at compile time. Backward compatible — perspectives withoutconvert_to:produce byte-identical SQL to before. - 3.2.c ✅ shipped 2026-05-27 — Coverage guarantee for missing
rates:
generate_signal_ymlnow adds anot_nulltest (withseverity: error) for every converted column when the perspective declaresconvert_to:. Thewhere: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_defaultstable — 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.
Phase 4 — Lens declarativity (UI)
Section titled “Phase 4 — Lens declarativity (UI)”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.
Background — the gap, recapped
Section titled “Background — the gap, recapped”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.
Choice adopted
Section titled “Choice adopted”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.
Data shape
Section titled “Data shape”// 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[]Derivation logic (in layout.server.ts)
Section titled “Derivation logic (in layout.server.ts)”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.
Backward-compatibility bridge
Section titled “Backward-compatibility bridge”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 ofavailableUnits→ 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.
Migration pattern — per file
Section titled “Migration pattern — per file”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.
File inventory + order
Section titled “File inventory + order”Eighteen files, three categories:
| Category | Files | Order |
|---|---|---|
Foundation — adds availableUnits to layout data | +layout.server.ts | First, alone |
| Listing pages — sort dropdowns + per-row stats | signals/+page.svelte, theses/+page.svelte, verdicts/+page.svelte, perspectives/+page.svelte, findings/+page.svelte | Second batch |
| Detail pages — single-entity stats | signals/[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.svelte | Third batch |
| Adjacent surfaces — dashboards, compare, builder, instruments | +page.svelte (tenant home), compare/+page.svelte, signal-builder/+page.svelte, instruments/+page.svelte | Fourth batch |
| Doc artifact | release-notes.md | One-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.
What the data table looks like
Section titled “What the data table looks like”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-suppressedThe 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).
i18n keys to add
Section titled “i18n keys to add”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 surface | Where |
|---|---|
extractAvailableUnits returns the expected shape for a tenant with both columns present | tests/test_lens_declarativity_layout.ts (or extend an existing layout spec) |
Empty array when both suppressed via economic_lens | same |
| 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 empty | same |
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.
Implementation surface
Section titled “Implementation surface”| File | Change |
|---|---|
explorer/src/lib/types.ts (or new availableUnits.ts) | New AvailableUnit interface. |
explorer/src/routes/[tenant]/+layout.server.ts | Add 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 pages | Replace 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.md | One-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. |
Outlook — what V1 makes possible later
Section titled “Outlook — what V1 makes possible later”- A new pack ships with
incidentsas a unit. Its signals carryout_incident_countas a quantity-typed output. After Phase 4, the Phase-5 typed-output walker adds an entry toavailableUnitswithkey: '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: falsefilters the unit out. - Per-identity unit visibility (per the Sense 43 Outlook
section)
lands the day the resolver grows identity-axis support. The
availableUnitsfilter pipeline already supports asuppressset; identity overlays inject into that set.
Out of scope (deliberate deferrals)
Section titled “Out of scope (deliberate deferrals)”- Walking typed
out_<name>outputs from the signal registry. V1 hardcodesmoney_at_riskandqty_at_riskas 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 keepsshow_money_at_risk/show_qty_at_riskas 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.
What changed since the April-era draft
Section titled “What changed since the April-era draft”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):
| Type | Why no window |
|---|---|
silver_audit | Point-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_filter | Filters entities by attribute. Orthogonal to time. |
enrichment | Metadata-adding signal. Time has no semantic place. |
hand_written | Phase 2 decision: author owns the SQL, including any time scoping. window: stays metadata. |
perspective | Aggregates 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 1Renderer 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:
outputs[0].unit— the unit declaration (UCUM symbol, ISO 4217 currency, etc.). Renders as the suffix on the value.windowblock — the temporal window, formatted in ISO 8601 duration prose with the anchor date resolved at bake time.- The signal’s
signal_id+ version — for the audit trail.
These render as an attribution line below the metric value:
Margin per itemCHF −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.
Implementation surface
Section titled “Implementation surface”| Path | Change |
|---|---|
scripts/signalcompile.py | Insert _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.py | Optional: warn (not error) when trend or temporal_sequence ship without window: — the bug-prevention nudge. Reconciliation can decide per case. |
scripts/notecheck.py | Validate that metric cells with signal_id: reference a slug present in signal_registry. |
scripts/notecompile.py | When 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.
Resolving the April open questions
Section titled “Resolving the April open questions”The April doc ended with nine open items. Each gets a recommendation here.
1. UCUM subset vs full
Section titled “1. UCUM subset vs full”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.
2. Compound dimensional analysis
Section titled “2. Compound dimensional analysis”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.
3. Quantity kind inference
Section titled “3. Quantity kind inference”Inferred when unambiguous, explicit required when ambiguous. The compiler emits the inference into the log so authors see what was inferred.
kg→mass(one-mapping, inferred)CHF→currency(inferred)%→dimensionless(inferred){count}→ ambiguous, explicitkind:required{items}/mo→ rate, inferkind: dimensionlessbut warn
4. Score derivation
Section titled “4. Score derivation”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.
5. Multi-currency timing
Section titled “5. Multi-currency timing”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.
6. Temporal window inheritance
Section titled “6. Temporal window inheritance”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.
7. money_at_risk backward-compat alias
Section titled “7. money_at_risk backward-compat alias”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).
8. Contextual unit discovery
Section titled “8. Contextual unit discovery”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.
9. Unit versioning
Section titled “9. Unit versioning”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.
What this doc does NOT try to do
Section titled “What this doc does NOT try to do”- 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
CHFthe sameCHFas 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
How this manifests in jinflow
Section titled “How this manifests in jinflow”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 onsignal_dormant_employee, time-bucketed values on the trend variants - CLI:
jin signal checkvalidates the type contract per signal ·jin signal compilecarries 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.
Connection to other Senses
Section titled “Connection to other Senses”- 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_skipcan citesignal_missing_billing_handoff(out_exposure: currency CHF); the Observation’s Validation narrative can quote the currency-typed value precisely instead of reaching into a magicmoney_at_riskcolumn. - 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.quantitygets unit-annotated;manual_uomsdeclaresstrength_per_unit; cross-dimension conversion (vials of substance → mg administered) uses the unit registry.
The one-sentence summary
Section titled “The one-sentence summary”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 →