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
73 changes: 73 additions & 0 deletions docs/adr/0006-discovery-and-validation-subsystems.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: "ADR 0006 — Discovery and validation are two subsystems"
description: Recognize the event-log (discovery) and the rich governance model (validation) as complementary subsystems; bridge them additively rather than delete or rewrite either.
---

# ADR 0006 — Discovery and validation are two subsystems; bridge, don't delete

**Status:** Accepted

## Context

The codebase contains what looks, from the export list, like competing paradigms:

- **The event-log (v0.3+):** `ModelRef` (a thin identity) + immutable `Snapshot`s + the
`DataNode` graph. This is the **discovery / inventory / agent** subsystem — *what models
exist, how they connect, what changed*.
- **The rich governance model (v0.2):** `Model`/`ModelVersion` carrying `intended_purpose`,
`risk_rating`, `affected_populations`, stakeholders, findings, deployments; the `validate`
engine; and the SR 11‑7 / EU AI Act / NIST **compliance profiles** that check those fields.
This is the **validation** subsystem — *can a model pass an examiner's bar?*

A surface-level read ("three paradigms in `__all__`, clean it up") suggests deleting the
v0.2 API. Mapping the dependencies shows that would be a serious mistake: the v0.2 model is
the substance behind the [Governance](../governance.md) page and the regulatory wedge — a
rich, field-level compliance model plus three non-trivial profiles, the audit-pack export,
and a meaningful share of the test suite. The thin event-log `ModelRef` carries none of those
fields, so it cannot be validated by the profiles as-is.

The real issue is not clutter. **The two subsystems are disconnected:** a *discovered* model
(`ModelRef`) has no path to *validation*. You can inventory a model or validate one, but not
both in one flow.

## Decision

1. **Retain the v0.2 governance/validation subsystem.** It is not legacy dead weight; it is
the validation half of the product. Do not delete it or rewrite its profiles destructively.
2. **Bridge the two subsystems *additively*.** Make a discovered model validatable by
projecting its event-log evidence (metadata + snapshots) into the governance concepts the
profiles check — without breaking the existing rich-`Model` path. The event-log is the
spine; validation becomes a capability over it.
3. **Document both layers honestly** — discovery and validation are two intentional,
complementary subsystems, not old-vs-new. The fix for "surface clutter" is *clarity*, not
deletion.

The bridge is a deliberate, staged effort (it touches the profile inputs and deserves its own
focused, well-tested change), not a rushed rewrite.

## Consequences

**Positive**

- The compliance capability — the product's differentiator — is preserved intact.
- Once bridged, discovery feeds validation: you can ask "is this *discovered* model SR 26‑2
ready?" in one system.
- The public surface is explained as two coherent layers rather than apologized for as clutter.

**Negative (accepted)**

- Two models of a "model" coexist (thin `ModelRef`, rich `Model`) until the bridge matures;
the mapping between them is an explicit layer to maintain.
- "Concept-based" validation over event-log evidence is looser than rich typed checks, so the
bridge must be designed to preserve validation meaningfulness, not just presence-of-a-field.

## Alternatives considered

- **Delete v0.2 and clean `__all__` (rejected):** destroys the validation subsystem and the
regulatory wedge; a shallow read of a deep system.
- **Immediately rewrite the profiles to be event-log-native (deferred):** correct direction,
but rushing it risks downgrading sophisticated validation to thin evidence-checks. Staged.
- **Leave them disconnected, document the boundary (interim):** honest but leaves the two
halves of governance unable to talk; acceptable only as a way station to the bridge.

See [Architecture](../concepts/architecture.md) and [Governance](../governance.md).
1 change: 1 addition & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ a new ADR that supersedes the old one rather than an edit.
| [0003](0003-agents-first.md) | Agents are the primary interface; the SDK is tool-shaped | Accepted |
| [0004](0004-framework-agnostic.md) | Framework-agnostic core; regulations are pluggable profiles | Accepted |
| [0005](0005-storage-agnostic.md) | Storage-agnostic via the LedgerBackend protocol | Accepted |
| [0006](0006-discovery-and-validation-subsystems.md) | Discovery and validation are two subsystems; bridge, don't delete | Accepted |

