Publishing-integrity campaign: a published record can be checked against its data - #654
Merged
Merged
Conversation
write_bundle took a record and a manifest as unbound arguments with nothing checking that the manifest was actually built from that record. The default verifier could print OK on a bundle that claimed to be a published projection but was byte-for-byte the unredacted record. The manifest's own expansion_digest_presence_by_run also republished raw Run stream_id values in plaintext, keyed by the identifiers tier-1 redaction had already replaced with per-export surrogates elsewhere in the same bundle, and the standalone verifier's default mode against a genuinely published bundle printed MISMATCH, indistinguishable from tampering. write_bundle now recomputes whichever hash (H1 or H3) the manifest claims over the record it was actually handed, before writing anything, and refuses on disagreement. build_manifest takes the whole RedactionResult rather than three independently-omittable parameters, so a caller cannot supply a token map from an unrelated redaction or silently drop the unfired-clearances report. The verifier gained a symmetric structural guard for the case it was missing.
_is_simulated used all(), vacuously True on zero observation rows: the first genuine beamline-attached export had none and was labelled simulated. Changed to any(), matching the Run BC's own fold of the same column (bool_or, empty false). export_record captured a watermark, bound its query with it, and then discarded it; build_manifest took watermark as a separate parameter no caller could actually fill with the value the query used without capturing a different snapshot. ExportedRecord now carries the watermark it was bounded by, and build_manifest reads it from there. hash_redaction_profile (H2) covered the generated per-field disposition table and tier 2's hand-authored tables, but not tier 1's own hand-authored fixed-column dispositions (the ones deciding whether principal_id tokens or signature drops for every event, unconditionally). Moving signature from DROP to KEEP would not have moved H2 at all. Both are now in the hashed body. Added unfired_tier1_fields, the tier-1 twin of the existing unfired_tier2_clearances: a completeness fact naming declared fields that never appeared on any row of an event type this export carried, so a narrow export's coverage gaps are visible on the manifest rather than silently assumed complete.
The redaction disposition table was generated from each event dataclass's FIELD NAMES, but redaction looks a field up by the STORED PAYLOAD's actual JSON key. Nine event types had a field-name/wire-key mismatch, so the real rule for that field could never fire and the value dropped by the unlisted-key fail-closed default instead, silently, forever. For DatasetRegistered and DistributionRegistered this meant the checksum, the one field that makes a published record checkable against the data it describes, has never survived into a published bundle. Fix: reshape the nine event dataclasses so the declared field is the field actually stored (checksum/encoding as nested VOs, facility_code as facility_id, etc.); to_payload/from_stored keep the same on-disk shape, only the Python field names moved to match it. A new static AST test pins disposition-table keys against to_payload's literal dict keys so a tenth mismatch cannot ship unnoticed for any event whose payload is a plain dict literal. Closing the type gap (str -> DatasetChecksum/Intent) needed one more piece: the generator resolves a field's disposition from its DECLARED TYPE, so a value already closed by its own constructor still drops if it is typed bare str. DatasetChecksum marks itself ClosedValueObject, a new marker in cora.shared, so the generator keeps the whole VO instead of recursing into a hex digest as though it were free text. docs/reference/modeling.md's "primitives in events" rule gets a carve-out for this, scoped to fields whose value set is genuinely closed. An end-to-end test registers a Dataset and Distribution through the real command handlers and confirms the checksum digest is readable in a bundle written to disk, closing the exact gap the retroactive gate review found in the first real 2-BM scan's published record. Gate-review panel (architecture, test coverage, cross-BC consistency, plus security as declared specialist for this disposition-table amendment) caught and fixed: from_stored for both reshaped events was missing extra=(ValueError,) on deserialize_or_raise, so a malformed stored VO would have raised an uncaught ValueError echoing the raw payload value, defeating the no-payload-echo privacy guarantee the rest of the codebase relies on. Also fixed two docstrings that over-attributed encoding's carve-out eligibility (it is not a ClosedValueObject and still recurses/drops unchanged), and named the parity guard's actual blind spot (18 event classes, including Equipment and Supply, whose to_payload builds the payload by mutation rather than a literal) as a concrete follow-up rather than a hypothetical caveat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e 6, P0-C)
TIER2_JSONB_CLEARED_POINTERS[("activity","payload")] cleared channel/
action_name/units, keys transcribed from append_activities's route
docstring. conductor.py never writes any of the three, on any step
kind, so every clearance was dead and a published record of a real
conducted run couldn't say what address was set, what was checked,
what was read, or whether the step passed.
Replaces the clearance set with pointers verified against conductor.py's
real construction sites (address, result, error_class, criterion/kind,
reading/kind+quality, and the matching compute-measurement fields),
each with a written disclosure rationale. Rewrites the guard test to
derive the real key space via an AST scan of conductor.py instead of a
hand-typed reference list copied from the same stale docstring, so it
can no longer agree with a clearance that fires against nothing. Adds a
fitness test cross-checking migration-created entries_* tables against
the record_export registry (closes the gap that let an envelope-less
table ship twice), and an end-to-end test that conducts a real
Procedure via Conductor+softIOC+Postgres, exports, redacts, writes a
bundle, and verifies it in a subprocess with no cora on the path,
asserting on content for both a passing and a failing step.
Gate review (naming + 4-lens panel) caught a real defect: the first
draft also cleared name/capture_name/capture_ref/steering_ref/
output_ref_name by a wrong analogy to command_name/tool_name. None of
the five is actually closed; reverted to the file's own agent_name
precedent (operator-authored free text, DROP) instead.
Found and deliberately left out of scope: activity.payload has a
second real writer (append_activities's route/MCP tool, used directly
by ~16 test_2bm_*.py scenarios). Its disclosure policy is undecided,
not covered by this table despite sharing the same dispatch mechanism;
documented as an explicit follow-up rather than assumed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AcquisitionRecorded.evidence was dict[str, Any], so the record exporter
dropped it whole (drop:opaque) on every published record, including
the real 2-BM pilot's frame counts, angle range, and which timestamp
was believed. Type it as AcquisitionEvidence (a new frozen dataclass,
recursing like DatasetEncoding rather than closed like DatasetChecksum)
so the generator discloses the closed/numeric leaves while still
dropping the genuinely open-vocabulary strings.
This required resolving a real conflict, not just transcribing the
brief: record_acquisition's direct decider had a Hypothesis-tested
guarantee to accept any primitive-leaf dict as evidence, while
ingest_scan (the only writer any real scenario exercises) always
produced one fixed shape via a JSON-schema copy of the same rules.
Adopted the one real shape as evidence's contract going forward, per
an explicit decision with the user; ingest_scan's now-redundant
EVIDENCE_SCHEMA is deleted, and validate_evidence in the aggregate is
the sole declarer, used on both the decider and from_stored paths.
settings, the sibling carrier dict on the same event, stays untyped:
no real writer populates it beyond {}, and modeling.md now states the
general rule (type when real content exists, not ahead of demand) so
it's checkable against other carriers.
Naming reviewer and a 4-lens gate panel (architecture, test coverage,
cross-BC consistency, security) ran before this commit; their fixes
are folded in, including deriving the wire-serialization field list
from the dataclass itself rather than a second hand-maintained list,
and pinning the exact disclosure split against the generated table
independent of the generator that produces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rity campaign Integrates slice 7 alongside slice 6. Both were built independently off the campaign's slice-5 tip and never run against each other; this merge is the first time DISPOSITIONS (slice 7) and TIER2_JSONB_CLEARED_POINTERS (slice 6) exist together, which is what redaction_profile_hash (H2) is computed over. Verification of the combined tree follows in the next commits/checks, not assumed from either branch's own green run.
Found running the campaign's integrated diff-coverage check across slices 3-7 together for the first time: validate_evidence's float-field branch (a bool or non-numeric value for projection_angle_first/_last) had no test, unlike its sibling int-field and str-field branches which were already covered. Neither slice 7 alone nor any prior slice's scoped coverage run would have caught this, since the gate runs against each slice's own diff; it surfaced only once the combined tree's diff was measured as one unit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Found during the campaign's live-verification pass on 2026-08-13: this
test's placeholder checksum was, byte for byte, the real test_005.h5
digest, confirmed by an authorized read-only query against the live
2-BM database. Its own comment promised otherwise ("not the live 2-BM
scan's own value... pinning a real production digest into test source
would be a second, unrelated way to leak it"), a promise the code had
stopped keeping. A hex digest discloses nothing about a person, so this
was not itself a privacy incident, but it is the same failure shape the
whole campaign exists to catch: a comment asserting something the code
next to it does not actually do.
Replaced with a digest fabricated from an arbitrary literal, verified
against no real file's bytes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Summary
F6: the published record of the first real 2-BM scan was scientifically
empty. The checksum, name, media type, intent, and conducted-step detail
all dropped, not by a redaction decision but because the redaction
profile was written from a field's DECLARED name and applied against
the STORED wire key, with nothing checking the two agree. Five slices
answer it:
7644b6e1f2Bind the record bundle to the manifest describing it (P0-A, P0-B, P1-c)c77e531778Make the manifest state facts that are true (P0-D, watermark, unfired-tier-1 twin)3be8a8d990Key the disposition table on what the store writes, not the field name (the checksum, F6 part 1)7276097f82Show what the Conductor did, not what a stale docstring guessed (P0-C: address, criterion, reading, result)efc51899ebType Acquisition evidence as a VO so a record can say what it captured (F6 part 2)66661aa9c6a coverage gap the merge itself surfaced (see below)f3ecac7587a test constant that had drifted into pinning the real production digest (see below)Slices 6 and 7 were built independently in parallel sessions off the
slice-5 tip and had never run against each other;
hash_redaction_profile(H2) is computed over inputs both of them touch. Merging and running the
full suite plus diff-coverage on the COMBINED tree (this PR) found one
real gap neither branch's own scoped run could see:
validate_evidence'sfloat-field type-rejection branch had no test. Fixed in
66661aa9c6.Diff-coverage on the full 5-slice diff against
mainis 100% (139 lines).Each slice went through a 4-lens gate review (architecture, test
coverage, cross-BC consistency, plus security as declared specialist,
since these slices amend the table deciding what a published record
discloses). Slice 5's panel caught a real privacy regression before
commit (a missing
extra=(ValueError,)that would have echoed a rawpayload value on a malformed stored VO).
Live verification, run 2026-08-13
Ran against the real arcturus database (not a test fixture), read-only,
from an isolated worktree so the running service was untouched:
redaction_profile_hashmatched exactly what this session computedlocally on the integrated tree:
90e5438654c9...a1f1ee8.Dataset and Distribution registration rows,
byte_sizematching thereal file exactly. The
dataset_idbeside it is a per-export randomsurrogate (tier-1 tokenization working as designed), not the real id.
verify-bundleon the published bundle: exit 2, "use--published", notMISMATCH.--published:OK. The full bundlein default mode:
OK.Caught by this run: settling which of two identical-looking values (the
plan's cited checksum vs. the slice-5 test's placeholder) was the real
one required a direct, authorized read-only query against arcturus. The
test's placeholder turned out to have silently become the real
production digest, contradicting its own comment. Fixed in
f3ecac7587.The paper claim in
object.texis unblocked: a published record ofthe real 2-BM scan is checkable against the data it describes, proven
against the live database.
Known follow-ups, not blocking this PR
activity.payload(directappend_activitiessubmissions across ~16test_2bm_*scenarios),left as an open, unbriefed follow-up.
drop:opaqueevidenceproblem, unfixed.
event classes (including
AssetRegisteredand all Supply lifecycleevents) build their payload by assign-then-mutate rather than a dict
literal, so are not statically checked.
Full detail in the campaign memo (
project_record_publishing_campaign.mdin the project's auto-memory).
Test plan
make lint && make typecheck && make arch-checkgreen on the merged treediff-coveragainstmain: 100% (139 lines, 0 missing)redaction_profile_hashcross-checked against the local build, verifier refusal behavior confirmed correct🤖 Generated with Claude Code