Sense 43: The Cascade — Configuration as Layered Authority
Sense 43 · In bloom · Last touched 2026-08-20
- last_verified: 2026-06-05
Synced from
docs/design/sense_43_the_cascade.mdin the engine repo — that’s the source; this page is a build-time mirror.
One mechanism. Multiple namespaces.
Configuration in jinflow is layered. Each authority — engine, pack, tenant, entity, explorer — can speak on any key. The resolver consults them top-down at the latest possible moment, and the chosen value carries its provenance. The KLS bakes the resolution so every snapshot is self-describing.
Status: Proposed Date: 2026-05-24 Author: the owner + Claude (in conversation, during Sense 14.2 Phase 3 design) Family: principle for Configuration Engine (the implementation reference); piloted by Sense 14.2 Typed Signals; narrated by Sense 18 The Ledger; Lab-room overrides per Sense 13 The Canvas.
What pulled us in
Section titled “What pulled us in”Configuration in jinflow has historically been scattered across many homes — some YAML, some Python constants, some Svelte hardcodes, some TypeScript identity literals. Adding a new configurable dimension has meant touching several files across several layers, each with its own loading path and override mechanism. The Configuration Engine (2026-03) solved this for display config. But the same shape applies beyond display — wherever there are defaults that can vary by pack, by tenant, by entity, or by exploration.
There are two related failures of the pre-cascade world:
Hidden authority. When a default lives in code, no one outside the engine team knows it exists. The pack author cannot override it; the tenant owner cannot pin it; the explorer cannot temporarily overrule it for a what-if. The default is silently authoritative because it has no challenger.
Hidden provenance. When the value finally arrives at a render or a SQL query, there is no record of which layer chose it. If a number surprises, you cannot ask “whose decision was this?” — the answer is hidden behind a stack of imports.
The Cascade names the architectural answer: configuration is a typed inheritance chain, namespaced by concern, resolved at the latest possible moment, with provenance recorded in the artifact it produces.
The Principle
Section titled “The Principle”Any setting whose value can legitimately differ across packs, tenants, entities, or exploration moments belongs in the cascade — not in code.
The cascade has one shape that all namespaces share:
| Level | Owner | Persistence |
|---|---|---|
| L0 — engine | jinflow code | Hardcoded fallback only |
| L1 — pack | packs/<pack>/config.yml | Pack git |
| L2 — tenant | tenants/<tenant>/config.yml | Tenant AFS git |
| L3 — entity / perspective | YAML in entities/, signals/, perspectives/ | Tenant AFS git |
| L4 — transient | URL params, REPL state | None — single view only |
L0 through L3 are persistent. They bake into the KLS via
jinflow make, get committed to git via the AFS round-trip, and the
artifact’s _jinflow_* metadata tables record the resolved value
along with the level that supplied it.
L4 is transient. It changes what the explorer sees, never what is stored. Which rooms admit L4 depends on the namespace, not on the room itself — see the Board, Lab, and the L4 contract section below for the namespace-axis breakdown. The short version: display L4 applies everywhere; analytical L4 is Lab-only.
The full mechanism — resolver algorithm, file structure, baked tables, CLI surface — is documented in the Configuration Engine reference. This Sense holds the principle and the namespaces; that doc holds the wiring.
The Namespaces
Section titled “The Namespaces”The cascade governs more than one kind of configuration. Each
namespace is a top-level block in config.yml with its own
schema and its own stakes. New namespaces are added only when a
concrete need surfaces — each one introduces a new region of
governance.
1. Display configuration — display:
Section titled “1. Display configuration — display:”How things look and how JinDesk behaves at runtime. The
scope is broader than the name suggests: it covers presentation
(precision, locale, branding), pagination behavior, search defaults,
column visibility, recents, and other runtime UI choices. The block
name was chosen early (when “display” covered most of what was in
it); the scope grew. Renaming the block to something like
presentation: is a future migration if the undersell ever becomes
noisy in practice.
display: paging: { default: 50, options: [25, 50, 100] } pagination: { position: bottom, align: center } precision: { ... } columns: { hide_patterns: [...] } recents: { ... } nav_labels: { ... }This namespace has been shipping since March 2026 and is the original use case for the Configuration Engine. It covers presentation, pagination, search behavior, formatting locale, column visibility, branding, and JinDesk UI defaults.
The risk it manages: wrong values mislead the eye — a price shown to one decimal when three are needed, a column hidden when it should be visible. The number itself is correct; only its presentation is off.
2. Analytical defaults — analytical_defaults:
Section titled “2. Analytical defaults — analytical_defaults:”How numbers are computed when the YAML does not pin the answer.
analytical_defaults: currency: anchor: transaction_date # transaction_date | reporting_date | period_end gap_policy: nearest_prior # for weekends/holidays time: window_anchor_resolution: max_observation_date gap_policy: nearest_prior quality: min_observations_for_aggregate: 12This namespace is proposed here and lands with
Sense 14.2 Phase 3 — currency conversion is the
forcing case. The pilot key is currency.anchor: do retroactive
conversions anchor at the transaction date (audit truth), the
reporting date (comparability), or period-end (accounting
convention)? All three are legitimate. The choice belongs in AFS,
not in a Python constant.
The risk it manages: wrong values mislead the mind — the same data produces different numbers depending on which default applied. A signal could be flagged “confirmed” under one anchor and “not_observed” under another. The presentation is fine; the answer itself has shifted.
Migration policy — proactive inventory, deliberate sweep. The
current set of code-resident defaults that change numbers is finite
and surveyable. As of this Sense, the surveyed candidates are:
currency.anchor (Phase 1 pilot), currency.gap_policy,
time.window_anchor_resolution, time.gap_policy,
rounding.aggregation, and quality.min_observations_for_aggregate.
Phase 1 lands the pilot; Phase 2 is a deliberate sweep of the rest —
each key surveyed for its current code location, migration risk, and
review owner. From Phase 2 onward, new analytical defaults go
straight into the cascade. Never into code.
3. Future namespaces (open door)
Section titled “3. Future namespaces (open door)”The cascade scales to additional sibling blocks when new classes of
configuration warrant their own region of authority — for example,
identity: if/when Sense 25 migrates its
config from TypeScript into the cascade. Each addition is a
deliberate decision; this Sense does not pre-commit to any of them.
The principle: new namespaces join display: and
analytical_defaults: as siblings under the same cascade, with the
same baking strategy and the same L4 mechanics.
Why namespaces, not one bag
Section titled “Why namespaces, not one bag”A pack author editing display precision and a tenant owner deciding
how historical conversions anchor are doing different kinds of
work. They have different reviewers, different audit
implications, different blast radius. Putting both choices in a
single undifferentiated config: blob would erase that distinction
at the schema level.
Sibling top-level blocks make the distinction visible in the file. The schema mirrors the governance: each namespace is a separate region of authority, with its own validation rules, its own bake strategy, and its own provenance trail.
It also leaves room for namespaces to evolve independently. The display cascade has been stable since March; the analytical cascade is still finding its keys. Forcing them through one schema would slow both down.
Compile-time and runtime behavior
Section titled “Compile-time and runtime behavior”At jinflow make time
Section titled “At jinflow make time”- The resolver reads each namespace’s blocks from L1, L2, and any relevant L3 YAMLs.
- Compilers that emit SQL consume resolved values via the resolver interface.
- The KLS bakes one metadata table per namespace:
_jinflow_config— the existing display-config bake._jinflow_analytical_defaults— new, lands with Phase 3.- (Future namespaces get their own tables in the same shape:
key,value,level,source_file,resolved_at.)
At JinDesk runtime
Section titled “At JinDesk runtime”- JinDesk reads from the baked tables when it needs to display “this number was conversion-anchored at transaction date” or “this column’s precision was set at the tenant level.”
- URL params apply L4 overrides, namespaced to match the config
block:
?display.precision.price=4— single-view display override.?ad.currency.anchor=reporting_date— single-view analytical override.
- A “posture chip” renders wherever an override is in effect, making the override visible to the eye.
Board, Lab, and the L4 contract
Section titled “Board, Lab, and the L4 contract”The Board is the URL idiom for authored truth (/board/<instance>
— the Lens-browser room from Sense 27). The Lab is the URL idiom
for exploration (/lab/<instance> — chart-based, supports
exploratory what-ifs). Studio composes authored views, like the
Board.
Which L4 overrides each room admits is decided by the namespace, not the room. The original framing of “Lab admits L4, Board does not” forced a single principle to do work it couldn’t carry. The honest axis is the kind of override:
| Namespace | Where L4 applies | Why |
|---|---|---|
display: | Board + Lab + Studio (everywhere) | Render-time only. Precision, locale, column visibility — overriding these doesn’t change what the number is, only how it is shown. No authored-truth claim is violated; the override is equivalent to resizing a column. |
analytical_defaults: | Lab only | Changing currency.anchor shifts what the number means. On a Board or Studio (authored-truth rooms), that’s epistemically dishonest — the viewer thought they were looking at the authored picture, now they’re not. Lab is the room where what-ifs belong. |
Display L4 — applies everywhere
Section titled “Display L4 — applies everywhere”?display.<key>=<value> is honored on Board, Lab, and Studio
alike. The posture chip appears; the override applies; the URL is
shareable. Same nature as any per-view display setting users
already control (sort, filter, pagination).
Analytical L4 — Lab only, with a Board fallback
Section titled “Analytical L4 — Lab only, with a Board fallback”?ad.<key>=<value> is honored in Lab. On Board (and Studio), a
non-blocking banner appears instead of silent rejection:
ℹ Override
?ad.currency.anchor=reporting_dateisn’t applied here. Board shows authored truth only. [Open in Lab →] [Why?] [✕]
The CTA routes to /lab/<same-instance>?<all-params> — same Lab
counterpart with all URL params preserved (filters, sort,
pagination, AND the analytical L4 override). Where no Lab
counterpart exists, the fallback CTA opens the default Lab with
the params attached. The “Why?” link expands a short inline
explanation of the namespace-axis distinction.
Common rules
Section titled “Common rules”Only recognized L4 keys trigger the banner or chip. The
resolver knows the schema of display.* and ad.* keys;
arbitrary URL noise (?utm_source=…, ?ref=…) is ignored.
L4 overrides live in the URL. URLs are shareable and bookmarkable — so a pasted-to-Slack L4 URL renders the override for any recipient who opens it. “Transient” means never persists to the cascade (never becomes a new L1/L2/L3 default). It does not mean the URL itself is one-shot.
Why a visible banner instead of silent rejection (for analytical on Board): silent operations are exactly the hidden authority failure mode this Sense is fighting. The banner is informational (does not block Board rendering), teachable (the “Why?” link explains the mechanism on first encounter), and recoverable (one click opens the view that honors the override).
Narrated by the Ledger
Section titled “Narrated by the Ledger”When Sense 18 lands, every Validation panel can quote the resolved cascade:
Margin: CHF −5.80 per item. Window: P12M ending 2026-04-30 (latest delivery),
analytical_defaults.time.window_anchor_resolutionresolved from tenant.yml. Currency: anchored at transaction date,analytical_defaults.currency.anchorresolved from pack default.
Every assumption traceable. Every assumption changeable. None hidden.
What this Sense does NOT do
Section titled “What this Sense does NOT do”- It does not redefine display configuration. The Configuration
Engine has been shipping the
display:cascade since March 2026 and continues unchanged. This Sense names the principle the engine already realises and opens it to additional namespaces. - It does not absorb every constant in the codebase. Only defaults whose value changes a number (analytical) or a presentation (display) belong here. Implementation constants, algorithm parameters that aren’t user-meaningful, and internal thresholds stay in code.
- It does not govern Subject Matter. Subject Matter declares facts about the world; analytical defaults declare how to compute facts about the world. Adjacent and complementary; not the same.
Phasing
Section titled “Phasing”Phase 1 — Schema + resolver + first analytical key ✅ SHIPPED 2026-05-24
Section titled “Phase 1 — Schema + resolver + first analytical key ✅ SHIPPED 2026-05-24”Engine commit 6f19e059 (under the “Sense 14.2 sub-phase 3.0” label —
the currency arc was the forcing function).
jinflow/config.pyresolver withENGINE_DEFAULTS(L0 fallbacks),Resolveddataclass,resolve()/resolve_all(). Reads<afs_root>/config.ymlfor theanalytical_defaults:block.bake_analytical_defaults()injinflow/cli/commands/baking.pywrites_{tenant}.analytical_defaults(key,value,level,source_file,resolved_at) per tenant.- Wired into
jinflow/cli/commands/make.pynext tobake_metadata. - Pilot key
currency.anchorconsumed end-to-end by the perspective compiler (Sense 14.2 sub-phases 3.2.b + 3.2.c). - 17 unit + bake tests in
tests/test_config_resolver.py.
Currently collapses pack (L1) and tenant (L2) into a single afs
level because tenant AFSes are file-copies of the pack at init;
disentangling waits for Sense 36 The Lineage
birth-manifest consultation.
Phase 2 — Proactive sweep of the analytical-defaults inventory
Section titled “Phase 2 — Proactive sweep of the analytical-defaults inventory”Partially shipped — survey revealed the inventory was over-specced.
A 2026-05-28 audit of the five candidates the original Sense draft listed found that only one was a real, in-code constant ready to migrate:
| Candidate | Verdict |
|---|---|
currency.gap_policy | ✅ shipped 2026-05-28 — was hardcoded nearest_prior in the convert_currency macro; now read from the cascade. |
time.window_anchor_resolution | Deferred — no signal currently relies on a default (every signal declares anchor: explicitly). Add when a real driver appears. |
time.gap_policy | Deferred — lives implicitly in pack-authored SQL (asof left join in gold_io_metering, gold_price_series). Making it cascade-aware is a cross-repo lift; wait for a forcing case. |
rounding.aggregation | Deferred — speculative. Doesn’t exist in code yet. |
quality.min_observations_for_aggregate | Deferred — speculative. Doesn’t exist in code yet. |
display.* (every display.… key) | Spec’d 2026-05-30, implementation pending. The display: namespace claims to cascade but the runtime path is single-layer. Design choice (a) — bake-time deep-merge — adopted. Full spec in the next section below. |
currency.gap_policy migration details (commit-shipped 2026-05-28):
- Added to
ENGINE_DEFAULTSwith valuenearest_prior. Valid values:nearest_prior(default),skip.interpolatereserved for future. convert_currencymacro reads{{ var('currency_gap_policy', 'nearest_prior') }}and gates the rate lookup accordingly.nearest_priorkeeps the original ASOF semantics;skiprequires exact-date match and returns NULL otherwise — paired with the perspective compiler’snot_nulltest (Sense 14.2 sub-phase 3.2.c),skipturns missing rates into a hard build failure.jinflow makenow injects every resolved analytical_defaults key as a dbt var automatically. Convention: cascade keya.b.cbecomes dbt vara_b_c. Future cascade keys ride the same wire with no per-key plumbing.
Future Phase 2 keys join the cascade one at a time, when a real driver arrives. The Sense doesn’t pre-commit beyond the keys the migration audit has already identified as real.
Phase 2 sub-task: display: cascade fix (spec, 2026-05-30)
Section titled “Phase 2 sub-task: display: cascade fix (spec, 2026-05-30)”The display: namespace cascade was documented but never
implemented at the bake/runtime path. This spec defines the fix.
The implementation lands as a follow-up commit, against this spec
verbatim.
Background — the gap, recapped. bake_metadata reads only the
tenant’s afs/config.yml and stores it as a single YAML blob in
_<tenant>.config.config_yaml. JinDesk (configEngine.ts)
reads that blob, parses it, and uses it. The pack’s config.yml
is never consulted at bake time and there are no engine fallbacks.
Tenants who want their pack’s defaults to apply must copy-paste
them into their own config.yml (which numetrix tenants do); a
pack that omits a key has no way to set a default for its tenants
(hrcentral.vai has no display.grouping because the hrcentral
pack didn’t ship one). Both failure modes hide the fact that the
cascade doesn’t work.
Choice adopted: (a) bake-time deep-merge. Engine fallbacks ⊕
pack config.yml ⊕ tenant config.yml, merged at jin make, the
result baked into the existing _<tenant>.config.config_yaml.
JinDesk’s read path stays single-blob. Per-key provenance lands
in a sibling table.
Merger semantics
Section titled “Merger semantics”Deep merge with the following rules, applied recursively:
| Shape | Behavior |
|---|---|
| Two maps | Keys union; conflicting keys recurse. |
| Two scalars | Right-side wins. |
| Two lists | Right-side fully replaces (no append, no element-wise merge). |
| Map vs scalar / list vs map | Right-side fully replaces. Type mismatch is a warning during bake. |
“Right side” means the higher-precedence side — tenant beats pack beats engine.
Worked example:
# engine fallback (L0 — built into jinflow.config)display: grouping: patterns: "_type|_status|_year" pagination: page_input: true
# pack config.yml (L1 — numetrix)display: grouping: patterns: "_type|_group|_source|_status|_year|_category|_code" pagination: begin_end: true align: right
# tenant config.yml (L2 — rmc)display: pagination: align: left
# resolved (baked into _<tenant>.config.config_yaml)display: grouping: patterns: "_type|_group|_source|_status|_year|_category|_code" pagination: page_input: true # from engine begin_end: true # from pack align: left # from tenant (overrode pack's `right`)The display.grouping.patterns string at L1 fully replaces L0’s
shorter list (string replace, not pattern concatenation). The
tenant didn’t touch grouping so the pack value carries through.
pagination.align cascades L0 → L1 → L2 by full replacement; the
last writer wins.
List replacement rationale: the existing pack/tenant
display.recents.dimensions and display.grouping.patterns are
authored as full specifications, not as fragments to extend.
Append semantics would require pack authors to write delta lists
which is a new authoring concept. Replace keeps current YAML files
working untouched.
Aggregation semantics (gap acknowledged, deferred)
Section titled “Aggregation semantics (gap acknowledged, deferred)”The merger rules above are uniform — every non-map value
replaces, full stop. V1 ships with this rule because the forcing
case (vai missing grouping.patterns) is solved by the L0 engine
fallback, not by aggregation between levels.
But uniform replacement is not the whole picture, and a future spec will need to address it deliberately. Three gaps the V1 rule papers over:
Gap 1 — Per-key aggregation policy. Some keys want replace
semantics (the last pagination.align writer wins, period).
Others want aggregation (a tenant adding _orgcode to the pack’s
existing _type|_status|_year grouping patterns shouldn’t have
to copy-paste the whole alternation). The right answer is per-key
policy, not a uniform rule. Candidate policies:
| Policy | Behavior | Likely keys |
|---|---|---|
replace | Right-side wins (V1 default) | pagination.align, paging, table.sticky_header |
union | Set-union of unique elements | display.locales, list of dossier categories |
append | Left then right, dedupe preserved | recents templates if ever made list-valued |
concat | String join with separator (e.g. | for regex alternation) | grouping.patterns, columns.hide |
deep_merge_special | Custom per-key merger | rare, escape hatch |
Gap 2 — Value-shape sensitivity. Aggregation rules depend on
the value’s shape. grouping.patterns is a string containing a
regex alternation, not a YAML list — appending two patterns means
string concatenation with a separator, not list append. Three
shape categories the future merger has to handle distinctly:
| Shape | Aggregation candidate |
|---|---|
| Scalar (number, bool) | replace only |
| String | replace, or concat with separator |
| YAML list | replace, append, or union |
| YAML map | already recursive (V1 deep-merge) |
Gap 3 — Axis-dependent aggregation. Per the [Outlook](#outlook ---additional-resolution-axes), future role/identity/ruleset axes will compose on top of the layer cascade. A role adding patterns will almost certainly want aggregation (not replacement) over the layer-merged result — otherwise a role overlay would have to re-state the pack’s full set. The aggregation policy may also differ by axis: layer cascade replaces, role overlay appends.
Why V1 still ships as-is:
The vai case — the immediate forcing function — needs L0 engine fallback, not inter-level aggregation. The grouping patterns aren’t being added to anything; they’re absent and need a baseline. The engine catalog (line 512) covers this.
Tenants who want to extend a pack’s regex string today still
have only one path: copy-paste-extend. That was true before this
spec and remains true after. The V1 fix does not make this
worse. It does not preclude a future fix either — the per-key
policy can be introduced later by adding an AGGREGATION_POLICY
catalog in jinflow.config and consulting it inside the merger,
with the V1 uniform-replace as the default policy when a key
isn’t listed.
Forcing cases that will trigger the future Aggregation Sense sub-task (when one shows up — none has yet):
- A tenant author asks: “How do I add
_orgcodeto my pack’s grouping patterns without restating the whole string?” - A role overlay needs to additively widen a pack’s pattern set without erasing it.
- A new namespace (e.g.
governance:) ships a list of required reviewers, where pack + tenant should both contribute, not override.
The V1 spec records the gap so future-Claude or future-sister doesn’t have to re-discover it.
Provenance shape
Section titled “Provenance shape”A sibling per-tenant table, mirroring analytical_defaults:
CREATE TABLE "_<tenant>".config_resolved ( key VARCHAR PRIMARY KEY, -- dot-path, e.g. 'display.grouping.patterns' value VARCHAR, -- JSON-encoded scalar/list/map level VARCHAR, -- 'engine' | 'pack' | 'tenant' source_file VARCHAR, -- relative path; NULL when level='engine' resolved_at TIMESTAMP)One row per leaf path in the merged tree. For the worked example, five rows:
| key | value | level | source_file |
|---|---|---|---|
display.grouping.patterns | "_type|_group|…" | pack | config.yml |
display.pagination.page_input | true | engine | NULL |
display.pagination.begin_end | true | pack | config.yml |
display.pagination.align | "left" | tenant | config.yml |
display.pagination (parent) | not emitted | n/a | n/a |
Only leaf keys get rows. Parent maps are reconstructable from the flat key paths if JinDesk ever wants to render a tree view.
Bake-vs-runtime contract
Section titled “Bake-vs-runtime contract”| Phase | Surface | Behavior |
|---|---|---|
Bake time (jin make) | bake_metadata in baking.py | Reads engine fallbacks (from a new jinflow.config.DISPLAY_DEFAULTS map), pack config.yml (located via afs.pack_root() or the Sense 36 birth-manifest lookup; see open question below), tenant config.yml. Deep-merges. Writes the merged YAML to _<tenant>.config.config_yaml (existing table — no schema change). Writes per-key provenance to _<tenant>.config_resolved (new table). |
| Runtime (JinDesk) | configEngine.ts loadConfig() | Unchanged. Continues to read _<tenant>.config.config_yaml as one blob. |
| Runtime (JinDesk, optional, future) | loadConfigProvenance(tenant, key) | New helper that reads _<tenant>.config_resolved. UI surfaces “who provided what” when a viewer asks “why is this column hidden?” Not part of the V1 cut — ship the merge first. |
JinDesk change for V1 is zero. The fix is invisible to runtime code except that values that used to be missing (vai grouping) now appear, and tenants who delete their copy-pasted pack defaults still get them at bake time.
Engine fallback catalog for display:
Section titled “Engine fallback catalog for display:”These keys ship with engine-level L0 defaults. A pack omitting them still gets a working baseline; a tenant adopting a pack that doesn’t ship them (hrcentral.vai today) gets the engine value instead of nothing.
| Key | Engine default | Rationale |
|---|---|---|
display.grouping.patterns | "_type|_status|_year" | A bare minimum that catches the common axis columns. Packs override with their domain set. Closes the vai gap. |
display.pagination.page_input | true | Page-number input is universally useful; no reason to hide it by default. |
display.pagination.align | "right" | Convention. |
display.sort.nulls | "standard" | SQL standard: ASC → NULLS LAST, DESC → NULLS FIRST. Matches current pack behaviour. |
display.columns.locale_filter | true | Locale columns hidden by default; packs can opt out. |
display.table.sticky_header | true | Universal JinDesk convention. |
display.table.max_height | "auto" | Universal JinDesk convention. |
display.search.case_sensitive | false | ILIKE default (matches numetrix pack). |
display.search.default_mode | "startswith" | Matches numetrix pack. |
Keys without engine defaults (purely pack/tenant concerns):
display.paging(the numeric breakdown is pack-specific)display.recents.*(templates depend on pack entity vocabulary)display.column_actions.*(entity-specific routing)display.layout.*(per-pack visual identity)
Migration story — no breakage
Section titled “Migration story — no breakage”Existing tenants with copy-pasted pack values: those values land at L2 (tenant) and override the same key at L1 (pack). The merged result is identical to today’s. Nothing breaks.
After the fix lands, a tenant author can delete the duplicated
section from their config.yml. On the next jin make, the same
values flow in from L1 (pack). The KLS is byte-identical.
For vai: the engine display.grouping.patterns default lands at
L0 immediately. The first make after the fix produces a KLS with
a working grouping config. No manual tenant edit required.
Implementation surface
Section titled “Implementation surface”| File | Change |
|---|---|
jinflow/config.py | Add DISPLAY_DEFAULTS dict (the engine catalog above). Add resolve_display(*, pack_config_path, tenant_config_path) -> tuple[dict, list[Resolved]] returning the merged YAML + per-key provenance. Reuses the Resolved dataclass. |
jinflow/cli/commands/baking.py | bake_metadata calls resolve_display, writes merged YAML to _<tenant>.config.config_yaml (existing path), provenance to _<tenant>.config_resolved (new table). The current line that reads only afs_root / "config.yml" is replaced. |
tests/test_config_resolver.py | Add resolve_display tests: pack-only, tenant-only, both, conflict (tenant wins), list replacement, engine fallback for missing key, vai-grouping case (pack and tenant both missing → engine wins). |
tests/test_config_resolver.py | Add bake table tests for _<tenant>.config_resolved mirroring the existing analytical_defaults tests. |
docs/design/config_engine.md | Update the implementation reference to mention the merger + provenance table. |
tests/packs/testpack/ | Add a config.yml at the pack root with a display: section the tests can exercise. Currently testpack has no config.yml. |
No JinDesk changes for V1. The provenance read helper is a future-3.b-or-later concern.
Open question
Section titled “Open question”Where does the bake step find the pack’s config.yml?
Two paths:
- Via
afs.pack_root()— already used bybake_metadatafor the brand-cascade case (baking.py:981). Returns the path to the pack repo on disk. Works for live-tenant builds where the packhub repos sit at~/jinflow-packhub/jinflow-pack-<name>/. - Via the Sense 36 birth manifest (
.pack-init.yml) — already in the AFS. Recordspack_origin, but resolving the path requires either consulting~/jinflow-packhub/by convention or shipping the pack snapshot inside the AFS at init time.
Path 1 works today and matches the existing brand-cascade fallback. Path 2 is more durable (the AFS becomes truly self-sufficient, no pack repo dependency at make time) but requires a small Sense 36 extension. Recommendation: Path 1 for V1; revisit when Sense 36 Phase 2 lands.
Outlook — additional resolution axes
Section titled “Outlook — additional resolution axes”This V1 cascades along one axis — layer — and resolves at one time — bake. Both choices are intentional simplifications. The longer trajectory layers in dimensions and timings the design needs to accommodate without preempting:
- Identity-axis overrides. A specific user Z may resolve a key to a different value than users A-Y do, even with the same pack/tenant cascade behind them. A named identity (an authenticated principal) carries an overlay that fires on top of the layered resolution.
- Role-axis overrides. A role (e.g. auditor, analyst, board-member) carries an overlay distinct from any one user’s preferences. A user’s effective resolution composes their role(s) ⊕ their personal identity ⊕ the L0-L2 cascade.
- Named-ruleset overlays. Bundled overrides activated by name (“audit mode,” “Q4-close mode,” “compliance review”). Orthogonal to identity — anyone running with the ruleset gets its values.
- Time-scoped rulesets. Overlays that fire only during certain windows (feature flags scoped to date ranges).
These dimensions are orthogonal to layer, not subordinate. They are not L4 instant overrides either — they’re durable, attributable resolutions that depend on who is asking rather than what room the URL points at.
Implications for V1 (constraints, not work):
- The
Resolved.levelfield isLiteral['engine', 'pack', 'tenant']for now. The implementation does not assume that vocabulary will never grow — extending to'role','identity','user','ruleset'should require new entries in the enum, not a refactor of the resolver shape. - The bake table
_<tenant>.config_resolvedrecords “the resolution at make time for an anonymous, no-identity, no-ruleset reader.” Future tables may sit alongside (config_resolved_by_role,config_resolved_by_principal) or the same table may grow a resolution-context key — both options stay open. - JinDesk’s
loadConfig()reads the merged blob today. It must be able to layer an identity-scoped or ruleset-scoped overlay later without the layered result needing to be recomputed from primary sources at request time. The bake stays the layer-axis truth; identity/ruleset overlays apply on top. - The merger function does not bake assumptions about resolution order beyond layer. When identity overlays arrive, the resolver will compose them in a specific order; the layer cascade stays the same shape, just one stage of a longer pipeline.
What V1 explicitly does NOT do:
- Implement any identity-, role-, or ruleset-axis resolution.
- Plumb identity context through JinDesk request path.
- Define the storage shape for per-identity overlays.
- Pre-commit to an order of composition (layer first, then role, then identity? or some other arrangement?).
These are decisions for the moment a real driver arrives — likely Sense 25 (The Pass), when identity becomes first-class. The current spec leaves them open.
See [[project_sense_43_resolution_strategies]] in memory for the fuller architectural reflection that motivated this outlook.
Out of scope (deliberate deferrals)
Section titled “Out of scope (deliberate deferrals)”- L3 (entity-level overrides) for display config. No driver yet.
- Per-key aggregation policy (union, append, concat). V1 uses uniform replace; the gap is acknowledged in the Aggregation semantics section above.
- The runtime provenance helper (
loadConfigProvenance). Bake emits the data; JinDesk consumes it in a follow-up. - Pack-level
analytical_defaults(the cascade resolver already ignores pack config; same gap exists, same fix shape, but separate implementation — handle when forced).
Phase 3 — L4 instant overrides
Section titled “Phase 3 — L4 instant overrides”Split by namespace, following the L4 contract above:
- 3.a ✅ shipped 2026-05-29 — Display L4 (everywhere).
?display.<key>=<value>applies on Board, Lab, and Studio. The posture chip renders wherever an override is active. No banner — display overrides don’t violate any authored-truth claim. Pilot key:?display.precision=Noverlays a synthetic catch-all precision rule before the baked patterns. Analytical (?ad.*) keys are parsed and surfaced on the chip but currently no-op at runtime (Phase 3.b). Files:configWithOverrides.ts,PostureChip.svelte, layout wire-through, 11 unit tests + a Playworks smoke spec. - 3.b — Analytical L4 (Lab-only with Board fallback). Deferred
until either a real driver appears or runtime SQL re-emit
arrives (likely with Sense 18). The
current arc bakes analytical defaults into compiled SQL at make
time, so a URL param can’t change the result without
re-materialising. When 3.b lands:
?ad.<key>=<value>honoured in Lab; Board/Studio show the “Open in Lab” banner.
Phase 4 — Ledger integration
Section titled “Phase 4 — Ledger integration”- Sense 18 narrates resolved cascade in Validation panels.
- Pending — blocks on Sense 18.
Phase 5 — Diff Loupe extension (future)
Section titled “Phase 5 — Diff Loupe extension (future)”- Snapshot comparison surfaces cascade diffs alongside data diffs — “this snapshot was built with anchor=transaction_date; that one with reporting_date.”
How this manifests in jinflow
Section titled “How this manifests in jinflow”The Cascade is the layered configuration mechanism. Each authority — engine, pack, tenant, perspective, transient Lab override — gets its own layer, and any property resolves through the cascade L0 → L1 → L2 → L3 → L4. The same mechanism serves two namespaces: analytical_defaults (what changes a number) and display (what changes how a number is shown). The Configuration Engine is the runtime resolver.
- Layers: L0 engine fallback → L1 pack
config.yml→ L2 tenantconfig.yml→ L3 perspective override → L4 transient Lab override - Namespaces:
analytical_defaults(per Sense 14.2 ground rules) ·display(paging, columns, table behaviour, format, recents, dates, column actions, layout)
Status: in bloom. Both namespaces are live and resolved on every page load; deeper override surfaces (perspective L3, transient L4) are forming.
Connection to other Senses
Section titled “Connection to other Senses”- Configuration Engine reference — the technical companion. This Sense holds principle; that doc holds resolver algorithm, file structure, baking, CLI, and open questions.
- Sense 14.2 — Typed Signals — Phase 3
currency.anchoris the pilot analytical key. Phase 5 temporal inheritance is the next migration candidate. - Sense 13 — The Canvas — Board / Lab split maps onto L3 (authored) vs L4 (transient) authority. Board reads authored cascade only; Lab admits L4 overrides.
- Sense 18 — The Ledger — will narrate cascade resolution as part of every Validation panel.
- Sense 25 — The Pass — a future
candidate for
identity:as its own namespace. - Sense 36 — The Lineage — the birth manifest records initial L1 declarations; pack upgrades reconcile cascade declarations the same way they reconcile any other artifact.
- The Four Convictions — Declarative: behavior comes from data, not code. The Cascade is the largest single move in that direction.
The one-sentence summary
Section titled “The one-sentence summary”Configuration is layered authority — one mechanism, many namespaces, resolved at the last possible moment, with provenance preserved in the artifact it produces.
Numerical neighbors: ← Sense 42: The Landscape · Sense 44: The Forge →