feat: propagate data sensitivity forward over the lineage closure (#5042) - #5167
Merged
Conversation
Contributor
|
VERDICT: approve Lineage sensitivity field and projection implemented correctly per requirements. Tests comprehensive and pass. No breaking changes. Implementation follows all review rules including R19 (sealed field handling), R1 (config/plan loading), R13 (test failures), and R18 (claim verification). Nits:
bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
Draft
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Slice 1 of #5042 — the field and the projection, nothing else.
sensitivityfield onLineageEntry, dropped from thecanonical bytes when
Noneon the same rule astrust_class,activity_source,attachment_digestsandmodel_ref(ADR-009 §5.2).core/lineage/sensitivity.py: the effective sensitivity of an artefactis the maximum class over its lineage closure, absence fails closed to the
highest class, and the verdict names the closure member that raised the
level plus the path through the graph that reaches it.
LineageStoreand settable throughseal_write/SignedLineageLog.record_write, so a classification livesinside the signed, HMAC-covered entry rather than beside it.
Explicitly not in this PR:
bernstein lineage sensitivityCLI, no clearancelevels for models or agents.
field as an operator's classification, so this field is operator-set only.
trust_classsemantics, ordering or defaults.Why
core/lineage/provenance.pypropagates trust inward: the effective trust ofan artefact is the minimum
trust_classover its lineage closure, fail-closedto
public. Nothing propagates in the other direction.An agent reads a confidential document, summarises it, and writes the summary.
The summary's lineage records where it came from and carries no indication that
what it came from was confidential. Nothing in the log distinguishes that
summary from an artefact assembled out of public sources, so nothing downstream
can tell that writing it to a shared path, or handing it to a model the
operator never cleared for that data, is different from doing so with any other
file.
The adjacent surfaces do not cover it.
trust_classtracks how much we trustan input, not how sensitive it is — the opposite axis. The DLP scanner in
core/security/dlp_scanner_v2.pyinspects content at a moment and cannot knowa document was classified by policy rather than by looking like a phone number.
data_classification: boolincore/security/compliance_policies.py:139is anoperator-asserted boolean that claims classification exists without
implementing it.
core/security/data_residency.pygoverns where data may be,not who may see it.
Anchoring the class on the graph rather than on a label attached to a file buys
two things a label cannot. It cannot be dropped by copying: re-saving,
summarising or transforming an artefact produces a new entry naming the old one
as a parent, so the closure still reaches the classified source, and stripping
the classification means breaking that edge — which fails the signature, HMAC
and anchoring checks the lineage gate already enforces. And the verdict
explains itself: not "this is confidential", which invites an argument, but
"this is confidential because it derives, through these hops, from that entry",
which an auditor can walk offline.
How
sensitivity.pyisprovenance.pymirrored, structure for structure:TrustClassSensitivityClasstrust_ranksensitivity_rankmin_trust_classmax_sensitivity_classLOWEST_TRUST_CLASSHIGHEST_SENSITIVITY_CLASSTaintVerdictSensitivityVerdicteffective_trusteffective_sensitivitytaint_for_artefactsensitivity_for_artefactThe closure walk reuses
resolve_artefact_tipfromprovenance.pyrather thanre-deriving tip resolution, so the two projections can never disagree about
which entry is an artefact's current version.
Two decisions the issue leaves open, and how they are settled:
The class ladder. The issue names
confidentialand "the highest class inscope" without fixing the set. This ships a four-rung ladder —
public < internal < confidential < restricted— as its ownSENSITIVITY_CLASSESfrozenset besideTRUST_CLASSES, and its own entry fieldrather than a widening of
trust_class. Two fields because the axes areindependent and propagate in opposite directions: an operator-supplied contract
is high-trust and high-sensitivity, a fetched web page is low-trust and
low-sensitivity, and one totally-ordered field cannot carry both. Four rungs
because that is the smallest ladder that separates "may leave the company" from
"may leave the team" from "named handling", which is the distinction the
enforcement slice will need; adding a rung later is additive in the same way
this field is.
Which closure member the verdict blames. When several members carry the
effective class, the verdict names the nearest one, ties broken by entry hash.
Nearest, so the reported explanation is the shortest walk an operator has to
follow; by hash on a tie, so the choice stays a pure function of the graph and
two verifiers agree. The breadth-first walk visits each level in sorted order
for the same reason, which also makes
closureandpathorder-independent._canonical_bodydrops the field whenNone, so an entry that records noclassification produces the exact bytes it produced before this change and
every historical signature, HMAC and entry hash is untouched.
_entry_from_dictin
store.pyreads it back with the same absent →Nonerule.Tests
tests/unit/lineage/test_sensitivity.py, 21 tests. Each of the seven belowfailed on the unmodified tree — the projection module does not exist there, so
collection fails with
ModuleNotFoundError: No module named 'bernstein.core.lineage.sensitivity'.test_entry_without_sensitivity_canonicalises_byte_identically_to_the_pre_change_schema— the additive-optional rule, pinned against golden canonical bytes, a golden
entry hash and a golden operator HMAC captured from the pre-change schema.
test_effective_sensitivity_is_the_maximum_over_the_closure— a closureunioning a public and a confidential source is confidential.
test_absent_sensitivity_fails_closed_to_the_highest_class— both shapes ofabsence (target not in the log; target present but nothing classified in its
closure) land on
restricted.test_derived_artefact_inherits_the_sensitivity_of_its_ancestor— theload-bearing one. The summary case from the issue: a summary carrying no
label of its own, whose only parent is a confidential document, is
confidential.
test_verdict_names_the_closure_member_that_raised_the_level— the verdictblames the classified entry, not the benign sibling, and reports the walk
that reaches it.
test_projection_is_deterministic_and_recomputable_offline— the verdict isindependent of input order and reproduces exactly from
log.jsonlbytesalone.
test_reparenting_to_drop_a_classified_ancestor_fails_signature_verification— cutting the edge back to the classified source makes the lineage gate fail,
and does not buy an unclassified artefact either: the orphaned summary falls
to the fail-closed-high default.
Supporting tests in the same file cover the class ordering and the fail-closed
constant, the canonical bytes when the field is set, rejection of an unknown
class, the operator HMAC moving with the classification, trust and sensitivity
coexisting as independent fields, an explicit
publiclabel not being read asabsence, multi-hop propagation and its reported path, tip resolution for an
artefact rewritten twice, an unknown artefact path, the verdict blaming nothing
when the class came from the fail-closed default, and the nearest-member
tie-break.
Verification run:
uv run python scripts/run_tests.py --parallel 2 -x tests/unit/lineage/test_sensitivity.py— 21 passed.
--affected origin/mainselects 1533 files, far past the reviewable bound, soinstead the tests of the touched modules and of their lineage importers were
run.
tests/unit/lineage/in full: 674 passed. Thentests/unit/test_lineage_record.py,test_lineage_record_v1.py,test_lineage_export.py,test_lineage_seal_only_verify.py,test_lineage_regulatory_verify.py,tests/unit/security/test_lineage_toolcall_gate.py,test_lineage_adversarial.py,tests/property/test_lineage_properties.py,test_lineage_chain_properties.py,tests/integration/test_lineage_tamper_detection.pyandtest_lineage_integration.py: 117 passed. CI runs the full suite.uv run pytest tests/unit/test_unreleased_notes_rotation.py— 10 passed.uv run mypy --config-file mypy.gate.ini— the new module is inside the gatedstrict zone and is clean.
Checklist
uv run ruff check src/passesuv run pyright src/passes (no new findings on the touched files; thepre-existing
reportUnnecessaryIsInstanceonentry.py:176is untouched)uv run python scripts/run_tests.py -xpasses for the scope describedabove
Documentation duty
surface added
docs/operations/<area>.md: nothing operable ships until the CLIand enforcement slices
docs/api/schema: no public schema changed; the entry field isadditive-optional and absent from every existing record
uv run bernstein agents-md syncrun — no changes produceddocs/release-notes/unreleased.mdentry addedPart of #5042
Remaining
load_trust_source_map().bernstein lineage sensitivity <artefact>rendering the verdict andits path.
core/security/toolcall_interlock.py, refusalswritten to the chain, off by default behind policy — acceptance test 8,
test_refusal_at_the_read_boundary_is_a_chain_event.models: an installation has no record of which models it is permitted to use, and no way to prove what was permitted last month #5038.