Ctrl K
ComunidadGuías…

docs/adr/0011-pipeline-runs-schema.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0011: pipeline_runs schema — durable history of every pipeline execution

## Metadata

| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-06-07 |
| **Authors** | DOMINUS team |
| **Deciders** | DOMINUS team |
| **Supersedes** | N/A |
| **Relates to** | ADR-0007 (backtest_signals), ADR-0008 (backtest engine), ADR-0009 (weight recalibration), ADR-0010 (rescore bridge) |
| **Project** | DOMINUS |

## Context

A `dominus run` today is a write-only operation. The orchestrator emits
`runId`, `stageSummary` (per-stage passed/filtered/duration), and
`totalDurationMs` to stdout, then forgets them. The `runId` string is
denormalised onto `candidates.pipeline_run_id` and `scoring_runs.run_id`
but the run as a logical event has no record. The consequences are real:

1. **No point-in-time auditability.** The backtest engine (ADR-0008)
   performs `scored_at <= outcome.occurred_at` joins, but cannot answer
   "which run produced this scoring snapshot?" without an implicit join
   through `candidates.pipeline_run_id` — a denormalised string that
   gives no way to ask what the run was *about*.
2. **No run history view.** The operator cannot list past runs, compare
   MAE between runs, see how long a run took, or identify runs that
   failed. A "compare two backtest runs" view (mentioned in ADR-0008's
   future-work) is impossible without a runs table.
3. **No retention policy.** As `scoring_runs` accumulates, the operator
   has no knob to prune. The only way to recover disk is a manual SQL
   sweep.
4. **No host version pinning on runs.** A run today does not record
   which DOMINUS version produced it. Comparing a 2025 score against a
   2026 score after a refactor is not safe — the algorithm might have
   changed.

The obvious shape of the fix is a `pipeline_runs` table with one row
per run, carrying the metadata the orchestrator already produces. But
two design decisions are non-trivial: (a) what fields to denormalise
versus store as JSON, and (b) whether to retain runs forever or
adopt a TTL.

## Decision Drivers

1. **Audit immutability** — runs are events that happened. Once written,
   they should not be edited, only completed.
2. **Query ergonomics** — the operator should be able to list runs by
   date range, look up one run by id, and read the per-stage summary
   without parsing JSON. Top-level fields for `started_at`,
   `total_duration_ms`, and `retained_until` belong as columns.
3. **Retention control** — at single-user scale (tens of runs per year)
   the table stays small, but the operator must have a knob to prune.
   180 days default; configurable via env; explicit `prune` command
   rather than implicit vacuum.
4. **Forward-only schema** — like every other migration, the new table
   must be additive. No backfill, no destructive change.
5. **No coupling to scoring_runs** — the runs table is the *event log*;
   the scoring rows are the *predictions*. They are joined by
   `run_id` string, not by FK, so a future refactor of either side
   does not break the other.

## Considered Options

### Option A: Top-level columns + JSON summary blobs (CHOSEN)

A `pipeline_runs` table with:

- `run_id` (PK, UUID text)
- `started_at`, `finished_at` (ISO text)
- `total_duration_ms` (integer)
- `stage_summary` (JSON text: `{StageName: {passed, filtered, durationMs}}`)
- `inputs` (JSON text: `{keywords, brandableNames, closeoutDomains, closeoutEntries}`)
- `results_summary` (JSON text: `{candidatesEvaluated, recommended, trademarkBlocked, unscored}`)
- `host_version` (text: package.json version)
- `retained_until` (ISO text: started_at + 180 days)
- `error` (text, nullable: captured exception message on failure)

Indexes on `started_at` (default sort order) and `retained_until`
(supports the prune query).

**Advantages:**
- Querying by date is a single column compare, not a JSON_EXTRACT.
- `retained_until` is a first-class column, not buried in JSON.
- Top-level fields survive schema changes to the JSON payloads.
- Pluggable JSON: future versions can add new fields to
  `stage_summary` without a migration.

**Disadvantages:**
- Two-level schema (columns + JSON) is slightly more code than
  pure-JSON. A single `payload` column would be simpler.
- The `inputs` JSON snapshot can grow large for big closeout CSV
  imports. We accept this; the JSON is the truth the run actually
  saw.

**Cost Implications:** Trivial. One migration, one repository, ~150 LOC
+ tests. No new dependencies.

**Risk Assessment:** Low. Forward-only migration on a brand new table.
Empty backfill. Cascade impact: none (no FKs).

---

### Option B: Single JSON payload column

Store the entire run as one JSON blob, query via SQLite JSON1
functions. `run_id` is still PK for fast lookup.

**Advantages:**
- Minimal schema. Future schema evolution is "change the JSON shape",
  not "add a column".
- Trivial to serialise/deserialise.

**Disadvantages:**
- Every "list runs" query is `ORDER BY json_extract(payload, '$.started_at')`,
  which prevents the use of an index on started_at and gets slower as
  the table grows. At 1000 rows this is fine; at 100k it hurts.
- `retained_until` would be a computed column or stored redundantly
  inside the JSON, with prune needing a JSON path expression.
- The architecture-guardian §3 rule "no JSON for queryable data" is
  softly violated — we *do* want to query by date.

