Skip to content

feat: propagate data sensitivity forward over the lineage closure (#5042) - #5167

Merged
chernistry merged 4 commits into
mainfrom
run-20260902T0220Z-issue5042
Sep 2, 2026
Merged

feat: propagate data sensitivity forward over the lineage closure (#5042)#5167
chernistry merged 4 commits into
mainfrom
run-20260902T0220Z-issue5042

Conversation

@chernistry

Copy link
Copy Markdown
Collaborator

What

Slice 1 of #5042 — the field and the projection, nothing else.

  • An additive optional sensitivity field on LineageEntry, dropped from the
    canonical bytes when None on the same rule as trust_class,
    activity_source, attachment_digests and model_ref (ADR-009 §5.2).
  • A new core/lineage/sensitivity.py: the effective sensitivity of an artefact
    is 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.
  • The field is round-tripped by LineageStore and settable through
    seal_write / SignedLineageLog.record_write, so a classification lives
    inside the signed, HMAC-covered entry rather than beside it.

Explicitly not in this PR:

  • No enforcement anywhere. Nothing refuses anything on the basis of a verdict.
  • No source-labelling map, no bernstein lineage sensitivity CLI, no clearance
    levels for models or agents.
  • No content-based inference. A scanner's guess must never land in the same
    field as an operator's classification, so this field is operator-set only.
  • No change to trust_class semantics, ordering or defaults.

Why

core/lineage/provenance.py propagates trust inward: the effective trust of
an artefact is the minimum trust_class over its lineage closure, fail-closed
to 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_class tracks how much we trust
an input, not how sensitive it is — the opposite axis. The DLP scanner in
core/security/dlp_scanner_v2.py inspects content at a moment and cannot know
a document was classified by policy rather than by looking like a phone number.
data_classification: bool in core/security/compliance_policies.py:139 is an
operator-asserted boolean that claims classification exists without
implementing it. core/security/data_residency.py governs 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.py is provenance.py mirrored, structure for structure:

provenance.py sensitivity.py
TrustClass SensitivityClass
trust_rank sensitivity_rank
min_trust_class max_sensitivity_class
LOWEST_TRUST_CLASS HIGHEST_SENSITIVITY_CLASS
TaintVerdict SensitivityVerdict
effective_trust effective_sensitivity
taint_for_artefact sensitivity_for_artefact

The closure walk reuses resolve_artefact_tip from provenance.py rather than
re-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 confidential and "the highest class in
scope" without fixing the set. This ships a four-rung ladder —
public < internal < confidential < restricted — as its own
SENSITIVITY_CLASSES frozenset beside TRUST_CLASSES, and its own entry field
rather than a widening of trust_class. Two fields because the axes are
independent 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 closure and path order-independent.

_canonical_body drops the field when None, so an entry that records no
classification produces the exact bytes it produced before this change and
every historical signature, HMAC and entry hash is untouched. _entry_from_dict
in store.py reads it back with the same absent → None rule.

Tests

tests/unit/lineage/test_sensitivity.py, 21 tests. Each of the seven below
failed on the unmodified tree — the projection module does not exist there, so
collection fails with ModuleNotFoundError: No module named 'bernstein.core.lineage.sensitivity'.

  1. 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.
  2. test_effective_sensitivity_is_the_maximum_over_the_closure — a closure
    unioning a public and a confidential source is confidential.
  3. test_absent_sensitivity_fails_closed_to_the_highest_class — both shapes of
    absence (target not in the log; target present but nothing classified in its
    closure) land on restricted.
  4. test_derived_artefact_inherits_the_sensitivity_of_its_ancestorthe
    load-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.
  5. test_verdict_names_the_closure_member_that_raised_the_level — the verdict
    blames the classified entry, not the benign sibling, and reports the walk
    that reaches it.
  6. test_projection_is_deterministic_and_recomputable_offline — the verdict is
    independent of input order and reproduces exactly from log.jsonl bytes
    alone.
  7. 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 public label not being read as
absence, 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/main selects 1533 files, far past the reviewable bound, so
    instead the tests of the touched modules and of their lineage importers were
    run. tests/unit/lineage/ in full: 674 passed. Then
    tests/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.py and
    test_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 gated
    strict zone and is clean.

Checklist

  • uv run ruff check src/ passes
  • uv run pyright src/ passes (no new findings on the touched files; the
    pre-existing reportUnnecessaryIsInstance on entry.py:176 is untouched)
  • uv run python scripts/run_tests.py -x passes for the scope described
    above
  • New code has type hints

Documentation duty

  • N/A — user-visible README section: internal substrate, no user-facing
    surface added
  • N/A — docs/operations/<area>.md: nothing operable ships until the CLI
    and enforcement slices
  • N/A — docs/api/ schema: no public schema changed; the entry field is
    additive-optional and absent from every existing record
  • uv run bernstein agents-md sync run — no changes produced
  • Tests cover the documented behaviour
  • docs/release-notes/unreleased.md entry added

Part of #5042

Remaining

@bernstein-orchestrator

Copy link
Copy Markdown
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:

  • Minor cleanup in verify_cli/bernstein_verify_receipt/verify.py removes verbose error details (non-blocking)
  • Documentation in unreleased.md correctly explains lineage sensitivity projection

bernstein v3.19.0 - unattended review run run-20260902T044857p1423567Z - no operator in the loop

Signed review receipt - verify with bernstein review-receipt verify

field value
diff_hash sha256:f430b632cc08cbf7415bcb28ea397f60865f4ea184ec2dd0fcfcc0a565f4f3d4
journal_entry_hash sha256:07e1722b0f1a8f3ba53177281a65039477dee11fa8f14ffd0a9ff2cc6404fa89

@bernstein-orchestrator bernstein-orchestrator Bot added the fleet-approved Reviewed, fixed and verified by the unattended contour label Sep 2, 2026
@bernstein-orchestrator
bernstein-orchestrator Bot marked this pull request as ready for review September 2, 2026 04:52
@chernistry
chernistry added this pull request to the merge queue Sep 2, 2026
@github-actions
github-actions Bot requested a review from Chirag6722 September 2, 2026 04:53
@chernistry chernistry added the fleet-blocked Unattended fix budget exhausted; needs an operator label Sep 2, 2026
Merged via the queue into main with commit a02ca06 Sep 2, 2026
63 of 64 checks passed
@chernistry
chernistry deleted the run-20260902T0220Z-issue5042 branch September 2, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core docs fleet-approved Reviewed, fixed and verified by the unattended contour fleet-blocked Unattended fix budget exhausted; needs an operator size/l tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant