Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Solve a specific problem:

Look up exact behavior:

- [Consequence delta](reference/consequence-delta.md)
- [Failure reasons](reference/failure-reasons.md)
- [MCP tool reference](reference/mcp-tools.md)
- [MCP tool profiles](reference/mcp-tool-profiles.md)
Expand Down Expand Up @@ -82,6 +83,7 @@ Understand how Atlas works and why:
- [Board renderer performance](explanation/board-renderer-performance.md)
- [Branching](explanation/branching.md)
- [Changesets](explanation/changesets.md)
- [Consequence deltas](explanation/consequence-delta.md)
- [Entity protection](explanation/entity-protection.md)
- [Reliability overlays](explanation/reliability-overlays.md)
- [Single-writer fencing](explanation/single-writer.md)
Expand Down
151 changes: 151 additions & 0 deletions docs/explanation/consequence-delta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Consequence deltas

A dry run answers "would this write be accepted?". A reviewer usually also
wants to know "what would it do to the model's health?": which findings it
creates, which it clears, and which it leaves alone. A consequence delta
answers that second question by comparing the findings of the world as it
is with the findings of the world the change would produce.

The delta is advisory. It never refuses a write and never changes the
status a dry run would otherwise return. It rides the dry-run responses of
`PutEntity`, `BatchMutate` and `MergeBranch`, and the matching MCP tools.
Field-level detail lives in the
[consequence delta reference](../reference/consequence-delta.md).

## Full-scope comparison is the reference

The server scans the whole visible world twice, once before and once after
the change, with the same suites, and compares the two finding sets. It does
not try to work out which findings a change could reach. That kind of
narrowing is attractive, since most changes touch a small corner of the
graph, but a narrowing that misses a reverse reference silently drops a
real consequence. Full scope is the behaviour any later optimization must
reproduce exactly, and it is the only behaviour shipped today.

Running every suite over the whole world costs time on every dry run, twice,
and the per-model suite grows faster than the world does because each model
pass reads the whole snapshot. The server records that cost on the
`trogon_atlas.consequence_delta.duration` histogram, so an operator can see
when a narrower comparison becomes worth building.

Because each scan is a flat pass over a snapshot rather than a walk along
references, reference cycles cannot make it loop.

## What makes two findings the same

Prose changes between releases and must not turn one finding into two, so a
finding's identity is built only from structured data:

- the suite that produced it,
- the entity whose pass produced it, for suites that run once per entity,
- the rule code,
- the exact subject entity, including its version,
- the subject field,
- every remaining structured field of the issue.

The producing entity matters because a per-model check reports its finding
on the member it is about, not on the model. Without it, two models that
both point at the same missing event would collapse into one finding, and
deleting one of them would look like a fix.

Severity is deliberately outside identity, so a rule that is promoted from
warning to error shows up as one finding whose severity changed rather than
one resolved and one introduced. The rule title, category and default
severity are copies of catalogue data and are excluded too. So is each
evidence entry's `detail`, which is prose like the message.

"Every remaining structured field" is computed from the issue message itself
rather than from a fixed list. When `ValidationIssue` gains structured
evidence, that evidence joins identity without any change to the delta, so
two findings of one rule on one subject that differ only in evidence stay
distinct.

## How findings are classified

Findings are grouped by identity on each side and then matched:

- Occurrences with equal identity and equal severity cancel out and are
counted as unchanged.
- Leftover occurrences with equal identity but different severity are a
severity change.
- A finding that appears on a newer version of its subject or producing
entity, when the same finding existed on an older version, is carried to
the successor rather than introduced. The entity versions its evidence
pins count the same way, so a slice that cites itself still carries its
finding to its next version, and evidence that moved to an older version
is not a successor.
- A finding whose subject or producing entity existed before and is gone
after left with that entity, which is not the same as being fixed.
- Anything else that appears is introduced, and anything else that
disappears is resolved.

Repeated occurrences of one identity are counted, not collapsed, so a
change that adds a second copy of an existing finding is reported.

## Unknown is not resolved

