Metadata-Version: 2.4
Name: mini-atlas-monitor
Version: 0.2.0
Summary: Read-only Mini Atlas monitor. One Waitress process serves the Flask API and the compiled React application.
License-Expression: Apache-2.0
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mini-atlas-simulator<0.3,>=0.2.0
Requires-Dist: Flask<4,>=3.0
Requires-Dist: Flask-RESTX<2,>=1.3
Requires-Dist: waitress<4,>=3.0
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# mini-atlas-api-flask-python

Source of the `mini-atlas-monitor` distribution. The Python import package remains `mini_atlas_api`. The console command is `mini-atlas`. One Waitress process serves the read-only HTTP API and, when the release assets are installed, the compiled React application.

## Pass 1 (current)

The HTTP API is a read-only monitor. User Python creates projects and runs through the simulator library (`create_project`, `start_run`). The HTTP monitor does not create projects, runs, or initialize a missing result store. Reading an existing SQLite catalog may create SQLite WAL/SHM sidecar files.

- Application factory (`create_app`) with required `RESULT_STORE` configuration
- `GET /api/v1/projects` — list projects (`ResultReader.list_projects`), including `run_count`
- `GET /api/v1/projects/{project_id}/runs` — paginated run list (`ResultReader.list_runs`), including `description`, `committed_steps`, `scheduled_steps`, and summary declaration metadata
- `GET /api/v1/runs/{run_id}` — run detail (`ResultReader.get_run`), including `description` when recorded
- `GET /api/v1/runs/{run_id}/lineage` — declared Lineage stages (`ResultReader.list_lineage_stages`)
- `GET /api/v1/runs/{run_id}/lineage-observations` — selected-step Lineage observations (`ResultReader.read_lineage_at_step`)
- `GET /api/v1/cursor` — latest store-wide `commit_id` (`ResultReader.latest_cursor`)
- `GET /api/v1/updates` — committed-step update polling (`ResultReader.get_updates`)
- `GET /api/v1/runs/{run_id}/steps` — committed steps at a frozen cursor (`ResultReader.list_committed_steps`)
- `GET /api/v1/runs/{run_id}/progress` — run progress at a frozen cursor (`ResultReader.run_progress`)
- `GET /api/v1/runs/{run_id}/metric-definitions` — declared metric metadata (`ResultReader.list_metric_definitions`)
- `GET /api/v1/runs/{run_id}/metrics` — snapshot-pinned metric series and optional selected-step values (`ResultReader.read_metrics`)
- `GET /api/v1/runs/{run_id}/streams` — declared streams and selected-step counts (`ResultReader.list_streams`)
- `GET /api/v1/runs/{run_id}/streams/{stream_name}/rows` — bounded selected-step table page (`ResultReader.read_table`)
- Waitress entry point (`run.py`)
- Local Swagger UI (no CDN)

Known resources reject `POST`, `PUT`, `PATCH`, and `DELETE` with HTTP 405, `Content-Type: application/json`, and `{"message": "The method is not allowed for the requested URL."}`. `Allow` lists the framework methods for that resource (`GET`, `HEAD`, and `OPTIONS` where the framework provides them) and does not advertise `POST`, `PUT`, `PATCH`, or `DELETE`. An unknown `/api` path stays the framework HTML 404. That 404 is not the React document and is not this JSON 405 body.

`GET /api/v1/projects` keeps three distinct store cases. A missing configured path returns `{"projects": []}` and does not create the path. An empty directory returns the same empty envelope and does not create a catalog. A corrupt nonempty store returns HTTP 503 with `{"message": "The configured result store could not be read."}`.

Store cursor is global publication order (latest committed `commit_id`), not simulated time. Clients poll `/updates` every 1–2 seconds; there is no server long-poll, SSE, or WebSockets. The API does not accept or return a client `generation` token.

## Dependencies

Runtime dependencies are declared in `pyproject.toml`:

- Python `>=3.10`
- `mini-atlas-simulator>=0.2.0,<0.3`
- `Flask>=3.0,<4`
- `Flask-RESTX>=1.3,<2`
- `waitress>=3.0,<4`