The narrative that ties these together is the [Architecture](../concepts/architecture.md) page.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,4 @@ nav:
- "ADR 0003 — Agents are the primary interface": adr/0003-agents-first.md
- "ADR 0004 — Framework-agnostic, pluggable profiles": adr/0004-framework-agnostic.md
- "ADR 0005 — Storage-agnostic backends": adr/0005-storage-agnostic.md
- "ADR 0006 — Discovery and validation are two subsystems": adr/0006-discovery-and-validation-subsystems.md
36 changes: 27 additions & 9 deletions src/model_ledger/sdk/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@

import builtins
from datetime import datetime, timezone
from typing import Any
from typing import TYPE_CHECKING, Any, TypedDict

from model_ledger.backends.ledger_memory import InMemoryLedgerBackend
from model_ledger.backends.ledger_protocol import LedgerBackend
from model_ledger.core.exceptions import ModelNotFoundError
from model_ledger.core.ledger_models import ModelRef, Snapshot, Tag

if TYPE_CHECKING:
from model_ledger.graph.models import DataNode


class AddResult(TypedDict):
"""Result of ``Ledger.add()`` — nodes newly recorded vs. skipped as unchanged."""

added: int
skipped: int


class ConnectResult(TypedDict):
"""Result of ``Ledger.connect()`` — dependency edges created vs. skipped as present."""

links_created: int
links_skipped: int


# Events that are internal ledger bookkeeping or governance actions on the
# composite itself. These are NOT propagated as member_changed to parent
# composites — only real domain events on member models should surface there.
Expand Down Expand Up @@ -324,7 +342,7 @@ def dependencies(

# --- Graph methods (v0.4.0) ---

def add(self, nodes):
def add(self, nodes: DataNode | builtins.list[DataNode]) -> AddResult:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Import DataNode for runtime type introspection

When callers or tool-schema/framework code introspect Ledger.add with typing.get_type_hints, this annotation is evaluated against model_ledger.sdk.ledger globals, but DataNode is only imported under TYPE_CHECKING and the local import inside add() has not run. In that context the new annotation raises NameError: name 'DataNode' is not defined, which can break the agent/tool use cases this SDK supports; keep DataNode available at module scope or supply it to the introspector.

Useful? React with 👍 / 👎.

"""Register DataNodes. Each becomes a ModelRef + discovered Snapshot.

Skips writing if the discovered payload is identical to the last snapshot
Expand Down Expand Up @@ -427,7 +445,7 @@ def add(self, nodes):

return {"added": added, "skipped": skipped}

def connect(self):
def connect(self) -> ConnectResult:
"""Match output ports to input ports. Write only new dependency links.

Uses cached nodes from add() if available (avoids re-reading from backend).
Expand Down Expand Up @@ -488,7 +506,7 @@ def connect(self):
continue
return {"links_created": links_created, "links_skipped": links_skipped}

def trace(self, name):
def trace(self, name: str) -> builtins.list[str]:
"""Topological path from sources to this node."""
self._resolve_model(name)
visited = set()
Expand All @@ -505,12 +523,12 @@ def _walk(n):
_walk(name)
return order

def upstream(self, name):
def upstream(self, name: str) -> builtins.list[str]:
"""All models this one depends on (transitive)."""
path = self.trace(name)
return [n for n in path if n != name]

def downstream(self, name):
def downstream(self, name: str) -> builtins.list[str]:
"""All models that depend on this one (transitive)."""
self._resolve_model(name)
visited = set()
Expand Down Expand Up @@ -877,7 +895,7 @@ def composite_summary(
)
return result

def _load_discovered_nodes(self):
def _load_discovered_nodes(self) -> builtins.list[DataNode]:
"""Rebuild DataNodes from stored discovery snapshots.

Uses bulk loading if the backend supports it (1 query instead of N).
Expand All @@ -895,9 +913,9 @@ def _load_discovered_nodes(self):
else:
# Fallback: per-model queries
all_snaps = []
for model in models:
for m in models:
all_snaps.extend(
self._backend.list_snapshots(model.model_hash, event_type="discovered")
self._backend.list_snapshots(m.model_hash, event_type="discovered")
)

# Group by model and take latest
Expand Down
Loading