A finding can only be called resolved if the after-scan actually looked
for it. If the suite that produced it did not complete after the change
(skipped, partial, errored, or simply not reported), its absence proves
nothing, and the delta reports it as unknown with the reason the suite
gave. The exception is a finding whose subject or origin no longer exists:
the change removed what the finding was about, so the delta reports it as
subject removed even when the after-scan's suite did not complete. The same holds the other way: a finding only counts as introduced if
the before-scan's suite completed, otherwise it might have been there all
along.

A truncated snapshot marks every compared suite partial, so a delta over a
truncated world classifies nothing as introduced or resolved. The delta's
`complete` flag is true only when every compared suite completed on both
sides, neither snapshot was truncated, and no applicable suite was left
out.

Both scans see only what the caller may read. An entity hidden from the
caller contributes no findings to either side, so the delta says nothing
about it rather than calling its findings resolved. A finding whose subject
was deleted is reported as removed with its subject, never as resolved.

## Suites the delta does not compare

Two parts of validation read the store directly rather than a candidate
world: specification type checks and data-product event checks. The delta
cannot evaluate them against a world that does not exist yet. When the
world holds entities those checks apply to, the delta names them as skipped
with `delta_not_supported` and reports itself incomplete. When it does not,
they are skipped as not applicable and do not affect completeness.

Data-flow inference and information-completeness checks are scoped
analyses, optionally backed by a language model, so two runs over one world
need not agree. They are listed as skipped with `analysis_not_compared` and
do not affect completeness, because they are not validation suites.

## Binding to the worlds compared

Each delta carries the snapshot id of both worlds, computed the same way as
[`GetSnapshotId`](snapshot-identity.md). The before id names the state the
preview started from; the after id names the content that would result. A
delta whose before id no longer matches the live snapshot describes a
world that has moved on. When either world holds an entity that cannot be
rendered canonically, it has no snapshot id; the delta still reports its
findings but leaves both ids empty and calls itself incomplete, because a
comparison that cannot name its worlds cannot be checked for staleness.

The ids cover only the two compared worlds. When the snapshot is truncated,
architecture validation also loads the targets a resource references from
the store, and a target outside both worlds can shape a finding without
being hashed into either id. Such a delta already calls itself incomplete,
so a matching before id does not prove those findings current. Binding that
context into the ids would change the id format and is a separate change.

The comparison runs after the dry run releases the mutation lock. The lock
only covers the same admission checks a real write would make; the scans
read worlds already held in memory, so a slow delta never delays other
writers. The price is that a concurrent write can land while the delta is
being computed, which is exactly what the before snapshot id lets a reader
detect.

## Default chosen for review

The delta compares full scope only and reports applicable suites it cannot
evaluate as gaps rather than approximating them. Both choices trade cost and
coverage for never reporting a resolution that was not proven.
8 changes: 5 additions & 3 deletions docs/explanation/validation-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ subset). Coverage work makes that answer observable either way.

Truncation, depth, and visibility flags must use the same scope and
snapshot identity vocabulary as coverage, so a truncated empty issue list
cannot look complete. Consequence deltas then compare findings only within
declared comparable scopes; an after-scan that omitted a suite or truncated
the snapshot cannot claim a finding was resolved.
cannot look complete. [Consequence deltas](consequence-delta.md) build on
the same coverage: each side of the comparison carries its own coverage,
and a finding missing from an after-scan whose suite did not complete is
reported as unknown, never as resolved, unless its subject or origin was
removed, in which case it is reported as subject removed.

## Where the code lives

Expand Down
99 changes: 99 additions & 0 deletions docs/reference/consequence-delta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Consequence delta

Wire contract for
[`ConsequenceDelta`](../../proto/trogonatlas/api/eventmodel/v1alpha1/validation.proto).
For the reasoning behind it, see the
[consequence delta explanation](../explanation/consequence-delta.md).

## Where it appears

| Response | Field | Set when |
| --- | --- | --- |
| `PutEntityResponse` | `consequence_delta` | `validate_only` was set and the write would be admitted |
| `BatchMutateResponse` | `consequence_delta` | `validate_only` was set and the status is `STATUS_VALIDATED` |
| `MergeBranchResponse` | `consequence_delta` | `dry_run` was set and validation was reached (`STATUS_VALIDATED` or `STATUS_INVALID`) |

