Skip to content

Add scope hierarchy resolution - #91

Merged
GabeBolton merged 1 commit into
mainfrom
feat/79-scope-resolution
Aug 11, 2026
Merged

GabeBolton merged 1 commit into
mainfrom
feat/79-scope-resolution

Conversation

@GabeBolton

Copy link
Copy Markdown
Contributor

Advances #79 (does not close it — this is the resolution library; the storage layer and UI are still to come).

src/pantograph/scopes.py — a pure library, no database, Dash or config imports. Given a scope tree and the records attached at various levels, compute the effective records for any scope. Scope is Product → Deployment → Program → … with no fixed depth, so a fourth level needs no schema change.

Purely additive: no existing file is touched, so nothing on main can regress. 183 → 217 tests.

Design calls a reviewer should check

Only removed is stored; added and overridden are derived. This contradicts the issue's wording, and the agent is right that the issue was wrong. removed must be written down because absence already means "inherited". But added and overridden are the same stored thing — a record declared here — differing only in whether an ancestor also declares that key. Store the label and it goes stale: the moment a product acquires a field a deployment had added, a stored added becomes a lie.

Re-declaring a key an ancestor removed currently reads as added. Arguable, and worth a ruling: "Maharashtra added face_scan" versus "Maharashtra overrode India's removal of face_scan". The second is more informative to a regulator and might justify a fourth label, restored. Kept at three labels until someone needs a fourth.

Multiple inheritance is unsupported and should stay so. Precedence between two ancestor chains has no natural answer and every scheme is a rule someone must memorise.

A missing grain dimension is a key value, not a wildcard. Wildcards make precedence depend on both specificity and depth, and those orderings can disagree.

A removal is never an effective record. resolve returns what a scope actually processes; tombstones surface only via divergences. Returning them in the row list invites a UI that prints "processes face_scan (removed)", which is the credibility failure the model warns about.

Verification

Failure capability checked by mutation, not assumed: twelve deliberate breakages, each confirmed to fail the suite.

Not verified: no integration with storage, config or UI, since none exists yet. The record key names are the agent's invention and may need renaming when the scope tables land.

🤖 Generated with Claude Code

Closes #79.

Scope is a tree - Product then Deployment then Program, and a fourth level is
plausible - so nothing in scopes.py counts or names levels. Depth is reported
and never branched on, which is what makes adding a level a row in the scope
table rather than a schema change. Several roots are allowed: a synthetic
"everything" node above them would invite a record attached to it, inherited by
products that share nothing.

Only divergences are stored, which is what makes the grain affordable - a
product with ~200 fields across a dozen deployments implies five-figure logical
rows and holds hundreds. Of the three divergence kinds only removed is stored,
as a tombstone, because an absence cannot mean "this deployment does not collect
this" when absence is already what inheritance means. added and overridden are
the same stored thing and differ only in what sits above, so divergences()
derives the label from the tree rather than trusting one the author typed - and
it moves on its own when an ancestor acquires the same key, which is correct.

A removal is never itself an effective record, so the register cannot print
"processes face_scan (removed)" as a row; it appears in the divergence view
instead. A scope may re-declare what an ancestor removed, and that reads as
added, since the parent having removed it does not have it.

Inherited records carry the provenance contract shared with #82, with from set
to the scope that declared the record rather than the immediate parent - that is
the scope you would edit to change what this one shows. Confidence and
confirmation carry across untouched; the resolver mutates nothing it is given.

A missing grain dimension is a key value, not a wildcard. Wildcards would make
override order depend on specificity as well as depth, and two orderings that
can disagree is one too many.

34 new tests. Each guard was verified by breaking it: twelve mutations to
scopes.py - unreversed ancestry, ignored tombstones, everything marked
inherited, no cycle check, unstable ordering, a grain missing two dimensions,
resolve_all not seeded from the parent - and every one was caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@GabeBolton
GabeBolton merged commit e169e5c into main Aug 11, 2026
2 checks passed
@GabeBolton
GabeBolton deleted the feat/79-scope-resolution branch August 11, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant