From 2d40f090858c55335ce5d569c89a9e7ec423ec4a Mon Sep 17 00:00:00 2001 From: Klosure <244417287@qq.com> Date: Sun, 26 Jul 2026 16:10:52 +0800 Subject: [PATCH] feat: add Track A authoring workflow --- dev/optimizer-evals/test_authoring_budget.py | 222 +++ .../test_authoring_provenance.py | 168 ++ dev/optimizer-evals/test_draft_lane.py | 376 ++++ .../test_orchestration_engine.py | 260 +++ .../references/authoring-protocol.md | 119 ++ .../references/authoring-rules.md | 62 +- .../scripts/authoring/__init__.py | 122 ++ .../scripts/authoring/budget.py | 1053 +++++++++++ .../scripts/authoring/draft.py | 1646 +++++++++++++++++ .../scripts/authoring/provenance.py | 612 ++++++ .../scripts/orchestration/__init__.py | 34 + .../scripts/orchestration/engine.py | 619 +++++++ .../scripts/orchestration/models.py | 861 +++++++++ 13 files changed, 6138 insertions(+), 16 deletions(-) create mode 100644 dev/optimizer-evals/test_authoring_budget.py create mode 100644 dev/optimizer-evals/test_authoring_provenance.py create mode 100644 dev/optimizer-evals/test_draft_lane.py create mode 100644 dev/optimizer-evals/test_orchestration_engine.py create mode 100644 runtime/skill-optimizer/references/authoring-protocol.md create mode 100644 runtime/skill-optimizer/scripts/authoring/__init__.py create mode 100644 runtime/skill-optimizer/scripts/authoring/budget.py create mode 100644 runtime/skill-optimizer/scripts/authoring/draft.py create mode 100644 runtime/skill-optimizer/scripts/authoring/provenance.py create mode 100644 runtime/skill-optimizer/scripts/orchestration/__init__.py create mode 100644 runtime/skill-optimizer/scripts/orchestration/engine.py create mode 100644 runtime/skill-optimizer/scripts/orchestration/models.py diff --git a/dev/optimizer-evals/test_authoring_budget.py b/dev/optimizer-evals/test_authoring_budget.py new file mode 100644 index 0000000..2d4d8dc --- /dev/null +++ b/dev/optimizer-evals/test_authoring_budget.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +import sys +import unittest + + +SCRIPTS = Path(__file__).resolve().parents[2] / "runtime" / "skill-optimizer" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from authoring.budget import ( # noqa: E402 + AuthoringBudgetExceeded, + AuthoringBudgetLedger, + AuthoringBudgetLimits, + AuthoringCostKind, + validate_authoring_budget_record, +) +from core.canonical import digest_json # noqa: E402 + + +def _resign(payload: dict) -> dict: + unsigned = {key: value for key, value in payload.items() if key != "content_digest"} + payload["content_digest"] = digest_json(unsigned) + return payload + + +class AuthoringBudgetTests(unittest.TestCase): + def test_small_skill_does_not_spend_available_optional_budget(self) -> None: + ledger = AuthoringBudgetLedger( + AuthoringBudgetLimits( + research_calls=3, + source_checks=2, + independent_calls=2, + candidate_count=2, + revision_count=2, + allowed_modules=("counter-design", "source-research"), + ) + ) + self.assertEqual(0, ledger.used("research_call")) + self.assertEqual(0, ledger.used("source_check")) + ledger.record(AuthoringCostKind.CANDIDATE, reason="one minimal candidate") + ledger.stop("core_complete") + self.assertEqual(1, ledger.used("candidate")) + self.assertEqual(0, ledger.used("independent_call")) + self.assertEqual((), ledger.selected_modules) + + def test_all_hard_counters_and_module_allowlist_are_enforced(self) -> None: + ledger = AuthoringBudgetLedger( + AuthoringBudgetLimits( + clarification_calls=1, + research_calls=1, + source_checks=1, + independent_calls=1, + candidate_count=1, + revision_count=1, + allowed_modules=("counter-design",), + ) + ) + ledger.record("clarification_call", reason="scope", source_ref="conversation:1") + ledger.record("research_call", reason="adjacent", source_ref="file:rules") + ledger.record("source_check", reason="verify", source_ref="file:rules") + ledger.record("independent_call", reason="counter", source_ref="trace:conflict") + ledger.select_module("counter-design", trigger_evidence=("trace:conflict",)) + ledger.record("candidate", reason="draft") + ledger.record("revision", reason="one causal fix", source_ref="trace:failure") + with self.assertRaises(AuthoringBudgetExceeded): + ledger.record("revision", reason="second fix") + with self.assertRaises(ValueError): + ledger.select_module("unplanned", trigger_evidence=("trace:x",)) + self.assertEqual(2, len(ledger.blocked_actions)) + + def test_blocked_actions_do_not_change_usage_and_are_replayed(self) -> None: + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(research_calls=1)) + ledger.record("research_call", reason="first", source_ref="file:one") + with self.assertRaises(AuthoringBudgetExceeded) as caught: + ledger.record("research_call", reason="over", source_ref="file:two") + self.assertIsNotNone(caught.exception.blocked_action) + self.assertEqual(1, ledger.used("research_call")) + self.assertEqual(1, len(ledger.events)) + self.assertEqual(1, len(ledger.blocked_actions)) + rebuilt = validate_authoring_budget_record(ledger.to_dict()) + self.assertEqual(ledger.canonical_digest, rebuilt.content_digest) + + def test_stopped_ledger_records_blocked_attempt_without_new_action(self) -> None: + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.stop("user_requested") + with self.assertRaises(AuthoringBudgetExceeded): + ledger.record("candidate", reason="late") + self.assertEqual(0, ledger.used("candidate")) + self.assertEqual(1, len(ledger.blocked_actions)) + + def test_selected_module_requires_trigger_evidence_and_is_frozen(self) -> None: + limits = AuthoringBudgetLimits( + allowed_modules=("counter-design", "source-research"), + max_selected_modules=1, + ) + ledger = AuthoringBudgetLedger(limits) + with self.assertRaises(ValueError): + ledger.select_module("counter-design", trigger_evidence=()) + with self.assertRaises(ValueError): + ledger.select_module("counter-design", trigger_evidence="trace:x") + with self.assertRaises(ValueError): + ledger.select_module("not-planned", trigger_evidence=("trace:x",)) + ledger.select_module( + "counter-design", + trigger_evidence=("trace:ambiguous-design",), + source_refs=("trace:ambiguous-design",), + ) + with self.assertRaises(AuthoringBudgetExceeded): + ledger.select_module("source-research", trigger_evidence=("trace:y",)) + self.assertEqual(("counter-design", "source-research"), limits.allowed_modules) + # The immutable tuple cannot be expanded by mutating a caller list. + source = ["counter-design"] + frozen = AuthoringBudgetLimits(allowed_modules=source) + source.append("unplanned") + self.assertEqual(("counter-design",), frozen.allowed_modules) + + def test_m_receipt_contains_no_quality_or_promotion_claim(self) -> None: + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.record("candidate", reason="host wrote candidate") + ledger.stop("core_complete") + payload = ledger.to_dict() + serialized = json.dumps(payload, sort_keys=True) + for forbidden in ( + "quality_pass", + "candidate_gain", + "promote", + "release", + "verified_claim", + "formal_outcome", + ): + self.assertNotIn(forbidden, serialized) + + def test_tampered_usage_actions_digest_and_module_are_rejected(self) -> None: + ledger = AuthoringBudgetLedger( + AuthoringBudgetLimits(candidate_count=1, revision_count=1, allowed_modules=("m",)) + ) + ledger.record("candidate", reason="draft") + ledger.select_module("m", trigger_evidence=("trace:need",)) + ledger.stop("done") + original = ledger.to_dict() + + usage = deepcopy(original) + usage["usage"]["candidate"] = 0 + with self.assertRaisesRegex(ValueError, "usage does not match|replayed actions"): + validate_authoring_budget_record(_resign(usage)) + + action = deepcopy(original) + action["actions"].append( + { + "sequence": 1, + "kind": "revision", + "amount": 2, + "reason": "forged", + "source_refs": [], + "trigger_evidence": [], + } + ) + action["usage"]["revision"] = 2 + with self.assertRaisesRegex(ValueError, "exceed|limit"): + validate_authoring_budget_record(_resign(action)) + + bad_digest = deepcopy(original) + bad_digest["content_digest"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(ValueError, "digest mismatch"): + validate_authoring_budget_record(bad_digest) + + bad_module = deepcopy(original) + bad_module["selected_modules"][0]["module_id"] = "unplanned" + with self.assertRaisesRegex(ValueError, "allowlist"): + validate_authoring_budget_record(_resign(bad_module)) + + def test_closed_record_rejects_claim_fields_even_when_resigned(self) -> None: + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.stop("done") + forged = ledger.to_dict() + forged["candidate_gain"] = 1 + with self.assertRaisesRegex(ValueError, "closed contract"): + validate_authoring_budget_record(_resign(forged)) + + def test_forged_blocked_action_must_replay_as_actually_blocked(self) -> None: + ledger = AuthoringBudgetLedger( + AuthoringBudgetLimits(allowed_modules=("counter-design",), max_selected_modules=1) + ) + ledger.stop("done") + forged = ledger.to_dict() + forged["blocked_actions"] = [ + { + "sequence": 0, + "kind": "module_selection", + "amount": 1, + "reason": "claimed block", + "limit": 1, + "used": 0, + "source_refs": [], + "trigger_evidence": ["trace:x"], + "module_id": "counter-design", + } + ] + with self.assertRaisesRegex(ValueError, "was allowed"): + validate_authoring_budget_record(_resign(forged)) + + def test_legacy_constructor_aliases_are_normalized(self) -> None: + limits = AuthoringBudgetLimits( + clarification_count=1, + research_count=2, + source_check_count=3, + counter_design_calls=1, + max_modules=1, + allowed_modules=("counter-design",), + ) + self.assertEqual(1, limits.clarification_calls) + self.assertEqual(2, limits.research_calls) + self.assertEqual(3, limits.source_checks) + self.assertEqual(1, limits.independent_calls) + self.assertEqual(1, limits.max_selected_modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/optimizer-evals/test_authoring_provenance.py b/dev/optimizer-evals/test_authoring_provenance.py new file mode 100644 index 0000000..c72e5fb --- /dev/null +++ b/dev/optimizer-evals/test_authoring_provenance.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import sys +import unittest + + +SCRIPTS = Path(__file__).resolve().parents[2] / "runtime" / "skill-optimizer" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from authoring.provenance import ( # noqa: E402 + DevelopmentExample, + ProvenanceError, + ProvenanceLedger, + SourceKind, + SourceResolution, + validate_development_examples, + validate_no_holdout_overlap, +) + + +class AuthoringProvenanceTests(unittest.TestCase): + def test_only_four_source_kinds_are_accepted(self) -> None: + examples = validate_development_examples( + ( + DevelopmentExample("e1", "observed", "conversation:12", "Real request"), + DevelopmentExample("e2", "user_confirmed", "user:confirmation-1", "Confirmed"), + DevelopmentExample("e3", "synthetic", "synthetic:boundary", "Generated"), + DevelopmentExample("e4", "assumed", "assumption:format", "Provisional"), + ), + source_resolver=lambda _kind, _ref: True, + ) + self.assertEqual( + ["observed", "user_confirmed", "synthetic", "assumed"], + [item.source_kind.value for item in examples], + ) + with self.assertRaises(ProvenanceError): + DevelopmentExample("bad", "inferred", "trace:x", "bad") + + def test_trusted_kinds_require_resolver_and_prefixes_are_not_authority(self) -> None: + observed = DevelopmentExample("real", "observed", "synthetic:invented", "Observed") + with self.assertRaisesRegex(ProvenanceError, "resolver"): + validate_development_examples((observed,)) + with self.assertRaisesRegex(ProvenanceError, "unresolved|unapproved"): + validate_development_examples((observed,), source_resolver=lambda _kind, _ref: False) + # An injected resolver, not a prefix heuristic, decides approval. + resolved = validate_development_examples( + (observed,), + source_resolver=lambda kind, ref: SourceResolution(kind, ref, True), + ) + self.assertEqual(observed.source_ref, resolved[0].source_ref) + + def test_resolver_mapping_must_be_closed_and_match_request(self) -> None: + example = DevelopmentExample("e", "user_confirmed", "user:event-1", "Confirmed") + malformed = { + "source_kind": "user_confirmed", + "source_ref": "user:event-1", + "approved": True, + "untrusted_note": "caller-authored", + } + with self.assertRaisesRegex(ProvenanceError, "closed contract"): + validate_development_examples((example,), source_resolver=lambda _k, _r: malformed) + mismatch = SourceResolution("observed", "conversation:other", True) + with self.assertRaisesRegex(ProvenanceError, "does not match"): + validate_development_examples((example,), source_resolver=lambda _k, _r: mismatch) + + def test_internal_record_has_content_and_record_digests(self) -> None: + example = DevelopmentExample("e", "synthetic", "synthetic:one", "Generated case") + payload = example.to_dict() + self.assertIn("content_digest", payload) + self.assertIn("record_digest", payload) + projection = example.projection() + self.assertEqual({"source_kind", "source_ref"}, set(projection)) + self.assertNotIn("summary", projection) + self.assertNotIn("content_digest", projection) + with self.assertRaisesRegex(ProvenanceError, "content_digest"): + DevelopmentExample( + "e", + "synthetic", + "synthetic:one", + "Generated case", + content_digest="sha256:" + "0" * 64, + ) + + def test_projection_is_strictly_two_fields_for_every_source_kind(self) -> None: + examples = tuple( + DevelopmentExample(str(index), kind, f"{kind}:ref", "summary") + for index, kind in enumerate(("observed", "user_confirmed", "synthetic", "assumed")) + ) + projected = validate_development_examples( + examples, + source_resolver=lambda _kind, _ref: True, + ) + for item in projected: + self.assertEqual({"source_kind", "source_ref"}, set(item.projection())) + + def test_synthetic_and_assumed_never_become_holdout_or_representative(self) -> None: + synthetic = DevelopmentExample("s", "synthetic", "synthetic:one", "Generated") + assumed = DevelopmentExample("a", "assumed", "assumption:one", "Provisional") + self.assertFalse(synthetic.supports_holdout) + self.assertFalse(assumed.supports_representativeness) + with self.assertRaisesRegex(ProvenanceError, "overlaps a holdout"): + validate_no_holdout_overlap( + (synthetic,), + holdout_examples=(DevelopmentExample("h", "synthetic", "synthetic:one", "Holdout"),), + ) + with self.assertRaisesRegex(ProvenanceError, "overlaps a holdout"): + validate_no_holdout_overlap((assumed,), holdout_refs=("assumption:one",)) + + def test_ledger_rejects_relabel_and_closes_projection_and_digests(self) -> None: + ledger = ProvenanceLedger( + source_resolver=lambda _kind, _ref: True, + ) + ledger.add(DevelopmentExample("same", "synthetic", "synthetic:one", "Generated")) + with self.assertRaisesRegex(ProvenanceError, "relabelled"): + ledger.add(DevelopmentExample("same", "observed", "conversation:one", "Generated")) + payload = ledger.to_dict() + self.assertEqual( + [{"source_kind": "synthetic", "source_ref": "synthetic:one"}], + payload["handoff_projection"], + ) + rebuilt = ProvenanceLedger.from_dict(payload) + self.assertEqual(ledger.record_digest, rebuilt.record_digest) + forged = deepcopy(payload) + forged["handoff_projection"][0]["summary"] = "claim" + with self.assertRaises(ProvenanceError): + ProvenanceLedger.from_dict(forged) + + def test_mapping_inputs_and_holdout_overlap_are_checked(self) -> None: + raw = { + "example_id": "e", + "source_kind": "synthetic", + "source_ref": "synthetic:e", + "summary": "Generated", + } + normalized = validate_development_examples((raw,)) + self.assertEqual("e", normalized[0].example_id) + with self.assertRaisesRegex(ProvenanceError, "overlaps a holdout"): + validate_development_examples((raw,), holdout_refs=("synthetic:e",)) + + def test_bool_resolver_is_only_an_injected_decision_not_a_source_field(self) -> None: + # A resolver may be a compatibility callable, but a provenance record + # cannot smuggle an approval boolean into its own mapping. + example = DevelopmentExample("e", "observed", "conversation:e", "Observed") + self.assertTrue( + validate_development_examples((example,), source_resolver=lambda _k, _r: True) + ) + forged = example.to_dict() + forged["approved"] = True + with self.assertRaisesRegex(ProvenanceError, "fields do not match"): + DevelopmentExample.from_dict(forged) + + def test_falsey_resolver_object_is_not_dropped_by_ledger(self) -> None: + class FalseyResolver: + def __bool__(self) -> bool: + return False + + def __call__(self, kind, ref) -> bool: + return kind is SourceKind.OBSERVED and ref == "conversation:e" + + ledger = ProvenanceLedger(source_resolver=FalseyResolver()) + ledger.add(DevelopmentExample("e", "observed", "conversation:e", "Observed")) + self.assertEqual(1, len(ledger.examples)) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/optimizer-evals/test_draft_lane.py b/dev/optimizer-evals/test_draft_lane.py new file mode 100644 index 0000000..2d8d307 --- /dev/null +++ b/dev/optimizer-evals/test_draft_lane.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from pathlib import Path +import os +import sys +import tempfile +import unittest + + +SCRIPTS = Path(__file__).resolve().parents[2] / "runtime" / "skill-optimizer" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from authoring.budget import AuthoringBudgetLedger, AuthoringBudgetLimits # noqa: E402 +from authoring.draft import ( # noqa: E402 + AuthorizationReceipt, + DraftAuthorizationError, + DraftContractError, + DraftIntegrityError, + DraftIsolationError, + DraftState, + DraftStatus, + basic_skill_structure_errors, + create_q0_draft, + finalize_q0_draft, + open_q0_draft, + verify_handoff, +) +from authoring.provenance import DevelopmentExample # noqa: E402 +from core.canonical import digest_json # noqa: E402 + + +def _authorization(_action, _target, _context): + return { + "action": "candidate_generation", + "approved": True, + "consumed": True, + "authorization_digest": digest_json({"grant": "candidate-generation"}), + } + + +def _write_skill(path: Path, name: str = "tiny-skill") -> None: + (path / "SKILL.md").write_text( + "---\n" + f"name: {name}\n" + "description: Do one bounded thing when explicitly requested.\n" + "---\n\n" + "# Tiny Skill\n\n" + "Perform the bounded workflow.\n", + encoding="utf-8", + ) + + +class DraftLaneTests(unittest.TestCase): + def test_authorization_is_checked_before_any_directory_is_created(self) -> None: + with tempfile.TemporaryDirectory() as directory: + run = Path(directory) / "run" + + def deny(_action, _target, _context): + self.assertFalse(run.exists()) + return { + "action": "candidate_generation", + "approved": False, + "consumed": False, + "authorization_digest": digest_json({"denied": True}), + } + + with self.assertRaises(DraftAuthorizationError): + open_q0_draft( + run, + draft_id="draft-1", + skill_name="tiny-skill", + task_mode="create", + authorization_context={"scope": "frozen"}, + authorization_consumer=deny, + ) + self.assertFalse(run.exists()) + + def test_two_phase_q0_returns_digest_bound_unverified_p0_handoff(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = open_q0_draft( + Path(directory) / "isolated-run", + draft_id="draft-1", + skill_name="tiny-skill", + task_mode="create", + authorization_context={"scope": "frozen"}, + authorization_consumer=_authorization, + ) + candidate = Path(workspace.candidate_path) + _write_skill(candidate) + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.record("candidate", reason="Host Agent wrote the Core candidate") + ledger.stop("core_complete") + result = finalize_q0_draft( + workspace, + development_examples=( + DevelopmentExample( + "example-1", + "synthetic", + "fixture:request-1", + "One bounded development example", + ), + ), + method_cost_usage=ledger.to_dict(), + stop_reason="core_complete", + ) + self.assertEqual(DraftState.DRAFT_READY, result.state) + self.assertFalse(result.handoff.formal_evaluation) + payload = result.handoff.to_dict() + self.assertEqual( + {"source_kind", "source_ref"}, + set(payload["development_examples"][0]), + ) + self.assertIn("quality_unverified_no_formal_evaluation", payload["limitations"]) + self.assertNotIn("candidate_gain", payload) + verify_handoff(result.handoff) + (candidate / "SKILL.md").write_text("drift\n", encoding="utf-8") + with self.assertRaises(DraftIntegrityError): + verify_handoff(result.handoff) + + def test_invalid_structure_and_description_only_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = open_q0_draft( + Path(directory) / "run", + draft_id="description", + skill_name="tiny-skill", + task_mode="description_only", + authorization_context={}, + authorization_consumer=_authorization, + ) + _write_skill(Path(workspace.candidate_path)) + result = finalize_q0_draft( + workspace, + development_examples=(), + method_cost_usage={}, + stop_reason="description_changed", + ) + self.assertEqual(DraftState.DRAFT_INVALID, result.state) + self.assertTrue(any("byte-preservation" in item for item in result.structure_errors)) + + bad = Path(directory) / "bad-skill" + bad.mkdir() + _write_skill(bad, "bad-skill") + (bad / "SKILL.md").write_text( + (bad / "SKILL.md").read_text(encoding="utf-8") + + "\nTODO: See [missing](references/missing.md).\n", + encoding="utf-8", + ) + errors = basic_skill_structure_errors(bad) + self.assertTrue(any("placeholder" in item for item in errors)) + self.assertTrue(any("missing referenced path" in item for item in errors)) + + def test_raw_behavior_summary_can_only_escalate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = open_q0_draft( + Path(directory) / "run", + draft_id="risk", + skill_name="tiny-skill", + task_mode="create", + authorization_context={}, + authorization_consumer=_authorization, + ) + _write_skill(Path(workspace.candidate_path)) + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.record("candidate", reason="Host Agent wrote candidate") + ledger.stop("core_complete") + result = finalize_q0_draft( + workspace, + development_examples=(), + method_cost_usage=ledger.to_dict(), + stop_reason="core_complete", + behavior_summary={ + "draft_disposition": "requires_escalation", + "minimum_risk": "R3", + }, + ) + self.assertEqual(DraftState.DRAFT_REQUIRES_ESCALATION, result.state) + self.assertIn("behavior_requires_escalation", result.handoff.limitations) + + def test_new_copy_api_rejects_raw_authority_and_symlink_escape(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + _write_skill(source, "tiny-skill") + isolated = root / "isolated" + isolated.mkdir() + destination = isolated / "tiny-skill" + scope = digest_json({"scope": 1}) + risk = digest_json({"risk": 1}) + with self.assertRaises(DraftAuthorizationError): + create_q0_draft( + source, + destination, + isolation_root=isolated, + scope_digest=scope, + risk_digest=risk, + authorization_verifier=lambda **_kwargs: True, + ) + self.assertFalse(destination.exists()) + + def typed(**kwargs): + return AuthorizationReceipt( + approved=True, + consumed=True, + grant_digest=digest_json({"grant": 1}), + event_digest=digest_json({"event": 1}), + scope_digest=kwargs["scope_digest"], + risk_digest=kwargs["risk_digest"], + target_digest=kwargs["target_digest"], + ) + + result = create_q0_draft( + source, + destination, + isolation_root=isolated, + scope_digest=scope, + risk_digest=risk, + authorization_verifier=typed, + ) + self.assertEqual(DraftStatus.DRAFT_READY, result.status) + self.assertIn("behavior risk not assessed", result.limitations) + + if hasattr(os, "symlink"): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + _write_skill(source, "tiny-skill") + isolated = root / "isolated" + isolated.mkdir() + outside = root / "outside" + outside.mkdir() + (isolated / "jump").symlink_to(outside, target_is_directory=True) + with self.assertRaises(DraftIsolationError): + create_q0_draft( + source, + isolated / "jump" / "tiny-skill", + isolation_root=isolated, + scope_digest=digest_json({"scope": 1}), + risk_digest=digest_json({"risk": 1}), + authorization_verifier=lambda **_kwargs: None, + ) + + def test_no_skill_and_formal_root_never_open_candidate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + formal = root / "installed" + formal.mkdir() + with self.assertRaises(ValueError): + open_q0_draft( + root / "no-skill", + draft_id="none", + skill_name="tiny-skill", + task_mode="no_skill", + authorization_context={}, + authorization_consumer=_authorization, + ) + with self.assertRaises(DraftIsolationError): + open_q0_draft( + formal / "run", + draft_id="wrong-root", + skill_name="tiny-skill", + task_mode="create", + authorization_context={}, + authorization_consumer=_authorization, + formal_skill_roots=(formal,), + ) + self.assertEqual([], list(formal.iterdir())) + + def test_symlinked_run_root_is_rejected_before_authorization(self) -> None: + if not hasattr(os, "symlink"): + self.skipTest("symlink unavailable") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + real = root / "real" + real.mkdir() + alias = root / "alias" + alias.symlink_to(real, target_is_directory=True) + called = False + + def consumer(*_args): + nonlocal called + called = True + return _authorization(*_args) + + with self.assertRaises(DraftIsolationError): + open_q0_draft( + alias / "run", + draft_id="draft", + skill_name="tiny-skill", + task_mode="create", + authorization_context={}, + authorization_consumer=consumer, + ) + self.assertFalse(called) + + def test_injected_structure_validator_cannot_bypass_core_gate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = open_q0_draft( + Path(directory) / "run", + draft_id="bad", + skill_name="tiny-skill", + task_mode="create", + authorization_context={}, + authorization_consumer=_authorization, + ) + # Leave the host-written candidate empty. A permissive custom + # callback must not replace the mandatory deterministic gate. + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.stop("structure_failed") + result = finalize_q0_draft( + workspace, + development_examples=(), + method_cost_usage=ledger.to_dict(), + stop_reason="structure_failed", + structure_validator=lambda _path: (), + ) + self.assertEqual(DraftState.DRAFT_INVALID, result.state) + self.assertTrue(result.structure_errors) + + def test_observed_example_without_resolver_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = open_q0_draft( + Path(directory) / "run", + draft_id="observed", + skill_name="tiny-skill", + task_mode="create", + authorization_context={}, + authorization_consumer=_authorization, + ) + _write_skill(Path(workspace.candidate_path)) + ledger = AuthoringBudgetLedger(AuthoringBudgetLimits(candidate_count=1)) + ledger.record("candidate", reason="host") + ledger.stop("done") + with self.assertRaises(DraftContractError): + finalize_q0_draft( + workspace, + development_examples=( + DevelopmentExample("e", "observed", "conversation:e", "real"), + ), + method_cost_usage=ledger.to_dict(), + stop_reason="done", + ) + + def test_caller_target_digest_cannot_rebind_another_candidate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + _write_skill(source, "tiny-skill") + isolated = root / "isolated" + isolated.mkdir() + destination = isolated / "tiny-skill" + called = False + + def verifier(**_kwargs): + nonlocal called + called = True + raise AssertionError("target mismatch must fail before authorization") + + with self.assertRaises(DraftAuthorizationError): + create_q0_draft( + source, + destination, + isolation_root=isolated, + scope_digest=digest_json({"scope": 1}), + risk_digest=digest_json({"risk": 1}), + target_digest=digest_json({"other_target": 1}), + authorization_verifier=verifier, + ) + self.assertFalse(called) + self.assertFalse(destination.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/optimizer-evals/test_orchestration_engine.py b/dev/optimizer-evals/test_orchestration_engine.py new file mode 100644 index 0000000..c6fe321 --- /dev/null +++ b/dev/optimizer-evals/test_orchestration_engine.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from pathlib import Path +import sys +import unittest + + +SCRIPTS = Path(__file__).resolve().parents[2] / "runtime" / "skill-optimizer" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from core.canonical import digest_json # noqa: E402 +from orchestration.engine import ( # noqa: E402 + ContinuationReplayError, + OrchestrationEngine, + OrchestrationError, +) +from orchestration.models import ( # noqa: E402 + ActionIntent, + ContinuationBinding, + DigestDriftError, + FactReceipt, + GrantBinding, + InteractionClass, + OrchestrationContext, + OrchestrationState, + WaitingForUserError, +) + + +def _continuation(label: str): + return { + "workflow_id": "wf-1", + "content_digest": digest_json({"continuation": label}), + } + + +def _context(*, mode: str = "create", state: str = "ready") -> OrchestrationContext: + return OrchestrationContext( + workflow_id="wf-1", + task_mode=mode, + task_digest=digest_json({"task": mode}), + scope_digest=digest_json({"scope": 1}), + risk_digest=digest_json({"risk": 1}), + target_digest=digest_json({"target": 1}), + state=state, + log_head_digest=digest_json({"head": 1}), + ) + + +class OrchestrationEngineTests(unittest.TestCase): + def test_a_b_c_classification_asks_only_for_b(self) -> None: + engine = OrchestrationEngine() + automatic = engine.next_action( + ActionIntent("copy-digest", "Carry candidate digest", automatic=True) + ) + choice = engine.next_action( + ActionIntent( + "choose-scope", + "Choose between value-valid scopes", + needs_value_decision=True, + question="Which scope do you want?", + ) + ) + fact = engine.next_action( + ActionIntent( + "show-risk", + "Relay behavior risk", + derived_fact={"minimum_risk": "R2"}, + source_refs=("behavior-report:1",), + ) + ) + self.assertEqual(InteractionClass.A_AUTOMATIC, automatic.classification) + self.assertEqual( + InteractionClass.B_USER_DECISION_OR_AUTHORIZATION, + choice.classification, + ) + self.assertEqual(InteractionClass.C_DERIVED_FACT, fact.classification) + self.assertFalse(fact.requires_user_input) + self.assertIsNone(fact.question) + + def test_only_real_action_authorizations_are_exposed(self) -> None: + for allowed in ("analysis_execution", "candidate_generation", "install"): + ActionIntent( + allowed, + f"Authorize {allowed}", + authorization_action=allowed, + question="Allow this real action?", + ) + for forbidden in ("budget_selection", "design_approval", "commit", "external_write"): + with self.assertRaises(ValueError): + ActionIntent( + forbidden, + "Fake authorization", + authorization_action=forbidden, + question="Allow?", + ) + + def test_wait_user_has_no_grant_and_continuation_is_one_use(self) -> None: + engine = OrchestrationEngine() + action = engine.next_action( + ActionIntent( + "authorize", + "Authorize analysis", + authorization_action="analysis_execution", + question="Run it?", + ) + ) + pause = engine.pause_for_user(action, continuation=_continuation("concurrent")) + payload = pause.to_dict() + self.assertEqual("WAIT_USER", payload["status"]) + self.assertNotIn("grant", payload) + self.assertNotIn("authorization_grant", payload) + + def attempt(_index): + try: + return engine.resume( + pause, + resolution={"user_answer": "yes"}, + continuation_consumer=lambda _record: True, + ) + except ContinuationReplayError as exc: + return exc + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(attempt, range(2))) + self.assertEqual(1, len([item for item in results if not isinstance(item, Exception)])) + self.assertTrue(engine.was_consumed(pause.continuation_digest)) + + def test_rejected_resume_does_not_consume_or_create_grant(self) -> None: + engine = OrchestrationEngine() + action = engine.next_action( + ActionIntent( + "authorize", + "Authorize candidate", + authorization_action="candidate_generation", + question="Create it?", + ) + ) + pause = engine.pause_for_user(action, continuation=_continuation("reject")) + with self.assertRaises(OrchestrationError): + engine.resume( + pause, + resolution={"user_answer": "yes"}, + continuation_consumer=lambda _record: False, + ) + self.assertFalse(engine.was_consumed(pause.continuation_digest)) + with self.assertRaises(ValueError): + engine.resume( + pause, + resolution={"authorization_grant": {"fake": True}}, + continuation_consumer=lambda _record: True, + ) + + def test_modern_fact_is_read_only_and_raw_summary_is_not_authority(self) -> None: + context = _context() + raw = OrchestrationEngine().next_action( + context, + "behavior_summary", + fact={"minimum_risk": "R0", "delivery_eligibility": "safe"}, + ) + self.assertIsNone(raw.fact) + self.assertFalse(raw.action.requires_user) + self.assertEqual({}, raw.action.payload) + + receipt = FactReceipt( + fact_kind="behavior_summary", + payload={"minimum_risk": "R2", "delivery_eligibility": "blocked"}, + source_digest=digest_json({"summary": 1}), + provider_digest=digest_json({"provider": 1}), + ) + trusted = OrchestrationEngine( + fact_provider=lambda **_kwargs: receipt + ).next_action(context, "behavior_summary") + self.assertEqual("R2", trusted.action.payload["minimum_risk"]) + self.assertFalse(trusted.action.requires_user) + + def test_digest_drift_invalidates_continuation_and_wait_user_writes_no_grant(self) -> None: + context = _context() + + class Continuations: + def pause(self, *, context, origin, allowed_continuation, expires_at): + return ContinuationBinding( + continuation_digest=digest_json({"continuation": origin}), + task_digest=context.task_digest, + scope_digest=context.scope_digest, + risk_digest=context.risk_digest, + target_digest=context.target_digest, + origin=origin, + allowed_continuation=allowed_continuation, + expires_at="2099-01-01T00:00:00+00:00", + log_head_digest=context.log_head_digest, + ) + + def consume(self, *, context, continuation, resolution): + return replace(continuation, consumed=True) + + engine = OrchestrationEngine(continuation_adapter=Continuations()) + requested = engine.next_action(context, "candidate_generation") + paused = engine.pause_for_user(requested, expires_at="2099-01-01T00:00:00+00:00") + with self.assertRaises(WaitingForUserError): + engine.authorize(paused.context, "candidate_generation") + drifted = replace(paused.context, scope_digest=digest_json({"scope": "changed"})) + with self.assertRaises(DigestDriftError): + engine.resume(paused.continuation, context=drifted, resolution={"answer": "yes"}) + resumed = engine.resume(paused.continuation, context=paused.context, resolution={"answer": "yes"}) + self.assertEqual(OrchestrationState.READY, resumed.state) + + def test_no_skill_cannot_generate_candidate(self) -> None: + with self.assertRaisesRegex(Exception, "no_skill"): + OrchestrationEngine().next_action(_context(mode="no_skill"), "draft") + + def test_nested_grants_are_forbidden_in_wait_and_resolution(self) -> None: + engine = OrchestrationEngine() + action = engine.next_action( + ActionIntent( + "authorize", + "Authorize analysis", + authorization_action="analysis_execution", + question="Run it?", + ) + ) + with self.assertRaises(ValueError): + engine.pause_for_user( + action, + continuation={ + "content_digest": digest_json({"continuation": "nested"}), + "metadata": {"authorization_grant": {"fake": True}}, + }, + ) + pause = engine.pause_for_user( + action, + continuation={"content_digest": digest_json({"continuation": "clean"})}, + ) + with self.assertRaises(ValueError): + engine.resume( + pause, + resolution={"nested": [{"grant": {"fake": True}}]}, + continuation_consumer=lambda _record: True, + ) + + def test_fact_receipt_kind_must_match_requested_operation(self) -> None: + context = _context() + receipt = FactReceipt( + fact_kind="quality_summary", + payload={"formal_outcome": "keep_baseline"}, + source_digest=digest_json({"source": 1}), + provider_digest=digest_json({"provider": 1}), + ) + with self.assertRaises(OrchestrationError): + OrchestrationEngine().next_action( + context, + "behavior_summary", + fact=receipt, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/runtime/skill-optimizer/references/authoring-protocol.md b/runtime/skill-optimizer/references/authoring-protocol.md new file mode 100644 index 0000000..091d14d --- /dev/null +++ b/runtime/skill-optimizer/references/authoring-protocol.md @@ -0,0 +1,119 @@ +# Authoring Protocol + +This is the host Agent's method for creating a Skill. It is deliberately prose: +the person or model operating the host writes and revises the candidate. The +Python authoring lane supplies only deterministic bookkeeping, provenance +boundaries, isolated draft validation, and a small handoff object. It must not +pretend to generate sound instructions, predict quality, or make a delivery +decision. + +## Discover + +Start with the user's conversation, approved files, real execution traces, and +facts the user has explicitly confirmed. Extract the goal, trigger boundary, +inputs, outputs, constraints, non-goals, and the smallest useful success +condition. Record where each fact came from. A string that merely looks like a +file, conversation, or approval reference is not evidence: the integration +layer must resolve `observed` and `user_confirmed` references to an approved +fact. If it cannot, keep the item provisional or stop; do not silently upgrade +it. + +Look for the actual repeated task and its failure cost. Read enough adjacent +Skills to understand the local contract, but do not import a framework merely +because it exists. A development example illustrates authoring; it is never a +sealed, unseen, representative, or holdout evaluation case. Keep `synthetic` +and `assumed` examples explicitly labelled. + +## Model + +From concrete development instances, describe the real workflow, the boundary +conditions, the likely failure points, and the work that would otherwise be +repeated. Separate facts from hypotheses and open choices. The description must +say both what the candidate does and when it should be used. Gotchas belong only +when they are grounded in an observation, reliable source, or an explicitly +labelled high-risk precaution. + +Do not reintroduce the fixed 11 viewpoints, fixed multi-agent rounds, a +three-round no-new-finding rule, universal counter-design, unanimous review, a +fixed candidate count, or a long forecast horizon. Those are conditional methods, not the protocol. A tiny +problem may use only the Core skeleton even when optional budget remains. + +## Plan resources + +For every proposed piece, ask what freedom it needs and what it costs to fail: + +- Put durable, concise behavior instructions in `SKILL.md`. +- Put conditional detail, examples, and domain references in `references/`. +- Put fragile, repeatable, deterministic transformations or checks in + `scripts/`. +- Put a reusable binary or template in `assets/` only when the candidate will + actually consume it. +- Leave it out when the model can infer it, it is not needed by the workflow, or + its necessity cannot be explained. + +Freeze the closed conditional-module allowlist for the run. Selecting a module +requires trigger evidence and consumes the single M soft budget; it never turns +the allowlist into an arbitrary plugin pool. The M ledger records actions and +limits, but it contains no quality, gain, promotion, release, or verified-claim +fields. + +## Draft + +The host writes the smallest candidate in an isolated Q0 run directory after a +real `candidate_generation` authorization has been consumed. Authorization must +be checked before the first directory creation or file write. The draft lane +does not create a formal campaign, evidence record, formal decision, package, +installation, or commit. It returns only a P0 handoff candidate path and +current-byte digest, together with provenance and explicit limitations. + +The deterministic structure gate checks that `SKILL.md` is a regular file, +frontmatter has a valid `name` and `description` under the current Skill +contract, the body is non-empty, local references exist and remain below the +candidate root, and the tree has no symlink, special file, cache, `__pycache__`, +or `.pyc` entry. A failed gate cannot be reported as `DRAFT_READY`. + +## Clean + +Remove TODOs, placeholders, dead directories, unexplained assets, duplicated +rules, and development-only material. Recompute the exact candidate bytes and +tree digest after cleaning. Re-check that the target is still the approved +isolated root and that no symlink or parent escape was introduced. If a script +was not run, say `script behavior unverified`; if no trusted behavior summary +from Track C exists, say `behavior risk not assessed`. Neither omission may be +written as `safe`. + +## Handoff + +Build the A → B projection with exactly these development-example fields: + +```text +source_kind +source_ref +``` + +The internal provenance record may also contain normalized content and record +digests for tamper evidence, but those fields do not cross this projection +without an explicitly versioned integration change. The P0 handoff sets +`formal_evaluation = false` and makes no claim about quality, gain, promotion, +release, host activation, or representativeness. + +Track B may later consume the handoff only after checking candidate bytes, +candidate digest, quality-plan binding, and any baseline binding from the shared +evidence graph. Track C may return behavior and delivery facts. Track A only +displays and routes those summaries; it cannot rewrite their risk or delivery +conclusions. + +## Revise + +Use Q feedback only when it is a real, bound failure or observation. Make one +causal revision at a time, re-run the affected deterministic checks, and keep +the original evidence visible. The authoring ledger can record the revision and +its source, but it must not claim that the candidate is better. Development +examples remain development examples after revision and cannot become holdout +evidence by relabelling. + +For `no_skill`, do not open a candidate directory. For +`description_only`, preserve every non-description byte and defer the formal +claim to the existing trusted chain. Any missing authorization, resolver, +scope/target binding, expiry check, or shared workflow receipt is a fail-closed +integration request, not permission to synthesize a result locally. diff --git a/runtime/skill-optimizer/references/authoring-rules.md b/runtime/skill-optimizer/references/authoring-rules.md index 8bb22a9..46b1ee9 100644 --- a/runtime/skill-optimizer/references/authoring-rules.md +++ b/runtime/skill-optimizer/references/authoring-rules.md @@ -1,35 +1,65 @@ # Authoring Rules -Use this reference before creating or modifying a candidate Skill. +Use this reference before creating or modifying a candidate Skill. The host +Agent remains responsible for the prose and for any judgment about whether a +Skill is useful. The deterministic authoring helpers only record bounded work, +source labels, isolated bytes, and handoff facts; they do not write a good +`SKILL.md`, evaluate quality, choose promotion, or install anything. ## Admit only useful content -Keep an instruction only when the model cannot reliably infer it, it addresses an observed failure or high-risk scenario, it is host/project/domain specific, it removes repeated work, or deleting it would fail a real test. +Keep an instruction only when the model cannot reliably infer it, it addresses +an observed failure or high-risk scenario, it is host/project/domain specific, +it removes repeated work, or deleting it would fail a real test. A gotcha must +be traceable to an observation, a reliable source, or an explicitly labelled +high-risk precaution. Do not turn a guess into a fact by repeating it. -Keep the entry `SKILL.md` below 500 lines when practical. Put conditional detail in references linked directly from the entry. Avoid nested reference chains and duplicated rules. +Keep the entry `SKILL.md` below 500 lines when practical. Put conditional detail +in directly linked references. Avoid nested reference chains, duplicated rules, +placeholder sections, and directories that the candidate never consumes. -Use principles for open judgment, parameterized steps for preferred but variable workflows, and scripts for fragile deterministic operations. Run added scripts against representative success and failure cases. +Use principles for open judgment, parameterized steps for preferred but variable +workflows, and scripts for fragile deterministic operations. Run added scripts +against representative success and failure cases before making a behavior +claim. Description must state both what the Skill does and when it should be +used. -Create an asset only when the generated Skill will copy or consume it. Keep research, reports, evidence, caches, and development fixtures out of runtime distributions. +Create an asset only when the generated Skill will copy or consume it. Keep +research, reports, evidence, caches, development fixtures, and holdout material +out of runtime distributions. Development examples are inputs to authoring, +not sealed or unseen evaluation cases. ## Create path -1. Define real core, boundary, and near-negative cases. +Use the fuller host method in `authoring-protocol.md`: + +`discover → model → plan resources → draft → clean → handoff → revise` + +1. Define real core, boundary, and near-negative cases from approved facts. 2. Check only approved local Skill roots for adjacent capability. -3. Freeze capability requirements and resolution, the ProcessPlan, design, risk findings and controls, spec, and suite. -4. Create the smallest candidate with only necessary resources after design approval. -5. Validate structure, applicable routing, explicit execution, artifacts, and baseline gain within the resolved claim cap. +3. Freeze capability requirements and resolution, the ProcessPlan, design, risk + findings and controls, spec, and suite when the shared workflow supplies + them; missing shared facts remain an integration blocker. +4. Create the smallest candidate with only necessary resources after design + approval, in the Q0 isolated draft lane when formal evaluation is not part + of this authoring step. +5. Validate structure, applicable routing, explicit execution, and artifacts + with deterministic checks. Do not infer baseline gain from authoring work. -If `task_mode=no_skill`, do not create a candidate. Run only the frozen no-Skill baseline probe and require a `baseline_sufficiency` outcome proof. +If `task_mode=no_skill`, do not create a candidate. Run only the frozen no-Skill +baseline probe and require a `baseline_sufficiency` outcome proof. A +`description_only` request must preserve every non-description byte and keep +explicit invocation results out of automatic-routing metrics. ## Optimize path 1. Snapshot the original tree and digest without following symlinks. -2. Reproduce the reported failure. -3. Attribute it to routing, execution, resource, efficiency, adapter, or evaluation. +2. Reproduce the reported failure from a real trace or user-confirmed fact. +3. Attribute it to routing, execution, resource, efficiency, adapter, or + evaluation. 4. Apply one causal hypothesis or atomic change set. 5. Re-run the failure and affected regressions. -6. Compare the candidate with the immutable baseline. -7. Return `KEEP_BASELINE` when gain is absent. Never return `NO_SKILL_CONFIRMED` merely because an optimization failed. - -For `description_only`, require every non-description byte to remain identical and keep explicit invocation results out of automatic-routing metrics. +6. Compare the candidate with the immutable baseline through the shared quality + evidence path, not through the authoring ledger. +7. Return `KEEP_BASELINE` when gain is absent. Never return + `NO_SKILL_CONFIRMED` merely because an optimization failed. diff --git a/runtime/skill-optimizer/scripts/authoring/__init__.py b/runtime/skill-optimizer/scripts/authoring/__init__.py new file mode 100644 index 0000000..5d559f9 --- /dev/null +++ b/runtime/skill-optimizer/scripts/authoring/__init__.py @@ -0,0 +1,122 @@ +"""Host-neutral authoring primitives. + +The package exports only deterministic budget and provenance controls here. +The draft lane is added by the Track A integration without importing any +quality, risk, or delivery implementation. +""" + +from .budget import ( + AUTHORING_BUDGET_RECORD_SCHEMA_VERSION, + AuthoringBlockedAction, + AuthoringBudgetExceeded, + AuthoringBudgetLedger, + AuthoringBudgetLimits, + AuthoringBudgetRecord, + AuthoringCostEvent, + AuthoringCostKind, + AuthoringModuleSelection, + validate_authoring_budget_record, +) +from .provenance import ( + PROVENANCE_SCHEMA_VERSION, + DevelopmentExample, + ProvenanceError, + ProvenanceLedger, + ReferenceResolver, + SourceKind, + SourceRequest, + SourceResolution, + SourceResolver, + validate_development_examples, + validate_no_holdout_overlap, +) +from .draft import ( + AUTHORING_HANDOFF_OBJECT_VERSION, + DRAFT_INVALID, + DRAFT_LANE_OBJECT_VERSION, + DRAFT_READY, + DRAFT_REQUIRES_ESCALATION, + AuthorizationReceipt, + AuthorizationVerifier, + AuthoringHandoff, + BehaviorSummary, + DraftAuthorizationError, + DraftContractError, + DraftIntegrityError, + DraftIsolationError, + DraftLaneError, + DraftResult, + DraftFinalizationResult, + DraftState, + DraftStatus, + DraftWorkspaceReceipt, + StructureReport, + StructureValidator, + AuthorizationConsumer, + basic_skill_structure_errors, + candidate_generation_target_digest, + create_draft, + create_q0_draft, + draft_q0, + finalize_q0_draft, + open_q0_draft, + validate_draft, + validate_skill_tree, + verify_handoff, +) + +__all__ = [ + "AUTHORING_BUDGET_RECORD_SCHEMA_VERSION", + "AuthoringBlockedAction", + "AuthoringBudgetExceeded", + "AuthoringBudgetLedger", + "AuthoringBudgetLimits", + "AuthoringBudgetRecord", + "AuthoringCostEvent", + "AuthoringCostKind", + "AUTHORING_HANDOFF_OBJECT_VERSION", + "AuthoringModuleSelection", + "AuthorizationReceipt", + "AuthorizationVerifier", + "AuthoringHandoff", + "BehaviorSummary", + "DevelopmentExample", + "DRAFT_INVALID", + "DRAFT_LANE_OBJECT_VERSION", + "DRAFT_READY", + "DRAFT_REQUIRES_ESCALATION", + "DraftAuthorizationError", + "DraftContractError", + "DraftIntegrityError", + "DraftIsolationError", + "DraftLaneError", + "DraftResult", + "DraftFinalizationResult", + "DraftState", + "DraftStatus", + "DraftWorkspaceReceipt", + "PROVENANCE_SCHEMA_VERSION", + "ProvenanceError", + "ProvenanceLedger", + "ReferenceResolver", + "SourceKind", + "SourceRequest", + "SourceResolution", + "SourceResolver", + "StructureReport", + "StructureValidator", + "AuthorizationConsumer", + "basic_skill_structure_errors", + "candidate_generation_target_digest", + "create_draft", + "create_q0_draft", + "draft_q0", + "finalize_q0_draft", + "open_q0_draft", + "validate_authoring_budget_record", + "validate_draft", + "validate_development_examples", + "validate_no_holdout_overlap", + "validate_skill_tree", + "verify_handoff", +] diff --git a/runtime/skill-optimizer/scripts/authoring/budget.py b/runtime/skill-optimizer/scripts/authoring/budget.py new file mode 100644 index 0000000..4ca755f --- /dev/null +++ b/runtime/skill-optimizer/scripts/authoring/budget.py @@ -0,0 +1,1053 @@ +"""Bounded, replayable authoring-cost records. + +The authoring manager (``M``) is intentionally small. It records the work +the host actually performed and enforces per-kind count ceilings; it does not +generate Skill content and it never produces a quality, promotion, release, +or verification claim. The record is content addressed so a handoff can +rebuild the ledger instead of trusting a caller supplied usage summary. + +This module is host neutral. In particular, it does not import the workflow +or evaluation layers. The latter may consume the plain mapping emitted by +``AuthoringBudgetRecord.to_dict`` at a later integration boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import re +from types import MappingProxyType +from typing import Any, Iterable, Mapping, Sequence + +from core.canonical import digest_json + + +AUTHORING_BUDGET_RECORD_SCHEMA_VERSION = "1.0.0" +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_MODULE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$") + + +class AuthoringBudgetExceeded(RuntimeError): + """Raised when recording another authoring action is not permitted.""" + + def __init__(self, message: str, *, blocked_action: "AuthoringBlockedAction | None" = None): + super().__init__(message) + self.blocked_action = blocked_action + + +class AuthoringCostKind(str, Enum): + """The deterministic counters maintained by M. + + ``COUNTER_DESIGN`` and the singular aliases intentionally share the + ``independent_call`` value. Counter-design is a kind of independent + authoring call, not a second public M tier or an unbounded plugin pool. + """ + + CLARIFICATION_CALL = "clarification_call" + RESEARCH_CALL = "research_call" + SOURCE_CHECK = "source_check" + INDEPENDENT_CALL = "independent_call" + CANDIDATE = "candidate" + REVISION = "revision" + + # Compatibility spellings used by early callers. + CLARIFICATION = "clarification_call" + RESEARCH = "research_call" + SOURCE = "source_check" + INDEPENDENT_DESIGN = "independent_call" + COUNTER_DESIGN = "independent_call" + COUNTER_DESIGN_CALL = "independent_call" + + +_COST_KIND_ALIASES: Mapping[str, AuthoringCostKind] = MappingProxyType( + { + "clarification": AuthoringCostKind.CLARIFICATION_CALL, + "clarification_calls": AuthoringCostKind.CLARIFICATION_CALL, + "research": AuthoringCostKind.RESEARCH_CALL, + "research_calls": AuthoringCostKind.RESEARCH_CALL, + "source": AuthoringCostKind.SOURCE_CHECK, + "source_check_calls": AuthoringCostKind.SOURCE_CHECK, + "source_checks": AuthoringCostKind.SOURCE_CHECK, + "independent_design": AuthoringCostKind.INDEPENDENT_CALL, + "independent_design_call": AuthoringCostKind.INDEPENDENT_CALL, + "counter_design": AuthoringCostKind.INDEPENDENT_CALL, + "counter_design_call": AuthoringCostKind.INDEPENDENT_CALL, + "counter_design_calls": AuthoringCostKind.INDEPENDENT_CALL, + "candidates": AuthoringCostKind.CANDIDATE, + "revisions": AuthoringCostKind.REVISION, + } +) + + +def _coerce_cost_kind(value: AuthoringCostKind | str) -> AuthoringCostKind: + if isinstance(value, AuthoringCostKind): + return value + if isinstance(value, str): + alias = _COST_KIND_ALIASES.get(value.strip().casefold()) + if alias is not None: + return alias + return AuthoringCostKind(value) + + +_LIMIT_FIELDS: Mapping[AuthoringCostKind, str] = MappingProxyType( + { + AuthoringCostKind.CLARIFICATION_CALL: "clarification_calls", + AuthoringCostKind.RESEARCH_CALL: "research_calls", + AuthoringCostKind.SOURCE_CHECK: "source_checks", + AuthoringCostKind.INDEPENDENT_CALL: "independent_calls", + AuthoringCostKind.CANDIDATE: "candidate_count", + AuthoringCostKind.REVISION: "revision_count", + } +) + + +_FORBIDDEN_M_KEYS = frozenset( + { + "quality_pass", + "candidate_gain", + "promote", + "promotion", + "release", + "verified_claim", + "verified_claims", + "formal_quality", + "formal_outcome", + } +) + + +def _non_negative_integer(value: int, field_name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{field_name} must be a non-negative integer") + return value + + +def _positive_integer(value: int, field_name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{field_name} must be a positive integer") + return value + + +def _non_empty_text(value: str, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + return value.strip() + + +def _digest(value: str, field_name: str) -> str: + if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None: + raise ValueError(f"{field_name} must be a sha256 digest") + return value + + +def _text_tuple(value: Iterable[str] | None, field_name: str) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, (str, bytes)): + raise ValueError(f"{field_name} must be an array of strings") + normalized = tuple(_non_empty_text(item, f"{field_name}[]") for item in value) + if len(set(normalized)) != len(normalized): + raise ValueError(f"{field_name} must not contain duplicates") + return normalized + + +def _forbidden_key(value: Any) -> str | None: + """Find a prohibited claim key in a caller-supplied mapping. + + The check is deliberately about keys, not prose. A source note may + mention a quality concern; that does not turn the note into a quality + claim. Exact normalized keys are rejected recursively so a caller cannot + hide a promotion claim below an arbitrary wrapper. + """ + + if isinstance(value, Mapping): + for raw_key, item in value.items(): + key = str(raw_key).strip().lower().replace("-", "_") + if key in _FORBIDDEN_M_KEYS: + return str(raw_key) + nested = _forbidden_key(item) + if nested is not None: + return nested + elif isinstance(value, (list, tuple)): + for item in value: + nested = _forbidden_key(item) + if nested is not None: + return nested + return None + + +@dataclass(frozen=True, init=False) +class AuthoringBudgetLimits: + """Frozen hard ceilings and a closed conditional-module allowlist. + + ``source_checks`` is separate from broad research calls because checking a + source is an observable cost even when no research call is made. The + ``counter_design_calls`` and ``max_modules`` keyword aliases are accepted + for callers of the earlier prototype and normalized into this one schema. + """ + + clarification_calls: int + research_calls: int + source_checks: int + independent_calls: int + candidate_count: int + revision_count: int + allowed_modules: tuple[str, ...] + max_selected_modules: int + + def __init__( + self, + clarification_calls: int = 0, + research_calls: int = 0, + independent_calls: int = 0, + candidate_count: int = 1, + revision_count: int = 0, + allowed_modules: Iterable[str] = (), + source_checks: int = 0, + *, + clarification_count: int | None = None, + research_count: int | None = None, + source_check_count: int | None = None, + counter_design_calls: int | None = None, + max_selected_modules: int | None = None, + max_modules: int | None = None, + ) -> None: + # Normalize compatibility aliases without allowing two disagreeing + # values to silently change a frozen plan. + aliases = ( + ("clarification_calls", clarification_count), + ("research_calls", research_count), + ("source_checks", source_check_count), + ) + values = { + "clarification_calls": clarification_calls, + "research_calls": research_calls, + "source_checks": source_checks, + } + for field_name, alias in aliases: + if alias is not None: + _non_negative_integer(alias, field_name) + if values[field_name] not in (0, alias): + raise ValueError(f"{field_name} and its alias disagree") + values[field_name] = alias + + independent = independent_calls + if counter_design_calls is not None: + _non_negative_integer(counter_design_calls, "counter_design_calls") + if independent not in (0, counter_design_calls): + raise ValueError("independent_calls and counter_design_calls disagree") + independent = counter_design_calls + + for field_name, value in ( + *values.items(), + ("independent_calls", independent), + ("candidate_count", candidate_count), + ("revision_count", revision_count), + ): + _non_negative_integer(value, field_name) + + if isinstance(allowed_modules, (str, bytes)): + raise ValueError("allowed_modules must be an array") + modules = tuple(_non_empty_text(item, "allowed_modules[]") for item in allowed_modules) + if len(set(modules)) != len(modules): + raise ValueError("allowed_modules must not contain duplicates") + if any(_MODULE_ID_RE.fullmatch(item) is None for item in modules): + raise ValueError("allowed_modules contains an invalid module identifier") + modules = tuple(sorted(modules)) + + supplied_max = max_selected_modules + if max_modules is not None: + _non_negative_integer(max_modules, "max_modules") + if supplied_max is not None and supplied_max != max_modules: + raise ValueError("max_selected_modules and max_modules disagree") + supplied_max = max_modules + if supplied_max is None: + supplied_max = len(modules) + _non_negative_integer(supplied_max, "max_selected_modules") + if supplied_max > len(modules): + raise ValueError("max_selected_modules cannot exceed allowed_modules") + + for field_name, value in values.items(): + object.__setattr__(self, field_name, value) + object.__setattr__(self, "independent_calls", independent) + object.__setattr__(self, "candidate_count", candidate_count) + object.__setattr__(self, "revision_count", revision_count) + object.__setattr__(self, "allowed_modules", modules) + object.__setattr__(self, "max_selected_modules", supplied_max) + + @property + def clarification_count(self) -> int: + return self.clarification_calls + + @property + def research_count(self) -> int: + return self.research_calls + + @property + def source_check_count(self) -> int: + return self.source_checks + + @property + def counter_design_calls(self) -> int: + return self.independent_calls + + @property + def max_modules(self) -> int: + return self.max_selected_modules + + def limit_for(self, kind: AuthoringCostKind | str) -> int: + normalized = _coerce_cost_kind(kind) + return int(getattr(self, _LIMIT_FIELDS[normalized])) + + def to_dict(self) -> dict[str, Any]: + return { + "clarification_calls": self.clarification_calls, + "research_calls": self.research_calls, + "source_checks": self.source_checks, + "independent_calls": self.independent_calls, + "candidate_count": self.candidate_count, + "revision_count": self.revision_count, + "allowed_modules": list(self.allowed_modules), + "max_selected_modules": self.max_selected_modules, + } + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "AuthoringBudgetLimits": + if not isinstance(value, Mapping): + raise ValueError("authoring budget limits must be an object") + canonical = { + "clarification_calls", + "research_calls", + "source_checks", + "independent_calls", + "candidate_count", + "revision_count", + "allowed_modules", + "max_selected_modules", + } + legacy = { + "clarification_calls", + "research_calls", + "independent_calls", + "candidate_count", + "revision_count", + "allowed_modules", + } + if set(value) not in (canonical, legacy): + raise ValueError("authoring budget limit fields do not match the closed contract") + modules = value["allowed_modules"] + if isinstance(modules, (str, bytes)) or not isinstance(modules, (list, tuple)): + raise ValueError("allowed_modules must be an array") + return cls( + clarification_calls=value["clarification_calls"], + research_calls=value["research_calls"], + source_checks=value.get("source_checks", 0), + independent_calls=value["independent_calls"], + candidate_count=value["candidate_count"], + revision_count=value["revision_count"], + allowed_modules=tuple(modules), + max_selected_modules=value.get("max_selected_modules"), + ) + + +@dataclass(frozen=True, init=False) +class AuthoringCostEvent: + """One consumed M action, with optional source and trigger evidence.""" + + sequence: int + kind: AuthoringCostKind + amount: int + reason: str + source_refs: tuple[str, ...] + trigger_evidence: tuple[str, ...] + + def __init__( + self, + sequence: int, + kind: AuthoringCostKind | str, + amount: int, + reason: str, + source_ref: str | None = None, + *, + source_refs: Iterable[str] | None = None, + trigger_evidence: Iterable[str] = (), + ) -> None: + _non_negative_integer(sequence, "sequence") + normalized_kind = _coerce_cost_kind(kind) + _positive_integer(amount, "amount") + normalized_reason = _non_empty_text(reason, "reason") + if source_ref is not None: + source_ref = _non_empty_text(source_ref, "source_ref") + refs = _text_tuple(source_refs, "source_refs") + if source_ref is not None: + if refs and refs != (source_ref,): + raise ValueError("source_ref and source_refs disagree") + refs = (source_ref,) + evidence = _text_tuple(trigger_evidence, "trigger_evidence") + object.__setattr__(self, "sequence", sequence) + object.__setattr__(self, "kind", normalized_kind) + object.__setattr__(self, "amount", amount) + object.__setattr__(self, "reason", normalized_reason) + object.__setattr__(self, "source_refs", refs) + object.__setattr__(self, "trigger_evidence", evidence) + + @property + def source_ref(self) -> str | None: + """Compatibility view for the old single-reference API.""" + + return self.source_refs[0] if self.source_refs else None + + def to_dict(self) -> dict[str, Any]: + return { + "sequence": self.sequence, + "kind": self.kind.value, + "amount": self.amount, + "reason": self.reason, + "source_refs": list(self.source_refs), + "trigger_evidence": list(self.trigger_evidence), + } + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "AuthoringCostEvent": + if not isinstance(value, Mapping): + raise ValueError("authoring cost event must be an object") + canonical = { + "sequence", + "kind", + "amount", + "reason", + "source_refs", + "trigger_evidence", + } + legacy = {"sequence", "kind", "amount", "reason"} + legacy_ref = legacy | {"source_ref"} + if set(value) not in (canonical, legacy, legacy_ref): + raise ValueError("authoring cost event fields do not match the closed contract") + refs = value.get("source_refs") + if refs is not None and (isinstance(refs, (str, bytes)) or not isinstance(refs, (list, tuple))): + raise ValueError("source_refs must be an array") + evidence = value.get("trigger_evidence", ()) + if isinstance(evidence, (str, bytes)) or not isinstance(evidence, (list, tuple)): + raise ValueError("trigger_evidence must be an array") + return cls( + sequence=value["sequence"], + kind=value["kind"], + amount=value["amount"], + reason=value["reason"], + source_ref=value.get("source_ref"), + source_refs=refs, + trigger_evidence=evidence, + ) + + +@dataclass(frozen=True, init=False) +class AuthoringModuleSelection: + """A conditional module selected from the frozen allowlist.""" + + module_id: str + trigger_evidence: tuple[str, ...] + source_refs: tuple[str, ...] + + def __init__( + self, + module_id: str, + trigger_evidence: Iterable[str], + *, + source_refs: Iterable[str] = (), + ) -> None: + normalized_id = _non_empty_text(module_id, "module_id") + if _MODULE_ID_RE.fullmatch(normalized_id) is None: + raise ValueError("module_id is not a valid closed module identifier") + evidence = _text_tuple(trigger_evidence, "trigger_evidence") + if not evidence: + raise ValueError("selected authoring modules require trigger evidence") + refs = _text_tuple(source_refs, "source_refs") + object.__setattr__(self, "module_id", normalized_id) + object.__setattr__(self, "trigger_evidence", evidence) + object.__setattr__(self, "source_refs", refs) + + def to_dict(self) -> dict[str, Any]: + return { + "module_id": self.module_id, + "trigger_evidence": list(self.trigger_evidence), + "source_refs": list(self.source_refs), + } + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "AuthoringModuleSelection": + if not isinstance(value, Mapping): + raise ValueError("authoring module selection must be an object") + canonical = {"module_id", "trigger_evidence", "source_refs"} + legacy = {"module_id", "trigger_evidence"} + if set(value) not in (canonical, legacy): + raise ValueError("authoring module fields do not match the closed contract") + evidence = value["trigger_evidence"] + refs = value.get("source_refs", ()) + if isinstance(evidence, (str, bytes)) or not isinstance(evidence, (list, tuple)): + raise ValueError("trigger_evidence must be an array") + if isinstance(refs, (str, bytes)) or not isinstance(refs, (list, tuple)): + raise ValueError("source_refs must be an array") + return cls(value["module_id"], tuple(evidence), source_refs=tuple(refs)) + + +@dataclass(frozen=True, init=False) +class AuthoringBlockedAction: + """An attempted action rejected before it could consume M budget.""" + + sequence: int + kind: str + amount: int + reason: str + limit: int + used: int + source_refs: tuple[str, ...] + trigger_evidence: tuple[str, ...] + module_id: str | None + + def __init__( + self, + sequence: int, + kind: AuthoringCostKind | str, + amount: int, + reason: str, + limit: int, + used: int, + *, + source_refs: Iterable[str] = (), + trigger_evidence: Iterable[str] = (), + module_id: str | None = None, + ) -> None: + _non_negative_integer(sequence, "blocked sequence") + # ``module_selection`` is intentionally outside the cost enum: a + # rejected module does not consume one of the action counters. + normalized_kind = str(kind.value if isinstance(kind, AuthoringCostKind) else kind) + if not normalized_kind: + raise ValueError("blocked kind must be non-empty") + _positive_integer(amount, "blocked amount") + _non_empty_text(reason, "blocked reason") + _non_negative_integer(limit, "blocked limit") + _non_negative_integer(used, "blocked used") + if module_id is not None: + module_id = _non_empty_text(module_id, "module_id") + object.__setattr__(self, "sequence", sequence) + object.__setattr__(self, "kind", normalized_kind) + object.__setattr__(self, "amount", amount) + object.__setattr__(self, "reason", reason.strip()) + object.__setattr__(self, "limit", limit) + object.__setattr__(self, "used", used) + object.__setattr__(self, "source_refs", _text_tuple(source_refs, "source_refs")) + object.__setattr__(self, "trigger_evidence", _text_tuple(trigger_evidence, "trigger_evidence")) + object.__setattr__(self, "module_id", module_id) + + def to_dict(self) -> dict[str, Any]: + return { + "sequence": self.sequence, + "kind": self.kind, + "amount": self.amount, + "reason": self.reason, + "limit": self.limit, + "used": self.used, + "source_refs": list(self.source_refs), + "trigger_evidence": list(self.trigger_evidence), + "module_id": self.module_id, + } + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "AuthoringBlockedAction": + expected = { + "sequence", + "kind", + "amount", + "reason", + "limit", + "used", + "source_refs", + "trigger_evidence", + "module_id", + } + if not isinstance(value, Mapping) or set(value) != expected: + raise ValueError("blocked authoring action fields do not match the closed contract") + for field_name in ("source_refs", "trigger_evidence"): + raw = value[field_name] + if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): + raise ValueError(f"blocked {field_name} must be an array") + return cls( + sequence=value["sequence"], + kind=value["kind"], + amount=value["amount"], + reason=value["reason"], + limit=value["limit"], + used=value["used"], + source_refs=value["source_refs"], + trigger_evidence=value["trigger_evidence"], + module_id=value["module_id"], + ) + + +def _canonical_usage(value: Mapping[str, Any], *, allow_legacy_missing: bool = False) -> dict[str, int]: + if not isinstance(value, Mapping): + raise ValueError("authoring budget usage must be an object") + expected = {kind.value for kind in AuthoringCostKind} + supplied = set(value) + if supplied != expected: + if allow_legacy_missing and supplied == expected - {AuthoringCostKind.SOURCE_CHECK.value}: + value = {**value, AuthoringCostKind.SOURCE_CHECK.value: 0} + else: + raise ValueError("authoring budget usage fields do not match the closed contract") + normalized: dict[str, int] = {} + for field_name in expected: + normalized[field_name] = _non_negative_integer(value[field_name], f"usage.{field_name}") + return {key: normalized[key] for key in sorted(normalized)} + + +@dataclass(frozen=True, init=False) +class AuthoringBudgetRecord: + """A sealed M ledger that can be replayed from ordinary JSON.""" + + schema_version: str + limits: AuthoringBudgetLimits + usage: Mapping[str, int] + actions: tuple[AuthoringCostEvent, ...] + selected_modules: tuple[AuthoringModuleSelection, ...] + blocked_actions: tuple[AuthoringBlockedAction, ...] + stop_reason: str | None + content_digest: str + + def __init__( + self, + schema_version: str, + limits: AuthoringBudgetLimits, + usage: Mapping[str, int], + actions: Iterable[AuthoringCostEvent], + selected_modules: Iterable[AuthoringModuleSelection], + stop_reason: str | None, + content_digest: str, + blocked_actions: Iterable[AuthoringBlockedAction] = (), + ) -> None: + object.__setattr__(self, "schema_version", schema_version) + object.__setattr__(self, "limits", limits) + object.__setattr__(self, "usage", usage) + object.__setattr__(self, "actions", tuple(actions)) + object.__setattr__(self, "selected_modules", tuple(selected_modules)) + object.__setattr__(self, "blocked_actions", tuple(blocked_actions)) + object.__setattr__(self, "stop_reason", stop_reason) + object.__setattr__(self, "content_digest", content_digest) + self._validate() + + def _validate(self) -> None: + if self.schema_version != AUTHORING_BUDGET_RECORD_SCHEMA_VERSION: + raise ValueError("unsupported authoring budget record schema_version") + if not isinstance(self.limits, AuthoringBudgetLimits): + raise TypeError("limits must be AuthoringBudgetLimits") + usage = _canonical_usage(self.usage, allow_legacy_missing=True) + object.__setattr__(self, "usage", MappingProxyType(usage)) + + totals = {kind.value: 0 for kind in AuthoringCostKind} + actions = tuple(self.actions) + for expected_sequence, event in enumerate(actions): + if not isinstance(event, AuthoringCostEvent): + raise TypeError("actions must contain AuthoringCostEvent records") + if event.sequence != expected_sequence: + raise ValueError("authoring cost actions must have contiguous sequence numbers") + totals[event.kind.value] += event.amount + if totals[event.kind.value] > self.limits.limit_for(event.kind): + raise ValueError(f"{event.kind.value} actions exceed the frozen hard limit") + if totals != dict(usage): + raise ValueError("authoring budget usage does not match replayed actions") + object.__setattr__(self, "actions", actions) + + modules = tuple(self.selected_modules) + ids: set[str] = set() + for selection in modules: + if not isinstance(selection, AuthoringModuleSelection): + raise TypeError("selected_modules must contain AuthoringModuleSelection records") + if selection.module_id in ids: + raise ValueError("selected authoring module IDs must be unique") + if selection.module_id not in self.limits.allowed_modules: + raise ValueError( + "selected authoring module is outside the frozen allowlist: " + f"{selection.module_id}" + ) + ids.add(selection.module_id) + if len(modules) > self.limits.max_selected_modules: + raise ValueError("selected authoring modules exceed the frozen module limit") + object.__setattr__(self, "selected_modules", tuple(sorted(modules, key=lambda item: item.module_id))) + + blocked = tuple(self.blocked_actions) + for expected_sequence, item in enumerate(blocked): + if not isinstance(item, AuthoringBlockedAction): + raise TypeError("blocked_actions must contain AuthoringBlockedAction records") + if item.sequence != expected_sequence: + raise ValueError("blocked authoring actions must have contiguous sequence numbers") + if item.kind in {kind.value for kind in AuthoringCostKind}: + kind = _coerce_cost_kind(item.kind) + # A stopped ledger rejects every later action even when its + # numerical counter still has room. Other blocked cost + # actions must be genuinely over their frozen ceiling. + stopped_attempt = ( + "stopped" in item.reason.casefold() + and self.stop_reason is not None + ) + if not stopped_attempt and item.used + item.amount <= self.limits.limit_for(kind): + raise ValueError("blocked action was not actually over budget") + if item.kind == "module_selection": + if item.module_id is None: + raise ValueError("blocked module selection requires module_id") + was_rejectable = ( + item.module_id not in self.limits.allowed_modules + or item.module_id in ids + or item.used >= self.limits.max_selected_modules + or ( + "stopped" in item.reason.casefold() + and self.stop_reason is not None + ) + ) + if not was_rejectable: + raise ValueError("blocked module selection was allowed by the frozen plan") + object.__setattr__(self, "blocked_actions", blocked) + + stop_reason = self.stop_reason + if stop_reason is not None: + stop_reason = _non_empty_text(stop_reason, "stop_reason") + object.__setattr__(self, "stop_reason", stop_reason) + _digest(self.content_digest, "authoring budget content_digest") + if digest_json(self.unsigned_dict()) != self.content_digest: + raise ValueError("authoring budget content_digest mismatch") + + def unsigned_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "limits": self.limits.to_dict(), + "usage": dict(self.usage), + "actions": [event.to_dict() for event in self.actions], + "selected_modules": [item.to_dict() for item in self.selected_modules], + "blocked_actions": [item.to_dict() for item in self.blocked_actions], + "stop_reason": self.stop_reason, + } + + def to_dict(self) -> dict[str, Any]: + return {**self.unsigned_dict(), "content_digest": self.content_digest} + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "AuthoringBudgetRecord": + if not isinstance(value, Mapping): + raise ValueError("authoring budget record must be an object") + canonical = { + "schema_version", + "limits", + "usage", + "actions", + "selected_modules", + "blocked_actions", + "stop_reason", + "content_digest", + } + legacy = canonical - {"blocked_actions"} + if set(value) not in (canonical, legacy): + raise ValueError("authoring budget record fields do not match the closed contract") + supplied_digest = _digest(value.get("content_digest"), "authoring budget content_digest") + unsigned = {key: item for key, item in value.items() if key != "content_digest"} + # Legacy records did not have blocked_actions. Normalize before + # checking the digest only when the caller supplied a genuinely old + # record; all newly emitted receipts are always canonical. + if "blocked_actions" not in unsigned: + unsigned = {**unsigned, "blocked_actions": []} + if digest_json(unsigned) != supplied_digest: + # Keep compatibility with a sealed legacy record whose digest did + # not include the newly introduced empty field. + legacy_unsigned = {key: item for key, item in value.items() if key != "content_digest"} + if "blocked_actions" in value or digest_json(legacy_unsigned) != supplied_digest: + raise ValueError("authoring budget content_digest mismatch") + unsigned = {**legacy_unsigned, "blocked_actions": []} + + limits_raw = value.get("limits") + if not isinstance(limits_raw, Mapping): + raise ValueError("authoring budget limits must be an object") + usage_raw = value.get("usage") + raw_actions = value.get("actions") + raw_modules = value.get("selected_modules") + raw_blocked = value.get("blocked_actions", []) + for name, raw in ( + ("actions", raw_actions), + ("selected_modules", raw_modules), + ("blocked_actions", raw_blocked), + ): + if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): + raise ValueError(f"authoring budget {name} must be an array") + return cls( + schema_version=value["schema_version"], + limits=AuthoringBudgetLimits.from_dict(limits_raw), + usage=_canonical_usage(usage_raw, allow_legacy_missing=True), + actions=tuple(AuthoringCostEvent.from_dict(item) for item in raw_actions), + selected_modules=tuple(AuthoringModuleSelection.from_dict(item) for item in raw_modules), + blocked_actions=tuple(AuthoringBlockedAction.from_dict(item) for item in raw_blocked), + stop_reason=value["stop_reason"], + content_digest=supplied_digest, + ) + + +def validate_authoring_budget_record( + value: AuthoringBudgetRecord | Mapping[str, Any], + *, + require_stopped: bool = False, + expected_stop_reason: str | None = None, +) -> AuthoringBudgetRecord: + """Rebuild and validate every M fact from a typed object or mapping.""" + + record = value if isinstance(value, AuthoringBudgetRecord) else AuthoringBudgetRecord.from_dict(value) + if require_stopped and record.stop_reason is None: + raise ValueError("finalized authoring budget record requires stop_reason") + if expected_stop_reason is not None: + normalized = _non_empty_text(expected_stop_reason, "expected_stop_reason") + if record.stop_reason != normalized: + raise ValueError("handoff stop_reason differs from the authoring budget record") + return record + + +class AuthoringBudgetLedger: + """Mutable run-time ledger with a frozen limit/allowlist snapshot.""" + + def __init__(self, limits: AuthoringBudgetLimits): + if not isinstance(limits, AuthoringBudgetLimits): + raise TypeError("limits must be AuthoringBudgetLimits") + self._limits = limits + self._usage = {kind.value: 0 for kind in AuthoringCostKind} + self._events: list[AuthoringCostEvent] = [] + self._modules: dict[str, AuthoringModuleSelection] = {} + self._blocked: list[AuthoringBlockedAction] = [] + self._stop_reason: str | None = None + + @property + def limits(self) -> AuthoringBudgetLimits: + return self._limits + + @property + def stopped(self) -> bool: + return self._stop_reason is not None + + @property + def stop_reason(self) -> str | None: + return self._stop_reason + + @property + def events(self) -> tuple[AuthoringCostEvent, ...]: + return tuple(self._events) + + @property + def actions(self) -> tuple[AuthoringCostEvent, ...]: + return self.events + + @property + def selected_modules(self) -> tuple[AuthoringModuleSelection, ...]: + return tuple(self._modules[module_id] for module_id in sorted(self._modules)) + + @property + def blocked_actions(self) -> tuple[AuthoringBlockedAction, ...]: + return tuple(self._blocked) + + def used(self, kind: AuthoringCostKind | str) -> int: + return self._usage[_coerce_cost_kind(kind).value] + + def remaining(self, kind: AuthoringCostKind | str) -> int: + normalized = _coerce_cost_kind(kind) + return self._limits.limit_for(normalized) - self.used(normalized) + + def _append_blocked( + self, + *, + kind: AuthoringCostKind | str, + amount: int, + reason: str, + limit: int, + used: int, + source_refs: Iterable[str] = (), + trigger_evidence: Iterable[str] = (), + module_id: str | None = None, + ) -> AuthoringBlockedAction: + item = AuthoringBlockedAction( + sequence=len(self._blocked), + kind=kind, + amount=amount, + reason=reason, + limit=limit, + used=used, + source_refs=source_refs, + trigger_evidence=trigger_evidence, + module_id=module_id, + ) + self._blocked.append(item) + return item + + def record( + self, + kind: AuthoringCostKind | str, + *, + reason: str, + source_ref: str | None = None, + source_refs: Iterable[str] | None = None, + trigger_evidence: Iterable[str] = (), + amount: int = 1, + ) -> AuthoringCostEvent: + normalized = _coerce_cost_kind(kind) + _positive_integer(amount, "amount") + # Check the hard ceiling before validating optional metadata so an + # over-limit attempt is deterministically classified as blocked. + current = self.used(normalized) + limit = self._limits.limit_for(normalized) + if self.stopped: + blocked = self._append_blocked( + kind=normalized, + amount=amount, + reason="authoring ledger is stopped", + limit=limit, + used=current, + source_refs=source_refs or ((source_ref,) if source_ref else ()), + trigger_evidence=trigger_evidence, + ) + raise AuthoringBudgetExceeded("authoring ledger is stopped", blocked_action=blocked) + if current + amount > limit: + blocked = self._append_blocked( + kind=normalized, + amount=amount, + reason=reason, + limit=limit, + used=current, + source_refs=source_refs or ((source_ref,) if source_ref else ()), + trigger_evidence=trigger_evidence, + ) + raise AuthoringBudgetExceeded( + f"{normalized.value} would exceed its limit ({current + amount} > {limit})", + blocked_action=blocked, + ) + event = AuthoringCostEvent( + sequence=len(self._events), + kind=normalized, + amount=amount, + reason=reason, + source_ref=source_ref, + source_refs=source_refs, + trigger_evidence=trigger_evidence, + ) + self._usage[normalized.value] = current + amount + self._events.append(event) + return event + + def select_module( + self, + module_id: str, + *, + trigger_evidence: Iterable[str], + source_refs: Iterable[str] = (), + ) -> AuthoringModuleSelection: + normalized_id = _non_empty_text(module_id, "module_id") + if isinstance(trigger_evidence, (str, bytes)): + raise ValueError("trigger_evidence must be an array of strings") + if isinstance(source_refs, (str, bytes)): + raise ValueError("source_refs must be an array of strings") + evidence = tuple(trigger_evidence) + refs = tuple(source_refs) + if self.stopped: + blocked = self._append_blocked( + kind="module_selection", + amount=1, + reason="authoring ledger is stopped", + limit=self._limits.max_selected_modules, + used=len(self._modules), + source_refs=refs, + trigger_evidence=evidence, + module_id=normalized_id, + ) + raise AuthoringBudgetExceeded("authoring ledger is stopped", blocked_action=blocked) + if normalized_id not in self._limits.allowed_modules: + blocked = self._append_blocked( + kind="module_selection", + amount=1, + reason="module is outside the frozen allowlist", + limit=self._limits.max_selected_modules, + used=len(self._modules), + source_refs=refs, + trigger_evidence=evidence, + module_id=normalized_id, + ) + # Keep the old ValueError surface for an invalid module while + # retaining the blocked attempt in the sealed ledger. + raise ValueError( + f"authoring module is outside the closed plan: {normalized_id}" + ) + if normalized_id in self._modules: + blocked = self._append_blocked( + kind="module_selection", + amount=1, + reason="module was already selected", + limit=self._limits.max_selected_modules, + used=len(self._modules), + source_refs=refs, + trigger_evidence=evidence, + module_id=normalized_id, + ) + raise ValueError(f"authoring module already selected: {normalized_id}") + if len(self._modules) >= self._limits.max_selected_modules: + blocked = self._append_blocked( + kind="module_selection", + amount=1, + reason="selected module count exceeds the frozen limit", + limit=self._limits.max_selected_modules, + used=len(self._modules), + source_refs=refs, + trigger_evidence=evidence, + module_id=normalized_id, + ) + raise AuthoringBudgetExceeded( + "selected module count exceeds the frozen limit", blocked_action=blocked + ) + selection = AuthoringModuleSelection( + normalized_id, + evidence, + source_refs=refs, + ) + self._modules[normalized_id] = selection + return selection + + def stop(self, reason: str) -> None: + normalized = _non_empty_text(reason, "stop_reason") + if self._stop_reason is not None: + raise ValueError("stop_reason is already recorded") + self._stop_reason = normalized + + def usage_dict(self) -> dict[str, int]: + return {kind.value: self._usage[kind.value] for kind in AuthoringCostKind} + + def to_record(self) -> AuthoringBudgetRecord: + unsigned = { + "schema_version": AUTHORING_BUDGET_RECORD_SCHEMA_VERSION, + "limits": self._limits.to_dict(), + "usage": self.usage_dict(), + "actions": [event.to_dict() for event in self._events], + "selected_modules": [item.to_dict() for item in self.selected_modules], + "blocked_actions": [item.to_dict() for item in self._blocked], + "stop_reason": self._stop_reason, + } + return AuthoringBudgetRecord.from_dict( + {**unsigned, "content_digest": digest_json(unsigned)} + ) + + def to_dict(self) -> dict[str, Any]: + return self.to_record().to_dict() + + @property + def canonical_digest(self) -> str: + return self.to_record().content_digest + + +__all__ = [ + "AUTHORING_BUDGET_RECORD_SCHEMA_VERSION", + "AuthoringBlockedAction", + "AuthoringBudgetExceeded", + "AuthoringBudgetLedger", + "AuthoringBudgetLimits", + "AuthoringBudgetRecord", + "AuthoringCostEvent", + "AuthoringCostKind", + "AuthoringModuleSelection", + "validate_authoring_budget_record", +] diff --git a/runtime/skill-optimizer/scripts/authoring/draft.py b/runtime/skill-optimizer/scripts/authoring/draft.py new file mode 100644 index 0000000..c6489e9 --- /dev/null +++ b/runtime/skill-optimizer/scripts/authoring/draft.py @@ -0,0 +1,1646 @@ +"""The deterministic Q0 authoring/draft lane. + +The host agent writes the candidate prose. This module only copies already +written bytes into an explicitly approved run directory, checks the narrow +Skill tree contract, and returns a P0 handoff. It intentionally has no +quality, gain, promotion, package, install, or git primitives. + +The authorization boundary is dependency injected. A caller may provide the +shared workflow event log (the normal integration path), or a typed verifier +receipt. Plain booleans and caller-authored dictionaries are deliberately not +accepted as authority. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field, is_dataclass, asdict +from enum import Enum +import os +from pathlib import Path +import re +import shutil +import stat +import tempfile +from typing import Any, Protocol, runtime_checkable + +from core.canonical import ( + AuthorizationKind, + digest_json, + tree_digest, +) +from core.workflow import ( + AppendOnlyEventLog, + AuthorizationGateDecision, + AuthorizationGrant, +) + + +# This is an internal object version. The cross-track handoff projection is +# intentionally kept to the fields documented by Track A and does not add a +# version field to that projection. +DRAFT_LANE_OBJECT_VERSION = "skill-optimizer.q0-draft-lane/v1" +AUTHORING_HANDOFF_OBJECT_VERSION = "skill-optimizer.authoring-handoff/v1" + + +class DraftStatus(str, Enum): + DRAFT_READY = "DRAFT_READY" + DRAFT_REQUIRES_ESCALATION = "DRAFT_REQUIRES_ESCALATION" + DRAFT_INVALID = "DRAFT_INVALID" + + +class DraftState(str, Enum): + """Compatibility lifecycle for the two-phase host-written draft API.""" + + DRAFT_OPEN = "DRAFT_OPEN" + DRAFT_READY = "DRAFT_READY" + DRAFT_REQUIRES_ESCALATION = "DRAFT_REQUIRES_ESCALATION" + DRAFT_INVALID = "DRAFT_INVALID" + + +# Compatibility constants are useful to host adapters which do not import the +# enum. They are plain values, never claims about quality. +DRAFT_READY = DraftStatus.DRAFT_READY.value +DRAFT_REQUIRES_ESCALATION = DraftStatus.DRAFT_REQUIRES_ESCALATION.value +DRAFT_INVALID = DraftStatus.DRAFT_INVALID.value + + +class DraftLaneError(RuntimeError): + """Base error for a Q0 draft operation.""" + + +class DraftAuthorizationError(DraftLaneError): + """The shared candidate-generation authority was not approved.""" + + +class DraftIsolationError(DraftLaneError): + """The requested destination is not an approved isolated root.""" + + +class DraftIntegrityError(DraftLaneError): + """Source or candidate bytes changed during a draft operation.""" + + +class DraftContractError(DraftLaneError): + """A handoff or provenance object is outside its closed contract.""" + + +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_MARKDOWN_LINK_RE = re.compile(r"!?(?:\[[^\]]*\])\(([^)]+)\)") +_HTML_LOCAL_LINK_RE = re.compile(r"<(?!https?://|mailto:)([^>]+)>") +_FORBIDDEN_TREE_DIRS = frozenset( + { + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".cache", + "cache", + ".tox", + ".nox", + ".git", + } +) +_FORBIDDEN_TREE_FILES = frozenset({".coverage", ".DS_Store"}) +_FORBIDDEN_TREE_SUFFIXES = frozenset({".pyc", ".pyo", ".cache"}) +_FORBIDDEN_PROVENANCE_KEYS = frozenset( + { + "quality_pass", + "candidate_gain", + "promote", + "promotion", + "release", + "verified_claim", + "verified_claims", + "formal_decision", + "quality_claim", + } +) +_TRUSTED_SOURCE_KINDS = frozenset({"observed", "user_confirmed"}) +_SOURCE_KINDS = frozenset({"observed", "user_confirmed", "synthetic", "assumed"}) +_OS_CANONICAL_SYMLINKS = { + Path("/var"): Path("/private/var"), + Path("/tmp"): Path("/private/tmp"), +} + + +def _require_text(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise DraftContractError(f"{field_name} must be non-empty text") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise DraftContractError(f"{field_name} must be valid UTF-8 text") from exc + return value + + +def _require_digest(value: Any, field_name: str, *, nullable: bool = False) -> str | None: + if value is None and nullable: + return None + if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None: + raise DraftContractError(f"{field_name} must be a sha256 digest") + return value + + +def _closed_digest_or_none(value: Any, field_name: str) -> str | None: + return _require_digest(value, field_name, nullable=True) + + +def _canonical_mapping(value: Any, field_name: str) -> dict[str, Any]: + if is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + if not isinstance(value, Mapping): + raise DraftContractError(f"{field_name} must be a mapping") + try: + # A round trip both normalizes enum/dataclass values and rejects values + # which cannot participate in the repository's canonical digest. + import json + + return json.loads(__import__("core.canonical", fromlist=["canonical_json"]).canonical_json(value)) + except (TypeError, ValueError) as exc: + raise DraftContractError(f"{field_name} is not canonically serializable") from exc + + +def _contains_forbidden_key(value: Any, path: str = "$") -> str | None: + if isinstance(value, Mapping): + for key, item in value.items(): + key_text = str(key) + if key_text.casefold() in {item.casefold() for item in _FORBIDDEN_PROVENANCE_KEYS}: + return f"{path}.{key_text}" + found = _contains_forbidden_key(item, f"{path}.{key_text}") + if found: + return found + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + found = _contains_forbidden_key(item, f"{path}[{index}]") + if found: + return found + return None + + +def _safe_resolved(path: str | os.PathLike[str], *, strict: bool) -> Path: + candidate = Path(path).expanduser() + try: + return candidate.resolve(strict=strict) + except (OSError, RuntimeError) as exc: + raise DraftIsolationError(f"cannot resolve path safely: {candidate}") from exc + + +def _lexical_absolute(path: str | os.PathLike[str]) -> Path: + """Return an absolute path without following any filesystem component.""" + + return Path(os.path.abspath(Path(path).expanduser())) + + +def _assert_no_symlink_components(path: Path, *, allow_missing_leaf: bool = False) -> None: + """Reject symlinks in every existing component of ``path``. + + ``Path.resolve`` alone is not enough: it follows a symlink and would make + an escape look like an ordinary descendant. We inspect lexical parents + with ``lstat`` before any write. + """ + + absolute = Path(os.path.abspath(path)) + current = Path(absolute.anchor) + parts = absolute.parts[1:] + for index, part in enumerate(parts): + current = current / part + try: + metadata = current.lstat() + except FileNotFoundError: + if allow_missing_leaf: + # All later components must also be absent; a symlink cannot + # be hidden after the first missing component without a race. + return + raise DraftIsolationError(f"path component does not exist: {current}") + except OSError as exc: + raise DraftIsolationError(f"cannot inspect path component: {current}") from exc + if stat.S_ISLNK(metadata.st_mode): + # macOS exposes /var and /tmp as stable system aliases. They are + # outside the caller-controlled run root and are safe to accept; + # every symlink below them remains rejected. + canonical = _OS_CANONICAL_SYMLINKS.get(current) + if canonical is not None and current.resolve(strict=True) == canonical: + continue + raise DraftIsolationError(f"symlink path component is not allowed: {current}") + + +def _is_within(path: Path, root: Path, *, strict: bool = False) -> bool: + try: + relative = path.relative_to(root) + except ValueError: + return False + return bool(relative.parts) if strict else True + + +def _scan_tree(root: Path) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return (errors, relative files) for a candidate tree. + + ``os.scandir`` + ``lstat`` is used instead of ``Path.rglob`` so a symlink + or special file is never followed while checking the tree. + """ + + errors: list[str] = [] + files: list[str] = [] + if not root.is_dir() or root.is_symlink(): + return ("candidate root must be a regular directory",), () + + def visit(current: Path, relative: str) -> None: + try: + entries = sorted(os.scandir(current), key=lambda item: item.name) + except OSError as exc: + errors.append(f"cannot scan {relative}: {exc}") + return + for entry in entries: + child = Path(entry.path) + child_relative = entry.name if relative == "." else f"{relative}/{entry.name}" + try: + metadata = child.lstat() + except OSError as exc: + errors.append(f"cannot inspect {child_relative}: {exc}") + continue + if stat.S_ISLNK(metadata.st_mode): + errors.append(f"symlink is not allowed: {child_relative}") + continue + if stat.S_ISDIR(metadata.st_mode): + if entry.name in _FORBIDDEN_TREE_DIRS: + errors.append(f"cache or control directory is not allowed: {child_relative}") + continue + visit(child, child_relative) + continue + if not stat.S_ISREG(metadata.st_mode): + errors.append(f"special filesystem entry is not allowed: {child_relative}") + continue + if entry.name in _FORBIDDEN_TREE_FILES: + # .DS_Store is not a cache generated by the draft lane but is + # development-only noise and therefore fails the closed tree + # contract just like other unexplained files. + errors.append(f"development artifact is not allowed: {child_relative}") + if child.suffix.lower() in _FORBIDDEN_TREE_SUFFIXES: + errors.append(f"bytecode cache is not allowed: {child_relative}") + files.append(child_relative) + + visit(root, ".") + return tuple(errors), tuple(sorted(files)) + + +def _parse_scalar_frontmatter(skill_md: Path) -> tuple[dict[str, str], str]: + """Parse the repository's deliberately narrow ``name``/``description`` subset.""" + + try: + text = skill_md.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise DraftContractError(f"cannot read {skill_md}: {exc}") from exc + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + raise DraftContractError("SKILL.md has no opening frontmatter delimiter") + try: + end = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---") + except StopIteration as exc: + raise DraftContractError("SKILL.md has no closing frontmatter delimiter") from exc + + result: dict[str, str] = {} + index = 1 + while index < end: + line = lines[index] + if not line.strip() or line.lstrip().startswith("#"): + index += 1 + continue + if line[:1].isspace() or ":" not in line: + raise DraftContractError( + f"SKILL.md:{index + 1}: only scalar top-level frontmatter is supported" + ) + key, raw_value = line.split(":", 1) + key = key.strip() + value = raw_value.strip() + if not key or not value or key in result: + raise DraftContractError(f"SKILL.md:{index + 1}: invalid or duplicate frontmatter field") + if value.startswith(('"', "'")): + quote = value[0] + if not value.endswith(quote): + raise DraftContractError(f"SKILL.md:{index + 1}: unterminated quoted scalar") + if quote == '"': + import json + + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise DraftContractError( + f"SKILL.md:{index + 1}: invalid quoted scalar" + ) from exc + if not isinstance(parsed, str): + raise DraftContractError(f"SKILL.md:{index + 1}: frontmatter value must be text") + value = parsed + else: + value = value[1:-1].replace("''", "'") + elif value in {"|", "|-", ">", ">-"}: + # Support the common block scalar shape without pulling in a YAML + # dependency. Indented lines are consumed until the delimiter. + continuation: list[str] = [] + cursor = index + 1 + while cursor < end and (not lines[cursor].strip() or lines[cursor][:1].isspace()): + continuation.append(lines[cursor].strip()) + cursor += 1 + value = "\n".join(continuation).strip() + index = cursor - 1 + result[key] = value + index += 1 + + if set(result) != {"name", "description"}: + missing = sorted({"name", "description"} - set(result)) + extra = sorted(set(result) - {"name", "description"}) + raise DraftContractError( + f"SKILL.md frontmatter fields are closed (missing={missing}, extra={extra})" + ) + name = result["name"] + description = result["description"] + if not _NAME_RE.fullmatch(name) or len(name) > 64: + raise DraftContractError("frontmatter name must be lowercase hyphenated text of at most 64 characters") + if not description.strip(): + raise DraftContractError("frontmatter description must be non-empty") + return result, "\n".join(lines[end + 1 :]) + + +def _validate_local_references(root: Path, body: str, files: Sequence[str] = ()) -> list[str]: + errors: list[str] = [] + documents: list[tuple[Path, str]] = [(root / "SKILL.md", body)] + # Check links in directly shipped text references too. Binary assets are + # ignored; their bytes are still covered by the tree digest and the + # special-file/symlink gate. + for relative in files: + if relative == "SKILL.md" or relative.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".zip", ".pdf")): + continue + document = root / relative + try: + text = document.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + documents.append((document, text)) + for document, text in documents: + references: list[str] = [] + for match in _MARKDOWN_LINK_RE.finditer(text): + target = match.group(1).strip().split(" ", 1)[0].strip("<>") + references.append(target) + references.extend(match.group(1).strip() for match in _HTML_LOCAL_LINK_RE.finditer(text)) + for target in references: + if not target or target.startswith(("#", "http://", "https://", "mailto:", "data:")): + continue + # Remove a fragment/query before treating the value as a filesystem + # path. Absolute paths and ``..`` escapes are always rejected. + target = target.split("#", 1)[0].split("?", 1)[0] + if not target: + continue + if target.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", target): + errors.append(f"local reference is absolute: {target}") + continue + resolved = (document.parent / target).resolve(strict=False) + if not _is_within(resolved, root): + errors.append(f"local reference escapes candidate root: {target}") + continue + if not resolved.exists() or not resolved.is_file(): + errors.append(f"local reference does not exist: {target}") + return errors + + +def validate_skill_tree( + root: str | os.PathLike[str], *, require_name_match: bool = True +) -> "StructureReport": + """Run the closed, deterministic Q0 structure gate without writing. + + ``require_name_match`` is disabled only for a source template before it is + published. The published candidate always uses the strict directory/name + binding used by the host package contract. + """ + + lexical = _lexical_absolute(root) + _assert_no_symlink_components(lexical) + path = _safe_resolved(lexical, strict=True) + errors, files = _scan_tree(path) + skill_md = path / "SKILL.md" + if not skill_md.exists() or not skill_md.is_file() or skill_md.is_symlink(): + errors = (*errors, "SKILL.md must be a regular file") + return StructureReport(False, tuple(sorted(set(errors))), None, None, files) + try: + frontmatter, body = _parse_scalar_frontmatter(skill_md) + except DraftContractError as exc: + return StructureReport(False, tuple(sorted(set((*errors, str(exc))))), None, None, files) + if not body.strip(): + errors = (*errors, "SKILL.md body must be non-empty") + placeholder_text = "\n".join((frontmatter["name"], frontmatter["description"], body)) + if re.search( + r"\b(?:TODO|TBD|PLACEHOLDER)\b||\{\{[^}]+\}\}", + placeholder_text, + re.IGNORECASE, + ): + errors = (*errors, "SKILL.md contains TODO/placeholder content") + if require_name_match and frontmatter["name"] != path.name: + errors = (*errors, "frontmatter name must match candidate directory name") + errors = (*errors, *_validate_local_references(path, body, files)) + try: + digest = tree_digest(path) if not errors else None + except (OSError, ValueError) as exc: + errors = (*errors, f"candidate tree digest failed: {exc}") + digest = None + return StructureReport(not errors, tuple(sorted(set(errors))), digest, frontmatter, files) + + +@dataclass(frozen=True) +class StructureReport: + valid: bool + errors: tuple[str, ...] = () + tree_digest: str | None = None + frontmatter: Mapping[str, str] | None = None + files: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + return { + "valid": self.valid, + "errors": list(self.errors), + "tree_digest": self.tree_digest, + "frontmatter": dict(self.frontmatter) if self.frontmatter is not None else None, + "files": list(self.files), + } + + +@dataclass(frozen=True) +class AuthorizationReceipt: + """Typed result accepted from an injected authorization verifier. + + ``consumed`` means the verifier checked/consumed the authoritative grant + before returning. The draft lane never fabricates this receipt from a + boolean or an arbitrary mapping. + """ + + approved: bool + consumed: bool + grant_digest: str + event_digest: str + scope_digest: str + risk_digest: str + target_digest: str | None = None + authorization_id: str | None = None + action: str = "candidate_generation" + + def __post_init__(self) -> None: + if not isinstance(self.approved, bool) or not isinstance(self.consumed, bool): + raise DraftContractError("authorization receipt flags must be boolean") + if self.action != AuthorizationKind.CANDIDATE_GENERATION.value: + raise DraftAuthorizationError("authorization receipt is not for candidate generation") + _require_digest(self.grant_digest, "authorization receipt grant_digest") + _require_digest(self.event_digest, "authorization receipt event_digest") + _require_digest(self.scope_digest, "authorization receipt scope_digest") + _require_digest(self.risk_digest, "authorization receipt risk_digest") + _closed_digest_or_none(self.target_digest, "authorization receipt target_digest") + if not self.approved or not self.consumed: + raise DraftAuthorizationError("candidate-generation authority was not approved and consumed") + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "grant_digest": self.grant_digest, + "event_digest": self.event_digest, + "scope_digest": self.scope_digest, + "risk_digest": self.risk_digest, + "target_digest": self.target_digest, + "authorization_id": self.authorization_id, + "action": self.action, + "approved": self.approved, + "consumed": self.consumed, + } + + +@dataclass(frozen=True) +class AuthoringHandoff: + """A → B P0 projection bound to the current candidate bytes.""" + + candidate_path: str + candidate_digest: str + task_mode: str + development_examples: tuple[Mapping[str, str], ...] = () + suggested_quality_targets: tuple[str, ...] = () + selected_authoring_modules: tuple[str, ...] = () + method_cost_usage: Mapping[str, Any] = field(default_factory=dict) + stop_reason: str | None = None + limitations: tuple[str, ...] = () + provenance_digest: str | None = None + formal_evaluation: bool = False + + def __post_init__(self) -> None: + _require_text(self.candidate_path, "candidate_path") + _require_digest(self.candidate_digest, "candidate_digest") + if self.task_mode not in {"create", "optimize", "description_only", "no_skill"}: + raise DraftContractError("task_mode is not a formal authoring mode") + if self.task_mode == "no_skill": + raise DraftContractError("no_skill cannot produce an authoring handoff") + if self.formal_evaluation is not False: + raise DraftContractError("Q0 handoff formal_evaluation must be false") + examples: list[dict[str, str]] = [] + for index, raw in enumerate(self.development_examples): + projection = raw + if not isinstance(projection, Mapping): + for method_name in ("projection", "to_projection", "to_handoff_dict"): + method = getattr(projection, method_name, None) + if callable(method): + projection = method() + break + if not isinstance(projection, Mapping) or set(projection) != {"source_kind", "source_ref"}: + raise DraftContractError( + f"development_examples[{index}] projection must contain exactly source_kind/source_ref" + ) + source_kind = projection["source_kind"] + source_ref = projection["source_ref"] + if source_kind not in _SOURCE_KINDS: + raise DraftContractError(f"development_examples[{index}].source_kind is invalid") + examples.append( + { + "source_kind": _require_text(source_kind, f"development_examples[{index}].source_kind"), + "source_ref": _require_text(source_ref, f"development_examples[{index}].source_ref"), + } + ) + object.__setattr__(self, "development_examples", tuple(examples)) + object.__setattr__(self, "suggested_quality_targets", _text_tuple(self.suggested_quality_targets, "suggested_quality_targets")) + object.__setattr__(self, "selected_authoring_modules", _text_tuple(self.selected_authoring_modules, "selected_authoring_modules")) + usage = _canonical_mapping(self.method_cost_usage, "method_cost_usage") + forbidden = _contains_forbidden_key(usage) + if forbidden: + raise DraftContractError(f"method_cost_usage contains forbidden quality/release field: {forbidden}") + object.__setattr__(self, "method_cost_usage", usage) + if self.stop_reason is None: + raise DraftContractError("AuthoringHandoff requires a frozen M stop_reason") + _require_text(self.stop_reason, "stop_reason") + try: + from authoring.budget import validate_authoring_budget_record + + budget_record = validate_authoring_budget_record( + usage, + require_stopped=True, + expected_stop_reason=self.stop_reason, + ) + except Exception as exc: + raise DraftContractError(f"method_cost_usage is not a valid stopped M receipt: {exc}") from exc + recorded_modules = tuple(item.module_id for item in budget_record.selected_modules) + if recorded_modules != tuple(sorted(self.selected_authoring_modules)): + raise DraftContractError("selected_authoring_modules do not match the M receipt") + object.__setattr__(self, "limitations", _text_tuple(self.limitations, "limitations")) + expected_provenance = digest_json([dict(item) for item in self.development_examples]) + if self.provenance_digest is None: + object.__setattr__(self, "provenance_digest", expected_provenance) + else: + _require_digest(self.provenance_digest, "provenance_digest") + if self.provenance_digest != expected_provenance: + raise DraftContractError("provenance_digest does not match development_examples projection") + # A handoff is only meaningful while its exact candidate bytes still + # exist. Rebuild the tree digest at construction time so a caller + # cannot seal an arbitrary path/digest pair and defer the check. + try: + self.verify_current_bytes() + except Exception as exc: + if isinstance(exc, DraftContractError): + raise + raise DraftIntegrityError(str(exc)) from exc + + def to_dict(self) -> dict[str, Any]: + # Keep the cross-track projection closed. Internal object version and + # authorization/behavior receipts stay in the draft result, not here. + return { + "candidate_path": self.candidate_path, + "candidate_digest": self.candidate_digest, + "task_mode": self.task_mode, + "development_examples": [dict(item) for item in self.development_examples], + "suggested_quality_targets": list(self.suggested_quality_targets), + "selected_authoring_modules": list(self.selected_authoring_modules), + "method_cost_usage": dict(self.method_cost_usage), + "stop_reason": self.stop_reason, + "limitations": list(self.limitations), + "provenance_digest": self.provenance_digest, + "formal_evaluation": False, + } + + def to_projection(self) -> dict[str, Any]: + """Return the frozen cross-track shape without P0-local metadata.""" + + value = self.to_dict() + value.pop("limitations") + value.pop("provenance_digest") + return value + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def verify_current_bytes(self) -> str: + lexical = _lexical_absolute(self.candidate_path) + _assert_no_symlink_components(lexical) + path = _safe_resolved(lexical, strict=True) + observed = tree_digest(path) + if observed != self.candidate_digest: + raise DraftIntegrityError("candidate bytes no longer match AuthoringHandoff candidate_digest") + return observed + + +def _text_tuple(values: Iterable[Any], field_name: str) -> tuple[str, ...]: + if isinstance(values, (str, bytes)): + raise DraftContractError(f"{field_name} must be an array of strings") + normalized = tuple(_require_text(value, f"{field_name}[]") for value in values) + if len(set(normalized)) != len(normalized): + raise DraftContractError(f"{field_name} must not contain duplicates") + return normalized + + +@dataclass(frozen=True) +class DraftResult: + status: DraftStatus + handoff: AuthoringHandoff | None + candidate_path: str | None + candidate_digest: str | None + structural_report: StructureReport + limitations: tuple[str, ...] = () + provenance: tuple[Mapping[str, Any], ...] = () + authorization_receipt_digest: str | None = None + formal_evaluation: bool = False + delivery_target: str = "P0" + + def __post_init__(self) -> None: + object.__setattr__(self, "status", DraftStatus(self.status)) + if self.formal_evaluation is not False: + raise DraftContractError("Q0 draft formal_evaluation must be false") + if self.candidate_digest is not None: + _require_digest(self.candidate_digest, "candidate_digest") + if self.authorization_receipt_digest is not None: + _require_digest(self.authorization_receipt_digest, "authorization_receipt_digest") + object.__setattr__(self, "limitations", _text_tuple(self.limitations, "limitations")) + + @property + def ready(self) -> bool: + return self.status is DraftStatus.DRAFT_READY + + def to_dict(self) -> dict[str, Any]: + return { + "object_version": DRAFT_LANE_OBJECT_VERSION, + "status": self.status.value, + "candidate_path": self.candidate_path, + "candidate_digest": self.candidate_digest, + "structural_report": self.structural_report.to_dict(), + "handoff": self.handoff.to_dict() if self.handoff is not None else None, + "limitations": list(self.limitations), + "provenance": [dict(item) for item in self.provenance], + "authorization_receipt_digest": self.authorization_receipt_digest, + "formal_evaluation": False, + "delivery_target": self.delivery_target, + } + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + +@dataclass(frozen=True) +class DraftWorkspaceReceipt: + """Receipt for an authorized empty host-writing workspace.""" + + draft_id: str + task_mode: str + run_directory: str + candidate_path: str + authorization_digest: str + state: DraftState = DraftState.DRAFT_OPEN + formal_evaluation: bool = False + + def __post_init__(self) -> None: + _require_text(self.draft_id, "draft_id") + if self.task_mode not in {"create", "optimize", "description_only"}: + raise ValueError("draft task_mode must be create, optimize, or description_only") + object.__setattr__(self, "state", DraftState(self.state)) + if self.state is not DraftState.DRAFT_OPEN: + raise ValueError("new draft workspace must be DRAFT_OPEN") + if self.formal_evaluation is not False: + raise ValueError("Q0 workspace cannot be a formal evaluation") + _require_digest(self.authorization_digest, "authorization_digest") + run_lexical = _lexical_absolute(self.run_directory) + candidate_lexical = _lexical_absolute(self.candidate_path) + _assert_no_symlink_components(run_lexical, allow_missing_leaf=True) + _assert_no_symlink_components(candidate_lexical, allow_missing_leaf=True) + run = _safe_resolved(run_lexical, strict=False) + candidate = _safe_resolved(candidate_lexical, strict=False) + if not _is_within(candidate, run, strict=True): + raise DraftIsolationError("candidate_path must be inside run_directory") + object.__setattr__(self, "run_directory", str(run)) + object.__setattr__(self, "candidate_path", str(candidate)) + + def unsigned_dict(self) -> dict[str, Any]: + return { + "draft_id": self.draft_id, + "task_mode": self.task_mode, + "run_directory": self.run_directory, + "candidate_path": self.candidate_path, + "authorization_digest": self.authorization_digest, + "state": self.state.value, + "formal_evaluation": False, + } + + @property + def receipt_digest(self) -> str: + return digest_json(self.unsigned_dict()) + + def to_dict(self) -> dict[str, Any]: + return {**self.unsigned_dict(), "receipt_digest": self.receipt_digest} + + +@dataclass(frozen=True) +class DraftFinalizationResult: + state: DraftState + structure_errors: tuple[str, ...] + handoff: AuthoringHandoff | None + behavior_summary: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "state", DraftState(self.state)) + object.__setattr__(self, "structure_errors", _text_tuple(self.structure_errors, "structure_errors")) + object.__setattr__(self, "behavior_summary", _canonical_mapping(self.behavior_summary, "behavior_summary")) + if self.state is DraftState.DRAFT_INVALID and self.handoff is not None: + raise ValueError("invalid draft cannot carry a handoff") + if self.state in {DraftState.DRAFT_READY, DraftState.DRAFT_REQUIRES_ESCALATION} and self.handoff is None: + raise ValueError("finalized draft state requires a handoff") + + def to_dict(self) -> dict[str, Any]: + return { + "state": self.state.value, + "structure_errors": list(self.structure_errors), + "handoff": self.handoff.to_dict() if self.handoff else None, + "behavior_summary": dict(self.behavior_summary), + } + + +AuthorizationConsumer = Callable[[str, str, Mapping[str, Any]], Mapping[str, Any]] +StructureValidator = Callable[[Path], Sequence[str] | Mapping[str, Any]] + + +@runtime_checkable +class AuthorizationVerifier(Protocol): + """Typed seam for a shared authorization/workflow integration.""" + + def consume_candidate_generation(self, **context: Any) -> AuthorizationReceipt: + ... + + +@runtime_checkable +class BehaviorSummary(Protocol): + """A trusted C summary must be supplied by an injected object/provider.""" + + trusted: bool + minimum_risk: str + delivery_eligibility: str + blocked: bool + escalation: bool + + +def _consume_authorization( + *, + authorization_verifier: Any, + event_log: AppendOnlyEventLog | None, + workflow_id: str | None, + authorization_grant: AuthorizationGrant | None, + scope_digest: str, + risk_digest: str, + target_digest: str | None, + task_mode: str, + destination: Path, + authorization_id: str | None, +) -> AuthorizationReceipt: + """Validate/consume authority without creating a directory. + + The order here is security-critical: callers invoke this before any + destination parent or temporary run directory is created. + """ + + _require_digest(scope_digest, "scope_digest") + _require_digest(risk_digest, "risk_digest") + _closed_digest_or_none(target_digest, "target_digest") + context = { + "kind": AuthorizationKind.CANDIDATE_GENERATION, + "action": AuthorizationKind.CANDIDATE_GENERATION.value, + "scope_digest": scope_digest, + "risk_digest": risk_digest, + "target_digest": target_digest, + "task_mode": task_mode, + "destination": str(destination), + "authorization_id": authorization_id, + } + + if authorization_verifier is not None: + # Do not accept booleans, dicts, or a self-signed grant as authority. + method = getattr(authorization_verifier, "consume_candidate_generation", None) + if method is None: + method = getattr(authorization_verifier, "consume", None) + if method is None and callable(authorization_verifier): + method = authorization_verifier + if method is None or not callable(method): + raise DraftAuthorizationError("authorization verifier has no typed consume method") + try: + result = method(**context) + except TypeError: + # A narrow positional compatibility path for Protocol adapters that + # expose ``consume_candidate_generation(scope, risk, target)``. + try: + result = method(scope_digest, risk_digest, target_digest) + except Exception as exc: # pragma: no cover - adapter-specific + raise DraftAuthorizationError(f"authorization verifier failed: {exc}") from exc + except Exception as exc: + raise DraftAuthorizationError(f"authorization verifier failed: {exc}") from exc + if isinstance(result, AuthorizationReceipt): + if result.scope_digest != scope_digest or result.risk_digest != risk_digest or result.target_digest != target_digest: + raise DraftAuthorizationError("authorization receipt scope/risk/target drifted") + return result + if isinstance(result, AuthorizationGateDecision): + # A gate decision alone lacks the event/grant identity needed for a + # trusted receipt, so only an adapter object carrying those fields + # may be accepted. This prevents a raw callback bool from widening + # the trust boundary. + raise DraftAuthorizationError("gate decision must be wrapped in AuthorizationReceipt with grant/event binding") + if isinstance(result, (bool, Mapping, AuthorizationGrant)): + raise DraftAuthorizationError("raw bool/mapping/grant cannot authorize candidate generation") + # Accept structurally typed third-party receipts without importing an + # internal Track B/C class. Every required field is checked strictly. + try: + receipt = AuthorizationReceipt( + approved=getattr(result, "approved"), + consumed=getattr(result, "consumed"), + grant_digest=getattr(result, "grant_digest"), + event_digest=getattr(result, "event_digest"), + scope_digest=getattr(result, "scope_digest"), + risk_digest=getattr(result, "risk_digest"), + target_digest=getattr(result, "target_digest", None), + authorization_id=getattr(result, "authorization_id", None), + action=getattr(result, "action", AuthorizationKind.CANDIDATE_GENERATION.value), + ) + except (AttributeError, TypeError, DraftLaneError) as exc: + raise DraftAuthorizationError("authorization verifier must return a typed consumed receipt") from exc + if receipt.scope_digest != scope_digest or receipt.risk_digest != risk_digest or receipt.target_digest != target_digest: + raise DraftAuthorizationError("authorization receipt scope/risk/target drifted") + return receipt + + if event_log is not None or workflow_id is not None or authorization_grant is not None: + raise DraftAuthorizationError( + "an event log or grant proves validation but not one-use consumption; " + "Gate 1 must inject a typed candidate-generation consumer receipt" + ) + raise DraftAuthorizationError( + "a typed candidate-generation authorization consumer is required" + ) + + +def _normalize_examples( + values: Iterable[Any], + *, + source_resolver: Any = None, + reference_resolver: Any = None, +) -> tuple[dict[str, str], ...]: + raw_values = tuple(values) + # Prefer the sibling provenance validator when present. It owns trusted + # source resolution; this adapter only enforces the frozen A→B projection. + try: + from authoring.provenance import validate_development_examples + except ImportError: + validate_development_examples = None + if validate_development_examples is not None: + try: + validated = validate_development_examples( + raw_values, + resolver=source_resolver, + source_resolver=source_resolver, + reference_resolver=reference_resolver, + ) + if validated is not None: + raw_values = tuple(validated) + except TypeError: + # Compatibility with the sibling's minimal signature. + try: + validated = validate_development_examples(raw_values, resolver=source_resolver) + if validated is not None: + raw_values = tuple(validated) + except Exception as exc: + raise DraftContractError(f"development example provenance is invalid: {exc}") from exc + except Exception as exc: + raise DraftContractError(f"development example provenance is invalid: {exc}") from exc + + normalized: list[dict[str, str]] = [] + for index, item in enumerate(raw_values): + projection: Any = item + for method_name in ("projection", "to_projection", "handoff_projection"): + method = getattr(item, method_name, None) + if callable(method): + projection = method() + break + if is_dataclass(projection) and not isinstance(projection, type): + projection = asdict(projection) + if not isinstance(projection, Mapping): + raise DraftContractError(f"development_examples[{index}] is not a mapping/provenance value object") + if set(projection) != {"source_kind", "source_ref"}: + # Internal records may expose extra digests; explicitly project + # only if their projection method was used. A raw mapping with + # extras is caller-authored and must not silently widen the seam. + raise DraftContractError( + f"development_examples[{index}] A→B projection must contain exactly source_kind/source_ref" + ) + kind = projection["source_kind"] + ref = projection["source_ref"] + if kind not in _SOURCE_KINDS: + raise DraftContractError(f"development_examples[{index}].source_kind is invalid") + if kind in _TRUSTED_SOURCE_KINDS and source_resolver is None and reference_resolver is None: + raise DraftContractError(f"development_examples[{index}] trusted source requires a resolver") + normalized.append( + { + "source_kind": _require_text(kind, f"development_examples[{index}].source_kind"), + "source_ref": _require_text(ref, f"development_examples[{index}].source_ref"), + } + ) + return tuple(normalized) + + +def _normalize_provenance_for_result(values: Iterable[Any]) -> tuple[Mapping[str, Any], ...]: + records: list[Mapping[str, Any]] = [] + for item in values: + if is_dataclass(item) and not isinstance(item, type): + value = asdict(item) + elif isinstance(item, Mapping): + value = dict(item) + else: + to_dict = getattr(item, "to_dict", None) + if not callable(to_dict): + raise DraftContractError("provenance record must be a mapping or to_dict value object") + value = to_dict() + if not isinstance(value, Mapping): + raise DraftContractError("provenance record to_dict() must return a mapping") + forbidden = _contains_forbidden_key(value) + if forbidden: + raise DraftContractError(f"provenance contains forbidden quality/release field: {forbidden}") + records.append(_canonical_mapping(value, "provenance record")) + return tuple(records) + + +def _behavior_escalation(summary: Any) -> tuple[bool, str | None]: + """Relay escalation signals without letting a caller mint a safe C result. + + Track A has no provider-receipt adapter yet. A supplied object may force a + conservative escalation, but cannot remove the honest ``not assessed`` + limitation or establish a low-risk conclusion. + """ + + if summary is None: + return False, None + if isinstance(summary, Mapping): + return True, "untrusted behavior summary supplied; behavior risk not assessed" + blocked = bool(getattr(summary, "blocked", False)) + escalation = bool(getattr(summary, "escalation", False)) + eligibility = str(getattr(summary, "delivery_eligibility", "unknown")).casefold() + minimum_risk = str(getattr(summary, "minimum_risk", "unknown")).upper() + if blocked or escalation or eligibility in {"blocked", "escalate", "requires_escalation"} or minimum_risk in {"R2", "R3", "HIGH", "CRITICAL"}: + return True, "behavior risk requires escalation" + return True, "behavior summary lacks a trusted provider receipt; behavior risk not assessed" + + +def _prepare_isolation( + *, + destination: Path, + isolation_root: str | os.PathLike[str] | None, + approved_target: str | os.PathLike[str] | None, +) -> tuple[Path, Path]: + if isolation_root is None: + raise DraftIsolationError("an explicit isolation_root/run_root is required") + root_lexical = _lexical_absolute(isolation_root) + _assert_no_symlink_components(root_lexical) + root = _safe_resolved(root_lexical, strict=True) + if not root.is_dir() or root.is_symlink(): + raise DraftIsolationError("isolation_root must be a regular directory") + _assert_no_symlink_components(root) + destination_lexical = _lexical_absolute(destination) + _assert_no_symlink_components(destination_lexical, allow_missing_leaf=True) + destination_resolved = _safe_resolved(destination_lexical, strict=False) + if not _is_within(destination_resolved, root, strict=True): + raise DraftIsolationError("candidate destination must be a strict child of isolation_root") + if approved_target is not None: + approved_lexical = _lexical_absolute(approved_target) + _assert_no_symlink_components(approved_lexical, allow_missing_leaf=True) + approved = _safe_resolved(approved_lexical, strict=False) + if approved != destination_resolved: + raise DraftIsolationError("candidate destination differs from the approved exact target") + _assert_no_symlink_components(destination_resolved, allow_missing_leaf=True) + if os.path.lexists(destination_resolved): + raise DraftIsolationError("candidate destination already exists") + return root, destination_resolved + + +def _copy_source_after_authorization(source: Path, destination: Path) -> None: + parent = destination.parent + parent.mkdir(parents=True, exist_ok=True) + _assert_no_symlink_components(parent) + if os.path.lexists(destination): + raise DraftIsolationError("candidate destination appeared before publication") + temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.q0-", dir=str(parent))) + payload = temporary / "payload" + published = False + try: + shutil.copytree(source, payload, symlinks=False, copy_function=shutil.copy2) + # The source is read-only checked before authorization and rechecked + # after the copy to close a concurrent source drift window. + if tree_digest(source) != tree_digest(payload): + raise DraftIntegrityError("source bytes changed while creating the draft") + os.replace(payload, destination) + published = True + _assert_no_symlink_components(destination) + if _safe_resolved(destination, strict=True) != destination: + raise DraftIsolationError("published candidate target changed through a symlink") + finally: + if not published and destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + shutil.rmtree(temporary, ignore_errors=True) + + +def _nearest_existing_parent(path: Path) -> Path: + current = path + while not os.path.lexists(current) and current.parent != current: + current = current.parent + return current + + +def candidate_generation_target_digest( + candidate_path: str | os.PathLike[str], + *, + task_mode: str, +) -> str: + """Bind candidate-generation authority to one exact lexical target.""" + + if task_mode not in {"create", "optimize", "description_only"}: + raise DraftContractError("candidate-generation target has an invalid task_mode") + target = _lexical_absolute(candidate_path) + return digest_json( + { + "action": AuthorizationKind.CANDIDATE_GENERATION.value, + "candidate_path": str(target), + "task_mode": task_mode, + } + ) + + +def open_q0_draft( + run_directory: str | os.PathLike[str], + *, + draft_id: str, + skill_name: str, + task_mode: str, + authorization_context: Mapping[str, Any], + authorization_consumer: AuthorizationConsumer, + formal_skill_roots: Iterable[str | os.PathLike[str]] = (), +) -> DraftWorkspaceReceipt: + """Open an empty host-writing directory after legacy adapter validation. + + This compatibility seam accepts a *closed mapping returned by an injected + consumer*. It never accepts a mapping as authority directly. New callers + should use :func:`create_q0_draft`, whose verifier requires a typed receipt. + Authorization is invoked before the first ``mkdir``. + """ + + draft_id = _require_text(draft_id, "draft_id").strip() + skill_name = _require_text(skill_name, "skill_name").strip() + if not _NAME_RE.fullmatch(skill_name) or len(skill_name) > 64: + raise ValueError("skill_name must be lowercase hyphen-case and at most 64 characters") + if task_mode not in {"create", "optimize", "description_only"}: + raise ValueError("Q0 draft cannot run for no_skill or an unknown task mode") + if not callable(authorization_consumer): + raise TypeError("authorization_consumer must be callable") + context = _canonical_mapping(authorization_context, "authorization_context") + run_lexical = _lexical_absolute(run_directory) + _assert_no_symlink_components(run_lexical, allow_missing_leaf=True) + run_path = _safe_resolved(run_lexical, strict=False) + candidate = run_path / "drafts" / draft_id / skill_name + if os.path.lexists(run_path): + _assert_no_symlink_components(run_path) + if not run_path.is_dir(): + raise DraftIsolationError("run_directory must be a directory") + else: + _assert_no_symlink_components(run_path, allow_missing_leaf=True) + for raw_root in formal_skill_roots: + root_lexical = _lexical_absolute(raw_root) + _assert_no_symlink_components(root_lexical, allow_missing_leaf=True) + root = _safe_resolved(root_lexical, strict=False) + if candidate == root or _is_within(candidate, root): + raise DraftIsolationError("P0 candidate path cannot be inside a formal Skill root") + if os.path.lexists(candidate): + raise DraftIsolationError("candidate path already exists") + parent = _nearest_existing_parent(candidate) + if parent.is_symlink() or (parent.exists() and not parent.is_dir()): + raise DraftIsolationError("candidate path has a symlink or non-directory ancestor") + + try: + decision = authorization_consumer( + AuthorizationKind.CANDIDATE_GENERATION.value, + str(candidate), + context, + ) + except Exception as exc: + raise DraftAuthorizationError(f"authorization consumer failed: {exc}") from exc + if not isinstance(decision, Mapping): + raise DraftAuthorizationError("authorization consumer returned no closed decision record") + allowed = { + "action", + "approved", + "consumed", + "authorization_digest", + } + if set(decision) != allowed: + raise DraftAuthorizationError("authorization decision fields do not match the closed compatibility contract") + if decision.get("action") != AuthorizationKind.CANDIDATE_GENERATION.value: + raise DraftAuthorizationError("authorization decision is for the wrong action") + if decision.get("approved") is not True or decision.get("consumed") is not True: + raise DraftAuthorizationError("candidate-generation authorization was not consumed") + authorization_digest = decision.get("authorization_digest") + try: + _require_digest(authorization_digest, "authorization_digest") + except DraftContractError as exc: + raise DraftAuthorizationError(str(exc)) from exc + + try: + candidate.mkdir(parents=True, exist_ok=False) + _assert_no_symlink_components(_lexical_absolute(candidate)) + if _safe_resolved(candidate, strict=True) != candidate: + raise DraftIsolationError("candidate target changed through a symlink") + except OSError as exc: + raise DraftIsolationError(f"cannot create isolated candidate directory: {exc}") from exc + except Exception: + if candidate.exists() and not candidate.is_symlink(): + shutil.rmtree(candidate, ignore_errors=True) + raise + return DraftWorkspaceReceipt( + draft_id=draft_id, + task_mode=task_mode, + run_directory=str(run_path), + candidate_path=str(candidate), + authorization_digest=authorization_digest, + ) + + +def basic_skill_structure_errors(candidate_path: Path) -> tuple[str, ...]: + """Compatibility wrapper over the strict deterministic structure report.""" + + try: + report = validate_skill_tree(candidate_path) + except (DraftLaneError, FileNotFoundError, OSError, ValueError) as exc: + return (str(exc),) + normalized: list[str] = [] + for error in report.errors: + if "TODO/placeholder" in error: + normalized.append("SKILL.md contains an unresolved placeholder") + elif "local reference does not exist" in error: + normalized.append(error.replace("local reference does not exist", "missing referenced path")) + else: + normalized.append(error) + return tuple(normalized) + + +def _structure_errors( + validator: StructureValidator, + candidate: Path, +) -> tuple[str, ...]: + result = validator(candidate) + if isinstance(result, Mapping): + raw = result.get("errors", ()) + if result.get("valid") is True and raw: + raise ValueError("structure validator returned valid with errors") + else: + raw = result + if isinstance(raw, (str, bytes)) or not isinstance(raw, Sequence): + raise TypeError("structure validator errors must be a sequence") + return tuple(_require_text(str(item), "structure_errors[]") for item in raw) + + +def finalize_q0_draft( + workspace: DraftWorkspaceReceipt, + *, + development_examples: Iterable[Any], + suggested_quality_targets: Iterable[str] = (), + selected_authoring_modules: Iterable[str] = (), + method_cost_usage: Mapping[str, Any], + stop_reason: str, + structure_validator: StructureValidator = basic_skill_structure_errors, + behavior_summary: Any = None, + reference_resolver: Any = None, + source_resolver: Any = None, +) -> DraftFinalizationResult: + """Finalize an existing host-written Q0 workspace into an honest P0 handoff.""" + + if not isinstance(workspace, DraftWorkspaceReceipt): + raise TypeError("workspace must be a DraftWorkspaceReceipt") + candidate_lexical = _lexical_absolute(workspace.candidate_path) + run_lexical = _lexical_absolute(workspace.run_directory) + _assert_no_symlink_components(candidate_lexical, allow_missing_leaf=True) + _assert_no_symlink_components(run_lexical) + candidate = _safe_resolved(candidate_lexical, strict=False) + run = _safe_resolved(run_lexical, strict=False) + if not _is_within(candidate, run, strict=True): + raise DraftIsolationError("candidate moved outside its isolated run directory") + _assert_no_symlink_components(candidate, allow_missing_leaf=True) + # The closed deterministic gate is mandatory. An injected validator may + # add project-specific checks, but it cannot replace or weaken the core + # Skill-tree invariants. + errors = list(basic_skill_structure_errors(candidate)) + if structure_validator is not basic_skill_structure_errors: + errors.extend(_structure_errors(structure_validator, candidate)) + if workspace.task_mode == "description_only" and structure_validator is basic_skill_structure_errors: + errors.append("description_only requires an injected baseline byte-preservation validator") + normalized_behavior: dict[str, Any] + if behavior_summary is None: + normalized_behavior = {} + elif isinstance(behavior_summary, Mapping): + normalized_behavior = _canonical_mapping(behavior_summary, "behavior_summary") + else: + to_dict = getattr(behavior_summary, "to_dict", None) + normalized_behavior = _canonical_mapping( + to_dict() if callable(to_dict) else {}, + "behavior_summary", + ) + if errors: + return DraftFinalizationResult( + state=DraftState.DRAFT_INVALID, + structure_errors=tuple(errors), + handoff=None, + behavior_summary=normalized_behavior, + ) + + # Rebuild the M record instead of trusting caller totals/stop flags. + try: + from authoring.budget import validate_authoring_budget_record + + budget_record = validate_authoring_budget_record( + method_cost_usage, + require_stopped=True, + expected_stop_reason=stop_reason, + ) + usage: Mapping[str, Any] = budget_record.to_dict() + recorded_modules = tuple(item.module_id for item in budget_record.selected_modules) + requested_modules = tuple(sorted(_text_tuple(selected_authoring_modules, "selected_authoring_modules"))) + if recorded_modules != requested_modules: + raise ValueError("handoff selected modules differ from the M ledger") + except ImportError: + usage = method_cost_usage + requested_modules = _text_tuple(selected_authoring_modules, "selected_authoring_modules") + examples = _normalize_examples( + development_examples, + source_resolver=source_resolver, + reference_resolver=reference_resolver, + ) + candidate_digest = tree_digest(candidate) + provenance_digest = digest_json([dict(item) for item in examples]) + limitations = [ + "quality_unverified_no_formal_evaluation", + "not_packaged_or_installed", + "host_activation_and_routing_not_proven", + "script_behavior_unverified", + ] + + # A raw mapping is never authoritative. Conservatively route it to + # escalation when it reports risk, while labelling it untrusted. A typed + # trusted C object may also request escalation; Track A only relays it. + escalation = False + if behavior_summary is None: + limitations.append("behavior_risk_not_assessed") + elif isinstance(behavior_summary, Mapping): + disposition = str(behavior_summary.get("draft_disposition", "unknown")) + if disposition in {"requires_escalation", "blocked"}: + escalation = True + limitations.append(f"behavior_{disposition}") + else: + escalation = True + limitations.append("behavior_summary_untrusted") + else: + escalation, reason = _behavior_escalation(behavior_summary) + if reason: + limitations.append(reason.replace(" ", "_")) + + handoff = AuthoringHandoff( + candidate_path=str(candidate), + candidate_digest=candidate_digest, + task_mode=workspace.task_mode, + development_examples=examples, + suggested_quality_targets=tuple(suggested_quality_targets), + selected_authoring_modules=requested_modules, + method_cost_usage=usage, + stop_reason=stop_reason, + limitations=tuple(limitations), + provenance_digest=provenance_digest, + formal_evaluation=False, + ) + handoff.verify_current_bytes() + return DraftFinalizationResult( + state=( + DraftState.DRAFT_REQUIRES_ESCALATION + if escalation + else DraftState.DRAFT_READY + ), + structure_errors=(), + handoff=handoff, + behavior_summary=normalized_behavior, + ) + + +def create_q0_draft( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], + *, + isolation_root: str | os.PathLike[str] | None = None, + run_root: str | os.PathLike[str] | None = None, + approved_target: str | os.PathLike[str] | None = None, + task_mode: str = "create", + scope_digest: str, + risk_digest: str, + target_digest: str | None = None, + authorization_verifier: Any = None, + authorizer: Any = None, + event_log: AppendOnlyEventLog | None = None, + workflow_id: str | None = None, + authorization_grant: AuthorizationGrant | None = None, + authorization_id: str | None = None, + development_examples: Iterable[Any] = (), + source_resolver: Any = None, + reference_resolver: Any = None, + suggested_quality_targets: Iterable[str] = (), + selected_authoring_modules: Iterable[str] = (), + method_cost_usage: Mapping[str, Any] | Any = None, + budget_ledger: Any = None, + stop_reason: str | None = None, + provenance: Iterable[Any] = (), + behavior_summary: Any = None, + scripts_ran: bool = False, +) -> DraftResult: + """Copy and validate a host-authored candidate in the Q0/P0 lane. + + Authorization is consumed before the first ``mkdir``/write. The function + returns a structural failure result for an invalid candidate and raises a + typed error for missing authority or an unsafe destination. It never + creates a formal evaluation object or calls package/install code. + """ + + if run_root is not None: + if isolation_root is not None and _lexical_absolute(isolation_root) != _lexical_absolute(run_root): + raise DraftIsolationError("isolation_root and run_root disagree") + isolation_root = run_root + if task_mode not in {"create", "optimize", "description_only", "no_skill"}: + raise DraftContractError("task_mode is not a formal authoring mode") + if task_mode == "no_skill": + raise DraftContractError("no_skill must not create a candidate directory") + if not isinstance(scripts_ran, bool): + raise DraftContractError("scripts_ran must be boolean") + + source_lexical = _lexical_absolute(source) + _assert_no_symlink_components(source_lexical) + source_path = _safe_resolved(source_lexical, strict=True) + if not source_path.is_dir() or source_path.is_symlink(): + raise DraftIsolationError("candidate source must be a regular directory") + source_errors, _ = _scan_tree(source_path) + if source_errors: + raise DraftContractError("candidate source fails the closed tree contract: " + "; ".join(source_errors)) + # Validate prose before authorization. This is read-only and guarantees + # that an invalid source cannot cause a partially written run directory. + source_report = validate_skill_tree(source_path, require_name_match=False) + if not source_report.valid: + raise DraftContractError("candidate source fails structure validation: " + "; ".join(source_report.errors)) + source_digest = tree_digest(source_path) + + destination_input = _lexical_absolute(destination) + # Pass the lexical target through the isolation gate so a symlink cannot + # be resolved away before the no-follow checks run. + destination_probe = destination_input + root, destination_path = _prepare_isolation( + destination=destination_probe, + isolation_root=isolation_root, + approved_target=approved_target, + ) + if destination_path == source_path or _is_within(destination_path, source_path): + raise DraftIsolationError("candidate destination cannot be source or nested inside source") + + computed_target_digest = candidate_generation_target_digest( + destination_path, + task_mode=task_mode, + ) + if target_digest is None: + target_digest = computed_target_digest + elif target_digest != computed_target_digest: + raise DraftAuthorizationError( + "target_digest does not bind the exact candidate-generation target" + ) + + verifier = authorization_verifier if authorization_verifier is not None else authorizer + receipt = _consume_authorization( + authorization_verifier=verifier, + event_log=event_log, + workflow_id=workflow_id, + authorization_grant=authorization_grant, + scope_digest=scope_digest, + risk_digest=risk_digest, + target_digest=target_digest, + task_mode=task_mode, + destination=destination_path, + authorization_id=authorization_id, + ) + + # First write occurs only after the authority has been validated. + _copy_source_after_authorization(source_path, destination_path) + try: + report = validate_skill_tree(destination_path) + if not report.valid: + shutil.rmtree(destination_path, ignore_errors=True) + return DraftResult( + status=DraftStatus.DRAFT_INVALID, + handoff=None, + candidate_path=None, + candidate_digest=None, + structural_report=report, + limitations=("structure validation failed",), + authorization_receipt_digest=receipt.content_digest, + ) + # Recheck source and exact candidate bytes before constructing the + # handoff. Both checks are against fresh filesystem observations. + if tree_digest(source_path) != source_digest: + raise DraftIntegrityError("source bytes changed after candidate publication") + observed_digest = tree_digest(destination_path) + if observed_digest != report.tree_digest: + raise DraftIntegrityError("candidate tree digest changed during handoff") + examples = _normalize_examples( + development_examples, + source_resolver=source_resolver, + reference_resolver=reference_resolver, + ) + if method_cost_usage is None and budget_ledger is not None: + for method_name in ("to_record", "to_dict"): + method = getattr(budget_ledger, method_name, None) + if callable(method): + method_cost_usage = method() + break + if method_cost_usage is None: + method_cost_usage = budget_ledger + if method_cost_usage is None: + # The copy API itself is a single Core candidate action. When a + # host does not provide its already-frozen M receipt, record that + # deterministic action locally rather than emitting an unbound + # handoff with an empty or caller-authored usage mapping. + from authoring.budget import AuthoringBudgetLedger, AuthoringBudgetLimits + + implicit_budget = AuthoringBudgetLedger( + AuthoringBudgetLimits(candidate_count=1) + ) + implicit_budget.record( + "candidate", + reason="Q0 copy API published the host-authored candidate", + ) + implicit_stop = stop_reason or "q0_copy_complete" + implicit_budget.stop(implicit_stop) + method_cost_usage = implicit_budget.to_dict() + stop_reason = implicit_stop + usage = method_cost_usage + normalized_provenance = _normalize_provenance_for_result(provenance) + handoff = AuthoringHandoff( + candidate_path=str(destination_path), + candidate_digest=observed_digest, + task_mode=task_mode, + development_examples=examples, + suggested_quality_targets=tuple(suggested_quality_targets), + selected_authoring_modules=tuple(selected_authoring_modules), + method_cost_usage=usage, + stop_reason=stop_reason, + formal_evaluation=False, + ) + # Verify once more through the public reconstruction method. This is + # intentionally after handoff construction so a caller cannot mutate a + # path between digest and return unnoticed. + handoff.verify_current_bytes() + limitations: list[str] = [ + "formal evaluation not run", + "candidate gain not assessed", + "formal decision not produced", + "P0 isolated handoff only", + "no package, install, overwrite, or git commit performed", + ] + # A caller boolean is not an execution receipt. Track A does not run + # candidate scripts, so Q0 must keep this limitation until Gate 1 wires + # a trusted execution/evidence adapter. + limitations.append("script behavior unverified") + description_only_unverified = task_mode == "description_only" + if description_only_unverified: + limitations.append( + "description-only non-description byte preservation requires the trusted existing chain" + ) + escalation, behavior_reason = _behavior_escalation(behavior_summary) + if behavior_reason is None: + limitations.append("behavior risk not assessed") + else: + limitations.append(behavior_reason) + status = ( + DraftStatus.DRAFT_REQUIRES_ESCALATION + if escalation or description_only_unverified + else DraftStatus.DRAFT_READY + ) + return DraftResult( + status=status, + handoff=handoff, + candidate_path=str(destination_path), + candidate_digest=observed_digest, + structural_report=report, + limitations=tuple(limitations), + provenance=normalized_provenance, + authorization_receipt_digest=receipt.content_digest, + ) + except Exception: + # Do not leave a candidate behind when a post-copy trust check fails. + if destination_path.exists(): + shutil.rmtree(destination_path, ignore_errors=True) + raise + + +def create_draft(*args: Any, **kwargs: Any) -> DraftResult: + """Compatibility alias for :func:`create_q0_draft`.""" + + return create_q0_draft(*args, **kwargs) + + +def draft_q0(*args: Any, **kwargs: Any) -> DraftResult: + return create_q0_draft(*args, **kwargs) + + +def validate_draft(root: str | os.PathLike[str]) -> StructureReport: + return validate_skill_tree(root) + + +def verify_handoff(handoff: AuthoringHandoff | Mapping[str, Any]) -> str: + if isinstance(handoff, AuthoringHandoff): + return handoff.verify_current_bytes() + if not isinstance(handoff, Mapping): + raise DraftContractError("handoff must be an AuthoringHandoff or mapping") + # Mapping handoffs are parsed as the closed cross-track projection; no + # caller-authored digest is trusted without recomputing bytes. + expected = { + "candidate_path", + "candidate_digest", + "task_mode", + "development_examples", + "suggested_quality_targets", + "selected_authoring_modules", + "method_cost_usage", + "stop_reason", + "formal_evaluation", + } + extended = {*expected, "limitations", "provenance_digest"} + if set(handoff) not in (expected, extended): + raise DraftContractError("handoff fields do not match the closed projection") + parsed = AuthoringHandoff( + candidate_path=handoff["candidate_path"], + candidate_digest=handoff["candidate_digest"], + task_mode=handoff["task_mode"], + development_examples=tuple(handoff["development_examples"]), + suggested_quality_targets=tuple(handoff["suggested_quality_targets"]), + selected_authoring_modules=tuple(handoff["selected_authoring_modules"]), + method_cost_usage=handoff["method_cost_usage"], + stop_reason=handoff["stop_reason"], + limitations=tuple(handoff.get("limitations", ())), + provenance_digest=handoff.get("provenance_digest"), + formal_evaluation=handoff["formal_evaluation"], + ) + return parsed.verify_current_bytes() + + +__all__ = [ + "AUTHORING_HANDOFF_OBJECT_VERSION", + "AuthorizationReceipt", + "AuthorizationVerifier", + "AuthoringHandoff", + "BehaviorSummary", + "DraftAuthorizationError", + "DraftContractError", + "DraftIntegrityError", + "DraftIsolationError", + "DraftLaneError", + "DraftResult", + "DraftFinalizationResult", + "DraftState", + "DraftStatus", + "DraftWorkspaceReceipt", + "DRAFT_INVALID", + "DRAFT_LANE_OBJECT_VERSION", + "DRAFT_READY", + "DRAFT_REQUIRES_ESCALATION", + "StructureReport", + "StructureValidator", + "AuthorizationConsumer", + "basic_skill_structure_errors", + "candidate_generation_target_digest", + "create_draft", + "create_q0_draft", + "draft_q0", + "finalize_q0_draft", + "open_q0_draft", + "validate_draft", + "validate_skill_tree", + "verify_handoff", +] diff --git a/runtime/skill-optimizer/scripts/authoring/provenance.py b/runtime/skill-optimizer/scripts/authoring/provenance.py new file mode 100644 index 0000000..4f0f6db --- /dev/null +++ b/runtime/skill-optimizer/scripts/authoring/provenance.py @@ -0,0 +1,612 @@ +"""Explicit provenance for development examples. + +The authoring host may use examples while modelling a workflow, but an +example's label is not evidence of truth, representativeness, or holdout +status. This module therefore keeps two deliberately different projections: + +* an internal, digest-closed record containing the normalized summary and + record digests; and +* the frozen A→B handoff projection containing *only* ``source_kind`` and + ``source_ref``. + +For ``observed`` and ``user_confirmed`` examples, a resolver supplied by the +integration layer must approve the exact kind/reference pair. Prefixes such +as ``observed:`` or ``conversation:`` are never treated as proof. Digests in +this module are integrity bindings, not signatures or identity credentials. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import inspect +import re +from typing import Any, Callable, Iterable, Mapping, Protocol, Sequence + +from core.canonical import digest_json + + +PROVENANCE_SCHEMA_VERSION = "1.0.0" +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_TRUSTED_KINDS = frozenset({"observed", "user_confirmed"}) + + +class ProvenanceError(ValueError): + """Raised when a source label, resolver result, or digest is invalid.""" + + +class SourceKind(str, Enum): + OBSERVED = "observed" + USER_CONFIRMED = "user_confirmed" + SYNTHETIC = "synthetic" + ASSUMED = "assumed" + + +def _required_text(value: str, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ProvenanceError(f"{field_name} must be a non-empty string") + return value.strip() + + +def _require_digest(value: str, field_name: str) -> str: + if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None: + raise ProvenanceError(f"{field_name} must be a sha256 digest") + return value + + +@dataclass(frozen=True) +class SourceRequest: + """Exact source lookup requested from the trusted integration resolver.""" + + source_kind: SourceKind + source_ref: str + + def __post_init__(self) -> None: + object.__setattr__(self, "source_kind", SourceKind(self.source_kind)) + object.__setattr__(self, "source_ref", _required_text(self.source_ref, "source_ref")) + + def to_dict(self) -> dict[str, str]: + return {"source_kind": self.source_kind.value, "source_ref": self.source_ref} + + +@dataclass(frozen=True) +class SourceResolution: + """A resolver's closed approval result. + + ``fact_digest`` is optional because some control planes expose only an + event/reference binding. When present it is checked for shape, but never + interpreted as a signature. The resolver itself remains the authority. + """ + + source_kind: SourceKind + source_ref: str + approved: bool + fact_digest: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "source_kind", SourceKind(self.source_kind)) + object.__setattr__(self, "source_ref", _required_text(self.source_ref, "source_ref")) + if not isinstance(self.approved, bool): + raise ProvenanceError("source resolution approved must be boolean") + if self.fact_digest is not None: + _require_digest(self.fact_digest, "source resolution fact_digest") + + def to_dict(self) -> dict[str, Any]: + return { + "source_kind": self.source_kind.value, + "source_ref": self.source_ref, + "approved": self.approved, + "fact_digest": self.fact_digest, + } + + +class SourceResolver(Protocol): + """Protocol implemented by the integration layer's source resolver.""" + + def resolve(self, request: SourceRequest) -> SourceResolution | Mapping[str, Any] | bool: + ... + + +# The old public name is retained for adapters that use a two-argument +# callable. A callable is still an injected integration seam; this module +# never treats a source-ref prefix as approval. +ReferenceResolver = Callable[[SourceKind, str], SourceResolution | Mapping[str, Any] | bool] + + +def _resolver_result( + result: SourceResolution | Mapping[str, Any] | bool, + request: SourceRequest, +) -> bool: + """Validate one resolver response without accepting self-authored labels.""" + + if isinstance(result, SourceResolution): + resolution = result + elif isinstance(result, bool): + # A boolean is accepted only as the result of the explicitly injected + # resolver callable. It is not accepted as a field in a provenance + # mapping and cannot be supplied by the example itself. + return result + elif isinstance(result, Mapping): + compact = {"source_kind", "source_ref", "approved"} + complete = compact | {"fact_digest"} + if set(result) not in (compact, complete): + raise ProvenanceError("source resolver result fields do not match the closed contract") + try: + resolution = SourceResolution( + source_kind=result["source_kind"], + source_ref=result["source_ref"], + approved=result["approved"], + fact_digest=result.get("fact_digest"), + ) + except (TypeError, ValueError) as exc: + raise ProvenanceError(f"invalid source resolver result: {exc}") from exc + else: + raise ProvenanceError("source resolver must return SourceResolution or a closed mapping") + if resolution.source_kind is not request.source_kind or resolution.source_ref != request.source_ref: + raise ProvenanceError("source resolver result does not match the requested source") + return resolution.approved + + +def _resolve_one( + resolver: SourceResolver | ReferenceResolver, + request: SourceRequest, +) -> bool: + try: + if hasattr(resolver, "resolve"): + method = resolver.resolve # type: ignore[union-attr] + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + signature = None + if signature is not None: + positional = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind + in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) + ] + has_varargs = any( + parameter.kind is parameter.VAR_POSITIONAL + for parameter in signature.parameters.values() + ) + if not has_varargs and len(positional) >= 2: + result = method(request.source_kind, request.source_ref) + else: + result = method(request) + else: + result = method(request) + else: + # Compatibility adapters historically accepted ``(kind, ref)``; + # newer adapters may accept one typed SourceRequest. Inspect the + # callable when possible so a TypeError raised *inside* a resolver + # is not mistaken for an arity mismatch. + try: + signature = inspect.signature(resolver) # type: ignore[arg-type] + except (TypeError, ValueError): + signature = None + if signature is not None: + positional = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind + in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) + ] + has_varargs = any( + parameter.kind is parameter.VAR_POSITIONAL + for parameter in signature.parameters.values() + ) + if not has_varargs and len(positional) == 1: + result = resolver(request) # type: ignore[misc] + else: + result = resolver(request.source_kind, request.source_ref) # type: ignore[misc] + else: + result = resolver(request.source_kind, request.source_ref) # type: ignore[misc] + except ProvenanceError: + raise + except Exception as exc: + raise ProvenanceError(f"source resolver failed for {request.source_ref}: {exc}") from exc + return _resolver_result(result, request) + + +def _content_digest(summary: str) -> str: + return digest_json({"summary": summary}) + + +@dataclass(frozen=True, init=False) +class DevelopmentExample: + """One normalized development example and its integrity bindings.""" + + example_id: str + source_kind: SourceKind + source_ref: str + summary: str + content_digest: str + record_digest: str + + def __init__( + self, + example_id: str, + source_kind: SourceKind | str, + source_ref: str, + summary: str, + content_digest: str | None = None, + record_digest: str | None = None, + ) -> None: + normalized_id = _required_text(example_id, "example_id") + try: + normalized_kind = SourceKind(source_kind) + except (TypeError, ValueError) as exc: + raise ProvenanceError(f"invalid source_kind: {source_kind!r}") from exc + normalized_ref = _required_text(source_ref, "source_ref") + normalized_summary = _required_text(summary, "summary") + expected_content = _content_digest(normalized_summary) + if content_digest is not None and content_digest != expected_content: + raise ProvenanceError("development example content_digest mismatch") + unsigned = { + "schema_version": PROVENANCE_SCHEMA_VERSION, + "example_id": normalized_id, + "source_kind": normalized_kind.value, + "source_ref": normalized_ref, + "summary": normalized_summary, + "content_digest": expected_content, + } + expected_record = digest_json(unsigned) + if record_digest is not None and record_digest != expected_record: + raise ProvenanceError("development example record_digest mismatch") + object.__setattr__(self, "example_id", normalized_id) + object.__setattr__(self, "source_kind", normalized_kind) + object.__setattr__(self, "source_ref", normalized_ref) + object.__setattr__(self, "summary", normalized_summary) + object.__setattr__(self, "content_digest", expected_content) + object.__setattr__(self, "record_digest", expected_record) + + @property + def trusted_source_required(self) -> bool: + return self.source_kind.value in _TRUSTED_KINDS + + @property + def supports_holdout(self) -> bool: + """Development examples never support an unseen/sealed holdout claim.""" + + return False + + @property + def supports_representativeness(self) -> bool: + """A source label alone never proves representativeness.""" + + return False + + def unsigned_dict(self) -> dict[str, Any]: + return { + "schema_version": PROVENANCE_SCHEMA_VERSION, + "example_id": self.example_id, + "source_kind": self.source_kind.value, + "source_ref": self.source_ref, + "summary": self.summary, + "content_digest": self.content_digest, + } + + def to_dict(self) -> dict[str, Any]: + """Return the internal digest-closed record (not the A→B projection).""" + + return {**self.unsigned_dict(), "record_digest": self.record_digest} + + def projection(self) -> dict[str, str]: + """Return the frozen A→B shape, intentionally exactly two fields.""" + + return { + "source_kind": self.source_kind.value, + "source_ref": self.source_ref, + } + + def to_handoff_dict(self) -> dict[str, str]: + return self.projection() + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "DevelopmentExample": + if not isinstance(value, Mapping): + raise ProvenanceError("development example must be an object") + canonical = { + "schema_version", + "example_id", + "source_kind", + "source_ref", + "summary", + "content_digest", + "record_digest", + } + legacy = {"example_id", "source_kind", "source_ref", "summary"} + if set(value) not in (canonical, legacy): + raise ProvenanceError("development example fields do not match the closed contract") + return cls( + example_id=value["example_id"], + source_kind=value["source_kind"], + source_ref=value["source_ref"], + summary=value["summary"], + content_digest=value.get("content_digest"), + record_digest=value.get("record_digest"), + ) + + +def _normalize_examples( + examples: Iterable[DevelopmentExample | Mapping[str, Any]], +) -> tuple[DevelopmentExample, ...]: + if isinstance(examples, (str, bytes, Mapping)): + raise ProvenanceError("development_examples must be an array") + normalized: list[DevelopmentExample] = [] + identifiers: set[str] = set() + for raw in examples: + example = raw if isinstance(raw, DevelopmentExample) else DevelopmentExample.from_dict(raw) + if example.example_id in identifiers: + raise ProvenanceError(f"duplicate development example ID: {example.example_id}") + identifiers.add(example.example_id) + normalized.append(example) + return tuple(normalized) + + +def _normalize_holdout_refs( + holdout_examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + holdout_refs: Iterable[str] = (), +) -> tuple[set[str], set[str], set[str]]: + ids: set[str] = set() + refs: set[str] = set() + contents: set[str] = set() + if isinstance(holdout_examples, (str, bytes, Mapping)): + raise ProvenanceError("holdout_examples must be an array") + for raw in holdout_examples: + example = raw if isinstance(raw, DevelopmentExample) else DevelopmentExample.from_dict(raw) + ids.add(example.example_id) + refs.add(example.source_ref) + contents.add(example.content_digest) + if isinstance(holdout_refs, (str, bytes)): + raise ProvenanceError("holdout_refs must be an array") + for ref in holdout_refs: + refs.add(_required_text(ref, "holdout_refs[]")) + return ids, refs, contents + + +def _check_holdout_overlap( + examples: Sequence[DevelopmentExample], + *, + holdout_examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + holdout_refs: Iterable[str] = (), +) -> None: + holdout_ids, holdout_refs_set, holdout_contents = _normalize_holdout_refs( + holdout_examples, + holdout_refs, + ) + for example in examples: + if ( + example.example_id in holdout_ids + or example.source_ref in holdout_refs_set + or example.content_digest in holdout_contents + ): + raise ProvenanceError( + f"development example {example.example_id} overlaps a holdout and cannot be used" + ) + + +def validate_development_examples( + examples: Iterable[DevelopmentExample | Mapping[str, Any]], + *, + source_resolver: SourceResolver | ReferenceResolver | None = None, + reference_resolver: SourceResolver | ReferenceResolver | None = None, + resolver: SourceResolver | ReferenceResolver | None = None, + holdout_examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + holdout_refs: Iterable[str] = (), + require_resolver: bool = True, +) -> tuple[DevelopmentExample, ...]: + """Validate labels, exact resolver approval, and holdout separation. + + ``reference_resolver`` is the compatibility spelling used by the first + Track A prototype. If more than one spelling is supplied they must refer + to the same callable/object; silently selecting one would make the source + fact ambiguous. + """ + + supplied = [item for item in (source_resolver, reference_resolver, resolver) if item is not None] + if len(supplied) > 1 and any(item is not supplied[0] for item in supplied[1:]): + raise ProvenanceError("multiple source resolvers were supplied") + active_resolver = supplied[0] if supplied else None + normalized = _normalize_examples(examples) + _check_holdout_overlap( + normalized, + holdout_examples=holdout_examples, + holdout_refs=holdout_refs, + ) + for example in normalized: + if not example.trusted_source_required: + continue + if active_resolver is None: + if require_resolver: + raise ProvenanceError( + f"a trusted source resolver is required for {example.source_kind.value}:" + f" {example.source_ref}" + ) + continue + request = SourceRequest(example.source_kind, example.source_ref) + if not _resolve_one(active_resolver, request): + raise ProvenanceError(f"unresolved or unapproved source_ref: {example.source_ref}") + return normalized + + +def validate_no_holdout_overlap( + examples: Iterable[DevelopmentExample | Mapping[str, Any]], + *, + holdout_examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + holdout_refs: Iterable[str] = (), +) -> tuple[DevelopmentExample, ...]: + """Explicit helper used by Q adapters before creating a holdout claim.""" + + normalized = _normalize_examples(examples) + _check_holdout_overlap( + normalized, + holdout_examples=holdout_examples, + holdout_refs=holdout_refs, + ) + return normalized + + +class ProvenanceLedger: + """Append-only internal provenance records with a frozen resolver seam.""" + + def __init__( + self, + examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + *, + source_resolver: SourceResolver | ReferenceResolver | None = None, + reference_resolver: SourceResolver | ReferenceResolver | None = None, + require_resolver: bool = True, + ) -> None: + supplied = [item for item in (source_resolver, reference_resolver) if item is not None] + if len(supplied) == 2 and supplied[0] is not supplied[1]: + raise ProvenanceError("multiple source resolvers were supplied") + self._resolver = supplied[0] if supplied else None + self._require_resolver = require_resolver + self._examples: dict[str, DevelopmentExample] = {} + for example in examples: + self.add(example) + + def add( + self, + example: DevelopmentExample | Mapping[str, Any], + *, + source_resolver: SourceResolver | ReferenceResolver | None = None, + reference_resolver: SourceResolver | ReferenceResolver | None = None, + ) -> DevelopmentExample: + normalized = example if isinstance(example, DevelopmentExample) else DevelopmentExample.from_dict(example) + if source_resolver is not None and reference_resolver is not None and source_resolver is not reference_resolver: + raise ProvenanceError("multiple source resolvers were supplied") + if source_resolver is not None: + active = source_resolver + elif reference_resolver is not None: + active = reference_resolver + else: + active = self._resolver + validate_development_examples( + (normalized,), + source_resolver=active, + require_resolver=self._require_resolver, + ) + existing = self._examples.get(normalized.example_id) + if existing is not None: + if existing != normalized: + raise ProvenanceError( + f"development example cannot be relabelled or replaced: {normalized.example_id}" + ) + raise ProvenanceError(f"duplicate development example ID: {normalized.example_id}") + self._examples[normalized.example_id] = normalized + return normalized + + def require_kind( + self, + example_id: str, + allowed: Iterable[SourceKind | str], + ) -> DevelopmentExample: + normalized_id = _required_text(example_id, "example_id") + if normalized_id not in self._examples: + raise ProvenanceError(f"unknown development example ID: {normalized_id}") + allowed_kinds = {SourceKind(item) for item in allowed} + example = self._examples[normalized_id] + if example.source_kind not in allowed_kinds: + raise ProvenanceError( + f"{normalized_id} is {example.source_kind.value}, not an allowed source kind" + ) + return example + + def assert_not_holdout( + self, + holdout_examples: Iterable[DevelopmentExample | Mapping[str, Any]] = (), + *, + holdout_refs: Iterable[str] = (), + ) -> None: + _check_holdout_overlap( + self.examples, + holdout_examples=holdout_examples, + holdout_refs=holdout_refs, + ) + + @property + def examples(self) -> tuple[DevelopmentExample, ...]: + return tuple(self._examples[key] for key in sorted(self._examples)) + + def projection(self) -> tuple[dict[str, str], ...]: + return tuple(example.projection() for example in self.examples) + + @property + def projection_digest(self) -> str: + return digest_json(list(self.projection())) + + @property + def record_digest(self) -> str: + return digest_json([example.to_dict() for example in self.examples]) + + @property + def provenance_digest(self) -> str: + """Compatibility alias; this is an integrity digest, not a signature.""" + + return self.record_digest + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": PROVENANCE_SCHEMA_VERSION, + "development_examples": [example.to_dict() for example in self.examples], + "handoff_projection": [dict(item) for item in self.projection()], + "projection_digest": self.projection_digest, + "record_digest": self.record_digest, + "provenance_claim": "labels_record_source_kind_not_truth_or_representativeness", + } + + @classmethod + def from_dict( + cls, + value: Mapping[str, Any], + *, + source_resolver: SourceResolver | ReferenceResolver | None = None, + require_resolver: bool = True, + ) -> "ProvenanceLedger": + expected = { + "schema_version", + "development_examples", + "handoff_projection", + "projection_digest", + "record_digest", + "provenance_claim", + } + if not isinstance(value, Mapping) or set(value) != expected: + raise ProvenanceError("provenance ledger fields do not match the closed contract") + if value["schema_version"] != PROVENANCE_SCHEMA_VERSION: + raise ProvenanceError("unsupported provenance schema_version") + raw = value["development_examples"] + if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): + raise ProvenanceError("development_examples must be an array") + ledger = cls( + (DevelopmentExample.from_dict(item) for item in raw), + source_resolver=source_resolver, + require_resolver=require_resolver, + ) + projection = value["handoff_projection"] + if projection != list(ledger.projection()): + raise ProvenanceError("handoff provenance projection mismatch") + if value["projection_digest"] != ledger.projection_digest: + raise ProvenanceError("provenance projection_digest mismatch") + if value["record_digest"] != ledger.record_digest: + raise ProvenanceError("provenance record_digest mismatch") + if value["provenance_claim"] != "labels_record_source_kind_not_truth_or_representativeness": + raise ProvenanceError("unsupported provenance claim") + return ledger + + +__all__ = [ + "PROVENANCE_SCHEMA_VERSION", + "DevelopmentExample", + "ProvenanceError", + "ProvenanceLedger", + "ReferenceResolver", + "SourceKind", + "SourceRequest", + "SourceResolution", + "SourceResolver", + "validate_development_examples", + "validate_no_holdout_overlap", +] diff --git a/runtime/skill-optimizer/scripts/orchestration/__init__.py b/runtime/skill-optimizer/scripts/orchestration/__init__.py new file mode 100644 index 0000000..57c6514 --- /dev/null +++ b/runtime/skill-optimizer/scripts/orchestration/__init__.py @@ -0,0 +1,34 @@ +"""Thin A/B/C orchestration primitives for the authoring lane.""" + +from .models import ( + ActionClass, + ActionIntent, + ActionStatus, + AuthorizationAdapter, + BehaviorAndDeliverySummary, + ContinuationAdapter, + ContinuationBinding, + ContinuationReplayError, + DigestDriftError, + FactProvider, + FactReceipt, + GrantBinding, + IntegrationRequiredError, + InteractionClass, + NextAction, + OrchestrationContext, + OrchestrationError, + OrchestrationResult, + OrchestrationState, + PauseRecord, + QualityEvidenceSummary, + REAL_ACTION_AUTHORIZATIONS, + ResumeDirective, + UnauthorizedActionError, + UnsupportedActionError, + WaitingForUserError, +) +from .engine import OrchestrationEngine, classify_action, classify_operation + + +__all__ = [name for name in globals() if not name.startswith("_")] diff --git a/runtime/skill-optimizer/scripts/orchestration/engine.py b/runtime/skill-optimizer/scripts/orchestration/engine.py new file mode 100644 index 0000000..97f1f9e --- /dev/null +++ b/runtime/skill-optimizer/scripts/orchestration/engine.py @@ -0,0 +1,619 @@ +"""Dependency-injected A/B/C next-action orchestration. + +The engine is intentionally thin. It classifies and carries digests; it does +not implement the shared workflow reducer, create grants, derive risk, or +recompute quality decisions. Integrations supply adapters for continuation, +authorization, and read-only evidence facts. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from datetime import datetime, timezone +import threading +from typing import Any + +from core.canonical import digest_json + +from .models import ( + ActionClass, + ActionIntent, + ActionStatus, + AuthorizationAdapter, + BehaviorAndDeliverySummary, + ContinuationAdapter, + ContinuationBinding, + ContinuationReplayError, + DigestDriftError, + FactProvider, + FactReceipt, + GrantBinding, + IntegrationRequiredError, + InteractionClass, + NextAction, + OrchestrationContext, + OrchestrationError, + OrchestrationResult, + OrchestrationState, + QualityEvidenceSummary, + PauseRecord, + ResumeDirective, + UnauthorizedActionError, + UnsupportedActionError, + WaitingForUserError, +) + + +_ALLOWED_GRANT_ACTIONS = frozenset( + {"analysis_execution", "candidate_generation", "install"} +) +_C_OPERATIONS = frozenset( + { + "quality", + "quality_evidence", + "quality_summary", + "behavior", + "behavior_risk", + "behavior_summary", + "delivery", + "delivery_receipt", + "risk", + "risk_findings", + "baseline", + "baseline_result", + "evidence", + "evidence_summary", + "formal_outcome", + "unknowns", + "claim", + "claims", + } +) +_B_OPERATIONS = frozenset( + { + "choose", + "choose_scope", + "scope", + "choose_target", + "target", + "budget", + "budget_selection", + "design", + "design_approval", + "approval", + "approve", + "authorize_analysis_execution", + "analysis_execution", + "authorize_candidate_generation", + "candidate_generation", + "authorize_install", + "install", + "user", + "value_decision", + } +) +_A_OPERATIONS = frozenset( + { + "discover", + "route", + "triage", + "model", + "plan_resources", + "plan", + "draft", + "create_draft", + "validate", + "validate_structure", + "structure", + "clean", + "handoff", + "revalidate", + "digest", + "compute_digest", + "resume_check", + "revise", + "no_skill_probe", + "description_only_check", + } +) + + +def _normalize_operation(operation: Any) -> str: + if not isinstance(operation, str) or not operation.strip(): + raise UnsupportedActionError("operation must be non-empty text") + normalized = operation.strip().casefold().replace("-", "_").replace(" ", "_") + return normalized + + +def classify_operation(operation: str) -> ActionClass: + """Classify a closed operation name without consulting user input.""" + + normalized = _normalize_operation(operation) + if normalized in _C_OPERATIONS or any( + token in normalized for token in ("quality", "behavior", "delivery", "evidence", "baseline", "risk", "claim") + ): + return ActionClass.FACT + if normalized in _B_OPERATIONS or normalized.startswith("authorize_") or normalized.startswith("choose_"): + return ActionClass.USER + if normalized in _A_OPERATIONS or normalized.startswith(("validate_", "compute_", "revalidate_")): + return ActionClass.AUTOMATIC + raise UnsupportedActionError(f"operation is outside the closed A/B/C set: {operation}") + + +def classify_action(intent: ActionIntent) -> NextAction: + """Compatibility helper for the original pure A/B/C API.""" + + if not isinstance(intent, ActionIntent): + raise TypeError("intent must be ActionIntent") + action_class = { + InteractionClass.A_AUTOMATIC: ActionClass.AUTOMATIC, + InteractionClass.B_USER_DECISION_OR_AUTHORIZATION: ActionClass.USER, + InteractionClass.C_DERIVED_FACT: ActionClass.FACT, + }[intent.interaction_class] + return NextAction( + action_id=intent.action_id, + operation=intent.action_id, + action_class=action_class, + status=(ActionStatus.DISPLAYED if action_class is ActionClass.FACT else ActionStatus.WAITING if action_class is ActionClass.USER else ActionStatus.PENDING), + requires_user=action_class is ActionClass.USER, + payload=intent.derived_fact or {}, + question=intent.question if action_class is ActionClass.USER else None, + source_refs=intent.source_refs, + summary_text=intent.summary, + authorization_action_name=intent.authorization_action, + limitations=( + ("caller-authored fact is unverified",) + if action_class is ActionClass.FACT + else () + ), + ) + + +def _handler_call(handler: Callable[..., Any], *, context: OrchestrationContext, operation: str, payload: Mapping[str, Any]) -> Any: + try: + return handler(context=context, operation=operation, payload=payload) + except TypeError: + try: + return handler(context, operation, payload) + except TypeError: + return handler(context) + + +class OrchestrationEngine: + """Pure-ish classifier with explicit adapter seams. + + Legacy ``ActionIntent`` calls are retained as a side-effect-free + classification convenience. Modern calls pass an + :class:`OrchestrationContext` and receive an :class:`OrchestrationResult`. + """ + + def __init__( + self, + *, + authorization_adapter: AuthorizationAdapter | Any = None, + continuation_adapter: ContinuationAdapter | Any = None, + fact_provider: FactProvider | Any = None, + automatic_handlers: Mapping[str, Callable[..., Any]] | None = None, + ) -> None: + self.authorization_adapter = authorization_adapter + self.continuation_adapter = continuation_adapter + self.fact_provider = fact_provider + self.automatic_handlers = dict(automatic_handlers or {}) + self._consumed: set[str] = set() + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Classification + # ------------------------------------------------------------------ + def classify(self, operation: str | ActionIntent, *, action_id: str | None = None, description: str | None = None, payload: Mapping[str, Any] | None = None) -> NextAction: + if isinstance(operation, ActionIntent): + intent = operation + return classify_action(intent) + normalized = _normalize_operation(operation) + action_class = classify_operation(normalized) + if action_id is None: + action_id = f"action-{digest_json({'operation': normalized})[7:19]}" + question = None + if action_class is ActionClass.USER: + question = description or f"Please choose or authorize: {normalized}." + return NextAction( + action_id=action_id, + operation=normalized, + action_class=action_class, + status=ActionStatus.WAITING if action_class is ActionClass.USER else ActionStatus.PENDING, + requires_user=action_class is ActionClass.USER, + payload=payload or {}, + question=question, + ) + + def classify_intent(self, intent: ActionIntent) -> NextAction: + return self.classify(intent) + + # ------------------------------------------------------------------ + # Legacy pure interaction API + # ------------------------------------------------------------------ + def next_action(self, context_or_intent: OrchestrationContext | ActionIntent, operation: str | None = None, *, payload: Mapping[str, Any] | None = None, fact: Any = None) -> NextAction | OrchestrationResult: + """Return a pure A/B/C action (legacy) or execute a modern request.""" + + if isinstance(context_or_intent, ActionIntent): + if operation is not None: + raise ValueError("legacy ActionIntent form does not accept operation") + return self.classify(context_or_intent) + if not isinstance(context_or_intent, OrchestrationContext): + raise TypeError("next_action requires OrchestrationContext or ActionIntent") + if operation is None: + raise ValueError("modern next_action requires an operation") + return self._next_modern(context_or_intent, operation, payload=payload, supplied_fact=fact) + + def _digest_refs(self, context: OrchestrationContext) -> dict[str, str | None]: + return { + "task_digest": context.task_digest, + "scope_digest": context.scope_digest, + "risk_digest": context.risk_digest, + "target_digest": context.target_digest, + "log_head_digest": context.log_head_digest, + } + + def _next_modern( + self, + context: OrchestrationContext, + operation: str, + *, + payload: Mapping[str, Any] | None, + supplied_fact: Any, + ) -> OrchestrationResult: + normalized = _normalize_operation(operation) + if context.state is OrchestrationState.WAIT_USER and normalized not in {"resume", "cancel"}: + raise WaitingForUserError("WAIT_USER context may only be resumed or cancelled") + action_class = classify_operation(normalized) + if context.task_mode == "no_skill" and normalized in { + "draft", + "create_draft", + "candidate_generation", + "authorize_candidate_generation", + }: + raise UnsupportedActionError("no_skill mode cannot generate a candidate") + action = self.classify(normalized, payload=payload) + refs = self._digest_refs(context) + action = NextAction( + action_id=action.action_id, + operation=action.operation, + action_class=action.action_class, + status=action.status, + requires_user=action.requires_user, + digest_refs=refs, + payload=action.payload, + limitations=action.limitations, + question=action.question, + source_refs=action.source_refs, + ) + if action_class is ActionClass.USER: + waiting_context = context.with_state(OrchestrationState.WAIT_USER) + return OrchestrationResult( + state=OrchestrationState.WAIT_USER, + action=action, + context=waiting_context, + ) + if action_class is ActionClass.FACT: + receipt = self._read_fact( + context, + normalized, + supplied_fact=supplied_fact, + payload=payload or {}, + ) + if receipt is None: + action = NextAction( + action_id=action.action_id, + operation=action.operation, + action_class=action.action_class, + status=ActionStatus.DISPLAYED, + requires_user=False, + digest_refs=refs, + payload={}, + limitations=("fact unavailable; caller-authored summary ignored",), + question=None, + source_refs=action.source_refs, + ) + else: + action = NextAction( + action_id=action.action_id, + operation=action.operation, + action_class=action.action_class, + status=ActionStatus.DISPLAYED, + requires_user=False, + digest_refs=refs, + payload=receipt.payload, + limitations=(), + question=None, + source_refs=action.source_refs, + ) + return OrchestrationResult( + state=OrchestrationState.READY, + action=action, + context=context, + fact=receipt, + ) + handler = self.automatic_handlers.get(normalized) + result_payload: Mapping[str, Any] = dict(action.payload) + if handler is not None: + value = _handler_call(handler, context=context, operation=normalized, payload=result_payload) + if value is not None: + if isinstance(value, Mapping): + result_payload = dict(value) + else: + result_payload = {"result": value} + action = NextAction( + action_id=action.action_id, + operation=action.operation, + action_class=action.action_class, + status=ActionStatus.EXECUTED, + requires_user=False, + digest_refs=refs, + payload=result_payload, + limitations=action.limitations, + question=None, + source_refs=action.source_refs, + ) + return OrchestrationResult( + state=OrchestrationState.READY, + action=action, + context=context, + ) + + def _read_fact( + self, + context: OrchestrationContext, + operation: str, + *, + supplied_fact: Any, + payload: Mapping[str, Any], + ) -> FactReceipt | None: + # Anything supplied on the call itself is caller-authored. Even a + # correctly shaped FactReceipt cannot mint its own provider authority; + # trusted facts must arrive through the injected read-only provider. + if supplied_fact is not None: + if isinstance(supplied_fact, FactReceipt) and supplied_fact.fact_kind != operation: + raise OrchestrationError("fact receipt kind does not match requested operation") + return None + provider = self.fact_provider + if provider is None: + return None + method = getattr(provider, "read", None) + if method is None and callable(provider): + method = provider + if not callable(method): + raise IntegrationRequiredError("fact_provider has no read seam") + try: + value = method(context=context, operation=operation, payload=payload) + except TypeError: + value = method(context, operation, payload) + if value is None: + return None + if not isinstance(value, FactReceipt): + raise OrchestrationError("fact provider must return a typed FactReceipt") + if value.fact_kind != operation: + raise OrchestrationError("fact provider returned a receipt for another operation") + return value + + # ------------------------------------------------------------------ + # Continuation / WAIT_USER seam + # ------------------------------------------------------------------ + def pause_for_user( + self, + action_or_result: NextAction | OrchestrationResult, + *, + continuation: Any = None, + context: OrchestrationContext | None = None, + origin: str | None = None, + expires_at: str | None = None, + ) -> OrchestrationResult | "LegacyPause": + """Pause B action through the injected continuation adapter. + + The legacy ``ActionIntent`` path accepts a typed continuation mapping + for compatibility. It still writes no grant and validates its digest. + """ + + if isinstance(action_or_result, OrchestrationResult): + result = action_or_result + action = result.action + context = result.context if context is None else context + if action is None: + raise ValueError("cannot pause without an action") + else: + action = action_or_result + result = None + if not isinstance(action, NextAction): + raise TypeError("pause_for_user requires a NextAction or OrchestrationResult") + if action.action_class is not ActionClass.USER or not action.requires_user: + raise ValueError("only B user actions may enter WAIT_USER") + if context is None: + # Legacy pure interaction envelope. + return self._legacy_pause(action, continuation) + if context.state is not OrchestrationState.WAIT_USER: + context = context.with_state(OrchestrationState.WAIT_USER) + if self.continuation_adapter is None: + raise IntegrationRequiredError("continuation adapter is required to enter WAIT_USER") + adapter = self.continuation_adapter + method = getattr(adapter, "pause", None) or getattr(adapter, "pause_for_user", None) + if not callable(method): + raise IntegrationRequiredError("continuation adapter has no pause method") + try: + binding = method( + context=context, + origin=origin or action.operation, + allowed_continuation=origin or action.operation, + expires_at=expires_at, + ) + except TypeError: + binding = method(context, origin or action.operation, expires_at) + if not isinstance(binding, ContinuationBinding): + raise OrchestrationError("continuation adapter must return a typed ContinuationBinding") + if binding.origin != (origin or action.operation): + raise DigestDriftError("continuation origin does not match the requested B action") + if not binding.matches(context, target_state=binding.allowed_continuation): + raise DigestDriftError("continuation does not match current task/scope/risk/target/log head") + waiting = context.with_state( + OrchestrationState.WAIT_USER, + continuation_digest=binding.continuation_digest, + ) + return OrchestrationResult( + state=OrchestrationState.WAIT_USER, + action=action, + context=waiting, + continuation=binding, + ) + + def _legacy_pause(self, action: NextAction, continuation: Any) -> PauseRecord: + if not isinstance(continuation, Mapping): + raise ValueError("continuation must be a mapping with content_digest") + # The shared workflow owns the canonical continuation payload and its + # event-chain digest. This compatibility envelope validates only the + # opaque digest shape; it deliberately does not re-hash caller fields. + return PauseRecord(action=action, continuation=continuation) + + def resume( + self, + pause_or_binding: Any, + *, + resolution: Mapping[str, Any] | None = None, + continuation_consumer: Callable[[Any], Any] | None = None, + context: OrchestrationContext | None = None, + ) -> Any: + """Consume a continuation exactly once. + + A lock covers the check-and-mark sequence so concurrent resumes cannot + both pass. The legacy callback may return a boolean only in this + compatibility method; modern adapters must return a typed binding. + """ + + if isinstance(pause_or_binding, PauseRecord): + pause = pause_or_binding + digest = pause.continuation_digest + directive = ResumeDirective( + action_id=pause.action.action_id, + continuation_digest=digest, + resolution=resolution or {}, + ) + with self._lock: + if digest in self._consumed: + raise ContinuationReplayError("continuation has already been consumed") + if continuation_consumer is None: + raise IntegrationRequiredError("continuation_consumer is required for legacy resume") + try: + accepted = continuation_consumer(pause.continuation) + except Exception as exc: + raise OrchestrationError(f"continuation consumer failed: {exc}") from exc + if isinstance(accepted, Mapping): + consumed_digest = accepted.get("consumed_continuation_digest", digest) + if consumed_digest != digest: + raise OrchestrationError("continuation consumer consumed a different continuation") + elif accepted is not True: + raise OrchestrationError("continuation consumer rejected the continuation") + self._consumed.add(digest) + return directive + if not isinstance(pause_or_binding, ContinuationBinding): + raise TypeError("resume requires a ContinuationBinding") + binding = pause_or_binding + if context is None: + raise ValueError("modern resume requires current context") + if context.state is not OrchestrationState.WAIT_USER: + raise WaitingForUserError("a continuation can only be consumed from WAIT_USER") + if binding.continuation_digest in self._consumed or binding.consumed: + raise ContinuationReplayError("continuation has already been consumed") + if not binding.matches(context): + raise DigestDriftError("continuation is stale or its task/scope/risk/target drifted") + with self._lock: + if binding.continuation_digest in self._consumed: + raise ContinuationReplayError("continuation has already been consumed") + adapter = self.continuation_adapter + if adapter is None: + raise IntegrationRequiredError("continuation adapter is required to resume") + method = getattr(adapter, "consume", None) or getattr(adapter, "resume", None) + if not callable(method): + raise IntegrationRequiredError("continuation adapter has no consume method") + try: + consumed = method(context=context, continuation=binding, resolution=resolution or {}) + except TypeError: + consumed = method(binding, resolution or {}) + if not isinstance(consumed, ContinuationBinding): + raise OrchestrationError("continuation adapter must return a typed ContinuationBinding") + if consumed.continuation_digest != binding.continuation_digest or not consumed.consumed: + raise OrchestrationError("continuation adapter did not consume the exact binding") + self._consumed.add(binding.continuation_digest) + consumed_digests = tuple((*context.consumed_continuation_digests, binding.continuation_digest)) + resumed = context.with_state( + OrchestrationState.READY, + continuation_digest=None, + consumed_continuation_digests=consumed_digests, + ) + return OrchestrationResult( + state=OrchestrationState.READY, + action=None, + context=resumed, + continuation=consumed, + ) + + def was_consumed(self, continuation_digest: str) -> bool: + with self._lock: + return continuation_digest in self._consumed + + # ------------------------------------------------------------------ + # Action authorization seam + # ------------------------------------------------------------------ + def authorize( + self, + context: OrchestrationContext, + action: str, + *, + authorization: Any = None, + ) -> GrantBinding: + normalized = _normalize_operation(action) + if normalized not in _ALLOWED_GRANT_ACTIONS: + raise UnauthorizedActionError( + "budget/design/value decisions are not action grants; only analysis_execution, candidate_generation, and install are grantable" + ) + if context.state is OrchestrationState.WAIT_USER: + raise WaitingForUserError("WAIT_USER cannot write or consume a grant") + adapter = self.authorization_adapter + if adapter is None: + raise IntegrationRequiredError("authorization adapter is required") + method = getattr(adapter, "consume", None) or getattr(adapter, "authorize", None) + if not callable(method): + raise IntegrationRequiredError("authorization adapter has no consume method") + try: + binding = method( + context=context, + action=normalized, + authorization=authorization, + ) + except TypeError: + binding = method(context, normalized, authorization) + if not isinstance(binding, GrantBinding): + raise UnauthorizedActionError("authorization adapter must return a typed consumed GrantBinding") + if not binding.matches(context, action=normalized): + raise DigestDriftError("grant task/scope/risk/target binding does not match current context") + return binding + + consume_authorization = authorize + + def route(self, context: OrchestrationContext, operation: str, **kwargs: Any) -> OrchestrationResult: + """Alias emphasizing that the engine routes but does not decide facts.""" + + result = self.next_action(context, operation, **kwargs) + assert isinstance(result, OrchestrationResult) + return result + + +LegacyPause = PauseRecord + + +__all__ = [ + "ContinuationReplayError", + "LegacyPause", + "OrchestrationEngine", + "OrchestrationError", + "PauseRecord", + "ResumeDirective", + "classify_action", + "classify_operation", +] diff --git a/runtime/skill-optimizer/scripts/orchestration/models.py b/runtime/skill-optimizer/scripts/orchestration/models.py new file mode 100644 index 0000000..6e93b82 --- /dev/null +++ b/runtime/skill-optimizer/scripts/orchestration/models.py @@ -0,0 +1,861 @@ +"""Small, host-neutral models for Track A's A/B/C orchestration seam. + +These objects deliberately do not duplicate the workflow reducer, ProcessPlan, +risk engine, quality engine, or installer. They carry frozen digests and typed +adapter receipts so a host can route the next action without asking a user to +copy digests or allowing a caller-authored summary to become evidence. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, is_dataclass, asdict, replace +from datetime import datetime, timezone +from enum import Enum +import re +from typing import Any, Protocol, runtime_checkable + +from core.canonical import canonical_json, digest_json + + +ORCHESTRATION_OBJECT_VERSION = "skill-optimizer.authoring-orchestration/v1" +CONTINUATION_BINDING_OBJECT_VERSION = "skill-optimizer.authoring-continuation/v1" +GRANT_BINDING_OBJECT_VERSION = "skill-optimizer.authoring-grant-binding/v1" +FACT_RECEIPT_OBJECT_VERSION = "skill-optimizer.authoring-fact-receipt/v1" + +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_FORMAL_MODES = frozenset({"create", "optimize", "description_only", "no_skill"}) +_ACTION_GRANTS = frozenset({"analysis_execution", "candidate_generation", "install"}) +REAL_ACTION_AUTHORIZATIONS = _ACTION_GRANTS + + +class OrchestrationError(RuntimeError): + """Base error raised by the thin orchestration seam.""" + + +class DigestDriftError(OrchestrationError): + pass + + +class ContinuationReplayError(OrchestrationError): + pass + + +class WaitingForUserError(OrchestrationError): + pass + + +class UnauthorizedActionError(OrchestrationError): + pass + + +class UnsupportedActionError(OrchestrationError): + pass + + +class IntegrationRequiredError(OrchestrationError): + pass + + +class ActionClass(str, Enum): + """A = deterministic, B = user choice/authorization, C = read-only fact.""" + + AUTOMATIC = "A" + USER = "B" + FACT = "C" + + # More descriptive aliases make adapters easier to read while preserving a + # compact serialized class used by the cross-track seam. + A = "A" + B = "B" + C = "C" + + +class OrchestrationState(str, Enum): + READY = "ready" + WAIT_USER = "wait_user" + RUNNING = "running" + BLOCKED = "blocked" + COMPLETE = "complete" + CANCELLED = "cancelled" + + +class ActionStatus(str, Enum): + PENDING = "pending" + EXECUTED = "executed" + WAITING = "waiting" + DISPLAYED = "displayed" + BLOCKED = "blocked" + + +class InteractionClass(str, Enum): + """Compatibility names for the original Track A interaction seam.""" + + A_AUTOMATIC = "A_AUTOMATIC" + B_USER_DECISION_OR_AUTHORIZATION = "B_USER_DECISION_OR_AUTHORIZATION" + C_DERIVED_FACT = "C_DERIVED_FACT" + + +@dataclass(frozen=True) +class ActionIntent: + """Describe one next action without embedding workflow/risk logic. + + This is intentionally a small compatibility/value object. The modern + engine also accepts an :class:`OrchestrationContext` plus an operation; + callers using this form get a pure A/B/C classification only. + """ + + action_id: str + description: str + automatic: bool = False + needs_value_decision: bool = False + authorization_action: str | None = None + question: str | None = None + derived_fact: Mapping[str, Any] | None = None + source_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + _text(self.action_id, "action_id") + _text(self.description, "description") + flags = sum( + bool(item) + for item in ( + self.automatic, + self.needs_value_decision, + self.authorization_action is not None, + self.derived_fact is not None, + ) + ) + if flags != 1: + raise ValueError("ActionIntent must select exactly one A, B, or C classification") + if not isinstance(self.automatic, bool) or not isinstance(self.needs_value_decision, bool): + raise ValueError("ActionIntent classification flags must be boolean") + if self.authorization_action is not None and self.authorization_action not in _ACTION_GRANTS: + raise ValueError( + "only analysis_execution, candidate_generation, and install may be action authorizations" + ) + if self.needs_value_decision and self.authorization_action is not None: + raise ValueError("value decisions and real action authorizations must be separate") + if self.question is not None: + _text(self.question, "question") + if (self.needs_value_decision or self.authorization_action is not None) and self.question is None: + raise ValueError("B-class action requires a user-facing question") + if not (self.needs_value_decision or self.authorization_action is not None) and self.question is not None: + raise ValueError("A/C actions must not ask the user a question") + if self.derived_fact is not None: + object.__setattr__(self, "derived_fact", _mapping(self.derived_fact, "derived_fact")) + object.__setattr__(self, "source_refs", _string_tuple(self.source_refs, "source_refs")) + if self.derived_fact is not None and not self.source_refs: + raise ValueError("C-class derived fact requires source_refs") + + @property + def interaction_class(self) -> InteractionClass: + if self.derived_fact is not None: + return InteractionClass.C_DERIVED_FACT + if self.needs_value_decision or self.authorization_action is not None: + return InteractionClass.B_USER_DECISION_OR_AUTHORIZATION + return InteractionClass.A_AUTOMATIC + + @property + def summary(self) -> str: + """Original field spelling retained for old host adapters.""" + + return self.description + + +def _text(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be non-empty text") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"{field_name} must be valid UTF-8 text") from exc + return value + + +def _digest(value: Any, field_name: str, *, nullable: bool = False) -> str | None: + if value is None and nullable: + return None + if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None: + raise ValueError(f"{field_name} must be a sha256 digest") + return value + + +def _mapping(value: Any, field_name: str, *, allow_none: bool = False) -> dict[str, Any]: + if value is None and allow_none: + return {} + if is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + if not isinstance(value, Mapping): + raise ValueError(f"{field_name} must be an object") + # Canonicalization rejects non-string keys, non-finite values, and opaque + # host objects at the seam. Decode the normalized JSON to detach it from + # caller mutations. + import json + + return json.loads(canonical_json(value)) + + +def _forbidden_grant_key(value: Any, path: str = "$") -> str | None: + """Reject grants hidden below an arbitrary continuation/resolution map.""" + + if isinstance(value, Mapping): + for key, item in value.items(): + key_text = str(key) + if key_text.casefold().replace("-", "_") in { + "grant", + "authorization_grant", + "authorization_receipt", + }: + return f"{path}.{key_text}" + found = _forbidden_grant_key(item, f"{path}.{key_text}") + if found is not None: + return found + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + found = _forbidden_grant_key(item, f"{path}[{index}]") + if found is not None: + return found + return None + + +def _string_tuple(value: Sequence[str] | tuple[str, ...], field_name: str) -> tuple[str, ...]: + if isinstance(value, (str, bytes)): + raise ValueError(f"{field_name} must be an array of strings") + result = tuple(_text(item, f"{field_name}[]") for item in value) + if len(set(result)) != len(result): + raise ValueError(f"{field_name} must not contain duplicates") + return result + + +def _timestamp(value: str, field_name: str) -> datetime: + _text(value, field_name) + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field_name} must be an ISO date-time") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field_name} must include a timezone") + return parsed + + +@dataclass(frozen=True) +class OrchestrationContext: + """Frozen inputs automatically carried into every next-action request.""" + + workflow_id: str + task_mode: str + task_digest: str + scope_digest: str + risk_digest: str + target_digest: str | None = None + state: OrchestrationState = OrchestrationState.READY + log_head_digest: str | None = None + continuation_digest: str | None = None + consumed_continuation_digests: tuple[str, ...] = () + active_grant: "GrantBinding | None" = None + + def __post_init__(self) -> None: + _text(self.workflow_id, "workflow_id") + if self.task_mode not in _FORMAL_MODES: + raise ValueError("task_mode must be create, optimize, description_only, or no_skill") + for field_name in ("task_digest", "scope_digest", "risk_digest"): + _digest(getattr(self, field_name), field_name) + _digest(self.target_digest, "target_digest", nullable=True) + _digest(self.log_head_digest, "log_head_digest", nullable=True) + _digest(self.continuation_digest, "continuation_digest", nullable=True) + object.__setattr__(self, "state", OrchestrationState(self.state)) + consumed = _string_tuple(self.consumed_continuation_digests, "consumed_continuation_digests") + for item in consumed: + _digest(item, "consumed_continuation_digests[]") + object.__setattr__(self, "consumed_continuation_digests", consumed) + if self.active_grant is not None and not isinstance(self.active_grant, GrantBinding): + raise ValueError("active_grant must be a GrantBinding or null") + + @classmethod + def from_values( + cls, + *, + workflow_id: str, + task_mode: str, + task: Any, + scope: Any, + risk: Any, + target: Any = None, + **kwargs: Any, + ) -> "OrchestrationContext": + """Convenience constructor which hashes values through canonical JSON.""" + + return cls( + workflow_id=workflow_id, + task_mode=task_mode, + task_digest=digest_json(task), + scope_digest=digest_json(scope), + risk_digest=digest_json(risk), + target_digest=None if target is None else digest_json(target), + **kwargs, + ) + + @property + def binding_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "workflow_id": self.workflow_id, + "task_mode": self.task_mode, + "task_digest": self.task_digest, + "scope_digest": self.scope_digest, + "risk_digest": self.risk_digest, + "target_digest": self.target_digest, + "state": self.state.value, + "log_head_digest": self.log_head_digest, + "continuation_digest": self.continuation_digest, + "consumed_continuation_digests": list(self.consumed_continuation_digests), + "active_grant": self.active_grant.to_dict() if self.active_grant else None, + } + + def with_state(self, state: OrchestrationState, **changes: Any) -> "OrchestrationContext": + return replace(self, state=state, **changes) + + +@dataclass(frozen=True) +class GrantBinding: + """Typed action grant bound to task/scope/risk/target digests.""" + + action: str + grant_digest: str + event_digest: str + task_digest: str + scope_digest: str + risk_digest: str + target_digest: str | None = None + authorization_id: str | None = None + expires_at: str | None = None + consumed: bool = True + + def __post_init__(self) -> None: + if self.action not in _ACTION_GRANTS: + raise ValueError( + "only analysis_execution, candidate_generation, and install may be action grants" + ) + for field_name in ("grant_digest", "event_digest", "task_digest", "scope_digest", "risk_digest"): + _digest(getattr(self, field_name), field_name) + _digest(self.target_digest, "target_digest", nullable=True) + if self.authorization_id is not None: + _text(self.authorization_id, "authorization_id") + if self.expires_at is not None: + _timestamp(self.expires_at, "expires_at") + if self.consumed is not True: + raise ValueError("a GrantBinding must represent an already consumed verifier receipt") + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "object_version": GRANT_BINDING_OBJECT_VERSION, + "action": self.action, + "grant_digest": self.grant_digest, + "event_digest": self.event_digest, + "task_digest": self.task_digest, + "scope_digest": self.scope_digest, + "risk_digest": self.risk_digest, + "target_digest": self.target_digest, + "authorization_id": self.authorization_id, + "expires_at": self.expires_at, + "consumed": self.consumed, + } + + def matches(self, context: OrchestrationContext, *, action: str | None = None) -> bool: + return ( + (action is None or self.action == action) + and self.task_digest == context.task_digest + and self.scope_digest == context.scope_digest + and self.risk_digest == context.risk_digest + and self.target_digest == context.target_digest + and (self.expires_at is None or _timestamp(self.expires_at, "expires_at") > datetime.now(timezone.utc)) + ) + + +@dataclass(frozen=True) +class ContinuationBinding: + """One-use continuation seam; actual event-chain semantics live in core.""" + + continuation_digest: str + task_digest: str + scope_digest: str + risk_digest: str + target_digest: str | None + origin: str + allowed_continuation: str + expires_at: str + consumed: bool = False + nonce: str | None = None + log_head_digest: str | None = None + + def __post_init__(self) -> None: + for field_name in ("continuation_digest", "task_digest", "scope_digest", "risk_digest"): + _digest(getattr(self, field_name), field_name) + _digest(self.target_digest, "target_digest", nullable=True) + _text(self.origin, "origin") + _text(self.allowed_continuation, "allowed_continuation") + _timestamp(self.expires_at, "expires_at") + if self.nonce is not None: + _text(self.nonce, "nonce") + if len(self.nonce) < 32: + raise ValueError("continuation nonce must contain at least 32 characters") + _digest(self.log_head_digest, "log_head_digest", nullable=True) + if not isinstance(self.consumed, bool): + raise ValueError("continuation consumed must be boolean") + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "object_version": CONTINUATION_BINDING_OBJECT_VERSION, + "continuation_digest": self.continuation_digest, + "task_digest": self.task_digest, + "scope_digest": self.scope_digest, + "risk_digest": self.risk_digest, + "target_digest": self.target_digest, + "origin": self.origin, + "allowed_continuation": self.allowed_continuation, + "expires_at": self.expires_at, + "consumed": self.consumed, + "nonce": self.nonce, + "log_head_digest": self.log_head_digest, + } + + def matches(self, context: OrchestrationContext, *, target_state: str | None = None) -> bool: + return ( + not self.consumed + and self.task_digest == context.task_digest + and self.scope_digest == context.scope_digest + and self.risk_digest == context.risk_digest + and self.target_digest == context.target_digest + and (target_state is None or target_state == self.allowed_continuation) + and _timestamp(self.expires_at, "expires_at") > datetime.now(timezone.utc) + and (self.log_head_digest is None or self.log_head_digest == context.log_head_digest) + ) + + +@dataclass(frozen=True) +class FactReceipt: + """A read-only fact returned by an injected trusted evidence provider.""" + + fact_kind: str + payload: Mapping[str, Any] + source_digest: str + provider_digest: str + verified: bool = True + + def __post_init__(self) -> None: + _text(self.fact_kind, "fact_kind") + _digest(self.source_digest, "source_digest") + _digest(self.provider_digest, "provider_digest") + if self.verified is not True: + raise ValueError("FactReceipt must be verified by its provider") + object.__setattr__(self, "payload", _mapping(self.payload, "payload")) + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "object_version": FACT_RECEIPT_OBJECT_VERSION, + "fact_kind": self.fact_kind, + "payload": dict(self.payload), + "source_digest": self.source_digest, + "provider_digest": self.provider_digest, + "verified": True, + } + + +_QUALITY_FIELDS = { + "candidate_digest", + "baseline_digest", + "quality_plan_digest", + "verified_claims", + "unverified_claims", + "blocked_claims", + "removed_claims", + "indicative_observations", + "formal_outcome", +} + + +@dataclass(frozen=True) +class QualityEvidenceSummary: + candidate_digest: str + baseline_digest: str | None + quality_plan_digest: str + verified_claims: tuple[Any, ...] = () + unverified_claims: tuple[Any, ...] = () + blocked_claims: tuple[Any, ...] = () + removed_claims: tuple[Any, ...] = () + indicative_observations: tuple[Any, ...] = () + formal_outcome: Any = None + + def __post_init__(self) -> None: + _digest(self.candidate_digest, "candidate_digest") + _digest(self.baseline_digest, "baseline_digest", nullable=True) + _digest(self.quality_plan_digest, "quality_plan_digest") + for field_name in ( + "verified_claims", + "unverified_claims", + "blocked_claims", + "removed_claims", + "indicative_observations", + ): + values = getattr(self, field_name) + if isinstance(values, (str, bytes)): + raise ValueError(f"{field_name} must be an array") + object.__setattr__(self, field_name, tuple(values)) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "QualityEvidenceSummary": + if set(value) != _QUALITY_FIELDS: + raise ValueError("quality evidence summary fields are not closed") + return cls( + candidate_digest=value["candidate_digest"], + baseline_digest=value["baseline_digest"], + quality_plan_digest=value["quality_plan_digest"], + verified_claims=tuple(value["verified_claims"]), + unverified_claims=tuple(value["unverified_claims"]), + blocked_claims=tuple(value["blocked_claims"]), + removed_claims=tuple(value["removed_claims"]), + indicative_observations=tuple(value["indicative_observations"]), + formal_outcome=value["formal_outcome"], + ) + + def to_dict(self) -> dict[str, Any]: + return { + "candidate_digest": self.candidate_digest, + "baseline_digest": self.baseline_digest, + "quality_plan_digest": self.quality_plan_digest, + "verified_claims": list(self.verified_claims), + "unverified_claims": list(self.unverified_claims), + "blocked_claims": list(self.blocked_claims), + "removed_claims": list(self.removed_claims), + "indicative_observations": list(self.indicative_observations), + "formal_outcome": self.formal_outcome, + } + + +_BEHAVIOR_FIELDS = { + "behavior_findings", + "minimum_risk", + "mandatory_controls", + "unknowns", + "delivery_target", + "delivery_eligibility", + "delivery_receipt", + "rollback_receipt", +} + + +@dataclass(frozen=True) +class BehaviorAndDeliverySummary: + behavior_findings: tuple[Any, ...] + minimum_risk: str + mandatory_controls: tuple[Any, ...] + unknowns: tuple[Any, ...] + delivery_target: str + delivery_eligibility: str + delivery_receipt: Any + rollback_receipt: Any + trusted: bool = False + + def __post_init__(self) -> None: + _text(self.minimum_risk, "minimum_risk") + _text(self.delivery_target, "delivery_target") + _text(self.delivery_eligibility, "delivery_eligibility") + for field_name in ("behavior_findings", "mandatory_controls", "unknowns"): + values = getattr(self, field_name) + if isinstance(values, (str, bytes)): + raise ValueError(f"{field_name} must be an array") + object.__setattr__(self, field_name, tuple(values)) + if not isinstance(self.trusted, bool): + raise ValueError("BehaviorAndDeliverySummary.trusted must be boolean") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "BehaviorAndDeliverySummary": + if set(value) != _BEHAVIOR_FIELDS: + raise ValueError("behavior/delivery summary fields are not closed") + return cls( + behavior_findings=tuple(value["behavior_findings"]), + minimum_risk=value["minimum_risk"], + mandatory_controls=tuple(value["mandatory_controls"]), + unknowns=tuple(value["unknowns"]), + delivery_target=value["delivery_target"], + delivery_eligibility=value["delivery_eligibility"], + delivery_receipt=value["delivery_receipt"], + rollback_receipt=value["rollback_receipt"], + # Parsing a caller-authored mapping validates shape only. It does + # not turn the mapping into a Track C conclusion. + trusted=False, + ) + + @property + def blocked(self) -> bool: + return self.delivery_eligibility.casefold() in {"blocked", "denied"} + + @property + def escalation(self) -> bool: + return self.delivery_eligibility.casefold() in {"escalate", "requires_escalation"} + + def to_dict(self) -> dict[str, Any]: + return { + "behavior_findings": list(self.behavior_findings), + "minimum_risk": self.minimum_risk, + "mandatory_controls": list(self.mandatory_controls), + "unknowns": list(self.unknowns), + "delivery_target": self.delivery_target, + "delivery_eligibility": self.delivery_eligibility, + "delivery_receipt": self.delivery_receipt, + "rollback_receipt": self.rollback_receipt, + } + + +@dataclass(frozen=True) +class NextAction: + action_id: str + operation: str + action_class: ActionClass + status: ActionStatus = ActionStatus.PENDING + requires_user: bool = False + digest_refs: Mapping[str, str | None] = field(default_factory=dict) + payload: Mapping[str, Any] = field(default_factory=dict) + limitations: tuple[str, ...] = () + question: str | None = None + source_refs: tuple[str, ...] = () + summary_text: str | None = None + authorization_action_name: str | None = None + + def __post_init__(self) -> None: + _text(self.action_id, "action_id") + _text(self.operation, "operation") + object.__setattr__(self, "action_class", ActionClass(self.action_class)) + object.__setattr__(self, "status", ActionStatus(self.status)) + if self.action_class is ActionClass.FACT and self.requires_user: + raise ValueError("C facts must never require a user decision") + if self.action_class is ActionClass.USER and not self.requires_user: + object.__setattr__(self, "requires_user", True) + if self.action_class is ActionClass.USER and self.question is None: + raise ValueError("B actions require a user-facing question") + if self.action_class is not ActionClass.USER and self.question is not None: + raise ValueError("A/C actions cannot ask the user a question") + refs = _mapping(self.digest_refs, "digest_refs") + for key, value in refs.items(): + _text(key, "digest_refs key") + _digest(value, f"digest_refs.{key}", nullable=True) + object.__setattr__(self, "digest_refs", refs) + object.__setattr__(self, "payload", _mapping(self.payload, "payload")) + object.__setattr__(self, "limitations", _string_tuple(self.limitations, "limitations")) + if self.question is not None: + _text(self.question, "question") + object.__setattr__(self, "source_refs", _string_tuple(self.source_refs, "source_refs")) + if self.summary_text is not None: + _text(self.summary_text, "summary_text") + if self.authorization_action_name is not None and self.authorization_action_name not in _ACTION_GRANTS: + raise ValueError("authorization_action_name is not a real action authorization") + if self.action_class is not ActionClass.USER and self.authorization_action_name is not None: + raise ValueError("only B-class actions may carry authorization_action_name") + + @property + def classification(self) -> InteractionClass: + return { + ActionClass.AUTOMATIC: InteractionClass.A_AUTOMATIC, + ActionClass.USER: InteractionClass.B_USER_DECISION_OR_AUTHORIZATION, + ActionClass.FACT: InteractionClass.C_DERIVED_FACT, + }[self.action_class] + + @property + def summary(self) -> str: + return self.summary_text or self.operation + + @property + def authorization_action(self) -> str | None: + return self.authorization_action_name + + @property + def derived_fact(self) -> Mapping[str, Any] | None: + return dict(self.payload) if self.action_class is ActionClass.FACT else None + + @property + def requires_user_input(self) -> bool: + return self.requires_user + + @property + def content_digest(self) -> str: + return digest_json(self.to_dict()) + + def to_dict(self) -> dict[str, Any]: + return { + "object_version": ORCHESTRATION_OBJECT_VERSION, + "action_id": self.action_id, + "operation": self.operation, + "action_class": self.action_class.value, + "status": self.status.value, + "requires_user": self.requires_user, + "digest_refs": dict(self.digest_refs), + "payload": dict(self.payload), + "limitations": list(self.limitations), + "question": self.question, + "source_refs": list(self.source_refs), + "summary": self.summary, + "classification": self.classification.value, + "requires_user_input": self.requires_user_input, + "authorization_action": self.authorization_action, + "derived_fact": self.derived_fact, + } + + +@dataclass(frozen=True) +class OrchestrationResult: + state: OrchestrationState + action: NextAction | None + context: OrchestrationContext + fact: FactReceipt | None = None + continuation: ContinuationBinding | None = None + grant: GrantBinding | None = None + error: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "state", OrchestrationState(self.state)) + if self.error is not None: + _text(self.error, "error") + + def to_dict(self) -> dict[str, Any]: + return { + "state": self.state.value, + "action": self.action.to_dict() if self.action else None, + "context": self.context.to_dict(), + "fact": self.fact.to_dict() if self.fact else None, + "continuation": self.continuation.to_dict() if self.continuation else None, + "grant": self.grant.to_dict() if self.grant else None, + "error": self.error, + } + + +@dataclass(frozen=True) +class PauseRecord: + """Legacy WAIT_USER envelope; it carries no grant.""" + + action: NextAction + continuation: Mapping[str, Any] + + def __post_init__(self) -> None: + if self.action.action_class is not ActionClass.USER: + raise ValueError("only B-class actions can enter WAIT_USER") + normalized = _mapping(self.continuation, "continuation") + digest = normalized.get("content_digest") + _digest(digest, "continuation.content_digest") + forbidden = _forbidden_grant_key(normalized) + if forbidden is not None: + raise ValueError("WAIT_USER continuation cannot contain an authorization grant") + object.__setattr__(self, "continuation", normalized) + + @property + def continuation_digest(self) -> str: + return str(self.continuation["content_digest"]) + + @property + def status(self) -> str: + return "WAIT_USER" + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "action": self.action.to_dict(), + "continuation": dict(self.continuation), + } + + +@dataclass(frozen=True) +class ResumeDirective: + """Opaque user resolution forwarded to the authoritative workflow.""" + + action_id: str + continuation_digest: str + resolution: Mapping[str, Any] + + def __post_init__(self) -> None: + _text(self.action_id, "action_id") + _digest(self.continuation_digest, "continuation_digest") + normalized = _mapping(self.resolution, "resolution") + forbidden = _forbidden_grant_key(normalized) + if forbidden is not None: + raise ValueError("orchestration resolution cannot create or carry a grant") + object.__setattr__(self, "resolution", normalized) + + def to_dict(self) -> dict[str, Any]: + return { + "action_id": self.action_id, + "continuation_digest": self.continuation_digest, + "resolution": dict(self.resolution), + } + + +@runtime_checkable +class AuthorizationAdapter(Protocol): + """Adapter seam; implementation must return a consumed GrantBinding.""" + + def consume(self, **context: Any) -> GrantBinding: + ... + + +@runtime_checkable +class ContinuationAdapter(Protocol): + """Adapter seam for the shared event-log continuation reducer.""" + + def pause(self, **context: Any) -> ContinuationBinding: + ... + + def consume(self, **context: Any) -> ContinuationBinding: + ... + + +@runtime_checkable +class FactProvider(Protocol): + def read(self, **context: Any) -> FactReceipt: + ... + + +__all__ = [ + "ActionClass", + "ActionStatus", + "ActionIntent", + "AuthorizationAdapter", + "BehaviorAndDeliverySummary", + "ContinuationAdapter", + "ContinuationBinding", + "ContinuationReplayError", + "DigestDriftError", + "FACT_RECEIPT_OBJECT_VERSION", + "FactProvider", + "FactReceipt", + "GRANT_BINDING_OBJECT_VERSION", + "GrantBinding", + "InteractionClass", + "IntegrationRequiredError", + "NextAction", + "ORCHESTRATION_OBJECT_VERSION", + "OrchestrationContext", + "OrchestrationError", + "OrchestrationResult", + "OrchestrationState", + "PauseRecord", + "REAL_ACTION_AUTHORIZATIONS", + "QualityEvidenceSummary", + "CONTINUATION_BINDING_OBJECT_VERSION", + "UnauthorizedActionError", + "UnsupportedActionError", + "WaitingForUserError", + "ResumeDirective", +]