Compare resolved canonical Path values, not raw string spellings, when detecting a question_path / proposal_path collision. A relative proposal_path and an absolute question_path that name the same file now fail closed with reason "path_collision" instead of resolving. Adds a regression test for the absolute-vs-relative same-file collision and keeps the existing exact-string collision test. The new test is non-vacuous: it fails under the prior raw-string comparison. Scope: ask_handle.py seam + its test only. No runtime.py, CLAIMS, metrics, telemetry, schema, or lane-pin changes.
428 lines
16 KiB
Python
428 lines
16 KiB
Python
"""Focused tests for the carried-handle ASK acquisition seam.
|
|
|
|
These tests intentionally avoid ``chat.runtime`` and never call the off-serving
|
|
producer (``pass_manager.contemplate`` / ``deliver_ask`` / ``render_question``).
|
|
Artifacts are hand-written in the exact producer shape (the same payload
|
|
convention as ``tests/test_ask_serving_integration.py``) with the producer's
|
|
content-address recipe applied directly — the seam must resolve a *pre-existing*
|
|
artifact, so the tests construct the pre-existing state, not the producer call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from core.config import RuntimeConfig
|
|
from core.epistemic_disclosure.ask_handle import (
|
|
AskArtifactHandle,
|
|
AskHandleResolution,
|
|
acquire_served_ask_from_handle,
|
|
resolve_served_ask_handle,
|
|
)
|
|
from core.epistemic_disclosure.disposition import ServedDisposition
|
|
|
|
_SEAM_PATH = (
|
|
Path(__file__).resolve().parents[1] / "core" / "epistemic_disclosure" / "ask_handle.py"
|
|
)
|
|
|
|
|
|
def _valid_payload(text: str = "How many crates are left?") -> dict:
|
|
return {
|
|
"status": "question_only",
|
|
"blocking_reason": "missing_total_count",
|
|
"owner_organ": "r2_constraint",
|
|
"question": {
|
|
"text": text,
|
|
"reason": "missing_total_count",
|
|
"slot_name": "total_count",
|
|
"expected_unit_or_type": "count_int",
|
|
"binding_target": "collective_unit_total",
|
|
},
|
|
"answer_binding": None,
|
|
"requires_review": True,
|
|
"served": False,
|
|
}
|
|
|
|
|
|
def _content_hash(payload: dict) -> str:
|
|
"""The producer's content address: sha256 over blocking_reason:slot_name:text."""
|
|
slot_name = payload["question"].get("slot_name") or ""
|
|
digest = f"{payload['blocking_reason']}:{slot_name}:{payload['question']['text']}"
|
|
return hashlib.sha256(digest.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _write_addressed_artifact(root: Path, payload: dict) -> tuple[Path, str]:
|
|
"""Write *payload* at its producer content-addressed path; return (path, hash)."""
|
|
digest = _content_hash(payload)
|
|
path = root / f"{digest}.json"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
return path, digest
|
|
|
|
|
|
def _handle(path: Path, digest: str, proposal_path: Path | None = None) -> AskArtifactHandle:
|
|
return AskArtifactHandle(
|
|
question_path=str(path),
|
|
content_hash=digest,
|
|
proposal_path=str(proposal_path) if proposal_path is not None else None,
|
|
)
|
|
|
|
|
|
# --- gate-first behavior -----------------------------------------------------
|
|
|
|
|
|
def test_gate_disabled_rejects_without_filesystem_access(tmp_path: Path, monkeypatch) -> None:
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
def boom(*args, **kwargs): # pragma: no cover - must not run
|
|
raise AssertionError("the seam must not touch the filesystem while the gate is dark")
|
|
|
|
monkeypatch.setattr(Path, "is_file", boom)
|
|
monkeypatch.setattr(Path, "read_text", boom)
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=False), _handle(path, digest)
|
|
)
|
|
|
|
assert isinstance(resolution, AskHandleResolution)
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "gate_disabled"
|
|
assert resolution.candidate is None
|
|
|
|
|
|
def test_gate_disabled_fallback_surface_is_unchanged(tmp_path: Path) -> None:
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
acquired = acquire_served_ask_from_handle(
|
|
RuntimeConfig(ask_serving_enabled=False),
|
|
handle=_handle(path, digest),
|
|
fallback_surface="I don't know — insufficient grounding for that yet.",
|
|
)
|
|
|
|
assert acquired.acquired is False
|
|
assert acquired.provider_called is False
|
|
assert acquired.decision.served is False
|
|
assert acquired.decision.surface == "I don't know — insufficient grounding for that yet."
|
|
assert acquired.decision.disposition is ServedDisposition.REFUSE
|
|
|
|
|
|
def test_default_config_is_dark(tmp_path: Path) -> None:
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
resolution = resolve_served_ask_handle(RuntimeConfig(), _handle(path, digest))
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "gate_disabled"
|
|
|
|
|
|
# --- valid resolution through the existing serving stack ---------------------
|
|
|
|
|
|
def test_valid_handle_resolves_to_acquisition_compatible_candidate(tmp_path: Path) -> None:
|
|
payload = _valid_payload("How many crates are left?")
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
|
|
assert resolution.resolved is True
|
|
assert resolution.reason == "resolved"
|
|
assert resolution.candidate is not None
|
|
assert resolution.candidate.terminal == "QUESTION_NEEDED"
|
|
assert resolution.candidate.question_path == str(path)
|
|
assert resolution.candidate.proposal_path is None
|
|
|
|
|
|
def test_valid_handle_serves_through_existing_stack(tmp_path: Path) -> None:
|
|
payload = _valid_payload("How many crates are left?")
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
acquired = acquire_served_ask_from_handle(
|
|
RuntimeConfig(ask_serving_enabled=True),
|
|
handle=_handle(path, digest),
|
|
fallback_surface="fallback",
|
|
)
|
|
|
|
assert acquired.acquired is True
|
|
assert acquired.decision.served is True
|
|
assert acquired.decision.terminal == "QUESTION_NEEDED"
|
|
assert acquired.decision.surface == "How many crates are left?"
|
|
assert acquired.decision.disposition is ServedDisposition.ASK
|
|
|
|
|
|
def test_resolved_candidate_carries_no_question_prose(tmp_path: Path) -> None:
|
|
payload = _valid_payload("How many crates are left?")
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
|
|
assert resolution.candidate is not None
|
|
candidate_fields = dataclasses.asdict(resolution.candidate)
|
|
assert "How many crates are left?" not in {
|
|
v for v in candidate_fields.values() if isinstance(v, str)
|
|
}
|
|
|
|
|
|
# --- fail-closed rejections ---------------------------------------------------
|
|
|
|
|
|
def test_handle_to_proposal_artifact_fails_closed(tmp_path: Path) -> None:
|
|
proposal_payload = {
|
|
"status": "proposal_only",
|
|
"family": "missing_category_pair",
|
|
"requires_review": True,
|
|
"mounted": False,
|
|
}
|
|
path = tmp_path / "proposals" / "p.json"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(proposal_payload), encoding="utf-8")
|
|
digest = hashlib.sha256(b"not-a-question-address").hexdigest()
|
|
addressed = path.parent / f"{digest}.json"
|
|
addressed.write_text(json.dumps(proposal_payload), encoding="utf-8")
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(addressed, digest)
|
|
)
|
|
|
|
# A proposal artifact has no Q1-D content-address fields → malformed body.
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "malformed_artifact"
|
|
|
|
acquired = acquire_served_ask_from_handle(
|
|
RuntimeConfig(ask_serving_enabled=True),
|
|
handle=_handle(addressed, digest),
|
|
fallback_surface="fallback",
|
|
)
|
|
assert acquired.decision.served is False
|
|
assert acquired.decision.surface == "fallback"
|
|
|
|
|
|
def test_missing_artifact_fails_closed(tmp_path: Path) -> None:
|
|
digest = _content_hash(_valid_payload())
|
|
missing = tmp_path / "questions" / f"{digest}.json"
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(missing, digest)
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "missing_artifact"
|
|
|
|
|
|
def test_malformed_json_fails_closed(tmp_path: Path) -> None:
|
|
digest = _content_hash(_valid_payload())
|
|
path = tmp_path / "questions" / f"{digest}.json"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("{bad json", encoding="utf-8")
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "malformed_artifact"
|
|
|
|
|
|
def test_valid_json_with_invalid_q1d_fields_fails_closed_via_adapter(tmp_path: Path) -> None:
|
|
"""Identity checks pass (the hash covers blocking_reason/slot/text only) but
|
|
the adapter's Q1-D policy still rejects — proving delegation, not duplication."""
|
|
for corruption in ("served", "requires_review"):
|
|
payload = _valid_payload()
|
|
payload[corruption] = not payload[corruption]
|
|
path, digest = _write_addressed_artifact(tmp_path / f"questions_{corruption}", payload)
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
assert resolution.resolved is True # identity intact
|
|
|
|
acquired = acquire_served_ask_from_handle(
|
|
RuntimeConfig(ask_serving_enabled=True),
|
|
handle=_handle(path, digest),
|
|
fallback_surface="fallback",
|
|
)
|
|
assert acquired.decision.served is False
|
|
assert acquired.decision.surface == "fallback"
|
|
|
|
|
|
def test_question_path_equal_to_proposal_path_fails_closed(tmp_path: Path) -> None:
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest, proposal_path=path)
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "path_collision"
|
|
|
|
|
|
def test_relative_proposal_path_same_file_as_absolute_question_path_fails_closed(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A relative ``proposal_path`` and an absolute ``question_path`` that name the
|
|
same file must collide and fail closed — the check compares canonical paths,
|
|
not raw string spellings, so differing spellings of one file cannot slip past.
|
|
"""
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
assert path.is_absolute()
|
|
|
|
# A genuinely relative spelling (from cwd) of the very same artifact file.
|
|
relative_proposal = Path(os.path.relpath(path, Path.cwd()))
|
|
assert not relative_proposal.is_absolute()
|
|
assert relative_proposal.resolve(strict=False) == path.resolve(strict=False)
|
|
assert str(relative_proposal) != str(path)
|
|
|
|
handle = AskArtifactHandle(
|
|
question_path=str(path),
|
|
content_hash=digest,
|
|
proposal_path=str(relative_proposal),
|
|
)
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), handle
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "path_collision"
|
|
|
|
|
|
def test_stale_or_replaced_artifact_fails_closed(tmp_path: Path) -> None:
|
|
"""The file at the handle's path no longer re-hashes to the handle's address."""
|
|
original = _valid_payload("How many crates are left?")
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", original)
|
|
|
|
replaced = _valid_payload("How many boxes were shipped?")
|
|
path.write_text(json.dumps(replaced, indent=2, sort_keys=True), encoding="utf-8")
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "content_hash_mismatch"
|
|
|
|
|
|
def test_handle_filename_must_be_content_addressed(tmp_path: Path) -> None:
|
|
payload = _valid_payload()
|
|
digest = _content_hash(payload)
|
|
path = tmp_path / "questions" / "q.json" # not the producer's addressed name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
resolution = resolve_served_ask_handle(
|
|
RuntimeConfig(ask_serving_enabled=True), _handle(path, digest)
|
|
)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "handle_address_mismatch"
|
|
|
|
|
|
def test_structurally_invalid_handles_fail_closed(tmp_path: Path) -> None:
|
|
config = RuntimeConfig(ask_serving_enabled=True)
|
|
payload = _valid_payload()
|
|
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
assert resolve_served_ask_handle(config, None).reason == "missing_handle"
|
|
assert (
|
|
resolve_served_ask_handle(config, AskArtifactHandle("", digest)).reason
|
|
== "malformed_handle"
|
|
)
|
|
assert (
|
|
resolve_served_ask_handle(config, AskArtifactHandle(" ", digest)).reason
|
|
== "malformed_handle"
|
|
)
|
|
assert (
|
|
resolve_served_ask_handle(
|
|
config, AskArtifactHandle(str(path), "not-a-sha256")
|
|
).reason
|
|
== "malformed_handle"
|
|
)
|
|
assert (
|
|
resolve_served_ask_handle(
|
|
config, AskArtifactHandle(str(path), digest.upper())
|
|
).reason
|
|
== "malformed_handle"
|
|
)
|
|
|
|
|
|
# --- no-scan / boundary proofs -------------------------------------------------
|
|
|
|
|
|
def test_seam_cannot_discover_artifacts_without_exact_handle(tmp_path: Path) -> None:
|
|
"""A valid artifact sitting in the sink is unreachable without its exact
|
|
content-addressed handle — there is no discovery path."""
|
|
payload = _valid_payload()
|
|
_write_addressed_artifact(tmp_path / "questions", payload)
|
|
|
|
wrong_digest = hashlib.sha256(b"some-other-question").hexdigest()
|
|
wrong = AskArtifactHandle(
|
|
question_path=str(tmp_path / "questions" / f"{wrong_digest}.json"),
|
|
content_hash=wrong_digest,
|
|
)
|
|
|
|
resolution = resolve_served_ask_handle(RuntimeConfig(ask_serving_enabled=True), wrong)
|
|
|
|
assert resolution.resolved is False
|
|
assert resolution.reason == "missing_artifact"
|
|
|
|
|
|
def test_seam_module_contains_no_sink_scanning_primitives() -> None:
|
|
tree = ast.parse(_SEAM_PATH.read_text(encoding="utf-8"), filename=str(_SEAM_PATH))
|
|
forbidden_attrs = {"glob", "rglob", "iterdir", "walk", "scandir", "listdir"}
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Attribute):
|
|
assert node.attr not in forbidden_attrs, f"sink scan primitive: {node.attr}"
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
assert node.func.id not in forbidden_attrs
|
|
|
|
|
|
def test_seam_module_does_not_import_runtime_producer_or_renderer() -> None:
|
|
source = _SEAM_PATH.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=str(_SEAM_PATH))
|
|
|
|
forbidden_modules = (
|
|
"chat",
|
|
"chat.runtime",
|
|
"chat.ask_runtime",
|
|
"generate.contemplation",
|
|
"generate.contemplation.pass_manager",
|
|
"generate.contemplation.findings",
|
|
"core.epistemic_questions.render",
|
|
"core.epistemic_questions.delivery",
|
|
)
|
|
forbidden_calls = {"contemplate", "deliver_ask", "render_question", "emit_question"}
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom) and node.module:
|
|
for forbidden in forbidden_modules:
|
|
assert node.module != forbidden
|
|
assert not node.module.startswith(forbidden + ".")
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
for forbidden in forbidden_modules:
|
|
assert alias.name != forbidden
|
|
assert not alias.name.startswith(forbidden + ".")
|
|
if isinstance(node, ast.Call):
|
|
if isinstance(node.func, ast.Name):
|
|
assert node.func.id not in forbidden_calls
|
|
if isinstance(node.func, ast.Attribute):
|
|
assert node.func.attr not in forbidden_calls
|
|
|
|
|
|
def test_seam_module_constructs_no_question_prose() -> None:
|
|
source = _SEAM_PATH.read_text(encoding="utf-8")
|
|
for forbidden_template in ("How many", "What ", "Which ", "Please provide"):
|
|
assert forbidden_template not in source
|