ssot-fanout
Keep one hand-edited master file and generate every downstream config from it. The targets are
heterogeneous — each speaks its own dialect (different key names, nesting, invocation shape) — so the
master maps a canonical model onto each target. When two targets would map the "same thing"
differently, or a mapping is otherwise ambiguous, the decision is made once and recorded in a lock
file so the next sync applies it automatically.
This is the pattern behind services/agent-hooks/hooks.master.json in bloodbank; that system is the
canonical reference implementation (see references/reference-implementation.md).
Operating principles
- One source of truth. The master file is the only hand-edited artifact. Every per-target
config and every per-consumer map is GENERATED. Never hand-edit a generated file or a consumer's
embedded fallback — edit the master and re-run sync. Drift is a bug, not a state to tolerate.
- Generated output is deterministic and idempotent. No timestamps, no random ordering. Re-running
sync with an unchanged master writes zero bytes. This is what lets
--check diff generated-on-disk
against generated-from-master and gate CI.
- Ambiguity is detected, resolved once, and remembered. The engine surfaces ambiguities; each
resolution is written to the lock keyed by a stable id. The next run auto-applies it — so re-syncs,
and even adding a brand-new target whose decisions are already made, are seamless.
- Consumers read the projection, fall back to an embedded default. A target's runtime loads the
generated map but degrades to a small embedded default if it's missing/corrupt — a generated artifact
going missing must never silently break the consumer.
- Validate the projection against the domain's contract. Generated output must satisfy whatever
schema/regex/allowlist the targets require; sync asserts this, it doesn't template blindly.
check is the gate; sync is the apply. CI runs --check (read-only, nonzero on drift or
unresolved ambiguity). Humans run --sync (writes) and --resolve (interactive, appends to lock).
Quick navigation
The three artifacts
| Artifact |
Role |
Hand-edited? |
*.master.json (SSOT) |
canonical model + per-target bindings/dialect |
yes — the only one |
*.mappings.lock.json |
remembered resolutions for ambiguous/divergent mappings |
only via --resolve (or a reviewed seed) |
sync.py (engine) |
detect → resolve → generate; --check / --apply / --resolve |
yes (it's code) |
…producing, per target: its native config (the dialect file the tool actually loads) and,
optionally, a machine map the consumer reads at runtime (e.g. event_map.generated.json).
The master's shape
The master separates what (a canonical catalog) from how each target expresses it:
- catalog — canonical entries keyed by id, each with a
role (the cross-target normalization unit)
plus whatever the domain needs (a type, a bucket, etc.).
- targets — per target: its
dialect, output paths, a runner/command template, and bindings
mapping the target's native names → a catalog entry + role + dialect detail (matcher, payload mode,
timeouts, extra args).
role is the key idea: divergence is detected when the same role maps to different canonical entries
across targets. That is exactly the ambiguity the lock resolves.
Workflow — operating an existing system
- Edit the master (
hooks.master.json): add/adjust catalog entries or a target's bindings.
mise run hooks:check (read-only). It reports drift (generated files stale) and ambiguities.
- If an ambiguity is unresolved, resolve it once:
python3 sync.py --apply --resolve (prompts and
appends to the lock), or seed the lock entry by hand and re-check. A decision already in the lock
applies automatically — nothing to do.
mise run hooks:sync to regenerate every target's native config + machine map (idempotent).
- Verify:
mise run smoketest:agent-hooks-ssot (every binding builds a contract+schema-valid output).
Workflow — building the pattern for a new domain
- Copy
assets/master.template.json and assets/mappings.lock.template.json; fill the catalog +
targets + bindings for your domain.
- Adapt
sync.py (references/engine-design.md gives the algorithm
and each dialect renderer). Keep generation deterministic.
- Point each consumer at its generated map with an embedded fallback (merge generated OVER default).
- Wire
check (CI gate) and sync (apply) tasks, plus a verifier that builds one output per binding
and validates it against the domain's contract.
- Seed the lock with the resolutions you make on first run; thereafter re-syncs are seamless.
Out of scope
- Versioning many files in parity (
package.json/pyproject.toml/tags) → use mise-versioning.
- Defining the event schemas or naming contract the agent-hooks system targets → that's
bloodbank/docs/event-naming.md and schemas/, not this pattern.
- Writing an individual hook script / publisher's data-shaping logic → this skill owns the
propagation of the mapping, not the per-event handler bodies.
- Single-target config with no dialect or ambiguity dimension (plain env substitution, one output)
→ just template it directly; the master/lock machinery is overkill.
1---2name: ssot-fanout3description: Single-source-of-truth config fan-out: keep ONE master file (e.g. hooks.master.json) and propagate it to many downstream targets that each have their own native format/dialect, with a lock file (hooks.mappings.lock.json) recording how ambiguous/divergent mappings were resolved so re-syncs are seamless. Reference implementation: bloodbank services/agent-hooks — hooks.master.json → sync.py → per-agent generated configs + event_map.generated.json, gated by `mise run hooks:check` / `hooks:sync`. Use when adding a new agent CLI or target to agent-hooks, editing hooks.master.json, fixing generated-config drift, resolving an ambiguous mapping, or designing a NEW master-config → multi-dialect propagation engine with ambiguity-resolution memory. Keywords: SSOT, single source of truth, fan-out, generated-config drift. Do NOT use for bumping versions across files (use mise-versioning), defining event schemas or the event-naming contract, or single-target config templating with no dialect/ambiguity dimension.4---56# ssot-fanout78Keep one hand-edited master file and **generate** every downstream config from it. The targets are9heterogeneous — each speaks its own dialect (different key names, nesting, invocation shape) — so the10master maps a *canonical* model onto each target. When two targets would map the "same thing"11differently, or a mapping is otherwise ambiguous, the decision is made once and recorded in a lock12file so the next sync applies it automatically.1314This is the pattern behind `services/agent-hooks/hooks.master.json` in bloodbank; that system is the15canonical reference implementation (see [references/reference-implementation.md](./references/reference-implementation.md)).1617## Operating principles1819- **One source of truth.** The master file is the *only* hand-edited artifact. Every per-target20 config and every per-consumer map is GENERATED. Never hand-edit a generated file or a consumer's21 embedded fallback — edit the master and re-run sync. Drift is a bug, not a state to tolerate.22- **Generated output is deterministic and idempotent.** No timestamps, no random ordering. Re-running23 sync with an unchanged master writes zero bytes. This is what lets `--check` diff generated-on-disk24 against generated-from-master and gate CI.25- **Ambiguity is detected, resolved once, and remembered.** The engine surfaces ambiguities; each26 resolution is written to the lock keyed by a *stable* id. The next run auto-applies it — so re-syncs,27 and even adding a brand-new target whose decisions are already made, are seamless.28- **Consumers read the projection, fall back to an embedded default.** A target's runtime loads the29 generated map but degrades to a small embedded default if it's missing/corrupt — a generated artifact30 going missing must never silently break the consumer.31- **Validate the projection against the domain's contract.** Generated output must satisfy whatever32 schema/regex/allowlist the targets require; sync asserts this, it doesn't template blindly.33- **`check` is the gate; `sync` is the apply.** CI runs `--check` (read-only, nonzero on drift or34 unresolved ambiguity). Humans run `--sync` (writes) and `--resolve` (interactive, appends to lock).3536## Quick navigation3738| Situation | Read |39|---|---|40| Operate the existing bloodbank agent-hooks system (add an agent CLI, fix drift, resolve a mapping) | [references/reference-implementation.md](./references/reference-implementation.md) |41| Build a NEW master → multi-dialect propagation engine for another domain | [references/engine-design.md](./references/engine-design.md) + `assets/master.template.json`, `assets/mappings.lock.template.json` |42| Output drifts, sync isn't idempotent, a consumer broke, an ambiguity won't clear | [references/gotchas.md](./references/gotchas.md) |4344## The three artifacts4546| Artifact | Role | Hand-edited? |47|---|---|---|48| `*.master.json` (SSOT) | canonical model + per-target bindings/dialect | **yes — the only one** |49| `*.mappings.lock.json` | remembered resolutions for ambiguous/divergent mappings | only via `--resolve` (or a reviewed seed) |50| `sync.py` (engine) | detect → resolve → generate; `--check` / `--apply` / `--resolve` | yes (it's code) |5152…producing, per target: its **native config** (the dialect file the tool actually loads) and,53optionally, a **machine map** the consumer reads at runtime (e.g. `event_map.generated.json`).5455## The master's shape5657The master separates *what* (a canonical catalog) from *how each target expresses it*:5859- **catalog** — canonical entries keyed by id, each with a `role` (the cross-target normalization unit)60 plus whatever the domain needs (a type, a bucket, etc.).61- **targets** — per target: its `dialect`, output paths, a `runner`/command template, and **bindings**62 mapping the target's *native* names → a catalog entry + role + dialect detail (matcher, payload mode,63 timeouts, extra args).6465`role` is the key idea: divergence is detected when the **same role maps to different canonical entries66across targets**. That is exactly the ambiguity the lock resolves.6768## Workflow — operating an existing system69701. Edit the master (`hooks.master.json`): add/adjust catalog entries or a target's bindings.712. `mise run hooks:check` (read-only). It reports drift (generated files stale) and **ambiguities**.723. If an ambiguity is unresolved, resolve it once: `python3 sync.py --apply --resolve` (prompts and73 appends to the lock), or seed the lock entry by hand and re-check. A decision already in the lock74 applies automatically — nothing to do.754. `mise run hooks:sync` to regenerate every target's native config + machine map (idempotent).765. Verify: `mise run smoketest:agent-hooks-ssot` (every binding builds a contract+schema-valid output).7778## Workflow — building the pattern for a new domain79801. Copy `assets/master.template.json` and `assets/mappings.lock.template.json`; fill the catalog +81 targets + bindings for your domain.822. Adapt `sync.py` ([references/engine-design.md](./references/engine-design.md) gives the algorithm83 and each dialect renderer). Keep generation deterministic.843. Point each consumer at its generated map with an embedded fallback (merge generated OVER default).854. Wire `check` (CI gate) and `sync` (apply) tasks, plus a verifier that builds one output per binding86 and validates it against the domain's contract.875. Seed the lock with the resolutions you make on first run; thereafter re-syncs are seamless.8889## Out of scope9091- **Versioning many files in parity** (`package.json`/`pyproject.toml`/tags) → use `mise-versioning`.92- **Defining the event schemas or naming contract** the agent-hooks system targets → that's93 `bloodbank/docs/event-naming.md` and `schemas/`, not this pattern.94- **Writing an individual hook script / publisher's data-shaping logic** → this skill owns the95 *propagation* of the mapping, not the per-event handler bodies.96- **Single-target config with no dialect or ambiguity dimension** (plain env substitution, one output)97 → just template it directly; the master/lock machinery is overkill.