Development installs use the project metadata (`requirements-dev.txt`). Install a local simulator wheel or checkout first. The simulator package is not published on PyPI. `requirements.txt` repeats the HTTP server ranges for the current image install and does not install the simulator.

## Supported versions

- **Python 3.10+** (developed and tested on **3.13** in this checkout).
- **Waitress** is the HTTP server (not Flask’s debug server).

## Install the monitor

Install a locally built simulator wheel, then the monitor wheel:

```bash
python3 -m venv .venv
.venv/bin/python -m pip install /path/to/mini_atlas_simulator-0.2.0-py3-none-any.whl
.venv/bin/python -m pip install /path/to/mini_atlas_monitor-0.2.0-py3-none-any.whl
```

The installed command serves the API and the compiled UI on one origin. It does not run Node, npm, Vite, or nginx.

```bash
mini-atlas --store /absolute/path/to/result-store --host 127.0.0.1 --port 8080 --no-browser
```

`--store` selects the result store and overrides `RESULT_STORE`. When `--store` is omitted, `RESULT_STORE` is the fallback. The default bind is `127.0.0.1` and the default port is `8080`. The default opens a browser after the socket binds. `--no-browser` skips that. `mini-atlas --help` exits 0 and does not start the server.

A missing store path, or a path that is a file, exits 2 and creates nothing. An existing directory is accepted, including an empty directory. Binding `0.0.0.0` or `::` prints a warning: the process is unauthenticated and belongs on a trusted network or behind an authenticating proxy. The browser URL for `0.0.0.0` is `http://127.0.0.1:<port>/`. The browser URL for `::` is `http://[::1]:<port>/`. This package does not add authentication. Keyboard interrupt prints `Shutting down.` and exits 0.

The monitor and the API are served at the origin root. `/assets/...` and `/api/v1/...` are paths from that root. Hosting the application under a path prefix is not supported.

An unknown `/api` route stays the framework HTML 404 and is never the React document. This package does not convert that 404 to JSON. `/swagger/` remains the Flask-RESTX UI, and `/api/v1/swagger.json` remains JSON. `GET /api/v1/meta` reports the installed monitor distribution, monitor version, simulator version, UI commit, and UI version. It does not open the store.

## JSON gzip

`mini-atlas` and `run.py` negotiate gzip for eligible JSON through a standard-library WSGI middleware attached once before Waitress starts. The metric response schema is unchanged: the same JSON bytes are sent, optionally gzip-encoded on the wire.

Compression applies only when the response `Content-Type` is `application/json` (an optional `charset` is allowed), the uncompressed body is at least 1,024 bytes, the body is not already encoded, and the request accepts `gzip` with a positive quality value. HTML, JavaScript, CSS, images, fonts, static assets, the React SPA shell, and the Swagger UI page are not compressed. Large `application/json` responses such as `/api/v1/swagger.json` or metric reads follow the same JSON rule. Clients that omit `Accept-Encoding` or send `identity` receive the same JSON bytes as before. Eligible responses that can vary by encoding include `Vary: Accept-Encoding`. HEAD is not gzip-encoded: Flask has already discarded the body, so HEAD keeps the application's identity `Content-Length` and an empty body. GET of the same JSON resource may still negotiate gzip.

Opening a catalog for a read can create `catalog.sqlite-wal` and `catalog.sqlite-shm` beside the catalog when the directory is writable. Those sidecars are SQLite files. They are not projects, runs, or result files.

## Frontend assets

The compiled React application is vendored from a pinned `mini-atlas-webapp` commit before `python -m build`. Node is required only for that release build, and only at a version matching the webapp `engines` range (`>=24 <25`). Vite remains the UI development server. Unit tests and `pip install` of a built wheel do not run Node.

From the pinned webapp commit:

```bash
npm ci
npm run build
```

From this repository:

```bash
python scripts/vendor_frontend.py \
  --dist /absolute/path/to/mini-atlas-webapp/dist \
  --commit <40-hex webapp commit> \
  --version <webapp package.json version> \
  --built-at <ISO-8601 commit timestamp>
python -m pip install build
python -m build
```

The wheel and sdist contain those bytes and `ui_provenance.json`. Rebuilding a wheel from that sdist does not run Node and does not need a webapp checkout. `python -m build` fails if `mini_atlas_api/web/index.html` or the provenance manifest is missing or inconsistent. When those assets are absent, `run.py` and the test client stay API-only.

