mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
cdb8485ec2
* feat(probe): pipeline wiring for #655 PR-C — trigger, consent surface, freshness, transmission ledger - claim_verification_protocol.md gains the Claim-Standing Probe Offer section: user-requested advisory view after the Phase E Claim Registry at Stage 2.5/4.5; HIGH-IMPACT-only trigger; ambiguity ineligible until researcher confirmation; never part of the integrity result (gate 12 boundary). - scripts/build_claim_standing_query_plan.py: §3.1 eligibility (gate 1) + §3.2 propose/bind consent surface (gate 2); emits schema-valid query-plan 1.0/1.1 or an explicit not_checked declination on cancel. - scripts/check_claim_standing_freshness.py: §7 probe-identity drift -> stale with closed reasons (gate 13); corrupt digests are errors. - shared/contracts/claim_standing/transmission_ledger.schema.json + scripts/check_claim_standing_transmissions.py: closed §6 per-event accounting; consented allowlist/roster fail-closed (gate 14). - 82 new tests across four files; CI manifest + integration-guard markers, protocol doc, contracts README, CHANGELOG aligned. Zero-spend: no live index, model, or network call anywhere; every probe surface still says STANCE CLASSIFICATION UNMEASURED. #655 stays open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m * fix(probe): PR-C review round 2 — consent binds full projection, honest declinations, freshness coverage, transmission completeness Applies the converged three-track findings (codex 5 P1 + 2 P2, security 2 P2 + 6 P3, simplify/reuse/altitude): - Gate 1: Stage 2.5 HIGH-IMPACT tier alone is the registry witness; E1 now records the five-part high_impact_basis; basis provenance recorded on the consent surface, never in the registry. - Gate 2: consent surface embeds the complete consentable-plan projection (surface hash covers every receipt-bound field; bind asserts digest equality); absence/invalidation/cancel each produce an explicit local not_checked declination (consent_absent/consent_invalidated/ consent_cancelled); bind --output is consent-gated to the derived .query-plan.json path. - Gate 13: freshness consumes the runner's single expected_identity authority, requires the record's ledger, compares consented provider/model, and declares assessed/unassessed bindings. - Gate 14: schema + self-digest validation of both inputs, duplicate attempt and off-target index refusal, explicit stance-transmission requirement, stance-record cross-check, event-kind fixed field-by-field, conservative-accounting semantics documented; uniqueItems on events. - Gate 12: attribute-level containment of the discovery module, no direct write calls, CLI paths included in the no-file-creation run; doc pins consolidated into the #655 integration guard (incl. Phase E surface and a forbidden-stale-sentence check). - Substrate: validator memoization, shared export-consent helpers (trailing-separator refusal), exported trigger constants pinned to the plan schema; falsey decisions refused, deep copies via copy.deepcopy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m * fix(probe): PR-C review round 3 — intake invariants in gate 14, cancel-flow fidelity, complete artifact roster codex R2 (1 P1 + 4 P2 + 1 P3) and security R2 (6 P3) convergence: - Gate 14: adopt substrate.validate_input so a re-sealed retrieval input omitting a planned attempt is refused; stance-record cross-check now schema-validates + digest-replays the record and compares per-row prompt hash and result state (closed row->event mapping). - Gate 2: an explicit cancel records consent_cancelled even after a retrieval_plus_stance surface was proposed (checked before the surface hash comparison); falsey filter lists refused at proposal; bind CLI exits 3 on declinations. - Consent surface: complete consented-artifact roster from new substrate.ARTIFACT_SUFFIXES (test-pinned to every owning module's suffix constant); trailing-separator authorized paths refused at proposal, in validate_plan, and via the shared helper now used by discovery and the renderer too. - Freshness: fail-closed parity guard between the runner's expected_identity and the stale-reason map; eligibility verdict gains a dispatchable flag distinct from the tier witness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m * fix(probe): PR-C review round 4 — failed stance calls bind their prompt hash codex R3's remaining P2: a judge_timeout / judge_error / oversized-output row left prompt_sha256 null, so the gate-14 cross-check could not hold a tampered failure event to the hash actually sent. The runner now binds prompt_sha256 + assessment_input_sha256 into the row BEFORE the transport call; validate_stance_record replays those hashes on transport-reaching failure rows (and requires them null on undispatched abstract_missing rows); the transmission cross-check requires a non-null, exactly-matching prompt hash for every transport-reaching row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m * test(probe): close the remaining capability-scan evasion forms Dotted from-imports of discovery symbols, discovery-alias rebinding, and getattr() dynamic lookup now fail the gate-12 wiring scan (security R2 P3-6 residue). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m * chore: ignore the maintainer-local deliverables/ directory Local working documents are not part of the public project; keep the directory permanently out of version control on every checkout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeJS63dMYTADV8FPR4Mx1m --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
644 lines
25 KiB
Python
644 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Consent-surface builder for the #655 claim-standing probe (design §3.1/§3.2).
|
|
|
|
Turns one Phase E Claim Registry row plus explicit researcher decisions into
|
|
a schema-valid `claim-standing-query-plan/1.0` (retrieval_only) or `/1.1`
|
|
(retrieval_plus_stance) plan, or into an explicit local `not_checked`
|
|
declination record. The §3.1 trigger is exact: at Stage 2.5 the recorded
|
|
`HIGH-IMPACT` tier is the registry witness (`RANDOM`, `TOP-UP`, and
|
|
`NOT-SELECTED` are never eligible); at Stage 4.5 the `ALL` registry is not
|
|
permission — a row is eligible only with the recorded five-part high-impact
|
|
basis, and a basis-less row stays ineligible until the researcher confirms
|
|
the classification. A Stage 2.5 row whose registry recorded no basis stays
|
|
eligible by tier, but binding a plan still needs the basis, supplied through
|
|
the same recorded researcher confirmation; the basis provenance (registry
|
|
vs researcher confirmation) is displayed on the consent surface and never
|
|
written back to the registry.
|
|
|
|
Consent is bound before any dispatchable plan exists. `propose` renders the
|
|
closed §3.2 consent surface, which embeds the complete consentable-plan
|
|
projection (claim, queries, roster, filters, content classes, caps, stance
|
|
plan, creation time) — what is shown IS what the receipt later binds.
|
|
`bind` compares the decisions' surface hash against the exact current
|
|
surface: absence produces a `consent_absent` declination, any drift a
|
|
`consent_invalidated` declination, and an explicit cancel a
|
|
`consent_cancelled` declination — each an explicit local `not_checked`
|
|
record with no plan and no network or model call anywhere in the builder.
|
|
Eligibility, hashing, and final validation replay through the Track A
|
|
substrate (`build_claim_standing_candidate_ledger`). The builder never
|
|
mutates its inputs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
from scripts import build_claim_standing_candidate_ledger as substrate
|
|
from scripts import claim_standing_discovery as discovery
|
|
except ImportError: # direct script execution
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import build_claim_standing_candidate_ledger as substrate # noqa: E402
|
|
import claim_standing_discovery as discovery # noqa: E402
|
|
|
|
SURFACE_KIND = "claim-standing-consent-surface/1.0"
|
|
DECLINATION_KIND = "claim-standing-probe-declination/1.0"
|
|
QUERY_PLAN_SUFFIX = ".query-plan.json"
|
|
DECISION_VALUES = ("retrieval_only", "retrieval_plus_stance", "cancel")
|
|
SESSION_ONLY_EXPORT_BOUNDARY = (
|
|
"No local export is authorized under session_only persistence."
|
|
)
|
|
ADVISORY_STATEMENT = (
|
|
"The probe result is advisory, search-bounded, and fallible; it is not a "
|
|
"gate, it never changes a Phase E verdict, checkpoint result, manuscript "
|
|
"byte, citation, or read ledger, and absent results never prove absence. "
|
|
"Every probe surface remains STANCE CLASSIFICATION UNMEASURED until the "
|
|
"#655 baseline measurement row exists."
|
|
)
|
|
CONSENT_CHOICES = [
|
|
"edit_or_redact_claim",
|
|
"approve_retrieval_only",
|
|
"approve_retrieval_plus_stance",
|
|
"cancel",
|
|
]
|
|
|
|
|
|
class PlanBuilderError(Exception):
|
|
"""Fail-closed refusal: no plan may be constructed from these inputs."""
|
|
|
|
|
|
def _fail(message: str) -> None:
|
|
raise PlanBuilderError(message)
|
|
|
|
|
|
def _require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
_fail(message)
|
|
|
|
|
|
def _valid_basis(values: Any, source: str) -> list[str]:
|
|
_require(
|
|
isinstance(values, list)
|
|
and bool(values)
|
|
and all(isinstance(item, str) for item in values),
|
|
f"{source}: high_impact_basis must be a non-empty string list",
|
|
)
|
|
unknown = [
|
|
item
|
|
for item in values
|
|
if item not in substrate.HIGH_IMPACT_BASIS_VALUES
|
|
]
|
|
if unknown:
|
|
_fail(f"{source}: unknown high_impact_basis token {unknown[0]!r}")
|
|
_require(
|
|
len(set(values)) == len(values),
|
|
f"{source}: high_impact_basis tokens must be unique",
|
|
)
|
|
return list(values)
|
|
|
|
|
|
def _confirmed_basis(confirmation: dict[str, Any]) -> list[str]:
|
|
_require(
|
|
confirmation.get("confirmed_high_impact") is True,
|
|
"eligibility confirmation must set confirmed_high_impact true",
|
|
)
|
|
_require(
|
|
isinstance(confirmation.get("recorded_at"), str)
|
|
and bool(confirmation["recorded_at"]),
|
|
"eligibility confirmation must record recorded_at",
|
|
)
|
|
return _valid_basis(
|
|
confirmation.get("high_impact_basis"), "eligibility confirmation"
|
|
)
|
|
|
|
|
|
def assess_eligibility(
|
|
registry_row: dict[str, Any], confirmation: dict[str, Any] | None = None
|
|
) -> dict[str, Any]:
|
|
"""Apply the §3.1 trigger to one Claim Registry row.
|
|
|
|
The verdict never mutates the row. At Stage 2.5 the recorded HIGH-IMPACT
|
|
tier alone is the eligibility witness; the plan's five-part basis then
|
|
comes from the registry when recorded, otherwise from a recorded
|
|
researcher confirmation. At Stage 4.5 a basis-less ALL row is ambiguous
|
|
and stays ineligible until the researcher confirms the classification.
|
|
A confirmation can supply a missing basis; it cannot override a wrong
|
|
selection tier.
|
|
"""
|
|
checkpoint = registry_row.get("checkpoint")
|
|
_require(
|
|
checkpoint in substrate.CHECKPOINT_REQUIRED_TIER,
|
|
f"checkpoint must be stage_2_5 or stage_4_5, got {checkpoint!r}",
|
|
)
|
|
tier = registry_row.get("registry_selection_tier")
|
|
reasons: list[str] = []
|
|
required_tier = substrate.CHECKPOINT_REQUIRED_TIER[checkpoint]
|
|
if tier != required_tier:
|
|
reasons.append(
|
|
f"registry_selection_tier {tier!r} is not probe-eligible at "
|
|
f"{checkpoint} (requires {required_tier!r}; RANDOM, TOP-UP, and "
|
|
"NOT-SELECTED rows are never eligible, and ALL is not permission "
|
|
"to probe every claim)"
|
|
)
|
|
recorded = registry_row.get("high_impact_basis")
|
|
_require(
|
|
recorded is None or isinstance(recorded, (list, tuple)),
|
|
"registry row: high_impact_basis must be a list when present",
|
|
)
|
|
basis: list[str] = []
|
|
basis_source: str | None = None
|
|
requires_confirmation = False
|
|
if not reasons:
|
|
if recorded:
|
|
basis = _valid_basis(list(recorded), "registry row")
|
|
basis_source = "registry"
|
|
elif confirmation is not None:
|
|
basis = _confirmed_basis(confirmation)
|
|
basis_source = "researcher_confirmation"
|
|
else:
|
|
requires_confirmation = True
|
|
if checkpoint == "stage_4_5":
|
|
reasons.append(
|
|
"the registry row records no five-part high-impact basis; "
|
|
"at Stage 4.5 the row is ambiguous and stays ineligible "
|
|
"until the researcher confirms the classification"
|
|
)
|
|
return {
|
|
"eligible": not reasons,
|
|
"high_impact_basis": basis,
|
|
"basis_source": basis_source,
|
|
"reasons": reasons,
|
|
"requires_confirmation": requires_confirmation,
|
|
# A row is dispatchable only when nothing further is owed by the
|
|
# researcher — consumers must never branch on `eligible` alone.
|
|
"dispatchable": not reasons and not requires_confirmation,
|
|
}
|
|
|
|
|
|
def _resolved_basis(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any]
|
|
) -> tuple[list[str], str, dict[str, Any] | None]:
|
|
confirmation = decisions.get("eligibility_confirmation")
|
|
verdict = assess_eligibility(registry_row, confirmation=confirmation)
|
|
if not verdict["eligible"]:
|
|
_fail("; ".join(verdict["reasons"]))
|
|
if not verdict["high_impact_basis"]:
|
|
_fail(
|
|
"the registry row records no five-part high-impact basis; the "
|
|
"researcher must confirm the classification "
|
|
"(decisions.eligibility_confirmation) before a consent surface "
|
|
"can bind one"
|
|
)
|
|
return (
|
|
verdict["high_impact_basis"],
|
|
verdict["basis_source"],
|
|
confirmation if verdict["basis_source"] == "researcher_confirmation" else None,
|
|
)
|
|
|
|
|
|
def _roster(decisions: dict[str, Any]) -> list[dict[str, Any]]:
|
|
indexes = decisions.get("indexes")
|
|
_require(
|
|
isinstance(indexes, list) and 1 <= len(indexes) <= 4,
|
|
"decisions.indexes must be a list naming 1..4 discovery indexes",
|
|
)
|
|
_require(
|
|
len(set(indexes)) == len(indexes),
|
|
"decisions.indexes must be unique",
|
|
)
|
|
defaults = discovery.provider_roster_defaults()
|
|
unknown = [index_id for index_id in indexes if index_id not in defaults]
|
|
if unknown:
|
|
_fail(f"index {unknown[0]!r} is not a declared discovery adapter")
|
|
return [defaults[index_id] for index_id in sorted(indexes)]
|
|
|
|
|
|
def _claim_text(registry_row: dict[str, Any]) -> str:
|
|
claim_text = registry_row.get("claim_text")
|
|
_require(
|
|
isinstance(claim_text, str) and bool(claim_text.strip()),
|
|
"registry row must carry non-empty claim_text",
|
|
)
|
|
return claim_text
|
|
|
|
|
|
def _queries(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any]
|
|
) -> list[dict[str, Any]]:
|
|
claim_text = _claim_text(registry_row)
|
|
derived = substrate.exact_claim_query(claim_text)
|
|
claim_sha = substrate.text_digest(claim_text)
|
|
supplied = decisions.get("queries")
|
|
if supplied is None:
|
|
default = decisions.get("default_query")
|
|
_require(
|
|
isinstance(default, dict),
|
|
"decisions.default_query is required when no queries are supplied",
|
|
)
|
|
supplied = [
|
|
{
|
|
"query_id": default.get("query_id", "q1"),
|
|
"accepted_query_text": derived,
|
|
"construction": "exact_claim",
|
|
"language": default.get("language"),
|
|
"date_filter": default.get("date_filter"),
|
|
"index_targets": default.get(
|
|
"index_targets", sorted(decisions.get("indexes") or [])
|
|
),
|
|
}
|
|
]
|
|
_require(
|
|
isinstance(supplied, list) and 1 <= len(supplied) <= 3,
|
|
"decisions.queries must be a list of 1..3 queries when present "
|
|
"(an explicit empty or non-list value is refused, never defaulted)",
|
|
)
|
|
queries: list[dict[str, Any]] = []
|
|
for entry in supplied:
|
|
_require(isinstance(entry, dict), "each query must be an object")
|
|
construction = entry.get("construction")
|
|
if construction == "assisted_then_researcher_approved":
|
|
_fail(
|
|
"assisted_then_researcher_approved is a future optional "
|
|
"planner mode; this builder does not authorize it (design "
|
|
"§4.1)"
|
|
)
|
|
_require(
|
|
construction in ("exact_claim", "researcher_authored"),
|
|
f"unknown query construction {construction!r}",
|
|
)
|
|
accepted = entry.get("accepted_query_text")
|
|
_require(
|
|
isinstance(accepted, str) and bool(accepted.strip()),
|
|
"accepted_query_text must be non-empty",
|
|
)
|
|
if construction == "exact_claim":
|
|
_require(
|
|
accepted == derived,
|
|
"an exact_claim query may only strip ARS citation markers and "
|
|
"collapse ASCII whitespace; edited text must be declared "
|
|
"researcher_authored",
|
|
)
|
|
original = derived
|
|
else:
|
|
original = entry.get("original_query_text", accepted)
|
|
queries.append(
|
|
{
|
|
"query_id": entry.get("query_id"),
|
|
"original_query_text": original,
|
|
"accepted_query_text": accepted,
|
|
"construction": construction,
|
|
"source_claim_sha256": claim_sha,
|
|
"language": entry.get("language"),
|
|
"date_filter": entry.get("date_filter"),
|
|
"index_targets": list(entry.get("index_targets") or []),
|
|
"query_sha256": substrate.text_digest(accepted),
|
|
}
|
|
)
|
|
return queries
|
|
|
|
|
|
def _stance_plan(decisions: dict[str, Any]) -> dict[str, Any] | None:
|
|
decision = decisions.get("decision")
|
|
_require(
|
|
decision in DECISION_VALUES,
|
|
f"decision must be one of {DECISION_VALUES}, got {decision!r}",
|
|
)
|
|
if decision != "retrieval_plus_stance":
|
|
return None
|
|
stance_plan = decisions.get("stance_plan")
|
|
_require(
|
|
isinstance(stance_plan, dict),
|
|
"retrieval_plus_stance requires an explicit stance_plan naming the "
|
|
"exact provider and model",
|
|
)
|
|
return copy.deepcopy(stance_plan)
|
|
|
|
|
|
def _explicit_string_list(decisions: dict[str, Any], field: str) -> list[str]:
|
|
values = decisions.get(field)
|
|
_require(
|
|
isinstance(values, list)
|
|
and bool(values)
|
|
and all(isinstance(item, str) for item in values),
|
|
f"decisions.{field} must be a non-empty string list (an explicit "
|
|
"falsey or wrong-type value is refused, never defaulted)",
|
|
)
|
|
return list(values)
|
|
|
|
|
|
def _draft_plan(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any]
|
|
) -> tuple[dict[str, Any], dict[str, Any] | None, str, dict[str, Any] | None]:
|
|
"""Everything of the future plan except the consent block and plan hash."""
|
|
basis, basis_source, confirmation = _resolved_basis(registry_row, decisions)
|
|
stance_plan = _stance_plan(decisions)
|
|
version = (
|
|
substrate.PLAN_VERSION_1_1 if stance_plan else substrate.PLAN_VERSION
|
|
)
|
|
draft: dict[str, Any] = {
|
|
"schema_version": version,
|
|
"probe_id": decisions.get("probe_id"),
|
|
"claim": {
|
|
"checkpoint": registry_row.get("checkpoint"),
|
|
"claim_id": registry_row.get("claim_id"),
|
|
"claim_text": _claim_text(registry_row),
|
|
"claim_sha256": substrate.text_digest(_claim_text(registry_row)),
|
|
"registry_selection_tier": registry_row.get(
|
|
"registry_selection_tier"
|
|
),
|
|
"high_impact_basis": basis,
|
|
"eligibility_confirmed_by_researcher": True,
|
|
},
|
|
"queries": _queries(registry_row, decisions),
|
|
"provider_roster": _roster(decisions),
|
|
"allowed_languages": _explicit_string_list(
|
|
decisions, "allowed_languages"
|
|
),
|
|
"allowed_document_types": _explicit_string_list(
|
|
decisions, "allowed_document_types"
|
|
),
|
|
"authorized_content_classes": list(
|
|
substrate.STANCE_CONTENT_CLASSES
|
|
if stance_plan
|
|
else substrate.RETRIEVAL_ONLY_CONTENT_CLASSES
|
|
),
|
|
"caps": dict(substrate.CAPS),
|
|
"created_at": decisions.get("created_at"),
|
|
}
|
|
if version == substrate.PLAN_VERSION_1_1:
|
|
draft["stance_plan"] = stance_plan
|
|
return draft, stance_plan, basis_source, confirmation
|
|
|
|
|
|
def build_consent_surface(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""The closed §3.2 consent receipt content, shown before any acceptance.
|
|
|
|
Embeds the complete consentable-plan projection of the plan `bind` would
|
|
construct from the same inputs, so the surface digest covers every field
|
|
the consent receipt later binds.
|
|
"""
|
|
return _surface_from_draft(
|
|
*_draft_plan(registry_row, decisions), decisions
|
|
)
|
|
|
|
|
|
def _surface_from_draft(
|
|
draft: dict[str, Any],
|
|
stance_plan: dict[str, Any] | None,
|
|
basis_source: str,
|
|
confirmation: dict[str, Any] | None,
|
|
decisions: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
local_persistence = decisions.get("local_persistence")
|
|
_require(
|
|
local_persistence in ("session_only", "explicit_local_export"),
|
|
"local_persistence must be session_only or explicit_local_export",
|
|
)
|
|
authorized_output_path = decisions.get("authorized_output_path")
|
|
if local_persistence == "explicit_local_export":
|
|
_require(
|
|
isinstance(authorized_output_path, str)
|
|
and not authorized_output_path.endswith(("/", "\\")),
|
|
"authorized_output_path must not end with a path separator: "
|
|
"derived artifact names would become hidden dotfiles",
|
|
)
|
|
constructions = {
|
|
query["construction"] for query in draft["queries"]
|
|
}
|
|
claim_display = dict(draft["claim"])
|
|
claim_display["high_impact_basis_source"] = basis_source
|
|
if confirmation is not None:
|
|
claim_display["basis_confirmation_recorded_at"] = confirmation[
|
|
"recorded_at"
|
|
]
|
|
return {
|
|
"surface_kind": SURFACE_KIND,
|
|
"probe_id": draft["probe_id"],
|
|
"checkpoint": draft["claim"]["checkpoint"],
|
|
"claim": claim_display,
|
|
"queries": draft["queries"],
|
|
"providers": draft["provider_roster"],
|
|
"consentable_plan": substrate.consentable_plan_projection(draft),
|
|
"query_transmission": {
|
|
"exact_claim_text": "exact_claim" in constructions,
|
|
"researcher_edited_query": "researcher_authored" in constructions,
|
|
},
|
|
"caps": draft["caps"],
|
|
"llm_transmission": {
|
|
"abstracts_to_llm": bool(
|
|
stance_plan and stance_plan.get("abstracts_to_llm_authorized")
|
|
),
|
|
"session_held_full_text_to_llm": bool(
|
|
stance_plan
|
|
and stance_plan.get("session_held_full_text_to_llm_authorized")
|
|
),
|
|
},
|
|
"stance_provider": (
|
|
{
|
|
"provider_identity": stance_plan["provider_identity"],
|
|
"model_identity": stance_plan["model_identity"],
|
|
}
|
|
if stance_plan
|
|
else None
|
|
),
|
|
"stance_retention": (
|
|
{
|
|
"retention_state": stance_plan["retention_state"],
|
|
"retention_reference": stance_plan["retention_reference"],
|
|
}
|
|
if stance_plan
|
|
else None
|
|
),
|
|
"local_persistence": local_persistence,
|
|
"authorized_output_path": authorized_output_path,
|
|
"derived_artifact_suffixes": dict(substrate.ARTIFACT_SUFFIXES),
|
|
"deletion_boundary": decisions.get("deletion_boundary"),
|
|
"export_boundary": (
|
|
substrate.EXPLICIT_LOCAL_EXPORT_BOUNDARY
|
|
if local_persistence == "explicit_local_export"
|
|
else SESSION_ONLY_EXPORT_BOUNDARY
|
|
),
|
|
"advisory_statement": ADVISORY_STATEMENT,
|
|
"choices": list(CONSENT_CHOICES),
|
|
}
|
|
|
|
|
|
def consent_surface_sha256(surface: dict[str, Any]) -> str:
|
|
return substrate.digest(surface)
|
|
|
|
|
|
def _declination(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any], reason: str
|
|
) -> dict[str, Any]:
|
|
recorded_at = decisions.get("recorded_at") or decisions.get("accepted_at")
|
|
_require(
|
|
isinstance(recorded_at, str) and bool(recorded_at),
|
|
"a declination must record recorded_at (or carry accepted_at)",
|
|
)
|
|
claim_text = _claim_text(registry_row)
|
|
return {
|
|
"record_kind": DECLINATION_KIND,
|
|
"probe_id": decisions.get("probe_id"),
|
|
"checkpoint": registry_row.get("checkpoint"),
|
|
"claim_id": registry_row.get("claim_id"),
|
|
"claim_sha256": substrate.text_digest(claim_text),
|
|
"status": "not_checked",
|
|
"reason": reason,
|
|
"recorded_at": recorded_at,
|
|
"network_calls": "none",
|
|
"model_calls": "none",
|
|
}
|
|
|
|
|
|
def bind_plan(
|
|
registry_row: dict[str, Any], decisions: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Bind researcher acceptance to the exact proposed consent surface.
|
|
|
|
Returns the schema-valid plan, or an explicit `not_checked` declination
|
|
record: `consent_absent` when no surface hash was supplied,
|
|
`consent_invalidated` when the supplied hash does not match the exact
|
|
current surface (claim edit, filter change, provider change, cap change,
|
|
persistence change), and `consent_cancelled` on an explicit cancel.
|
|
Never mutates its inputs; performs no network or model call.
|
|
"""
|
|
# An explicit cancel is a refusal of consent, not an acceptance to
|
|
# verify: it needs no surface hash and is recorded before any binding
|
|
# comparison (a proposed retrieval_plus_stance surface followed by a
|
|
# cancel must record consent_cancelled, never consent_invalidated).
|
|
if decisions.get("decision") == "cancel":
|
|
return _declination(registry_row, decisions, "consent_cancelled")
|
|
draft, stance_plan, basis_source, confirmation = _draft_plan(
|
|
registry_row, decisions
|
|
)
|
|
surface = _surface_from_draft(
|
|
draft, stance_plan, basis_source, confirmation, decisions
|
|
)
|
|
supplied_hash = decisions.get("consent_surface_sha256")
|
|
if supplied_hash is None:
|
|
return _declination(registry_row, decisions, "consent_absent")
|
|
if supplied_hash != consent_surface_sha256(surface):
|
|
return _declination(registry_row, decisions, "consent_invalidated")
|
|
|
|
version = draft["schema_version"]
|
|
consent: dict[str, Any] = {
|
|
"consent_receipt_id": decisions.get("consent_receipt_id"),
|
|
"decision": decisions["decision"],
|
|
"accepted_at": decisions.get("accepted_at"),
|
|
"claim_sha256": draft["claim"]["claim_sha256"],
|
|
"provider_roster_sha256": substrate.digest(draft["provider_roster"]),
|
|
"caps_sha256": substrate.digest(draft["caps"]),
|
|
"queries_sha256": substrate.digest(draft["queries"]),
|
|
"stance_classification_authorized": bool(stance_plan),
|
|
"advisory_acknowledged": True,
|
|
"search_bounded_acknowledged": True,
|
|
"local_persistence": surface["local_persistence"],
|
|
"provider_retention_disclosed": True,
|
|
"deletion_boundary": surface["deletion_boundary"],
|
|
"export_boundary": surface["export_boundary"],
|
|
"authorized_output_path": surface["authorized_output_path"],
|
|
}
|
|
if version == substrate.PLAN_VERSION_1_1:
|
|
consent["stance_plan_sha256"] = (
|
|
substrate.digest(stance_plan) if stance_plan else None
|
|
)
|
|
plan = dict(draft)
|
|
plan["consent"] = consent
|
|
consent["consentable_plan_sha256"] = substrate.digest(
|
|
substrate.consentable_plan_projection(plan)
|
|
)
|
|
_require(
|
|
consent["consentable_plan_sha256"]
|
|
== substrate.digest(surface["consentable_plan"]),
|
|
"internal consistency failure: the bound consentable-plan projection "
|
|
"does not equal the projection the accepted surface displayed",
|
|
)
|
|
consent["receipt_sha256"] = substrate.bound_digest(consent, "receipt_sha256")
|
|
plan["plan_sha256"] = substrate.bound_digest(plan, "plan_sha256")
|
|
try:
|
|
substrate.validate_schema(
|
|
plan, substrate.plan_schema_filename(plan), "query plan"
|
|
)
|
|
substrate.validate_plan(plan)
|
|
except substrate.LedgerError as exc:
|
|
raise PlanBuilderError(f"bound plan failed validation: {exc}") from exc
|
|
return plan
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
for name, help_text in (
|
|
("propose", "print the consent surface and its binding digest"),
|
|
("bind", "bind an accepted consent surface into a query plan"),
|
|
):
|
|
sub = commands.add_parser(name, help=help_text)
|
|
sub.add_argument("--registry-claim", type=Path, required=True)
|
|
sub.add_argument("--decisions", type=Path, required=True)
|
|
if name == "bind":
|
|
sub.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help=(
|
|
"write the bound plan to the consent-derived path; "
|
|
"requires the receipt to say explicit_local_export. "
|
|
"Omit to print the plan for session-only use."
|
|
),
|
|
)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
registry_row = substrate.load_json(args.registry_claim)
|
|
decisions = substrate.load_json(args.decisions)
|
|
if args.command == "propose":
|
|
surface = build_consent_surface(registry_row, decisions)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"consent_surface": surface,
|
|
"consent_surface_sha256": consent_surface_sha256(
|
|
surface
|
|
),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
else:
|
|
result = bind_plan(registry_row, decisions)
|
|
if result.get("record_kind") == DECLINATION_KIND:
|
|
# Distinct status: a declination is neither a bound plan
|
|
# (0) nor an assessment error (1).
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 3
|
|
elif args.output is None:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
else:
|
|
authorized = substrate.require_export_consent(
|
|
result, args.output, QUERY_PLAN_SUFFIX
|
|
)
|
|
substrate.write_new_ledger(authorized, result)
|
|
print(f"query plan written: {authorized}")
|
|
except (
|
|
PlanBuilderError,
|
|
substrate.LedgerError,
|
|
OSError,
|
|
ValueError,
|
|
KeyError,
|
|
TypeError,
|
|
AttributeError,
|
|
) as exc:
|
|
print(f"QUERY-PLAN ERROR: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|