Files
imbad0202__academic-researc…/scripts/check_passport_reset_contract.py
Edward Cheng-I Wu 5d769b52d1 feat(#111): slr_lineage emission on systematic-review handoff (audit-trail-shipped) (#112)
* docs(#111): slr_lineage emission design doc

Pipeline plumbing design for #108 R8 follow-up. Closes the upstream
signal gap so `disclosure --policy-anchor=prisma-trAIce` dispatches
automatically when the documented `deep-research systematic-review →
academic-paper full → disclosure` path runs.

Three decisions (per impl-spec §3 row #1 frozen choice):
- Transport: Schema 9 Material Passport top-level optional `slr_lineage`
- Emission: pipeline_orchestrator_agent at Stage 1→2 handoff
- G1 boundary: corpus entry schema frozen, passport schema permitted
  per v3.6.3/4/7 extension pattern

Files touched: 3 docs + 1 new conformance test. 1053-baseline frozen,
#108 referee/protocol/anchor table/v3.2 venue track all out-of-scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(#111): slr_lineage emission on systematic-review handoff

Closes the pipeline-plumbing gap from #108 (PR #110 round-8 P2 #1):
`disclosure --policy-anchor=prisma-trAIce` now dispatches automatically
when `deep-research systematic-review → academic-paper full → disclosure`
runs, without manual `mode=systematic-review` at cold-start.

Three pieces (per design 2026-05-15):
- Schema 9 Material Passport gains optional top-level `slr_lineage: bool`
  + dedicated subsection documenting semantics, producer, consumer,
  backward compat, and the G1 boundary note (passport-level vs
  corpus-entry-level distinction)
- pipeline_orchestrator_agent §4 Transition Management gains a
  "Run-level lineage emission (v3.7.4+)" step computed at every handoff
- New `scripts/slr_lineage.py` pure resolver + 12 conformance tests
  covering resolver semantics (7) + renderer integration (3) + e2e
  handoff (2)

#108 contract frozen: referee, protocol doc, anchor table, v3.2 venue
track, corpus entry schema all out-of-scope per #111 §Scope. G1 boundary
honored — Decision Doc §4.4 #11 G1 scope is corpus entry schema,
passport-schema extensions follow v3.6.3/4/7 precedent.

Backward compat: pre-v3.7.4 passports lack the field; renderer treats
absence as false (cold-start fallback identical to pre-v3.7.4 behavior).

Tests: 1053-baseline frozen, +12 new tests cover acceptance #1-#3.
All unittest (394) + pytest (401) + check_spec_consistency green.

Closes #111

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(#111): /simplify pass — 5 findings fixed

Three-agent review (reuse / quality / efficiency) findings:

1. SLR_MODES drift bait removed — slr_lineage.py now imports from
   policy_anchor_disclosure_referee instead of redefining + manual
   "keep in sync" comment. Single source of truth.
2. Design doc status updated from "pending approval" to "implemented
   (commit 7a2f789; pending PR)".
3. CHANGELOG entry stamped (unreleased) + provisional v3.7.4 disclaimer
   per feedback_version_bump_sweep_checklist. Version label confirmed
   at release tag, not pre-baked.
4. Duplicate test path collapsed — test_pre_111_passport_falls_back_to
   _cold_start first half duplicated test_non_slr_pipeline_passport_
   still_blocks_prisma_track. Renamed + trimmed to cover only the
   unique positive cold-start path.
5. Orchestrator emission step reworded — was "every handoff transition"
   (monotonic flag = redundant recomputation). Now "compute once at
   Stage 1 → Stage 2 handoff, propagate persisted value thereafter".

False positives skipped: 3 TestCase classes (justified separation),
defensive .get() (real test_stage_without_mode_skipped case),
all-stages scan (run-level contract, not Stage-1-only).

Regression: 12 #111 tests + 394 unittest baseline + check_spec_consistency
all green. Net -4 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#111): codex round-1 [P2] — monotonic OR preserves slr_lineage across resume

Codex review against #108 baseline caught a real cascade bug: the
emission step as worded ("compute from stages") would overwrite a
persisted slr_lineage=true when called inside a `resume_from_passport=
<hash>` session, because state_tracker.stages is empty (reconstructed
from ledger only). That defeats the auto-dispatch goal #111 exists to
achieve — exactly the gap users would hit when resuming an SLR pipeline
in a fresh session and then invoking disclosure mode.

Fix:
- New `slr_lineage.emit(stages, incoming_slr_lineage)` wrapper applying
  the monotonic OR: bool(incoming) or resolve_from_stages(stages). A
  true value persists across resume; a false value can still flip true
  in-session when Stage 1 carries SLR mode.
- pipeline_orchestrator_agent §"Run-level lineage emission" reworded
  to specify the OR semantics + cite the resume edge case explicitly.
- handoff_schemas.md producer description updated to match.
- Design doc §4.2 marks the OR as load-bearing per codex P2 closure.
- CHANGELOG bumped to +17 tests (5 new EmitMonotonicOrTest cases).

Tests:
- 5 new cases (RED then GREEN) covering: resume preserves true / in-
  session flips false → true / no-evidence false / None incoming /
  default arg ergonomics.
- 399 full unittest regression + spec consistency green. No #108
  contract drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#111): codex round-2 [P2]×2 — reset-boundary emission + dual-path import

Round 2 caught two real cascades that survived round 1:

R2-1 [P2] Reset-boundary emission missed:
Under ARS_PASSPORT_RESET=1 on a systematic-review run, the v3.6.3
reset protocol freezes the passport at the FULL checkpoint AND halts
before the Stage 1 → Stage 2 handoff. Handoff-only emission therefore
misses the only write opportunity; the resuming fresh session sees
state_tracker.stages={} + flag-less incoming passport → OR resolves
false → PRISMA-trAIce dispatch blocks. Fixed by reworking the orchestrator
prose to specify emit() runs BEFORE any passport write — both the
normal handoff and the reset-boundary FULL-checkpoint write. The flag
is hashed into the boundary entry per the JCS protocol.

R2-2 [P2] Module not importable as scripts.slr_lineage:
The repo has both namespace-style callers (`from scripts.X import Y`
in test_check_sprint_contract / _test_helpers) and sibling-style
callers (sys.path-prepending in test_slr_lineage_emission). The
original `from policy_anchor_disclosure_referee import SLR_MODES`
only worked in the latter style. Fixed by dual-path try/except import
preserving the single source of truth for SLR_MODES while supporting
both styles.

Files:
- pipeline_orchestrator_agent.md §"Run-level lineage emission": prose
  reworked to specify "before any passport write" + new §"Reset-
  boundary interaction" subsection citing v3.6.3 protocol.
- slr_lineage.py: dual-path import with try/except.
- design doc §4.2: both R2-1 + R2-2 closures documented.

Tests: 17 existing #111 tests still green (emit's monotonic OR
covers the resume case at the helper level — R2-1 is prose-only).
Verified `from scripts.slr_lineage import emit` works from repo root.
399 full unittest regression + spec consistency green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#111): codex round-3 [P2] — retract incorrect hash-coverage claim

Round 3 caught a factual error introduced by round-2 fix:

The round-2 prose claimed slr_lineage is "hashed into the boundary
entry per the JCS protocol." That's wrong. The v3.6.3 reset_boundary
hash covers only reset_boundary[] ledger entries (per the closed
schema at shared/contracts/passport/reset_ledger_entry.schema.json
which disallows extra fields). slr_lineage lives at passport top-level
alongside origin_skill / verification_status / version_label, NOT
inside the ledger.

Fix: rewrite the §"Reset-boundary interaction" subsection to be
honest about what protection v3.7.4 provides:
- The OR ensures the field is correctly written before passport
  freeze (correctness-at-write).
- The field is NOT hash-integrity-checked by the boundary hash
  (same trust model as other top-level Schema 9 fields).
- The need at v3.7.4 is correctness, not integrity.

Design doc §4.2 updated to mirror the honest accounting.

No test changes — the test suite already covers the correctness
side (EmitMonotonicOrTest's resume_preserves_true_when_stages_empty).
Integrity-side guarantees were never tested because they never existed.

399 full unittest regression + spec consistency green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#111): CI lint — co-locate passport_as_reset_boundary reference + skip .codex-rounds

CI's check_passport_reset_contract.py caught two real issues:

1. Design doc mentioned ARS_PASSPORT_RESET but didn't reference
   passport_as_reset_boundary.md per the v3.6.3 contract. Added
   explicit references at both mention sites (the §"Reset-boundary
   path" subsection now points readers at the protocol doc twice).

2. The lint scans local scratch dirs (.codex-rounds/ is in .gitignore
   alongside .context/) and false-flags codex review logs that quote
   the token from past sessions. .context was already in SKIP_DIRS;
   .codex-rounds is the same pattern. Added.

scripts/check_passport_reset_contract.py is a v3.6.3 lint, not in
#108 frozen scope — the .codex-rounds skip is a real lint bug fix.

Verified: 399 unittest baseline + 12 check_passport_reset_contract
mutation tests + spec consistency lint all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:18:18 +08:00

215 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Enforce the ARS v3.6.3 passport-reset-boundary co-location contract.
Contract: any text file that mentions the env-flag token `ARS_PASSPORT_RESET`
MUST also contain a reference to the protocol-doc stem `passport_as_reset_boundary`
so a reader encountering the flag can trace it back to the authoritative spec at
`academic-pipeline/references/passport_as_reset_boundary.md`.
Also enforces: `pending_decision.options[]` must have unique `value` fields within
each options array in YAML fenced blocks found in markdown files under the repo root.
Exemptions:
- The protocol doc itself — identified by filename + parent-dir match
(`references/passport_as_reset_boundary.md`). More robust than path-suffix
match against `--root`: works whether the lint is invoked from repo root
or from a sub-tree.
- Binary / non-UTF-8 files — skipped silently to avoid false positives from
embedded bytes that happen to spell the flag token.
Exit code: 0 clean, 1 on any violation (list of offending files printed to
stderr). CLI: `--root <path>` (default `.`).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
FLAG_TOKEN = "ARS_PASSPORT_RESET"
PROTOCOL_TOKEN = "passport_as_reset_boundary"
PROTOCOL_FILENAME = "passport_as_reset_boundary.md"
PROTOCOL_PARENT_DIRNAME = "references"
# Directories we never scan: VCS, caches, build output, local tooling scratch.
# `.context` and `.codex-rounds` are hand-off / codex-review log scratch
# (gitignored per repo .gitignore); files there routinely quote the
# ARS_PASSPORT_RESET token from prior work without owing the protocol-doc
# co-location.
SKIP_DIRS = {
".git",
"node_modules",
"__pycache__",
".venv",
"dist",
"build",
".gstack",
".context",
".codex-rounds",
}
def _is_under_skip_dir(path: Path, root: Path) -> bool:
"""True if any path component between root and file is a skip-listed dir."""
try:
rel = path.relative_to(root)
except ValueError:
return False
return any(part in SKIP_DIRS for part in rel.parts)
def _read_text_or_none(path: Path) -> str | None:
"""Return file contents as UTF-8 text, or None if unreadable / non-text."""
try:
return path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return None
def _is_protocol_doc(path: Path) -> bool:
"""True if `path` IS the authoritative protocol reference doc."""
return path.name == PROTOCOL_FILENAME and path.parent.name == PROTOCOL_PARENT_DIRNAME
def scan(root: Path) -> list[str]:
"""Walk `root` and return list of violation messages (empty if clean)."""
violations: list[str] = []
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
if _is_under_skip_dir(path, root):
continue
content = _read_text_or_none(path)
if content is None or FLAG_TOKEN not in content:
continue
if _is_protocol_doc(path):
# Protocol doc is exempt — it IS the reference.
continue
if PROTOCOL_TOKEN not in content:
rel = path.relative_to(root).as_posix()
violations.append(
f"{rel}: mentions {FLAG_TOKEN} but does not reference "
f"{PROTOCOL_TOKEN} (co-location required by ARS v3.6.3 contract)"
)
return violations
# Regex to extract fenced YAML blocks from markdown files.
# Accepts `yaml`/`yml`, optional trailing whitespace after the fence,
# and CRLF line endings (Windows-authored docs).
_FENCED_YAML_RE = re.compile(
r"```ya?ml[ \t]*\r?\n(.*?)\r?\n```",
re.DOTALL,
)
def scan_duplicate_option_values(root: Path) -> list[str]:
"""Walk `root` for markdown files; find YAML fenced blocks with pending_decision;
check each options array for duplicate `value` fields.
Returns a list of violation messages (empty if clean).
"""
violations: list[str] = []
for path in sorted(root.rglob("*.md")):
if not path.is_file():
continue
if _is_under_skip_dir(path, root):
continue
content = _read_text_or_none(path)
if content is None or "pending_decision" not in content:
continue
rel = path.relative_to(root).as_posix()
for match in _FENCED_YAML_RE.finditer(content):
block_text = match.group(1)
if "pending_decision" not in block_text:
continue
try:
data = yaml.safe_load(block_text)
except yaml.YAMLError:
# Malformed YAML block: skip silently. Other lints cover schema validity.
continue
if not isinstance(data, dict):
continue
entries = data.get("reset_boundary", [])
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
pd = entry.get("pending_decision")
if not isinstance(pd, dict):
continue
options = pd.get("options", [])
if not isinstance(options, list):
continue
seen: set[str] = set()
duplicates: set[str] = set()
for opt in options:
if not isinstance(opt, dict):
continue
val = opt.get("value")
if val is None:
continue
if val in seen:
duplicates.add(str(val))
seen.add(str(val))
entry_hash = entry.get("hash", "<unknown>")
for dup in sorted(duplicates):
violations.append(
f"{rel}: pending_decision.options[] in entry "
f"hash={entry_hash} contains duplicate value "
f"'{dup}'. Values must be unique within each options "
f"array (ARS v3.6.3 contract)."
)
return violations
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Enforce the ARS v3.6.3 passport-reset co-location contract: every "
f"file mentioning {FLAG_TOKEN} must also reference {PROTOCOL_TOKEN}."
)
)
parser.add_argument(
"--root",
default=".",
help="Repo root to scan (default: current directory).",
)
args = parser.parse_args(argv)
root = Path(args.root).resolve()
if not root.is_dir():
print(f"error: --root {args.root} is not a directory", file=sys.stderr)
return 2
violations = scan(root)
violations += scan_duplicate_option_values(root)
if violations:
print("Passport reset contract lint FAILED:", file=sys.stderr)
for v in violations:
print(f" - {v}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())