## Development checkout

```bash
python3 -m venv .venv
.venv/bin/python -m pip install /path/to/mini-atlas-simulator
.venv/bin/python -m pip install -r requirements-dev.txt
```

Use a local `mini-atlas-simulator` checkout or wheel. Do not rely on PyPI for the simulator package.

## Result store

Choose an empty directory on the host (for example `/path/to/result-store`). User Python writes catalog and Parquet data there through the simulator library. HTTP requests do not create projects or store artifacts. Known-resource mutation methods return the JSON 405 described above. Missing, empty, and corrupt stores stay distinct on GET.

## Run the API (native, Waitress)

```bash
.venv/bin/python run.py --store /path/to/result-store
```

Or set `RESULT_STORE` and omit `--store`:

```bash
export RESULT_STORE=/path/to/result-store
.venv/bin/python run.py --host 127.0.0.1 --port 8080
```

Optional: `--host` (default `127.0.0.1`), `--port` (default `8080`). Bind `0.0.0.0` only when you intend to expose the API on the network (containers use `0.0.0.0` internally).

Flask works without the React app. Point a browser or `curl` at `http://127.0.0.1:8080/api/v1/cursor` or `/swagger/` to verify.

## Production container (local)

Build from this repository with the simulator supplied as a **narrow additional build context** (see `Dockerfile`). The image builds `mini-atlas-simulator` and `mini-atlas-monitor` wheels from that context and the committed vendored UI, installs them with `pip`, and runs the installed `mini-atlas` command. The runtime image does not contain Flask or simulator source checkouts, Node, npm, or nginx.

| Item | Required | Meaning |
|------|----------|---------|
| Store path (CLI) | Yes | `--store /data/store` in the image command |
| Host bind mount | Yes (Compose) | Set `MINI_ATLAS_RESULT_STORE` to an **absolute** host directory before `docker compose up` in `mini-atlas-webapp` |
| Mount mode | Read-write | SQLite may create `catalog.sqlite-wal` and `catalog.sqlite-shm` beside the catalog on first open. A fresh read-only bind mount can return 503 until sidecars exist; do not treat `:ro` as fully supported for a new store. |
| Container user | uid **10001** | The host directory must be readable and writable by uid 10001 (or group-accessible with the same effective permissions). |

Health check: `GET /api/v1/cursor` (returns `{"latest_cursor": 0}` on an empty store).

```bash
docker build -f Dockerfile \
  --build-context simulator=/path/to/mini-atlas-simulator \
  -t mini-atlas-monitor:local .
```

Compose (from `mini-atlas-webapp`): one `monitor` service, `127.0.0.1:8080` only. Deployers may place their own reverse proxy in front of the image; nginx is not part of the product runtime.

## Tests

```bash
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -p no:cacheprovider
```

### Run list query parameters

`GET /api/v1/projects/{project_id}/runs` accepts optional:

- `q` — run name substring
- `status` — `pending`, `running`, `completed`, or `failed`
- `sort_by` + `sort_dir` — both required together; columns `name`, `status`, `wall_start`, `run_id`; directions `asc` or `desc` (omit both for reader default: `wall_start` desc, then `run_id` asc)
- `limit` (1–1000, default 50) and `offset` (default 0)
- repeated `summary` — metric `definition_id`s for optional row summaries

Example:

```http
GET /api/v1/projects/01J.../runs?limit=50&offset=0&summary=yield&summary=precision
```

Response envelope:

```json
{
  "observed_cursor": 40,
  "page": {"limit": 50, "offset": 0, "next_offset": null, "returned": 1, "matched_rows": 1},
  "runs": []
}
```

Each project row may include `run_count`. Each run row may include `description`, `committed_steps`, and `scheduled_steps`. Each requested summary item may include `declared`, `label`, `unit`, `grain`, and `display`. Flask copies those public reader fields and does not calculate them. Missing, logged-null, explicit zero, and undeclared summaries stay distinct.

Run detail:

```http
GET /api/v1/runs/01J...
```

Run detail may include `description` (`null` when the run has none).

### Store cursor and update polling

```http
GET /api/v1/cursor
```

```json
{"latest_cursor": 12}
```

Missing or empty store directories return `{"latest_cursor": 0}` without creating storage.

```http
GET /api/v1/updates?after_cursor=0&limit=100
```

Optional query parameters:

- `after_cursor` — last applied store `commit_id` (defaults to `0` when omitted; must be `>= 0`)
- `limit` — page size `1`–`1000` (default `100`)
- repeated `run_ids` — optional ULID filter

Response envelope (matches `ResultReader.get_updates`):

```json
{
  "after_cursor": 0,
  "through_cursor": 2,
  "latest_cursor": 5,
  "commits": [
    {"commit_id": 1, "run_id": "01J...", "step_n": 1, "as_of": "2025-01-01T00:00:00Z"}
  ]
}
```

`through_cursor` is the safe next poll cursor (last `commit_id` returned on this page). `latest_cursor` is the observed store head and may be greater than `through_cursor` when the page is truncated by `limit`. Invalid or future cursors return HTTP 400 and are never clamped.

### Committed steps and progress (frozen cursor)

```http
GET /api/v1/runs/01J.../steps?through_cursor=12
GET /api/v1/runs/01J.../progress?through_cursor=12
GET /api/v1/runs/01J.../progress?through_cursor=12&step_n=3
```

`through_cursor` is required. Optional `step_n` selects an exact committed step; when omitted, progress uses the reader default (latest committed step at that cursor).

Steps envelope:

```json
{
  "run_id": "01J...",
  "through_cursor": 12,
  "steps": [{"step_n": 1, "as_of": "2025-01-01T00:00:00Z", "commit_id": 1}]
}
```

Progress envelope (from `ResultReader.run_progress`):

```json
{
  "run_id": "01J...",
  "through_cursor": 12,
  "scheduled_steps": 600,
  "committed_steps": 3,
  "step_n": 3,
  "as_of": "2025-01-03T00:00:00Z",
  "commit_id": 8
}
```

Zero committed steps return HTTP 200 with `steps: []` or null progress fields; step 1 is never invented.

### Metric definitions and reads (frozen cursor)

```http
GET /api/v1/runs/01J.../metric-definitions
GET /api/v1/runs/01J.../metrics?through_cursor=12&definition_id=metric_a&definition_id=metric_b
GET /api/v1/runs/01J.../metrics?through_cursor=12&step_n=5&definition_id=metric_a
```

`through_cursor` is required on metric reads and freezes the publication snapshot. Optional repeated `definition_id` selects metrics; when omitted, the reader returns **all declared metrics** in deterministic `definition_id` order. Optional `step_n` selects committed-step card values; when supplied it must be committed at `through_cursor`. Series points may still include steps after the selected step when they are committed at the frozen cursor.

Metric values are logged facts from user Python. Flask does not compute coverage, percentages, cumulative totals, or chart ranges. Missing points are not coerced to zero. `display.format=percent` is presentation metadata only (stored ratios such as `0.099` stay numeric). `chart_range` and response `generation` are client-owned; this API does not accept them.

Definitions envelope:

```json
{
  "run_id": "01J...",
  "definitions": [
    {
      "definition_id": "slice_node_count",
      "label": "Slice nodes",
      "unit": "count",
      "grain": "per_step",
      "direction": "none",
      "series": {"group": "graph_asof", "role": "member"}
    }
  ]
}
```

Metric read envelope (matches `ResultReader.read_metrics` / `MetricRead`):

```json
{
  "run_id": "01J...",
  "through_cursor": 12,
  "step_n": 5,
  "definitions": [],
  "values": [],
  "series": {}
}
```

When `step_n` is omitted, `values` is empty and `step_n` is null. Zero-commit runs may still expose declared definitions; metric series and values are empty until steps commit.

### Stream discovery and table pages (frozen cursor, one step)

```http
GET /api/v1/runs/01J.../streams?through_cursor=12&step_n=5
GET /api/v1/runs/01J.../streams/subject_scores/rows?through_cursor=12&step_n=5&limit=100&offset=0
```

`through_cursor` and `step_n` are **required** on both endpoints. They pin the publication snapshot and select exactly one committed step. Invalid or uncommitted `step_n` returns HTTP 400 and is never clamped to nearest or latest. There is no `from_n`/`to_n`, cross-step history, subject history, or show-all mode.

Stream list (`ResultReader.list_streams`):

```json
{
  "run_id": "01J...",
  "through_cursor": 12,
  "step_n": 5,
  "as_of": "2025-01-06T00:00:00Z",
  "streams": [
    {
      "name": "subject_scores",
      "schema_version": 1,
      "role": "scores",
      "subject_column": "subject_id",
      "selection": "all",
      "tiebreak_columns": ["subject_id"],
      "logged_rows": 400,
      "coverage": {"status": "ok"},
      "declaration_coverage": {"scope": "dataset"}
    }
  ]
}
```

Per-stream `coverage.status` at the selected step is one of `ok`, `not_recorded`, or `logged_empty` (table rows use additional statuses below). Declared stream metadata comes from user Python; roles are generic hints only.

Table page query parameters:

| Parameter | Required | Meaning |
|-----------|----------|---------|
| `through_cursor` | yes | Frozen store `commit_id` |
| `step_n` | yes | Committed step for this page |
| `limit` | no | Page size (reader default **100**, maximum **10000**) |
| `offset` | no | Row offset (default **0**) |
| `sort` | no | Repeatable `column:asc` or `column:desc` (user sort first; reader applies declared tie-breaks then `_row_id ASC`) |
| `filter` | no | Repeatable structured filter (see below) |

Filter wire syntax (maps to `ResultReader.read_table` `filter=` tuples):

- `column:is_null` or `column:is_not_null`
- `column:op:json_value` for all other operators

Supported operators: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `in`, `not_in`, `contains`, `is_null`, `is_not_null`. The JSON value must be valid JSON (`true`, `false`, numbers, strings in quotes, arrays for `in`/`not_in`). Malformed entries, unknown operators, unknown columns, and invalid filter or query input, including type mismatches, return HTTP 400. Missing, corrupt, or unreadable result data returns HTTP 503 with the store-unreadable message. Unexpected server failures return HTTP 500.

Examples:

```http
GET .../rows?through_cursor=12&step_n=5&sort=score:desc&filter=flagged:eq:true&filter=score:ge:0.5
GET .../rows?through_cursor=12&step_n=5&filter=subject_id:in:["A-1","A-2"]
GET .../rows?through_cursor=12&step_n=5&filter=note:is_null
```

Table page envelope (`TablePage`):

```json
{
  "stream": "subject_scores",
  "run_id": "01J...",
  "through_cursor": 12,
  "step_n": 5,
  "page": {
    "limit": 100,
    "offset": 0,
    "next_offset": 100,
    "returned": 100,
    "logged_rows": 400,
    "matched_rows": 400
  },
  "coverage": {
    "status": "ok",
    "steps_not_recorded": [],
    "steps_logged_empty": []
  },
  "columns": [{"name": "_row_id", "type": "int64"}],
  "rows": [{"_row_id": 1, "_step_id": 5, "_as_of": "2025-01-06T00:00:00Z", "score": 0.91}]
}
```

Row `coverage.status` meanings:

| Status | Meaning |
|--------|---------|
| `ok` | Matching rows returned (or metadata-only success) |
| `not_recorded` | Stream not logged at this step; `rows` empty |
| `logged_empty` | Writer logged a zero-row table; `logged_rows` is 0 |
| `filtered_empty` | Stored rows exist but filter matched none (`logged_rows > 0`, `matched_rows = 0`) |
| `page_past_end` | Matches exist but `offset >= matched_rows`; `next_offset` is null |

Arbitrary user columns are returned as JSON objects in `rows` (not a fixed score schema). Integers with magnitude `>= 2^53` serialize as decimal strings; smaller integers stay JSON numbers. Booleans stay booleans; `null` stays null; UTC timestamps end in `Z`. Non-finite floats are not emitted.

`stream_name` must be a safe declared name (alphanumeric start; `[A-Za-z0-9._-]`; no slashes or traversal). Unknown undeclared streams return HTTP 400 from the reader. Missing configured store paths return HTTP 404 for syntactically valid unknown runs without creating artifacts; corrupt stores return HTTP 503.

Client stale-response generation is not an API field.

## Rule Foundation and first-class decision reads