**Cost Implications:** Lower code volume, higher query cost.

**Risk Assessment:** Low technical, medium long-term (query degradation).

---

### Option C: Separate normalised tables (pipeline_runs + pipeline_run_stages + pipeline_run_inputs)

Normalise the run into 3 tables: `pipeline_runs` (one row per run,
metadata), `pipeline_run_stages` (one row per stage per run), and
`pipeline_run_inputs` (one row per input domain per run).

**Advantages:**
- Cleanest relational shape. Stage rows are first-class queryable.
- Per-domain input history.

**Disadvantages:**
- 3 tables for a feature that is mostly read-once. The "list runs"
  view needs a JOIN.
- Per-stage rows are write-once: we never query "all stage 3
  failures across all runs" because the operator does not have that
  question.
- The per-domain input history already lives in
  `candidates.pipeline_run_id` (denormalised on candidates). Adding
  a `pipeline_run_inputs` table is duplicate state.
- Most "queries" are "show me one run". A single normalised table
  covers 95% of the use cases; the rest is operator-initiated and
  can use SQL.

**Cost Implications:** Higher code volume, more migrations, more
repository classes. The complexity is not justified at single-user
scale.

**Risk Assessment:** Low technical, medium complexity (more code paths
to maintain).

## Decision

**Chosen option: Option A — top-level columns + JSON summary blobs.**

The split mirrors the way the orchestrator already thinks: top-level
fields are *facts* the operator will filter and sort by (when, how
long, until when). JSON blobs are *evidence* the operator will read
once when investigating a run. SQLite indexes work natively on
columns; JSON is for inspection, not filtering.

`run_id` is the primary key as a string (not auto-increment integer)
because:

1. The orchestrator generates the UUID before any row is written
   (so the run can be referenced by other rows that may be created
   in parallel).
2. Cross-system uniqueness: a future cloud-based worker could
   generate a UUID locally and have it collide-safe with the local
   DB.
3. The `candidates.pipeline_run_id` column is already a string and
   references this table by value, not by FK.

`retained_until = started_at + 180 days` is the default. The operator
can override per-run by setting `PIPELINE_RUNS_TTL_DAYS` in `.env`,
or prune manually with `dominus runs prune [--older-than-days N]`.
180 days gives six months of "what did the engine do recently?"
visibility — enough to cross-compare two adjacent closeout-batch
imports.

The table has no FK to other tables. Joins are by string equality
on `run_id`. This is deliberate: deleting a run (e.g. via prune) is
a defensible operator action and must not cascade into
`scoring_runs` or `candidates`. The relationship is "these candidates
participated in this run", not "this run owns these candidates".

## Consequences

### Positive
- **Auditable run history.** Every `dominus run` leaves a permanent
  record of when it ran, what it consumed, what it produced, and how
  long it took.
- **Retention knob.** `dominus runs prune` keeps the table bounded.
  180-day default is invisible to the operator at single-user scale.
- **Future-proof JSON payloads.** The shape of `stage_summary` and
  `results_summary` can evolve across DOMINUS versions without a
  migration. The reading code tolerates unknown fields.
- **No coupling.** `pipeline_runs` does not depend on `scoring_runs`
  or `candidates`; it sits at the event-log layer.

### Negative
- **Two-level schema.** Columns + JSON is slightly more code than
  pure JSON. We accept this for the indexing benefits.
- **`retained_until` must be respected by ops.** A buggy
  `prune` command could delete runs the operator wanted. The CLI
  defaults to `--dry-run` for `prune` and requires `--force` to
  actually delete.
- **No cross-DB portability.** The JSON blobs assume SQLite's JSON1
  extension. We accept this; the project targets SQLite exclusively.

### Compliance and Security Implications
- No PII, no credentials, no API keys. The `inputs` JSON snapshot
  contains the user's own keyword/brandable/closeout domain lists,
  which the operator entered themselves.
- `retained_until` is a retention policy: the operator's data is
  pruned on a schedule they control. This is GDPR-aligned (the
  operator is the data controller).

### Migration and Monitoring Plan
- **Migration:** `0008_create_pipeline_runs` adds one table and two
  indexes, all `IF NOT EXISTS`. Tested in `:memory:` before commit.
- **Rollout:** Forward-only. Existing databases gain the table at
  startup; no data is touched.
- **Monitoring:** A simple `SELECT COUNT(*) FROM pipeline_runs` after
  a run confirms the row was written. The CLI's `dominus runs list`
  surfaces the same count visually.
- **Rollback:** Drop the table. No foreign data references it from
  outside this layer (joins are by string).

### Validation
- Unit tests in `pipeline-runs-repository.test.ts` cover: insert,
  findById, findAll with date filters, prune by `retained_until`,
  ordering.
- Integration test in `pipeline-runs-integration.test.ts` exercises
  the full flow: a `dominus run` produces 1 `pipeline_runs` row plus
  candidate and scoring rows.
- Production validation: a series of 3+ `dominus run` commands in
  one week should produce 3 rows in `dominus runs list`.

---

*This ADR was created following the MADR 4.0.0 standard. All DOMINUS ADRs
should be consistent with the ADR series starting at `docs/adr/0001-project-architecture.md`.
Template: `.claude/skills/adr/template.md`.*