A real write never carries a delta. An absent delta means none was
computed, never that nothing changed. Failing to compute a delta never
fails the dry run it rides on.

The MCP tools `put_entity`, `put_entity_json`, `batch_mutate`,
`batch_mutate_json`, `apply_manifests` and `merge_branch`, and the
`trogon-atlas apply --dry-run` and `trogon-atlas branch merge --dry-run`
JSON output, add a `consequence_delta` object with the same fields.

`apply_manifests` and `trogon-atlas apply --dry-run` send no batch when
every manifest is unchanged, so they carry no delta in that case.

## `ConsequenceDelta`

| Field | Meaning |
| --- | --- |
| `changes` | Findings whose standing moved, in a stable order |
| `unchanged_count` | Occurrences present on both sides with the same severity |
| `before_coverage` | Coverage of the scan of the world before the change |
| `after_coverage` | Coverage of the scan of the world after the change |
| `complete` | True only when every compared suite completed on both sides, no snapshot was truncated, no applicable suite was left out, and both snapshot ids are set |
| `before_snapshot_id` | Snapshot id of the world the preview started from; empty when that world holds an entity without content identity |
| `after_snapshot_id` | Snapshot id of the world the change would produce; empty under the same condition |

## `FindingChange`

| Field | Meaning |
| --- | --- |
| `kind` | See the kinds table |
| `check_id` | Suite that produced the finding |
| `origin` | For the `model` and `scenarios` suites, which run once per entity, the entity whose pass produced the finding; absent for store-wide suites |
| `before` | A representative occurrence before the change, when one existed |
| `after` | A representative occurrence after the change, when one exists |
| `occurrences` | How many occurrences this change accounts for |
| `occurrences_before` | Occurrences of this identity before the change (for a successor, of the predecessor) |
| `occurrences_after` | Occurrences of this identity after the change |
| `reason` | For `KIND_UNKNOWN`, why the relevant scan cannot vouch for the finding |

## Kinds

| Kind | `before` | `after` | Meaning |
| --- | --- | --- | --- |
| `KIND_INTRODUCED` | absent | set | The change creates the finding; the before-scan's suite completed |
| `KIND_RESOLVED` | set | absent | The change clears the finding; the after-scan's suite completed |
| `KIND_SEVERITY_CHANGED` | set | set | Same finding, different severity |
| `KIND_SUBJECT_REMOVED` | set | absent | The finding's subject or origin entity no longer exists after the change; reported even when the after-scan's suite did not complete |
| `KIND_CARRIED_TO_SUCCESSOR` | set | set | The finding moved to a newer version of its subject or origin, with no version going down |
| `KIND_UNKNOWN` | either | either | A finding appeared or disappeared, but the scan on the other side did not complete its suite |

For `KIND_UNKNOWN`, `before` alone means a resolution is unproven, and
`after` alone means novelty is unproven.

## Finding identity

Two occurrences are the same finding when they agree on suite, `origin`
(kind, namespace, slug and version), rule `code`, subject kind, namespace,
slug and version, `subject_field`, and
every other structured field of `ValidationIssue`. Message prose, severity,
`rule_title`, `rule_category` and `rule_default_severity` are excluded.

## Coverage check ids

The coverage of each scan lists these checks:

| Check | Compared | Notes |
| --- | --- | --- |
| `store_hygiene` | yes | `PARTIAL` with `snapshot_truncated` when the snapshot hit the cap |
| `architecture` | yes | As above |
| `model` | yes | As above |
| `strategic` | yes | As above |
| `scenarios` | yes | As above |
| `specification_types` | no | `SKIPPED` with `delta_not_supported` when the world holds specifications, otherwise `kind_not_applicable` |
| `data_product_events` | no | `SKIPPED` with `delta_not_supported` when the world holds data products, otherwise `kind_not_applicable` |
| `data_flow` | no | Always `SKIPPED` with `analysis_not_compared` |
| `information_completeness` | no | Always `SKIPPED` with `analysis_not_compared` |

A compared suite missing from a scan's coverage is treated as not
completed, with reason `suite_not_reported`.

## Metrics

| Metric | Instrument | Unit |
| --- | --- | --- |
| `trogon_atlas.consequence_delta.duration` | histogram | s |
Loading
Loading