These are read-only HTTP projections of the committed simulator public APIs.
The API does not execute rule or decision logic, verify `logic_ref`, compute
counts, infer set overlap, populate evaluated subjects, or promote a legacy
generic `role=decisions` stream that has no first-class target declaration.
There is no React or Dash Rules tab in this repository.

### Routes

- `GET /api/v1/runs/{run_id}/rules` lists declarations at any commit state,
  including zero-commit runs. It has no accepted query parameters and returns
  `{"run_id": "...", "definitions": [...]}`. Each definition preserves
  `rule_id`, label, description, version, target namespace, parameters,
  claimed `logic_ref`, optional category/tags, and the
  `definition_fingerprint`.
- `GET /api/v1/runs/{run_id}/rule-aggregates` requires
  `through_cursor` (nonnegative) and `step_n` (positive and committed at that
  cursor). Repeat `rule_id` to select rules; omit it for every declared rule.
  The reader preserves logged zero, null individual measures, and missing
  aggregates (`logged: false`).
- `GET /api/v1/runs/{run_id}/rule-aggregate-series` requires
  `through_cursor`, explicit positive `from_n` and `to_n`, one or more
  repeatable `rule_id` values, and one or more repeatable `field` values.
  The simulator enforces the maximum span and series count. `chart_range`,
  `generation`, `show_all`, and implicit latest-step selection are rejected.
- `GET /api/v1/runs/{run_id}/rule-set-aggregates` requires
  `through_cursor` and `step_n`. It returns the user-logged
  `intersection`, `union`, and ordered `difference` records without Flask
  arithmetic or inference.
- `GET /api/v1/runs/{run_id}/rules/{rule_id}/evidence-status` requires
  `through_cursor` and `step_n`. Its authoritative status is
  `not_recorded`, `logged_empty`, or `recorded`.
  `rule_id` is an opaque identifier and may contain `/`, including a
  trailing slash or an embedded `//`. Percent-encode each slash as `%2F`.
  The value is not a filesystem path. A rule id that begins with `/`
  is not representable: routing merges that empty segment and redirects
  to the id without the leading slash.
- `GET /api/v1/runs/{run_id}/rules/{rule_id}/evidence` is the dedicated
  bounded one-rule evidence page. It requires `through_cursor` and `step_n`;
  optional `limit` is 1–1000 (default 100), `offset` is nonnegative, and
  repeatable `sort=column:asc|desc` and structured `filter` values are
  supported. The route always supplies exactly one `rule_id` equality
  predicate to the simulator `read_table` reader. Extra `rule_id` filters,
  arbitrary stream names, `show_all`, `generation`, and `chart_range` are
  rejected. Page statuses are `ok`, `filtered_empty`, `page_past_end`,
  `logged_empty`, and `not_recorded`.
- `GET /api/v1/runs/{run_id}/decision-target` returns
  `{"run_id": "...", "decision_target": null}` when no first-class target
  exists, or the declared target metadata when it does. It has no accepted
  query parameters and works before the first commit.
- `GET /api/v1/runs/{run_id}/decision-status` requires `through_cursor` and
  `step_n`, returning the authoritative
  `not_recorded`/`logged_empty`/`recorded` receipt status.
- `GET /api/v1/runs/{run_id}/decisions/rows` requires `through_cursor` and
  `step_n`; it accepts the same bounded `limit` (default 100, maximum 1000),
  `offset`, repeatable `sort`, and structured `filter` parameters as table
  pages. It uses the declared first-class decision stream only and calls the
  simulator `read_table` reader. Legacy decision-role streams without a
  target are returned as an empty, `not_recorded` decision page.

All structured filters use `column:op:json_value`; null checks use
`column:is_null` or `column:is_not_null`. Operators are `eq`, `ne`, `lt`,
`le`, `gt`, `ge`, `in`, `not_in`, `contains`, `is_null`, and `is_not_null`.
Sort/filter columns must be safe identifiers. Singular values cannot be
repeated; rule IDs and fields cannot be blank or duplicated. Invalid cursor,
future cursor, uncommitted step, malformed query, unsupported field, and
unknown rule values return the established 400 envelope:
`{"error": "invalid_request", "message": "..."}`. A valid unknown run is
404; corrupt catalogs, unsupported formats, and registered-file failures are
503 with the generic store-read envelope. Unexpected runtime failures remain
500.

