feat(inbox): C2.5 — inbox hardening + SDK version guard (closes C-F7, C-N3, R-6)
- File-layer decision vocabulary (§4.2 set) with SKIP semantics — an unknown decision never reaches the store (C-F7, the review's run proof is the fixture) - Fail-fast caps (max_files / max_rationale_chars) via InboxLimitError raised OUTSIDE the tolerant try — a cap breach is never swallowed as a skip - R-6 id grammar (mirrors ingest _ID_RE) as a pydantic pattern on VerdictDocument.id AND re-checked in write_verdict, since model_copy(update=) bypasses model validation — traversal ids can no longer write outside the inbox - promotion._filename_token: any sanitised id maps to a content hash — 'e/vil' can no longer clobber the distinct id 'evil' (restarbeid-funn 2) - SDK pinned >=0.2.111,<0.3 + version guard test naming the sdk_client.py attribute premises; resolved 0.2.120, all premises re-verified against it - sdk_client read loop bound offline with REAL SDK message types (R-4/R-5): text aggregation, error fail-paths, usage/cost extraction, _total_tokens fail-closed, non-positive budget guard - test_sdk_isolation comment no longer claims the --system-prompt "" serialization the test body does not bind (honesty rule §1) Guard-G2 assessment (guard-plan §4): the allowlist + caps + id grammar landed here are G2's necessary part; an optional scan_output depth pass over rationale (still a verbatim prose channel into the fold prompt, R-9) remains relevant as a later additive session — the trigger picture is unchanged. 4 detach proofs red → restored green. Full gate: 389 passed (365→389), ruff+format+mypy clean; golden + shared/ + runs/s10/ byte-untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e7ce6b0a31
commit
80a2fa1a77
9 changed files with 441 additions and 35 deletions
|
|
@ -5,7 +5,7 @@ description = "Sibling implementation of the portfolio-optimiser method on the C
|
|||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"claude-agent-sdk>=0.2",
|
||||
"claude-agent-sdk>=0.2.111,<0.3",
|
||||
"pydantic>=2",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,20 @@ Role split (§3 Step 7, unwaivable): the system READS the inbox (tolerant load,
|
|||
merge into the store); writing is the authoring primitive's job, used only by
|
||||
the expert/persona side — a run never persists its own captured verdict back.
|
||||
|
||||
Decision vocabulary at this layer: the file carries the expert decision as a
|
||||
plain string — the run-path feedback contract (§4.1) and the promotion gate's
|
||||
accepted set (§6) are where the vocabulary is policed, not the raw file layer.
|
||||
Decision vocabulary at this layer (C2.5, C-F7): loading polices the §4.2 set
|
||||
{approved, rejected, approved_with_adjustment} with SKIP semantics — an
|
||||
unknown decision never reaches the store, but never raises either (the raw
|
||||
layer is written out of band). The run-path feedback contract (§4.1) and the
|
||||
promotion gate's accepted set (§6) police their own vocabularies on top.
|
||||
Capacity is the exception to tolerance: an inbox over the file cap, or a
|
||||
rationale over the length cap, FAILS FAST with a precise error — a silent
|
||||
skip there would silently drop expert knowledge (never a silent cut).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
|
@ -26,6 +32,23 @@ from portfolio_optimiser_claude.experience import (
|
|||
mint_verdict_id,
|
||||
)
|
||||
|
||||
# R-6 id grammar — mirrors ingest.py's _ID_RE: lowercase alphanumerics and
|
||||
# hyphens only, so a verdict id can NEVER traverse paths (no dots, no
|
||||
# separators). Minted ids (16 hex chars, §4.2) always match.
|
||||
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
# §4.2: the file layer's full decision vocabulary.
|
||||
_FILE_DECISIONS = frozenset({"approved", "rejected", "approved_with_adjustment"})
|
||||
|
||||
# Fail-fast capacity defaults — generous for any real expert inbox, small
|
||||
# enough that a runaway writer cannot flood the fold.
|
||||
_DEFAULT_MAX_FILES = 1_000
|
||||
_DEFAULT_MAX_RATIONALE_CHARS = 20_000
|
||||
|
||||
|
||||
class InboxLimitError(ValueError):
|
||||
"""An inbox cap was exceeded — refusing to load (fail-fast, never a silent cut)."""
|
||||
|
||||
|
||||
class ProposalFeatures(BaseModel):
|
||||
"""§4.2 ``proposal_features``: the structural features of the judged candidate.
|
||||
|
|
@ -43,7 +66,7 @@ class ProposalFeatures(BaseModel):
|
|||
class VerdictDocument(BaseModel):
|
||||
"""One verdict file (§4.2). A LOADED ``id`` is kept verbatim — never re-minted."""
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
id: str = Field(min_length=1, pattern=_ID_RE.pattern)
|
||||
decision: str = Field(min_length=1)
|
||||
rationale: str = Field(min_length=1)
|
||||
proposal_features: ProposalFeatures
|
||||
|
|
@ -90,8 +113,16 @@ def write_verdict(inbox_dir: Path, verdict: VerdictDocument) -> Path:
|
|||
"""The authoring primitive (§5): ``{id}.json``, written deterministically.
|
||||
|
||||
Creates the directory if needed; sorted keys, 2-space indent. The disk
|
||||
layer is LAST-write-wins per file (§4.2).
|
||||
layer is LAST-write-wins per file (§4.2). The id grammar is re-checked
|
||||
HERE (R-6): ``model_copy(update=...)`` bypasses model validation, so the
|
||||
write seam fails closed on its own — nothing is ever written outside
|
||||
``inbox_dir``.
|
||||
"""
|
||||
if _ID_RE.fullmatch(verdict.id) is None:
|
||||
raise ValueError(
|
||||
f"refusing to write verdict: id {verdict.id!r} violates the id grammar "
|
||||
f"{_ID_RE.pattern!r} (R-6 path safety)"
|
||||
)
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = inbox_dir / f"{verdict.id}.json"
|
||||
payload = json.dumps(verdict.model_dump(), sort_keys=True, indent=2)
|
||||
|
|
@ -99,34 +130,67 @@ def write_verdict(inbox_dir: Path, verdict: VerdictDocument) -> Path:
|
|||
return path
|
||||
|
||||
|
||||
def load_inbox(inbox_dir: Path) -> list[VerdictDocument]:
|
||||
def load_inbox(
|
||||
inbox_dir: Path,
|
||||
*,
|
||||
max_files: int = _DEFAULT_MAX_FILES,
|
||||
max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS,
|
||||
) -> list[VerdictDocument]:
|
||||
"""Tolerant load (§5): the raw layer is written out of band — skip, never raise.
|
||||
|
||||
A missing folder yields zero verdicts; files that are not ``.json``, fail
|
||||
to parse, or lack a required top-level key are SKIPPED. Deterministic
|
||||
order: sorted by filename.
|
||||
to parse, lack a required top-level key, violate the id grammar, or carry
|
||||
a decision outside the §4.2 vocabulary are SKIPPED. Deterministic order:
|
||||
sorted by filename. The caps are the one place tolerance ends: more
|
||||
candidate files than ``max_files``, or a rationale longer than
|
||||
``max_rationale_chars``, raises :class:`InboxLimitError` (C2.5).
|
||||
"""
|
||||
if not inbox_dir.is_dir():
|
||||
return []
|
||||
paths = [
|
||||
path
|
||||
for path in sorted(inbox_dir.iterdir(), key=lambda p: p.name)
|
||||
if path.suffix == ".json" and path.is_file()
|
||||
]
|
||||
if len(paths) > max_files:
|
||||
raise InboxLimitError(
|
||||
f"inbox {inbox_dir} holds {len(paths)} verdict files, over the cap of "
|
||||
f"{max_files} — refusing to load"
|
||||
)
|
||||
verdicts: list[VerdictDocument] = []
|
||||
for path in sorted(inbox_dir.iterdir(), key=lambda p: p.name):
|
||||
if path.suffix != ".json" or not path.is_file():
|
||||
continue
|
||||
for path in paths:
|
||||
try:
|
||||
verdicts.append(VerdictDocument.model_validate(json.loads(path.read_text("utf-8"))))
|
||||
document = VerdictDocument.model_validate(json.loads(path.read_text("utf-8")))
|
||||
except (OSError, ValueError, ValidationError):
|
||||
continue
|
||||
if document.decision not in _FILE_DECISIONS:
|
||||
continue # §4.2 vocabulary (C-F7): unknown decision → SKIP, never the store
|
||||
# Outside the tolerant try on purpose — a cap breach must NEVER be
|
||||
# swallowed as one more skipped file.
|
||||
if len(document.rationale) > max_rationale_chars:
|
||||
raise InboxLimitError(
|
||||
f"verdict file {path.name} carries a rationale of "
|
||||
f"{len(document.rationale)} chars, over the cap of "
|
||||
f"{max_rationale_chars} — refusing to load"
|
||||
)
|
||||
verdicts.append(document)
|
||||
return verdicts
|
||||
|
||||
|
||||
def merge_inbox_into_store(store: VerdictStore, inbox_dir: Path) -> int:
|
||||
def merge_inbox_into_store(
|
||||
store: VerdictStore,
|
||||
inbox_dir: Path,
|
||||
*,
|
||||
max_files: int = _DEFAULT_MAX_FILES,
|
||||
max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS,
|
||||
) -> int:
|
||||
"""Merge, never replace (§5): per-verdict add, first-write-wins per id.
|
||||
|
||||
Runs BEFORE the Step-1 fold, so a passed-in store's existing verdicts
|
||||
survive (cross-project threading) and repeated merges are idempotent.
|
||||
Returns the number of inbox verdicts ingested; never writes anything.
|
||||
"""
|
||||
verdicts = load_inbox(inbox_dir)
|
||||
verdicts = load_inbox(inbox_dir, max_files=max_files, max_rationale_chars=max_rationale_chars)
|
||||
for verdict in verdicts:
|
||||
store.add(verdict.to_record())
|
||||
return len(verdicts)
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ class PromotionError(ValueError):
|
|||
|
||||
|
||||
def _filename_token(verdict_id: str) -> str:
|
||||
# Path-safe, fail-closed against escaping names: sanitise to the safe
|
||||
# alphabet; a degenerate token falls back to a content hash.
|
||||
# Path-safe, fail-closed against escaping names: an id that survives the
|
||||
# safe alphabet unchanged keeps its name; ANY id the sanitiser had to
|
||||
# touch maps to a content hash instead (C2.5) — stripping alone let
|
||||
# 'e/vil' collide onto the same promoted file as the distinct id 'evil'.
|
||||
token = _TOKEN_UNSAFE.sub("", verdict_id)
|
||||
if not token.strip("."):
|
||||
if token != verdict_id or not token.strip("."):
|
||||
token = hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16]
|
||||
return token
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ turn, a first-class USD cap (``ClaudeAgentOptions.max_budget_usd``) ON TOP of
|
|||
the §8 token/round meter that the loop already charges, and NO filesystem
|
||||
settings (``setting_sources=[]``).
|
||||
|
||||
Verified against claude-agent-sdk 0.2.110: ``query()`` yields
|
||||
``AssistantMessage`` (text blocks + real model id) and a closing
|
||||
``ResultMessage`` (provider-reported ``usage`` + ``total_cost_usd``). A reply
|
||||
without usage is passed through as ``None`` so the meter fails CLOSED (§8) —
|
||||
this client never invents a count.
|
||||
Verified against claude-agent-sdk 0.2.110 at source level and release notes
|
||||
through 0.2.120 — pinned ``>=0.2.111,<0.3`` with a version guard
|
||||
(``tests/test_sdk_version_guard.py``) that forces re-verification of these
|
||||
premises before any widening: ``query()`` yields ``AssistantMessage`` (text
|
||||
blocks + real model id) and a closing ``ResultMessage`` (provider-reported
|
||||
``usage`` + ``total_cost_usd``). A reply without usage is passed through as
|
||||
``None`` so the meter fails CLOSED (§8) — this client never invents a count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ from __future__ import annotations
|
|||
from typing import Any, AsyncIterator
|
||||
|
||||
import pytest
|
||||
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock, ThinkingBlock
|
||||
|
||||
from portfolio_optimiser_claude import sdk_client
|
||||
from portfolio_optimiser_claude.contracts import ModelMapContract
|
||||
from portfolio_optimiser_claude.sdk_client import SdkModelClient, build_call_options
|
||||
from portfolio_optimiser_claude.sdk_client import SdkModelClient, _total_tokens, build_call_options
|
||||
|
||||
|
||||
class TestBuildCallOptions:
|
||||
|
|
@ -32,8 +33,11 @@ class TestBuildCallOptions:
|
|||
assert options.setting_sources == []
|
||||
|
||||
def test_the_system_prompt_is_empty(self) -> None:
|
||||
# None serializes to --system-prompt "" (verified against 0.2.110):
|
||||
# no Claude Code preset, no appended operator instructions.
|
||||
# Pins the OPTION value: None, not the Claude Code preset. That None
|
||||
# reaches the spawned CLI as --system-prompt "" was verified by
|
||||
# READING subprocess_cli.py (0.2.110–0.2.120) — this test does NOT
|
||||
# bind that transport serialization; doing so would couple the suite
|
||||
# to SDK-private API (the F11 fragility this repo retired).
|
||||
options = build_call_options("model-x", max_budget_usd=0.25)
|
||||
assert options.system_prompt is None
|
||||
|
||||
|
|
@ -75,3 +79,148 @@ class TestCompleteThreadsIsolatedOptions:
|
|||
assert captured["options"].model == "model-default"
|
||||
# No usage surfaced by the fake → the reply fails CLOSED (§8).
|
||||
assert reply.usage_tokens is None
|
||||
|
||||
|
||||
def _stream_of(*messages: Any) -> Any:
|
||||
"""A fake ``query`` yielding a scripted stream of REAL SDK message objects."""
|
||||
|
||||
def fake_query(*, prompt: str, options: Any) -> AsyncIterator[Any]:
|
||||
async def _stream() -> AsyncIterator[Any]:
|
||||
for message in messages:
|
||||
yield message
|
||||
|
||||
return _stream()
|
||||
|
||||
return fake_query
|
||||
|
||||
|
||||
def _assistant(*blocks: Any, model: str = "model-real", error: Any = None) -> AssistantMessage:
|
||||
return AssistantMessage(content=list(blocks), model=model, error=error)
|
||||
|
||||
|
||||
def _result(
|
||||
usage: dict[str, Any] | None = None,
|
||||
total_cost_usd: float | None = None,
|
||||
is_error: bool = False,
|
||||
subtype: str = "success",
|
||||
errors: list[str] | None = None,
|
||||
) -> ResultMessage:
|
||||
return ResultMessage(
|
||||
subtype=subtype,
|
||||
duration_ms=1,
|
||||
duration_api_ms=1,
|
||||
is_error=is_error,
|
||||
num_turns=1,
|
||||
session_id="s",
|
||||
usage=usage,
|
||||
total_cost_usd=total_cost_usd,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def _client() -> SdkModelClient:
|
||||
return SdkModelClient(ModelMapContract(profiles={"anthropic": {"default": "model-default"}}))
|
||||
|
||||
|
||||
_FULL_USAGE = {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"cache_creation_input_tokens": 3,
|
||||
"cache_read_input_tokens": 2,
|
||||
}
|
||||
|
||||
|
||||
class TestCompleteAsyncStreamBinding:
|
||||
"""C2.5 (R-4/R-5): the read loop is BOUND offline with real SDK message types.
|
||||
|
||||
Before C2.5 nothing in the suite executed sdk_client's aggregation,
|
||||
error, usage or cost branches — the fake stream (real ``AssistantMessage``
|
||||
/ ``ResultMessage`` / ``TextBlock`` objects, so constructor drift also
|
||||
goes red) binds every branch without a key or the network.
|
||||
"""
|
||||
|
||||
def test_text_aggregates_and_non_text_blocks_are_ignored(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sdk_client,
|
||||
"query",
|
||||
_stream_of(
|
||||
_assistant(TextBlock("{"), ThinkingBlock(thinking="hmm", signature="sig")),
|
||||
_assistant(TextBlock("}")),
|
||||
_result(usage=_FULL_USAGE, total_cost_usd=0.01),
|
||||
),
|
||||
)
|
||||
client = _client()
|
||||
reply = client.complete("p", role="proposer")
|
||||
assert reply.text == "{}"
|
||||
assert reply.model == "model-real"
|
||||
assert client.last_model == "model-real"
|
||||
|
||||
def test_usage_tokens_sum_the_four_provider_fields(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sdk_client,
|
||||
"query",
|
||||
_stream_of(_assistant(TextBlock("ok")), _result(usage=_FULL_USAGE)),
|
||||
)
|
||||
assert _client().complete("p", role="proposer").usage_tokens == 20
|
||||
|
||||
def test_cost_accumulates_across_calls(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
for cost in (0.01, 0.02):
|
||||
monkeypatch.setattr(
|
||||
sdk_client,
|
||||
"query",
|
||||
_stream_of(_assistant(TextBlock("ok")), _result(total_cost_usd=cost)),
|
||||
)
|
||||
client.complete("p", role="proposer")
|
||||
assert client.total_cost_usd == pytest.approx(0.03)
|
||||
|
||||
def test_an_assistant_error_fails_the_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
sdk_client, "query", _stream_of(_assistant(TextBlock("x"), error="rate_limit"))
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="rate_limit"):
|
||||
_client().complete("p", role="proposer")
|
||||
|
||||
def test_a_result_error_fails_the_call_naming_subtype_and_errors(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sdk_client,
|
||||
"query",
|
||||
_stream_of(
|
||||
_assistant(TextBlock("x")),
|
||||
_result(is_error=True, subtype="error_during_execution", errors=["boom"]),
|
||||
),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="error_during_execution.*boom"):
|
||||
_client().complete("p", role="proposer")
|
||||
|
||||
|
||||
class TestTotalTokensFailsClosed:
|
||||
"""§8: the meter is never fed an invented count — no usage stays ``None``."""
|
||||
|
||||
def test_no_usage_dict_is_none(self) -> None:
|
||||
assert _total_tokens(None) is None
|
||||
|
||||
def test_an_empty_usage_dict_is_none(self) -> None:
|
||||
assert _total_tokens({}) is None
|
||||
|
||||
def test_non_int_fields_are_ignored_not_coerced(self) -> None:
|
||||
assert _total_tokens({"input_tokens": "10"}) is None
|
||||
assert _total_tokens({"input_tokens": 10, "output_tokens": "x"}) == 10
|
||||
|
||||
|
||||
class TestBudgetGuard:
|
||||
"""§8: a non-positive per-call USD cap is refused at construction."""
|
||||
|
||||
@pytest.mark.parametrize("cap", [0.0, -0.5])
|
||||
def test_non_positive_caps_are_rejected(self, cap: float) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SdkModelClient(
|
||||
ModelMapContract(profiles={"anthropic": {"default": "m"}}),
|
||||
max_budget_usd_per_call=cap,
|
||||
)
|
||||
|
|
|
|||
82
tests/test_sdk_version_guard.py
Normal file
82
tests/test_sdk_version_guard.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""SDK version guard (C2.5, closes C-N3) — LOAD-BEARING (§11).
|
||||
|
||||
The seam this file keeps alive: every SDK attribute premise in
|
||||
``sdk_client.py`` was verified against a CONCRETE version range
|
||||
(0.2.110 read at source level, release notes through 0.2.120; sdk-review
|
||||
2026-07-16). The pin ``claude-agent-sdk>=0.2.111,<0.3`` freezes that range —
|
||||
this guard makes an upgrade outside it a RED test naming exactly which
|
||||
premises must be re-verified, instead of a silent behaviour drift.
|
||||
|
||||
Offline-safe: reads installed package metadata only — no key, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
|
||||
# The verified range — MUST match the pyproject pin (bound below).
|
||||
_VERIFIED_FLOOR = (0, 2, 111)
|
||||
_VERIFIED_CEILING = (0, 3)
|
||||
_PIN = "claude-agent-sdk>=0.2.111,<0.3"
|
||||
|
||||
# The sdk_client.py attribute premises the verified range vouches for
|
||||
# (sdk-review 2026-07-16, verified against package source through 0.2.120).
|
||||
_SDK_PREMISES = (
|
||||
"AssistantMessage.error/.model/.content",
|
||||
"ResultMessage.usage/.total_cost_usd/.is_error/.subtype/.errors",
|
||||
"ClaudeAgentOptions.max_budget_usd/.setting_sources/.system_prompt/.max_turns/.model/.tools",
|
||||
"TextBlock.text",
|
||||
"query() yields AssistantMessage then a closing ResultMessage",
|
||||
)
|
||||
|
||||
|
||||
def _parse(raw: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in raw.split(".")[:3])
|
||||
|
||||
|
||||
def check_sdk_version() -> str:
|
||||
"""Fail if the installed SDK is outside the verified range — naming the premises."""
|
||||
raw = importlib.metadata.version("claude-agent-sdk")
|
||||
if not (_VERIFIED_FLOOR <= _parse(raw) < _VERIFIED_CEILING):
|
||||
raise AssertionError(
|
||||
f"claude-agent-sdk {raw} is OUTSIDE the verified range >=0.2.111,<0.3. "
|
||||
"Re-verify the sdk_client.py attribute premises against the new version "
|
||||
"BEFORE widening the pin (pyproject.toml + this guard together): "
|
||||
+ "; ".join(_SDK_PREMISES)
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
class TestSdkVersionGuard:
|
||||
def test_the_installed_sdk_is_within_the_verified_range(self) -> None:
|
||||
assert check_sdk_version() == importlib.metadata.version("claude-agent-sdk")
|
||||
|
||||
def test_a_version_above_the_ceiling_trips_the_guard(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.3.0")
|
||||
with pytest.raises(AssertionError) as err:
|
||||
check_sdk_version()
|
||||
message = str(err.value)
|
||||
assert "sdk_client.py" in message
|
||||
for premise in _SDK_PREMISES:
|
||||
assert premise in message
|
||||
|
||||
def test_a_version_below_the_floor_trips_the_guard(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# 0.2.110 works but misses the 0.2.111 read-loop fixes (NDJSON
|
||||
# whitespace on >64 KiB lines, plain-string content TypeError,
|
||||
# zombie subprocess on cancellation) — below the floor is unverified.
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.110")
|
||||
with pytest.raises(AssertionError):
|
||||
check_sdk_version()
|
||||
|
||||
def test_the_pyproject_pin_matches_the_verified_range(self) -> None:
|
||||
# Detach-proof: the pin and this guard cannot drift apart silently.
|
||||
assert _PIN in _PYPROJECT.read_text(encoding="utf-8")
|
||||
|
|
@ -13,7 +13,9 @@ from __future__ import annotations
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from _scripted import ScriptedClient, reply
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser_claude.budget import BudgetMeter
|
||||
from portfolio_optimiser_claude.contracts import TerminationContract
|
||||
|
|
@ -25,6 +27,7 @@ from portfolio_optimiser_claude.experience import (
|
|||
mint_verdict_id,
|
||||
)
|
||||
from portfolio_optimiser_claude.inbox import (
|
||||
InboxLimitError,
|
||||
ProposalFeatures,
|
||||
VerdictDocument,
|
||||
load_inbox,
|
||||
|
|
@ -162,6 +165,98 @@ class TestTolerantLoad:
|
|||
assert [d.id for d in load_inbox(tmp_path)] == sorted([late.id, early.id])
|
||||
|
||||
|
||||
class TestFileLayerVocabulary:
|
||||
"""C2.5 (C-F7): the file layer polices the §4.2 decision set — SKIP, never raise."""
|
||||
|
||||
def test_an_unknown_decision_never_reaches_the_store(self, tmp_path: Path) -> None:
|
||||
# Fixture = the review's RUN C-F7 proof: before C2.5 this string
|
||||
# entered the store and its rationale folded into the next prompt.
|
||||
doc = _document().model_copy(update={"decision": "hva-som-helst"})
|
||||
(tmp_path / f"{doc.id}.json").write_text(json.dumps(doc.model_dump()), encoding="utf-8")
|
||||
store = VerdictStore()
|
||||
assert merge_inbox_into_store(store, tmp_path) == 0
|
||||
assert len(store) == 0
|
||||
|
||||
def test_the_full_file_layer_vocabulary_loads(self, tmp_path: Path) -> None:
|
||||
# Control: exactly the §4.2 set {approved, rejected,
|
||||
# approved_with_adjustment} passes the file layer.
|
||||
decisions = ("approved", "rejected", "approved_with_adjustment")
|
||||
for index, decision in enumerate(decisions):
|
||||
write_verdict(
|
||||
tmp_path, _document(_features(codes=frozenset({f"C{index}"})), decision=decision)
|
||||
)
|
||||
assert {d.decision for d in load_inbox(tmp_path)} == set(decisions)
|
||||
|
||||
|
||||
class TestInboxCaps:
|
||||
"""C2.5: configurable caps fail FAST with a precise error — never a silent cut."""
|
||||
|
||||
def test_rationale_over_the_cap_fails_fast_with_a_precise_error(self, tmp_path: Path) -> None:
|
||||
doc = _document(rationale="x" * 201)
|
||||
write_verdict(tmp_path, doc)
|
||||
with pytest.raises(InboxLimitError) as err:
|
||||
load_inbox(tmp_path, max_rationale_chars=200)
|
||||
message = str(err.value)
|
||||
assert doc.id in message
|
||||
assert "201" in message
|
||||
assert "200" in message
|
||||
|
||||
def test_rationale_at_the_cap_loads(self, tmp_path: Path) -> None:
|
||||
write_verdict(tmp_path, _document(rationale="x" * 200))
|
||||
assert len(load_inbox(tmp_path, max_rationale_chars=200)) == 1
|
||||
|
||||
def test_more_files_than_the_cap_fails_fast(self, tmp_path: Path) -> None:
|
||||
for index in range(3):
|
||||
write_verdict(tmp_path, _document(_features(codes=frozenset({f"C{index}"}))))
|
||||
with pytest.raises(InboxLimitError) as err:
|
||||
load_inbox(tmp_path, max_files=2)
|
||||
message = str(err.value)
|
||||
assert "3" in message
|
||||
assert "2" in message
|
||||
|
||||
def test_merge_threads_the_caps_through(self, tmp_path: Path) -> None:
|
||||
write_verdict(tmp_path, _document(rationale="x" * 201))
|
||||
with pytest.raises(InboxLimitError):
|
||||
merge_inbox_into_store(VerdictStore(), tmp_path, max_rationale_chars=200)
|
||||
|
||||
|
||||
class TestIdGrammar:
|
||||
"""C2.5 (R-6): the id grammar is policed BEFORE ``write_verdict`` touches disk."""
|
||||
|
||||
def test_a_traversal_id_is_rejected_at_construction(self) -> None:
|
||||
# The security agent's RUN R-6 proof: '../../escaped' used to
|
||||
# construct fine and write outside the inbox.
|
||||
with pytest.raises(ValidationError):
|
||||
VerdictDocument(
|
||||
id="../../escaped",
|
||||
decision="approved",
|
||||
rationale=RATIONALE,
|
||||
proposal_features=ProposalFeatures(
|
||||
affected_codes=["E01"],
|
||||
measure_type="led-retrofit",
|
||||
claimed_saving_nok=25000.0,
|
||||
description="surface text",
|
||||
),
|
||||
)
|
||||
|
||||
def test_write_verdict_refuses_an_escaping_id_writing_nothing(self, tmp_path: Path) -> None:
|
||||
# model_copy(update=...) bypasses model validation — the write seam
|
||||
# must fail closed on its own (detach the grammar here → a file lands
|
||||
# OUTSIDE the inbox → red).
|
||||
inbox = tmp_path / "inbox"
|
||||
doc = _document().model_copy(update={"id": "../../escaped"})
|
||||
with pytest.raises(ValueError):
|
||||
write_verdict(inbox, doc)
|
||||
assert not (tmp_path / "escaped.json").exists()
|
||||
assert not inbox.exists()
|
||||
|
||||
def test_a_loaded_file_with_an_escaping_id_is_skipped(self, tmp_path: Path) -> None:
|
||||
# Tolerant load stays tolerant: a hostile id is SKIPPED, never raised.
|
||||
doc = _document().model_copy(update={"id": "../../escaped"})
|
||||
(tmp_path / "escaped.json").write_text(json.dumps(doc.model_dump()), encoding="utf-8")
|
||||
assert load_inbox(tmp_path) == []
|
||||
|
||||
|
||||
class TestMergeIntoStore:
|
||||
"""§5: merge, never replace — first-write-wins per id, idempotent, read-only."""
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,18 @@ class TestPathSafety:
|
|||
assert not (tmp_path / "evil").exists()
|
||||
assert "/" not in path.name and "\\" not in path.name
|
||||
|
||||
def test_sanitised_ids_never_collide_with_a_distinct_id_owning_the_name(
|
||||
self, bundle: Path
|
||||
) -> None:
|
||||
# C2.5 restarbeid-funn 2: 'e/vil' used to sanitise to 'evil' — the
|
||||
# SAME promoted filename as the distinct id 'evil', so the second
|
||||
# promotion silently overwrote the first curated verdict.
|
||||
first = _promote(_document(verdict_id="e/vil", rationale=f"one ({MARKER})"), bundle)
|
||||
second = _promote(_document(verdict_id="evil", rationale=f"two ({MARKER})"), bundle)
|
||||
assert first != second
|
||||
assert parse_concept_file(first).frontmatter["verdict_id"] == "e/vil"
|
||||
assert parse_concept_file(second).frontmatter["verdict_id"] == "evil"
|
||||
|
||||
def test_degenerate_token_falls_back_to_a_content_hash(self, bundle: Path) -> None:
|
||||
path = _promote(_document(verdict_id="///"), bundle)
|
||||
assert path.parent == bundle
|
||||
|
|
|
|||
18
uv.lock
generated
18
uv.lock
generated
|
|
@ -177,7 +177,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "claude-agent-sdk"
|
||||
version = "0.2.110"
|
||||
version = "0.2.120"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -185,13 +185,13 @@ dependencies = [
|
|||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/98/8fdab35ed9e1a36bc7afab4d390cc5002094a4950996c079da9aa4541cc4/claude_agent_sdk-0.2.110.tar.gz", hash = "sha256:538b548bac07a22f65686abab063a902ac76ba35989d0f073c942f96248e9fa3", size = 255632, upload-time = "2026-06-24T22:11:52.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/93/29d4fdaa13e69034faf8d3503df915b07c820e2c08e3d6a7515149cde5bb/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fed0e0f4804d9f9cff80ab7d1b44142ebd1046cdd29ca74caef4c92c35fff8d8", size = 64924533, upload-time = "2026-06-24T22:11:55.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/03/b40bb673cd93cdc3928262c1be75fde34a7bed4bf2c2c20e04218e2005ea/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:62b23869d46cef6f6ff1d00ceaa5e846e2f1d297478421c835efb8fe99369d4f", size = 69704449, upload-time = "2026-06-24T22:11:59.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/18/ab67cb5ce641333385bed55ed8e9665c00f7d30d1f6ab12f8463ddb7695f/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:324e49553c6303d6b267217dc2912652b97af2bc96503efd12095ae915b46b83", size = 74879555, upload-time = "2026-06-24T22:12:03.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/88/3627d7d14310cfec66977551263e219365244a906fc7ca1209fb0c3a6cec/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:56371dd7a2c66c0bd497dc0b3cab4193a228b196f600676393d69c0ecee37cfb", size = 75924237, upload-time = "2026-06-24T22:12:07.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/79/c9066c5c387d42c19a4b675ec1ff5219f8920cfda8ff8b527119fd69b774/claude_agent_sdk-0.2.110-py3-none-win_amd64.whl", hash = "sha256:4235d4de6d685a189c12612095ab192b759280ede1f3aed0c3e784d52c3555f9", size = 75448209, upload-time = "2026-06-24T22:12:11.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -277,7 +277,7 @@ name = "exceptiongroup"
|
|||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
|
|
@ -601,7 +601,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "claude-agent-sdk", specifier = ">=0.2" },
|
||||
{ name = "claude-agent-sdk", specifier = ">=0.2.111,<0.3" },
|
||||
{ name = "pydantic", specifier = ">=2" },
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue