kaldamus entity reference · EnrichmentConfig · EvalConfig · GenerationConfig

Externalized configs

Every other page in this set documents a row a job produces. This one documents the three hand-edited, git-tracked JSON files that decide what those jobs do — nothing here is generated, mutated, or purged by the pipeline itself.

01

Three registries, one file each that matters

RegistryDrivesShape
EnrichmentConfigthe enrichment stage of update-events — classifier + geo routingper-section {backend, model, base_url, extra_body}
EvalConfigpolicy for update-reports — sampling, judge, per-check thresholdsflat — no per-artifact sections
GenerationConfigsummaries / scripts / prophecies + factors + dependenciesone file, five per-artifact prompt sections
Concept
Example
Three parallel registries of hand-edited, git-tracked JSON files — not rows any job generates, mutates, or purges. The pipeline only ever reads them.
a person edits default.json
Identity is the filename stem — default.json is one config, experimental.json another; identical contents under different names are different configs.
default.jsonexperimental.json
default.json is the only required file per registry; everything else is an optional named variant, added by copying a file.
copy → rename new config, no code change
02

How a config gets picked

flowchart TD
    classDef enrichNode fill:#16233b,stroke:#5a8fd4,stroke-width:1.5px,color:#dfe7f5;
    classDef genNode fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
    classDef decisionNode fill:#12151f,stroke:#5a6178,stroke-width:1px,color:#c9cbd6;

    subgraph EV["update-events — enrichment stage"]
        A1{"--gen-config NAME?"}:::decisionNode
        A1 -- yes --> A2["name = NAME"]:::enrichNode
        A1 -- no --> A3["env GENERATION_CONFIG,
else default"]:::enrichNode A2 --> A4["reads BOTH under that name:
enrichment + generation"]:::enrichNode A3 --> A4 end subgraph EVJ["update-reports — eval stage"] B1{"--enrichment-config NAME?"}:::decisionNode B1 -- yes --> B2["use NAME"]:::enrichNode B1 -- no --> B3{"enrichments.json envelope
records a name?"}:::decisionNode B3 -- yes --> B4["use envelope name"]:::enrichNode B3 -- no --> B5["env ENRICHMENT_CONFIG,
else default"]:::enrichNode C1{"--eval-config NAME?"}:::decisionNode C1 -- yes --> C2["use NAME"]:::genNode C1 -- no --> C3["env EVAL_CONFIG,
else default"]:::genNode end

Three independent resolution paths, not one. Blue resolves an EnrichmentConfig name; gold a GenerationConfig or EvalConfig name. The eval stage's middle branch is the one place a name is inferred rather than passed or defaulted.

Concept
Example
Every job resolves to default unless a --gen-config / --enrichment-config / --eval-config flag (or its env var) names something else.
no flag default
--gen-config picks both the enrichment and generation registries at once — they share a name during update-events by construction.
one name two files loaded
The eval stage infers the enrichment name it scores from what update-events last recorded, so it grades the config that actually produced the data.
reads the enrichments.json envelope name
That inference is where drift is caught: if the config's recomputed hash no longer matches what was recorded, the eval warns and keeps going.
hash mismatch "config edited after run"
03

How state is managed

Config files (this page)Every other entity in this set
Who writes ita person, in an editor, whenevera job process, on a schedule or CLI run
Persisted asone JSON file per name, tracked in gitone row inside a JSON array a repo owns
How it changesgit commit — ordinary source historyfirst-write-wins / upsert / append
Deletionrm the file, or don't — nothing automated touches itTTL cascade, scripted purge, retention cap
Concept
Example
The only writer of a config file is a person, in an editor; the only history it has is git log — none of the row-state machinery elsewhere in this set applies.
git log is the version history
A schema migrator lifts old on-disk shapes silently on read, so a config written two schema generations ago still loads — no migration job to run first.
v1 enrich_model v2 sections, on load
That's the promise of a hand-edited file over a generated row: nobody has to run a backfill before the next read succeeds.
an un-migrated config loads correctly today
04

Transient, or permanent?

Concept
Example
Neither question applies to the file — it's always-mutable source. What's permanent is the fingerprint some rows stamp onto themselves, not the file.
edit default.json all default jobs see it next read
A content_hash is derived at load, not identity — edit one byte and it changes on the very next read.
sha256(canonical_json(payload−name))[:16]
Some rows copy the producing config's hash into their body forever, independent of what the file says later.
EventForecast.config_hash, NewsBrief.config_hash
But the fingerprint isn't universal — Summary, Script, Prophecy, Factor carry none; recovering their config means correlating generated_at against git log or a ConfigSnapshot.
a Prophecy row no config_hash field
05

Two sections, one local model

flowchart LR
    classDef enrichNode fill:#16233b,stroke:#5a8fd4,stroke-width:1.5px,color:#dfe7f5;
    classDef genNode fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
    classDef decisionNode fill:#12151f,stroke:#5a6178,stroke-width:1px,color:#c9cbd6;
    classDef deadEnd fill:#12151a,stroke:#3a4058,stroke-width:1px,color:#7a8296,stroke-dasharray:3 3;

    A["classifier.backend"]:::enrichNode --> B{"= mlx?"}:::decisionNode
    B -- no --> F["loads independently"]:::enrichNode
    B -- yes --> E{"geo.backend also mlx?"}:::decisionNode
    E -- no --> G["geo loads independently"]:::enrichNode
    E -- yes --> H{"classifier.model ==
geo.model?"}:::decisionNode H -- yes --> I["one load;
geo reuses the model"]:::genNode H -- no --> J["ValueError at __init__:
share one local model"]:::deadEnd

The one cross-section constraint in EnrichmentConfig. There is no partial-load state — the exception fires before either validator is usable.

Concept
Example
Most fields are independent knobs, but one constraint isn't: if classifier and geo both select mlx, they must name the same model — the pipeline refuses to start otherwise.
two different mlx models ValueError
An mlx model is singular process state — two local models are never co-resident, so geo reuses the classifier's loaded model rather than loading a second.
same id one load, shared client
The check has never fired in production — no live config selects mlx — it exists for the day someone writes an mlx experimental config.
default configs run hosted backends

See also