Every step-scoped response includes the pinned `through_cursor` and exact
`step_n`; cursors are never clamped. A response remains frozen after later
commits when the same cursor is reused. Definitions and target declarations
are available at zero commits, but no step response invents step 1.
`logged_empty` is distinct from `not_recorded`, and both are distinct from a
page `filtered_empty`. Rows are ordered by requested sort, simulator-declared
tie-breaks, then `_row_id`, with bounded predicate-pushed pages rather than
full evidence or decision materialization.

Evidence hits are triggered-subject observations only. Decision rows preserve
`decision_id`, `target_id`, claimed `contributing_rules` provenance,
`payload_json`, `_row_id`, observation metadata, and virtual commit identity.
Neither hits nor decisions are evaluation truth. User Python owns rule
execution, decision logic, evaluated populations, formulas, overlap
arithmetic, and truth reconciliation.

Examples:

```http
GET /api/v1/runs/01J.../rules
GET /api/v1/runs/01J.../rule-aggregates?through_cursor=12&step_n=5&rule_id=R12
GET /api/v1/runs/01J.../rule-aggregate-series?through_cursor=12&from_n=1&to_n=30&rule_id=R12&field=unique_triggered_n
GET /api/v1/runs/01J.../rules/R12/evidence?through_cursor=12&step_n=5&limit=100&sort=target_id:asc
GET /api/v1/runs/01J.../decision-status?through_cursor=12&step_n=5
GET /api/v1/runs/01J.../decisions/rows?through_cursor=12&step_n=5&filter=target_id:eq:%22A-19%22
```

Swagger documents these routes at `/swagger/` and continues to serve
Flask-RESTX assets locally; no CDN assets are used.

## Lineage reads

These are read-only HTTP projections of `ResultReader.list_lineage_stages` and
`ResultReader.read_lineage_at_step`. Flask does not declare stages, log
observations, infer parents or statuses, execute a workflow, or derive counts.
Missing is not zero. An unobserved stage is `logged: false` with `status: null`
and empty `counts`. The Lineage UI is a later React task. The simulator
continues to work without the monitor.

### Routes

- `GET /api/v1/runs/{run_id}/lineage` lists declared stages in declaration
  order. It has no accepted query parameters and is valid at zero commits. An
  old store, or a run with no declarations, returns `"stages": []`.
- `GET /api/v1/runs/{run_id}/lineage-observations` requires `through_cursor`
  (nonnegative) and `step_n` (positive and committed at that cursor). There is
  no nearest-step, latest, series, trend, paging, filter, generation, or
  chart-range parameter. A frozen cursor that has not committed the requested
  step returns HTTP 400. Stage ids are never placed in URL paths.

Examples:

```http
GET /api/v1/runs/01J.../lineage
GET /api/v1/runs/01J.../lineage-observations?through_cursor=12&step_n=5
```

Declarations envelope:

```json
{
  "run_id": "01J...",
  "stages": [
    {
      "stage_id": "evaluate",
      "label": "Evaluate subjects",
      "description": "Apply the declared evaluation",
      "parents": ["prepare"],
      "counts": ["evaluated_n", "flagged_n"],
      "ordinal": 2
    }
  ]
}
```

Selected-step observations envelope:

```json
{
  "run_id": "01J...",
  "through_cursor": 12,
  "step_n": 5,
  "as_of": "2025-01-06T00:00:00Z",
  "commit_id": 7,
  "observations": [
    {
      "stage_id": "evaluate",
      "logged": true,
      "status": "completed",
      "counts": {"evaluated_n": 8120, "flagged_n": 0}
    },
    {
      "stage_id": "publish",
      "logged": false,
      "status": null,
      "counts": {}
    }
  ]
}
```

Stored Lineage statuses are `completed`, `skipped`, and `failed`. Integers with
magnitude `>= 2^53` serialize as decimal strings.

## Configuration

| Key | Required | Meaning |
|-----|----------|---------|
| `RESULT_STORE` | Yes | Filesystem path to the Mini Atlas result store. Never supplied by HTTP clients. |
