From bdd24ad279b1f38b0d40c32ca9007fc695100a7b Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:01:01 -0700 Subject: [PATCH 1/8] Freeze control-surface summary contract --- code-mower-package-manifest.json | 30 + src/code_mower/cloud_client/bundle.py | 1 + src/code_mower/cloud_client/events.py | 7 + ...trol_surface_session_summary.accepted.json | 502 +++++++++++ ..._surface_session_summary.expectations.json | 46 + ...face_session_summary.fixture-manifest.json | 32 + ...trol_surface_session_summary.rejected.json | 813 ++++++++++++++++++ ...ontrol_surface_session_summary.schema.json | 233 +++++ src/code_mower/control_surface_summary.py | 268 ++++++ src/code_mower/package_manifest.py | 30 + tests/test_control_surface_summary.py | 121 +++ 11 files changed, 2083 insertions(+) create mode 100644 src/code_mower/control_surface_session_summary.accepted.json create mode 100644 src/code_mower/control_surface_session_summary.expectations.json create mode 100644 src/code_mower/control_surface_session_summary.fixture-manifest.json create mode 100644 src/code_mower/control_surface_session_summary.rejected.json create mode 100644 src/code_mower/control_surface_session_summary.schema.json create mode 100644 src/code_mower/control_surface_summary.py create mode 100644 tests/test_control_surface_summary.py diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index aef76089..c75d5628 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -892,6 +892,36 @@ "source": "src/code_mower/context_store.py", "target": "src/code_mower/context_store.py" }, + { + "kind": "fixture", + "source": "src/code_mower/control_surface_session_summary.accepted.json", + "target": "src/code_mower/control_surface_session_summary.accepted.json" + }, + { + "kind": "fixture", + "source": "src/code_mower/control_surface_session_summary.expectations.json", + "target": "src/code_mower/control_surface_session_summary.expectations.json" + }, + { + "kind": "fixture", + "source": "src/code_mower/control_surface_session_summary.fixture-manifest.json", + "target": "src/code_mower/control_surface_session_summary.fixture-manifest.json" + }, + { + "kind": "fixture", + "source": "src/code_mower/control_surface_session_summary.rejected.json", + "target": "src/code_mower/control_surface_session_summary.rejected.json" + }, + { + "kind": "schema", + "source": "src/code_mower/control_surface_session_summary.schema.json", + "target": "src/code_mower/control_surface_session_summary.schema.json" + }, + { + "kind": "core", + "source": "src/code_mower/control_surface_summary.py", + "target": "src/code_mower/control_surface_summary.py" + }, { "kind": "core", "source": "src/code_mower/controller.py", diff --git a/src/code_mower/cloud_client/bundle.py b/src/code_mower/cloud_client/bundle.py index 22b92b00..62c14fa0 100644 --- a/src/code_mower/cloud_client/bundle.py +++ b/src/code_mower/cloud_client/bundle.py @@ -26,6 +26,7 @@ "board_snapshot", "builder_run", "calibration_run", + "control_surface_session_summary", "controller_decision", "dogfood_upload", "lane_policy_snapshot", diff --git a/src/code_mower/cloud_client/events.py b/src/code_mower/cloud_client/events.py index 017982e5..3d7f0115 100644 --- a/src/code_mower/cloud_client/events.py +++ b/src/code_mower/cloud_client/events.py @@ -11,6 +11,10 @@ from typing import Any, Mapping from code_mower import __version__ +from code_mower.control_surface_summary import ( + EVENT_TYPE as CONTROL_SURFACE_SUMMARY_EVENT_TYPE, + validate_control_surface_summary, +) from code_mower.participants import PARTICIPANTS from code_mower.provider_registry import REFERENCE_PROVIDERS from code_mower.providers import ( @@ -417,6 +421,8 @@ def validate_cloud_event(value: Any) -> dict[str, Any]: validate_adoption_run_payload(value) if value["event_type"] == REVIEWER_FINDING_OUTCOME_EVENT_TYPE: validate_reviewer_finding_outcome_payload(value) + if value["event_type"] == CONTROL_SURFACE_SUMMARY_EVENT_TYPE: + validate_control_surface_summary(value) validate_work_type_metadata(value["dimensions"], value["event_type"], value["tool"]) return value @@ -928,6 +934,7 @@ def normalize_event(value: dict[str, Any], event_type: str) -> dict[str, Any]: PR_OUTCOME_EVENT_TYPE, PRODUCTIVITY_EVENT_TYPE, REVIEWER_FINDING_OUTCOME_EVENT_TYPE, + CONTROL_SURFACE_SUMMARY_EVENT_TYPE, "value_report_snapshot", }: normalized["tool"] = build_code_mower_tool_provenance( diff --git a/src/code_mower/control_surface_session_summary.accepted.json b/src/code_mower/control_surface_session_summary.accepted.json new file mode 100644 index 00000000..47e13039 --- /dev/null +++ b/src/code_mower/control_surface_session_summary.accepted.json @@ -0,0 +1,502 @@ +{ + "events": [ + { + "event": { + "created_at": "2026-09-20T12:01:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000001", + "state": "pending", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000001", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "pending", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "pending_no_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "running_no_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:03:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "answer_question", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000003", + "state": "waiting_for_user", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000003", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_user", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "waiting_for_user" + }, + { + "event": { + "created_at": "2026-09-20T12:04:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "respond_to_approval", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000004", + "state": "waiting_for_approval", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000004", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_approval", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "waiting_for_approval" + }, + { + "event": { + "created_at": "2026-09-20T12:05:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "inspect_failure", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000005", + "state": "suspended", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000005", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "suspended", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "suspended_no_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:06:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000006", + "state": "uncertain", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000006", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "uncertain", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "uncertain_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:07:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "outcome": "succeeded", + "owner_action": "none", + "pr_number": "921", + "pr_state": "open", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000007", + "state": "complete", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000007", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 1, + "dispatch_count": 1, + "elapsed_seconds": 420.0, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "complete", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "complete_with_open_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:08:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "failed", + "owner_action": "inspect_failure", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000008", + "state": "failed", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000008", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "failed", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "failed_without_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:09:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "cancelled", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000009", + "state": "terminated", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000009", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 1, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "terminated", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "terminated_without_pr" + }, + { + "event": { + "created_at": "2026-09-20T12:10:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "outcome": "unknown", + "owner_action": "none", + "pr_number": "921", + "pr_state": "open", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000a", + "state": "archived", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000a", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "archived", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "archived_with_retained_pr" + } + ], + "schema": "code_mower.controlSurfaceSessionSummaryAcceptedFixtures.v1" +} diff --git a/src/code_mower/control_surface_session_summary.expectations.json b/src/code_mower/control_surface_session_summary.expectations.json new file mode 100644 index 00000000..9b6eaa8c --- /dev/null +++ b/src/code_mower/control_surface_session_summary.expectations.json @@ -0,0 +1,46 @@ +{ + "contract_schema": "code_mower.controlSurfaceSessionSummary.v1", + "expectations": [ + { + "name": "legacy_client", + "requirement": "Existing event types remain accepted when the summary event is absent." + }, + { + "name": "capability_absent", + "requirement": "The client emits no summary unless hosted acceptance names the exact schema, capability version, and fixture-manifest digest." + }, + { + "name": "capability_mismatch", + "requirement": "A schema, capability-version, or fixture-manifest digest mismatch disables emission and preserves local-only operation." + }, + { + "name": "rollback", + "requirement": "Client rollback stops new emission and does not delete or reinterpret accepted rows." + }, + { + "name": "tenant_isolation", + "requirement": "Authenticated tenant binding, not payload routing fields, scopes repository identity; equal repo slugs in two tenants remain isolated." + }, + { + "name": "export", + "requirement": "Export returns each accepted summary once and contains none of the forbidden fields represented by rejected fixtures." + }, + { + "name": "deletion", + "requirement": "Deletion removes the summary, its aggregate contribution, and its export representation." + }, + { + "name": "retention", + "requirement": "Retention removes only expired rows and recomputes aggregate counts and freshness." + }, + { + "name": "token_revocation", + "requirement": "Token revocation prevents new ingest while authenticated export and deletion continue to control existing rows." + }, + { + "name": "aggregate_reconciliation", + "requirement": "Aggregate counts and freshness reconcile with the accepted canary local Board projection." + } + ], + "schema": "code_mower.controlSurfaceSessionSummaryExpectations.v1" +} diff --git a/src/code_mower/control_surface_session_summary.fixture-manifest.json b/src/code_mower/control_surface_session_summary.fixture-manifest.json new file mode 100644 index 00000000..37002f1c --- /dev/null +++ b/src/code_mower/control_surface_session_summary.fixture-manifest.json @@ -0,0 +1,32 @@ +{ + "contract_schema": "code_mower.controlSurfaceSessionSummary.v1", + "digest_scope": "exact_file_bytes", + "encoding": "utf-8", + "files": [ + { + "bytes": 16933, + "path": "control_surface_session_summary.accepted.json", + "role": "accepted", + "sha256": "f6629a2561068fd1ca495cc7e028bc527840fb6ed13ef4f182593ca2a4b0ab38" + }, + { + "bytes": 1869, + "path": "control_surface_session_summary.expectations.json", + "role": "expectations", + "sha256": "01c8e9bb26f60fb8163a69801070ab6eabeadf20c271a41455634063966c7a21" + }, + { + "bytes": 27232, + "path": "control_surface_session_summary.rejected.json", + "role": "rejected", + "sha256": "13425843929101c5dac575238a5896e4a49737fb74fbab605fbbe36286e65760" + }, + { + "bytes": 5278, + "path": "control_surface_session_summary.schema.json", + "role": "schema", + "sha256": "5b84475db6671c19752f6c6528bde217186ccdc6e83e97682f75b32f7d0582d5" + } + ], + "schema": "code_mower.controlSurfaceSessionFixtureManifest.v1" +} diff --git a/src/code_mower/control_surface_session_summary.rejected.json b/src/code_mower/control_surface_session_summary.rejected.json new file mode 100644 index 00000000..b5c5a383 --- /dev/null +++ b/src/code_mower/control_surface_session_summary.rejected.json @@ -0,0 +1,813 @@ +{ + "events": [ + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "extra": "x", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "unknown_root_field" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "extra": "x", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "unknown_dimension" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "extra": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "unknown_metric" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "pull_request": { + "number": 1, + "title": "private" + }, + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "unknown_pull_request_field" + }, + { + "event": { + "answer": "private answer", + "context": "context", + "created_at": "2026-09-20T12:02:00Z", + "credential": "credential", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "graph": "graph", + "install_id": "", + "lens": "", + "local_path": "/Users/example/repo", + "message": "private message", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "prompt": "private prompt", + "provider": "codex", + "provider_reference": "provider-session", + "raw_diff": "diff", + "raw_output": "output", + "repo_slug": "example/project", + "response_url": "https://slack.invalid/private", + "schema": "code_mower.benchmarkEvent.v1", + "slack_channel": "C123", + "slack_user": "U123", + "source": "code-mower slack lifecycle", + "source_code": "secret", + "status": "running", + "task": "private task", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + }, + "transcript": "chat" + }, + "name": "forbidden_content_fields" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session_id": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "forbidden_session_id_key" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v2" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "summary_schema_v2" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v2", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "wrong_event_envelope_schema" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "email", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "wrong_surface" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "invalid_repo_slug" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "slack-team", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "tenant_identity_in_event" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "complete", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "complete", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "state_outcome_mismatch" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "answer_question", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "state_owner_action_mismatch" + }, + { + "event": { + "created_at": "2026-09-20 12:00:00", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "bad_timestamp" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "bad_pr_shape" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "devin-session-12345", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "remote_provider_reference_instead_of_opaque_session" + } + ], + "schema": "code_mower.controlSurfaceSessionSummaryRejectedFixtures.v1" +} diff --git a/src/code_mower/control_surface_session_summary.schema.json b/src/code_mower/control_surface_session_summary.schema.json new file mode 100644 index 00000000..179f2cf5 --- /dev/null +++ b/src/code_mower/control_surface_session_summary.schema.json @@ -0,0 +1,233 @@ +{ + "$defs": { + "dimensions": { + "additionalProperties": false, + "properties": { + "capability_version": { + "const": 1 + }, + "control_surface": { + "const": "slack" + }, + "head_sha": { + "pattern": "^[0-9a-f]{40}$", + "type": "string" + }, + "outcome": { + "enum": [ + "unknown", + "succeeded", + "failed", + "cancelled" + ] + }, + "owner_action": { + "enum": [ + "none", + "answer_question", + "respond_to_approval", + "inspect_provider", + "inspect_failure" + ] + }, + "pr_number": { + "pattern": "^[1-9][0-9]{0,9}$", + "type": "string" + }, + "pr_state": { + "enum": [ + "open", + "merged", + "closed_unmerged" + ] + }, + "privacy_classification": { + "const": "metadata_only" + }, + "session": { + "pattern": "^[0-9a-f]{32}$", + "type": "string" + }, + "state": { + "enum": [ + "archived", + "complete", + "failed", + "pending", + "running", + "suspended", + "terminated", + "uncertain", + "waiting_for_approval", + "waiting_for_user" + ] + }, + "summary_schema": { + "const": "code_mower.controlSurfaceSessionSummary.v1" + } + }, + "required": [ + "summary_schema", + "capability_version", + "control_surface", + "session", + "privacy_classification", + "state", + "outcome", + "owner_action" + ], + "type": "object" + }, + "metrics": { + "additionalProperties": false, + "properties": { + "cancel_count": { + "maximum": 10000, + "minimum": 0, + "type": "integer" + }, + "collect_count": { + "maximum": 10000, + "minimum": 0, + "type": "integer" + }, + "dispatch_count": { + "maximum": 10000, + "minimum": 0, + "type": "integer" + }, + "elapsed_seconds": { + "minimum": 0, + "type": "number" + }, + "message_count": { + "maximum": 10000, + "minimum": 0, + "type": "integer" + }, + "summary_count": { + "const": 1 + }, + "usage_acu": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "summary_count", + "dispatch_count", + "message_count", + "cancel_count", + "collect_count" + ], + "type": "object" + } + }, + "$id": "code_mower.controlSurfaceSessionSummary.v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Closed metadata-only Slack lifecycle event inside code_mower.benchmarkEvent.v1.", + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "dimensions": { + "$ref": "#/$defs/dimensions" + }, + "event_id": { + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "type": "string" + }, + "event_type": { + "const": "control_surface_session_summary" + }, + "install_id": { + "const": "" + }, + "lens": { + "const": "" + }, + "metrics": { + "$ref": "#/$defs/metrics" + }, + "provider": { + "oneOf": [ + { + "enum": [ + "antigravity", + "claude", + "codex", + "cursor", + "cursor-bugbot", + "devin", + "gitar", + "greptile", + "grok-bot", + "muse", + "qodo" + ] + }, + { + "pattern": "^custom:[a-z][a-z0-9-]{0,63}$", + "type": "string" + } + ] + }, + "repo_slug": { + "pattern": "^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$", + "type": "string" + }, + "schema": { + "const": "code_mower.benchmarkEvent.v1" + }, + "source": { + "const": "code-mower slack lifecycle" + }, + "status": { + "enum": [ + "archived", + "complete", + "failed", + "pending", + "running", + "suspended", + "terminated", + "uncertain", + "waiting_for_approval", + "waiting_for_user" + ] + }, + "team_id": { + "const": "" + }, + "tool": { + "type": "object" + } + }, + "required": [ + "schema", + "event_type", + "event_id", + "created_at", + "repo_slug", + "team_id", + "install_id", + "source", + "provider", + "lens", + "status", + "metrics", + "dimensions", + "tool" + ], + "title": "Control-surface session summary v1", + "type": "object", + "x-code-mower-invariants": [ + "status equals dimensions.state", + "complete=>succeeded, failed=>failed, terminated=>cancelled, every other state=>unknown", + "owner_action is derived from the validated local lifecycle reason", + "head_sha and pr_state require pr_number", + "usage_acu is allowed only for devin and only when already observed" + ] +} diff --git a/src/code_mower/control_surface_summary.py b/src/code_mower/control_surface_summary.py new file mode 100644 index 00000000..17f51e33 --- /dev/null +++ b/src/code_mower/control_surface_summary.py @@ -0,0 +1,268 @@ +"""Closed metadata-only telemetry for control-surface session lifecycles.""" + +from __future__ import annotations + +import datetime as dt +import math +import re +from typing import Any, Mapping + +EVENT_TYPE = "control_surface_session_summary" +SUMMARY_SCHEMA = "code_mower.controlSurfaceSessionSummary.v1" +CAPABILITY_VERSION = 1 +SOURCE = "code-mower slack lifecycle" +PRIVACY_CLASSIFICATION = "metadata_only" + +ROOT_FIELDS = frozenset( + { + "schema", + "event_type", + "event_id", + "created_at", + "repo_slug", + "team_id", + "install_id", + "source", + "provider", + "lens", + "status", + "metrics", + "dimensions", + "tool", + } +) +DIMENSION_FIELDS = frozenset( + { + "summary_schema", + "capability_version", + "control_surface", + "session", + "privacy_classification", + "state", + "outcome", + "owner_action", + "pr_number", + "head_sha", + "pr_state", + } +) +REQUIRED_DIMENSION_FIELDS = frozenset( + { + "summary_schema", + "capability_version", + "control_surface", + "session", + "privacy_classification", + "state", + "outcome", + "owner_action", + } +) +METRIC_FIELDS = frozenset( + { + "summary_count", + "dispatch_count", + "message_count", + "cancel_count", + "collect_count", + "elapsed_seconds", + "usage_acu", + } +) +REQUIRED_METRIC_FIELDS = frozenset( + { + "summary_count", + "dispatch_count", + "message_count", + "cancel_count", + "collect_count", + } +) +STATES = frozenset( + { + "archived", + "complete", + "failed", + "pending", + "running", + "suspended", + "terminated", + "uncertain", + "waiting_for_approval", + "waiting_for_user", + } +) +OUTCOME_BY_STATE = { + "complete": "succeeded", + "failed": "failed", + "terminated": "cancelled", +} +OWNER_ACTIONS_BY_STATE = { + "waiting_for_user": frozenset({"answer_question"}), + "waiting_for_approval": frozenset({"respond_to_approval"}), + "uncertain": frozenset({"inspect_provider"}), + "failed": frozenset({"inspect_failure"}), + "suspended": frozenset({"inspect_failure"}), +} +PROVIDERS = frozenset( + { + "antigravity", + "claude", + "codex", + "cursor", + "cursor-bugbot", + "devin", + "gitar", + "greptile", + "grok-bot", + "muse", + "qodo", + } +) + +_CUSTOM_PROVIDER = re.compile(r"custom:[a-z][a-z0-9-]{0,63}") +_EVENT_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") +_REPO_SLUG = re.compile(r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}") +_SESSION = re.compile(r"[0-9a-f]{32}") +_HEAD_SHA = re.compile(r"[0-9a-f]{40}") +_PR_NUMBER = re.compile(r"[1-9][0-9]{0,9}") + + +def _error(detail: str) -> Exception: + # Keep this module importable without initializing the cloud_client package; + # cloud_client.events imports this validator while that package is loading. + from .cloud_client.errors import CloudBundleError + + return CloudBundleError(f"invalid control-surface session summary: {detail}") + + +def _closed(value: Mapping[str, Any], expected: frozenset[str], name: str) -> None: + keys = set(value) + if keys != expected: + raise _error(f"{name} fields do not match the closed v1 contract") + + +def _closed_with_optional( + value: Mapping[str, Any], + allowed: frozenset[str], + required: frozenset[str], + name: str, +) -> None: + keys = set(value) + if not required <= keys or not keys <= allowed: + raise _error(f"{name} fields do not match the closed v1 contract") + + +def _utc_timestamp(value: object) -> dt.datetime: + if not isinstance(value, str): + raise _error("created_at must be a UTC timestamp") + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise _error("created_at must be an ISO 8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() != dt.timedelta(0): + raise _error("created_at must be a UTC timestamp") + return parsed + + +def _bounded_count(value: object, field: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 10000: + raise _error(f"metrics.{field} must be an integer from 0 through 10000") + + +def _optional_metric(value: object, field: str) -> None: + if isinstance(value, bool) or not isinstance(value, int | float): + raise _error(f"metrics.{field} must be a finite non-negative number") + if not math.isfinite(value) or value < 0: + raise _error(f"metrics.{field} must be a finite non-negative number") + + +def validate_control_surface_summary(value: Mapping[str, Any]) -> None: + """Validate the new event's complete closed shape and cross-field invariants.""" + + from .cloud_client.bundle import validate_metadata_payload + + if not isinstance(value, Mapping): + raise _error("event must be an object") + validate_metadata_payload(value) + _closed(value, ROOT_FIELDS, "event") + if value.get("schema") != "code_mower.benchmarkEvent.v1": + raise _error("unsupported event envelope schema") + if value.get("event_type") != EVENT_TYPE: + raise _error("unsupported event type") + if not isinstance(value.get("event_id"), str) or _EVENT_ID.fullmatch(value["event_id"]) is None: + raise _error("event_id must be a lowercase UUID") + _utc_timestamp(value.get("created_at")) + if not isinstance(value.get("repo_slug"), str) or _REPO_SLUG.fullmatch(value["repo_slug"]) is None: + raise _error("repo_slug must be canonical OWNER/REPO metadata") + if value.get("team_id") != "" or value.get("install_id") != "": + raise _error("tenant identity must come from authenticated ingest") + if value.get("source") != SOURCE or value.get("lens") != "": + raise _error("producer provenance does not match the v1 contract") + provider = value.get("provider") + if not isinstance(provider, str) or ( + provider not in PROVIDERS and _CUSTOM_PROVIDER.fullmatch(provider) is None + ): + raise _error("provider must be a bounded categorical provider name") + + dimensions = value.get("dimensions") + metrics = value.get("metrics") + if not isinstance(dimensions, Mapping) or not isinstance(metrics, Mapping): + raise _error("dimensions and metrics must be objects") + _closed_with_optional( + dimensions, DIMENSION_FIELDS, REQUIRED_DIMENSION_FIELDS, "dimension" + ) + _closed_with_optional(metrics, METRIC_FIELDS, REQUIRED_METRIC_FIELDS, "metric") + + if dimensions.get("summary_schema") != SUMMARY_SCHEMA: + raise _error("unsupported summary schema") + if type(dimensions.get("capability_version")) is not int or dimensions["capability_version"] != CAPABILITY_VERSION: + raise _error("unsupported capability version") + if dimensions.get("control_surface") != "slack": + raise _error("unsupported control surface") + if not isinstance(dimensions.get("session"), str) or _SESSION.fullmatch(dimensions["session"]) is None: + raise _error("session must be a Code Mower opaque correlation key") + if dimensions.get("privacy_classification") != PRIVACY_CLASSIFICATION: + raise _error("privacy classification must be metadata_only") + + state = dimensions.get("state") + if state not in STATES: + raise _error("unsupported lifecycle state") + if value.get("status") != state: + raise _error("envelope status must equal the lifecycle state") + expected_outcome = OUTCOME_BY_STATE.get(state, "unknown") + if dimensions.get("outcome") != expected_outcome: + raise _error("outcome is inconsistent with lifecycle state") + allowed_actions = OWNER_ACTIONS_BY_STATE.get(state, frozenset({"none"})) + if dimensions.get("owner_action") not in allowed_actions: + raise _error("owner_action is inconsistent with lifecycle state") + + pr_fields = {"pr_number", "head_sha", "pr_state"} & set(dimensions) + if pr_fields and "pr_number" not in pr_fields: + raise _error("PR metadata requires pr_number") + if "pr_number" in dimensions and ( + not isinstance(dimensions["pr_number"], str) + or _PR_NUMBER.fullmatch(dimensions["pr_number"]) is None + ): + raise _error("pr_number must be a canonical positive integer string") + if "head_sha" in dimensions and ( + not isinstance(dimensions["head_sha"], str) + or _HEAD_SHA.fullmatch(dimensions["head_sha"]) is None + ): + raise _error("head_sha must be a lowercase full commit SHA") + if "pr_state" in dimensions and dimensions["pr_state"] not in { + "open", + "merged", + "closed_unmerged", + }: + raise _error("unsupported pr_state") + + if metrics.get("summary_count") != 1 or type(metrics.get("summary_count")) is not int: + raise _error("metrics.summary_count must equal 1") + for field in ("dispatch_count", "message_count", "cancel_count", "collect_count"): + _bounded_count(metrics.get(field), field) + for field in ("elapsed_seconds", "usage_acu"): + if field in metrics: + _optional_metric(metrics[field], field) + if "usage_acu" in metrics and provider != "devin": + raise _error("usage_acu is available only for devin observations") diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index 317d33ee..cfb843ef 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -31,6 +31,36 @@ ("src/code_mower/board_local_observation.py", "src/code_mower/board_local_observation.py", "core"), ("src/code_mower/board_remote_observation.py", "src/code_mower/board_remote_observation.py", "core"), ("src/code_mower/board_service.py", "src/code_mower/board_service.py", "core"), + ( + "src/code_mower/control_surface_summary.py", + "src/code_mower/control_surface_summary.py", + "core", + ), + ( + "src/code_mower/control_surface_session_summary.schema.json", + "src/code_mower/control_surface_session_summary.schema.json", + "schema", + ), + ( + "src/code_mower/control_surface_session_summary.accepted.json", + "src/code_mower/control_surface_session_summary.accepted.json", + "fixture", + ), + ( + "src/code_mower/control_surface_session_summary.rejected.json", + "src/code_mower/control_surface_session_summary.rejected.json", + "fixture", + ), + ( + "src/code_mower/control_surface_session_summary.expectations.json", + "src/code_mower/control_surface_session_summary.expectations.json", + "fixture", + ), + ( + "src/code_mower/control_surface_session_summary.fixture-manifest.json", + "src/code_mower/control_surface_session_summary.fixture-manifest.json", + "fixture", + ), ("src/code_mower/file_locks.py", "src/code_mower/file_locks.py", "core"), ("src/code_mower/builder_runs.py", "src/code_mower/builder_runs.py", "core"), ("src/code_mower/builder_experiment.py", "src/code_mower/builder_experiment.py", "core"), diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py new file mode 100644 index 00000000..69e846c8 --- /dev/null +++ b/tests/test_control_surface_summary.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import hashlib +import importlib.resources +import json +from pathlib import Path + +import pytest + +from code_mower.cloud_client.errors import CloudBundleError +from code_mower.cloud_client.events import validate_cloud_event +from code_mower.control_surface_summary import ( + CAPABILITY_VERSION, + EVENT_TYPE, + SUMMARY_SCHEMA, + validate_control_surface_summary, +) +from code_mower.package_manifest import PACKAGE_FILES + + +ROOT = Path(__file__).resolve().parents[1] +RESOURCE_ROOT = ROOT / "src" / "code_mower" +PREFIX = "control_surface_session_summary" + + +def _fixture(name: str) -> dict: + return json.loads((RESOURCE_ROOT / f"{PREFIX}.{name}.json").read_text()) + + +def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries() -> None: + rows = _fixture("accepted")["events"] + + assert len(rows) == 10 + assert {row["event"]["dimensions"]["state"] for row in rows} == { + "archived", + "complete", + "failed", + "pending", + "running", + "suspended", + "terminated", + "uncertain", + "waiting_for_approval", + "waiting_for_user", + } + for row in rows: + validate_control_surface_summary(row["event"]) + assert validate_cloud_event(row["event"]) == row["event"] + + +def test_all_canonical_rejected_events_fail_closed() -> None: + rows = _fixture("rejected")["events"] + + assert len(rows) == 16 + for row in rows: + with pytest.raises(CloudBundleError): + validate_control_surface_summary(row["event"]) + with pytest.raises(CloudBundleError): + validate_cloud_event(row["event"]) + + +def test_fixture_manifest_binds_exact_source_and_installed_resource_bytes() -> None: + manifest_path = RESOURCE_ROOT / f"{PREFIX}.fixture-manifest.json" + manifest = json.loads(manifest_path.read_text()) + + assert manifest["contract_schema"] == SUMMARY_SCHEMA + assert manifest["digest_scope"] == "exact_file_bytes" + assert [row["path"] for row in manifest["files"]] == sorted( + row["path"] for row in manifest["files"] + ) + package_root = importlib.resources.files("code_mower") + for row in manifest["files"]: + source = (RESOURCE_ROOT / row["path"]).read_bytes() + installed = package_root.joinpath(row["path"]).read_bytes() + assert installed == source + assert len(source) == row["bytes"] + assert hashlib.sha256(source).hexdigest() == row["sha256"] + + +def test_contract_resources_are_part_of_materialized_packages() -> None: + targets = {target for _source, target, _kind in PACKAGE_FILES} + required = { + "src/code_mower/control_surface_summary.py", + *{ + f"src/code_mower/{PREFIX}.{suffix}.json" + for suffix in ( + "schema", + "accepted", + "rejected", + "expectations", + "fixture-manifest", + ) + }, + } + + assert required <= targets + + +def test_schema_and_expectations_pin_capability_and_hosted_data_controls() -> None: + schema = _fixture("schema") + expectations = _fixture("expectations") + + assert schema["$id"] == SUMMARY_SCHEMA + assert schema["properties"]["event_type"]["const"] == EVENT_TYPE + assert ( + schema["$defs"]["dimensions"]["properties"]["capability_version"]["const"] + == CAPABILITY_VERSION + ) + names = {row["name"] for row in expectations["expectations"]} + assert { + "capability_absent", + "capability_mismatch", + "rollback", + "tenant_isolation", + "export", + "deletion", + "retention", + "token_revocation", + "aggregate_reconciliation", + } <= names + From 7d8e161c7079f4bc495767db66a834203980d7b8 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:02:54 -0700 Subject: [PATCH 2/8] Keep rejected fixtures privacy-scan safe --- .../control_surface_session_summary.fixture-manifest.json | 4 ++-- src/code_mower/control_surface_session_summary.rejected.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code_mower/control_surface_session_summary.fixture-manifest.json b/src/code_mower/control_surface_session_summary.fixture-manifest.json index 37002f1c..3ea1fa07 100644 --- a/src/code_mower/control_surface_session_summary.fixture-manifest.json +++ b/src/code_mower/control_surface_session_summary.fixture-manifest.json @@ -16,10 +16,10 @@ "sha256": "01c8e9bb26f60fb8163a69801070ab6eabeadf20c271a41455634063966c7a21" }, { - "bytes": 27232, + "bytes": 27228, "path": "control_surface_session_summary.rejected.json", "role": "rejected", - "sha256": "13425843929101c5dac575238a5896e4a49737fb74fbab605fbbe36286e65760" + "sha256": "0a3f0293bab4b45a08d6f6a2559b0a3e5cafd3c457dde4297b896af10ddbb6fe" }, { "bytes": 5278, diff --git a/src/code_mower/control_surface_session_summary.rejected.json b/src/code_mower/control_surface_session_summary.rejected.json index b5c5a383..704cf600 100644 --- a/src/code_mower/control_surface_session_summary.rejected.json +++ b/src/code_mower/control_surface_session_summary.rejected.json @@ -224,7 +224,7 @@ "graph": "graph", "install_id": "", "lens": "", - "local_path": "/Users/example/repo", + "local_path": "", "message": "private message", "metrics": { "cancel_count": 0, From 8e8c1e24091953ce5682db341cf85e78c2d0c449 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:08:00 -0700 Subject: [PATCH 3/8] Gate and project control-surface summaries --- ...face_session_summary.fixture-manifest.json | 4 +- ...ontrol_surface_session_summary.schema.json | 29 +++ src/code_mower/control_surface_summary.py | 182 ++++++++++++++++++ tests/test_control_surface_summary.py | 94 +++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) diff --git a/src/code_mower/control_surface_session_summary.fixture-manifest.json b/src/code_mower/control_surface_session_summary.fixture-manifest.json index 3ea1fa07..2d51b1cc 100644 --- a/src/code_mower/control_surface_session_summary.fixture-manifest.json +++ b/src/code_mower/control_surface_session_summary.fixture-manifest.json @@ -22,10 +22,10 @@ "sha256": "0a3f0293bab4b45a08d6f6a2559b0a3e5cafd3c457dde4297b896af10ddbb6fe" }, { - "bytes": 5278, + "bytes": 6001, "path": "control_surface_session_summary.schema.json", "role": "schema", - "sha256": "5b84475db6671c19752f6c6528bde217186ccdc6e83e97682f75b32f7d0582d5" + "sha256": "88823fc7fd523411b5abeb3a6c38ba741672f99d16c6db7e133b274d6de7c2c2" } ], "schema": "code_mower.controlSurfaceSessionFixtureManifest.v1" diff --git a/src/code_mower/control_surface_session_summary.schema.json b/src/code_mower/control_surface_session_summary.schema.json index 179f2cf5..607ae16b 100644 --- a/src/code_mower/control_surface_session_summary.schema.json +++ b/src/code_mower/control_surface_session_summary.schema.json @@ -1,5 +1,34 @@ { "$defs": { + "capability": { + "additionalProperties": false, + "properties": { + "accepting": { + "type": "boolean" + }, + "capability_version": { + "const": 1 + }, + "fixture_manifest_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "schema": { + "const": "code_mower.controlSurfaceSessionSummaryCapability.v1" + }, + "summary_schema": { + "const": "code_mower.controlSurfaceSessionSummary.v1" + } + }, + "required": [ + "schema", + "summary_schema", + "capability_version", + "fixture_manifest_sha256", + "accepting" + ], + "type": "object" + }, "dimensions": { "additionalProperties": false, "properties": { diff --git a/src/code_mower/control_surface_summary.py b/src/code_mower/control_surface_summary.py index 17f51e33..6885a207 100644 --- a/src/code_mower/control_surface_summary.py +++ b/src/code_mower/control_surface_summary.py @@ -3,13 +3,22 @@ from __future__ import annotations import datetime as dt +import hashlib +import json import math import re +import uuid +from functools import lru_cache +from pathlib import Path from typing import Any, Mapping +from . import __version__ +from .providers import build_code_mower_tool_provenance + EVENT_TYPE = "control_surface_session_summary" SUMMARY_SCHEMA = "code_mower.controlSurfaceSessionSummary.v1" CAPABILITY_VERSION = 1 +CAPABILITY_SCHEMA = "code_mower.controlSurfaceSessionSummaryCapability.v1" SOURCE = "code-mower slack lifecycle" PRIVACY_CLASSIFICATION = "metadata_only" @@ -126,6 +135,15 @@ _SESSION = re.compile(r"[0-9a-f]{32}") _HEAD_SHA = re.compile(r"[0-9a-f]{40}") _PR_NUMBER = re.compile(r"[1-9][0-9]{0,9}") +_CAPABILITY_FIELDS = frozenset( + { + "schema", + "summary_schema", + "capability_version", + "fixture_manifest_sha256", + "accepting", + } +) def _error(detail: str) -> Exception: @@ -266,3 +284,167 @@ def validate_control_surface_summary(value: Mapping[str, Any]) -> None: _optional_metric(metrics[field], field) if "usage_acu" in metrics and provider != "devin": raise _error("usage_acu is available only for devin observations") + + +@lru_cache(maxsize=1) +def fixture_manifest_digest() -> str: + """Return the exact installed fixture-manifest identity advertised by hosted.""" + + raw = Path(__file__).with_name( + "control_surface_session_summary.fixture-manifest.json" + ).read_bytes() + return hashlib.sha256(raw).hexdigest() + + +def capability_accepts_summary(value: object) -> bool: + """Fail closed unless hosted advertises this exact installed contract.""" + + return bool( + isinstance(value, Mapping) + and set(value) == _CAPABILITY_FIELDS + and value.get("schema") == CAPABILITY_SCHEMA + and value.get("summary_schema") == SUMMARY_SCHEMA + and type(value.get("capability_version")) is int + and value["capability_version"] == CAPABILITY_VERSION + and value.get("fixture_manifest_sha256") == fixture_manifest_digest() + and value.get("accepting") is True + ) + + +def opaque_session(logical_session: str) -> str: + """Derive a stable unlinkable cloud key, never reuse a Slack/provider id.""" + + if not isinstance(logical_session, str) or not logical_session or len(logical_session) > 128: + raise _error("logical session must be bounded local metadata") + seed = (SUMMARY_SCHEMA + "\0" + logical_session).encode("utf-8") + return hashlib.sha256(seed).hexdigest()[:32] + + +def _stamp(value: dt.datetime) -> str: + if not isinstance(value, dt.datetime) or value.tzinfo is None: + raise _error("observed_at must include a UTC offset") + return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def _owner_action(lifecycle: Mapping[str, Any]) -> str: + reason = lifecycle["reason"] + return { + "user_input_required": "answer_question", + "approval_required": "respond_to_approval", + "provider_unavailable": "inspect_provider", + "reconcile_dispatch": "inspect_provider", + "inspect_provider_then_acknowledge": "inspect_provider", + "result_not_ready": "inspect_provider", + "result_unavailable": "inspect_provider", + "session_failed": "inspect_failure", + "session_suspended": "inspect_failure", + "none": "none", + }[reason] + + +def build_control_surface_summary( + *, + logical_session: str, + repo_slug: str, + provider: str, + lifecycle: Mapping[str, Any], + observed_at: dt.datetime, + pr_number: int | None = None, + head_sha: str | None = None, + pr_state: str | None = None, + elapsed_seconds: float | None = None, + usage_acu: float | None = None, +) -> dict[str, Any]: + """Build one retry-stable allowlisted summary from a public lifecycle.""" + + from .remote_session import RemoteError, public_projection + + try: + projected = public_projection(dict(lifecycle)) + except (RemoteError, TypeError, ValueError): + raise _error("lifecycle must be the closed public projection") from None + created_at = _stamp(observed_at) + state = projected["state"] + dimensions: dict[str, Any] = { + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "control_surface": "slack", + "session": opaque_session(logical_session), + "privacy_classification": PRIVACY_CLASSIFICATION, + "state": state, + "outcome": OUTCOME_BY_STATE.get(state, "unknown"), + "owner_action": _owner_action(projected), + } + if pr_number is not None: + dimensions["pr_number"] = str(pr_number) + if head_sha is not None: + dimensions["head_sha"] = head_sha + if pr_state is not None: + dimensions["pr_state"] = pr_state + metrics: dict[str, Any] = { + "summary_count": 1, + **{f"{key}_count": projected["counts"][key] for key in ("dispatch", "message", "cancel", "collect")}, + } + if elapsed_seconds is not None: + metrics["elapsed_seconds"] = elapsed_seconds + if usage_acu is not None: + metrics["usage_acu"] = usage_acu + event_seed = json.dumps( + [repo_slug, provider, created_at, dimensions, metrics], + sort_keys=True, + separators=(",", ":"), + ) + event = { + "schema": "code_mower.benchmarkEvent.v1", + "event_type": EVENT_TYPE, + "event_id": str(uuid.uuid5(uuid.NAMESPACE_URL, event_seed)), + "created_at": created_at, + "repo_slug": repo_slug, + "team_id": "", + "install_id": "", + "source": SOURCE, + "provider": provider, + "lens": "", + "status": state, + "metrics": metrics, + "dimensions": dimensions, + "tool": build_code_mower_tool_provenance( + source=SOURCE, + version=__version__, + role="reporter", + ), + } + validate_control_surface_summary(event) + return event + + +def gated_control_surface_summary( + capability: object, **summary: Any +) -> dict[str, Any] | None: + """Return no cloud event until exact hosted acceptance is advertised.""" + + if not capability_accepts_summary(capability): + return None + return build_control_surface_summary(**summary) + + +def slack_board_run( + *, + logical_session: str, + binding: Any, + provider: str, + observed_at: dt.datetime, + lifecycle: Mapping[str, Any], +) -> Any: + """Adapt a Slack-requested lifecycle into the existing local Board seam.""" + + from .board_local_observation import run_from_remote_lifecycle + + return run_from_remote_lifecycle( + id="slack-" + opaque_session(logical_session)[:20], + binding=binding, + provider=provider, + role="builder", + observed_at=observed_at, + lifecycle=lifecycle, + ) diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py index 69e846c8..7e30d879 100644 --- a/tests/test_control_surface_summary.py +++ b/tests/test_control_surface_summary.py @@ -3,16 +3,25 @@ import hashlib import importlib.resources import json +from datetime import UTC, datetime from pathlib import Path import pytest from code_mower.cloud_client.errors import CloudBundleError from code_mower.cloud_client.events import validate_cloud_event +from code_mower.board_local_observation import WorkBinding from code_mower.control_surface_summary import ( + CAPABILITY_SCHEMA, CAPABILITY_VERSION, EVENT_TYPE, SUMMARY_SCHEMA, + build_control_surface_summary, + capability_accepts_summary, + fixture_manifest_digest, + gated_control_surface_summary, + opaque_session, + slack_board_run, validate_control_surface_summary, ) from code_mower.package_manifest import PACKAGE_FILES @@ -119,3 +128,88 @@ def test_schema_and_expectations_pin_capability_and_hosted_data_controls() -> No "aggregate_reconciliation", } <= names + +def test_capability_gate_requires_exact_installed_contract_identity() -> None: + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, + } + + assert capability_accepts_summary(capability) + for changed in ( + {**capability, "accepting": False}, + {**capability, "capability_version": 2}, + {**capability, "fixture_manifest_sha256": "0" * 64}, + {**capability, "unknown": "field"}, + None, + ): + assert not capability_accepts_summary(changed) + + +def test_summary_builder_is_retry_stable_and_capability_gated() -> None: + lifecycle = { + "schema": "code_mower.remote_session.v1", + "state": "complete", + "reason": "none", + "next_action": "none", + "counts": {"dispatch": 1, "message": 1, "cancel": 0, "collect": 1}, + } + values = { + "logical_session": "private-slack-session-reference", + "repo_slug": "example/project", + "provider": "devin", + "lifecycle": lifecycle, + "observed_at": datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + "pr_number": 921, + "head_sha": "a" * 40, + "pr_state": "open", + "elapsed_seconds": 60.0, + "usage_acu": 0.25, + } + first = build_control_surface_summary(**values) + second = build_control_surface_summary(**values) + + assert first == second + assert first["dimensions"]["session"] == opaque_session(values["logical_session"]) + assert values["logical_session"] not in json.dumps(first) + assert gated_control_surface_summary(None, **values) is None + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, + } + assert gated_control_surface_summary(capability, **values) == first + + +def test_slack_lifecycle_uses_existing_local_board_adapter() -> None: + binding = WorkBinding( + session_id="a" * 32, + work_id="work1", + repository="example/project", + worktree_id="sha256:" + "b" * 64, + ) + lifecycle = { + "schema": "code_mower.remote_session.v1", + "state": "waiting_for_user", + "reason": "user_input_required", + "next_action": "none", + "counts": {"dispatch": 1, "message": 0, "cancel": 0, "collect": 0}, + } + + run = slack_board_run( + logical_session="private-slack-session-reference", + binding=binding, + provider="codex", + observed_at=datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + lifecycle=lifecycle, + ) + + assert run.binding == binding + assert run.phase == "waiting_for_user" + assert run.source_kind == "remote_session" + assert run.lifecycle == lifecycle From 301bc4ff03cbd7c42e808bef81ce037aa213f18d Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:14:08 -0700 Subject: [PATCH 4/8] Document control-surface telemetry boundary --- code-mower-package-manifest.json | 5 +++ docs/README.md | 2 +- docs/control-surface-telemetry.md | 72 ++++++++++++++++++++++++++++++ docs/docs-manifest.yml | 2 + src/code_mower/package_manifest.py | 1 + 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 docs/control-surface-telemetry.md diff --git a/code-mower-package-manifest.json b/code-mower-package-manifest.json index c75d5628..9363d99f 100644 --- a/code-mower-package-manifest.json +++ b/code-mower-package-manifest.json @@ -127,6 +127,11 @@ "source": "docs/context-setup.md", "target": "docs/context-setup.md" }, + { + "kind": "doc", + "source": "docs/control-surface-telemetry.md", + "target": "docs/control-surface-telemetry.md" + }, { "kind": "doc", "source": "docs/devin-peer-support-qualification.md", diff --git a/docs/README.md b/docs/README.md index 79c04321..c2263c43 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,6 @@ historical release evidence and are not current operating guidance. ## Lifecycle -The manifest currently classifies 62 supporting, 53 frozen, and 5 archived document(s). +The manifest currently classifies 63 supporting, 53 frozen, and 5 archived document(s). See [Documentation lifecycle](documentation-lifecycle.md) before adding, moving, or changing release-sensitive documentation. diff --git a/docs/control-surface-telemetry.md b/docs/control-surface-telemetry.md new file mode 100644 index 00000000..34551f07 --- /dev/null +++ b/docs/control-surface-telemetry.md @@ -0,0 +1,72 @@ +# Control-surface session summaries + +This supporting contract describes the metadata-only lifecycle summaries used +by the optional [Slack integration](slack-setup.md). Installation and Slack +administration remain in the Slack guide. General upload behavior remains in +the [cloud data contract](cloud-data-contract.md), and local work presentation +remains in the [Board data contract](board-data-contract.md). + +## Boundary + +`code_mower.controlSurfaceSessionSummary.v1` is a closed specialization of the +existing `code_mower.benchmarkEvent.v1` envelope. It reports one meaningful +session observation rather than a command, message, or transcript stream. The +packaged schema and fixtures are the normative machine-readable contract: + +- `control_surface_session_summary.schema.json`; +- `control_surface_session_summary.accepted.json`; +- `control_surface_session_summary.rejected.json`; +- `control_surface_session_summary.expectations.json`; +- `control_surface_session_summary.fixture-manifest.json`. + +The summary allows an opaque Code Mower correlation key, repository slug, +categorical provider, lifecycle state and outcome, observation time, operation +counts, bounded owner action, optional pull-request metadata, and already +available elapsed-time or Devin ACU measurements. The producer hashes its +local logical session into a 32-character key before building the event. It +never forwards a Slack identity or provider session reference. + +The closed validator rejects unknown root, dimension, and metric fields. Task +text, messages, answers, prompts, response URLs, Slack identities, source, +diffs, transcripts, tokens, paths, context or graph data, provider references, +and raw output cannot enter this event. + +## Local Board projection + +Slack-requested work uses the existing public remote-session lifecycle and +local Board adapter. The adapter maps `pending`, `running`, owner-waiting, +completion, failure, suspension, termination, archival, and uncertainty into +the same Board phases used by other remote work. This is local observation; +it does not require cloud access and grants no dispatch or merge authority. + +## Hosted capability gate + +Production emission stays disabled until the hosted service advertises this +exact closed object: + +```json +{ + "accepting": true, + "capability_version": 1, + "fixture_manifest_sha256": "", + "schema": "code_mower.controlSurfaceSessionSummaryCapability.v1", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" +} +``` + +Every field must match, and unknown fields fail closed. A missing capability, +version mismatch, digest mismatch, or `accepting: false` leaves operation local +only. Client rollback stops new emission without rewriting accepted rows. + +The hosted service must vendor the five packaged resources byte-for-byte, +scope repository identity through authenticated tenant policy, and implement +the retention, export, deletion, tenant-isolation, and aggregate-reconciliation +expectations in the packaged expectations fixture. + +## Contract changes + +The fixture manifest hashes exact UTF-8 file bytes. It does not hash itself; +the hosted capability advertises the SHA-256 of the manifest bytes separately. +After qualification, changing the schema or any fixture requires a new schema +or manifest version. Do not refresh hashes in place to accept changed meaning. + diff --git a/docs/docs-manifest.yml b/docs/docs-manifest.yml index b718ef6d..9aa9415c 100644 --- a/docs/docs-manifest.yml +++ b/docs/docs-manifest.yml @@ -61,6 +61,8 @@ documents: status: supporting - path: docs/commercial-boundary.md status: supporting +- path: docs/control-surface-telemetry.md + status: supporting - path: docs/context-connections.md status: supporting - path: docs/context-delivery.md diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index cfb843ef..f6897a27 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -698,6 +698,7 @@ ("docs/cloud-benchmarking.md", "docs/cloud-benchmarking.md", "doc"), ("docs/repo-strategy.md", "docs/repo-strategy.md", "doc"), ("docs/commercial-boundary.md", "docs/commercial-boundary.md", "doc"), + ("docs/control-surface-telemetry.md", "docs/control-surface-telemetry.md", "doc"), ("docs/public-release-checklist.md", "docs/public-release-checklist.md", "doc"), ("docs/operational-evidence.md", "docs/operational-evidence.md", "doc"), ("docs/release-qualification.md", "docs/release-qualification.md", "doc"), From b3e6ff9710b9575f53f5da343691ea62a108217c Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:19:25 -0700 Subject: [PATCH 5/8] Wire hosted summary capability and transitions --- docs/control-surface-telemetry.md | 10 +++- src/code_mower/cloud_client/endpoints.py | 5 ++ src/code_mower/control_surface_summary.py | 66 +++++++++++++++++++++++ tests/test_control_surface_summary.py | 64 ++++++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/docs/control-surface-telemetry.md b/docs/control-surface-telemetry.md index 34551f07..f63c2903 100644 --- a/docs/control-surface-telemetry.md +++ b/docs/control-surface-telemetry.md @@ -57,6 +57,15 @@ exact closed object: Every field must match, and unknown fields fail closed. A missing capability, version mismatch, digest mismatch, or `accepting: false` leaves operation local only. Client rollback stops new emission without rewriting accepted rows. +The client reads this object from +`GET /api/health` at `capabilities.control_surface_session_summary`; the cloud +doctor exposes an accepted exact match in its service-check detail. + +After acceptance, the producer emits the first observation and then only a +meaningful lifecycle, operation-count, owner-action, or pull-request change. +Timestamp-only changes and changing elapsed time or usage on a nonterminal +session do not create another event. Terminal elapsed-time or usage changes +remain meaningful reconciliation evidence. The hosted service must vendor the five packaged resources byte-for-byte, scope repository identity through authenticated tenant policy, and implement @@ -69,4 +78,3 @@ The fixture manifest hashes exact UTF-8 file bytes. It does not hash itself; the hosted capability advertises the SHA-256 of the manifest bytes separately. After qualification, changing the schema or any fixture requires a new schema or manifest version. Do not refresh hashes in place to accept changed meaning. - diff --git a/src/code_mower/cloud_client/endpoints.py b/src/code_mower/cloud_client/endpoints.py index 972d8f19..7516a09c 100644 --- a/src/code_mower/cloud_client/endpoints.py +++ b/src/code_mower/cloud_client/endpoints.py @@ -8,6 +8,8 @@ import urllib.request from typing import Any +from code_mower.control_surface_summary import capability_from_health + DEFAULT_HEALTH_PATH = "/api/health" DEFAULT_DASHBOARD_PATH = "/dashboard" @@ -100,6 +102,9 @@ def probe_cloud_service(endpoint: str, *, timeout: float) -> dict[str, Any]: for key in ("app", "supabaseConfigured"): if key in parsed and isinstance(parsed[key], str | bool | int | float): detail[key] = parsed[key] + summary_capability = capability_from_health(parsed) + if summary_capability is not None: + detail["control_surface_session_summary"] = summary_capability return { "name": "service", "status": "pass", diff --git a/src/code_mower/control_surface_summary.py b/src/code_mower/control_surface_summary.py index 6885a207..d52d53d5 100644 --- a/src/code_mower/control_surface_summary.py +++ b/src/code_mower/control_surface_summary.py @@ -311,6 +311,18 @@ def capability_accepts_summary(value: object) -> bool: ) +def capability_from_health(value: object) -> dict[str, Any] | None: + """Read only the exact capability from the hosted health response.""" + + if not isinstance(value, Mapping): + return None + capabilities = value.get("capabilities") + if not isinstance(capabilities, Mapping): + return None + candidate = capabilities.get(EVENT_TYPE) + return dict(candidate) if capability_accepts_summary(candidate) else None + + def opaque_session(logical_session: str) -> str: """Derive a stable unlinkable cloud key, never reuse a Slack/provider id.""" @@ -428,6 +440,60 @@ def gated_control_surface_summary( return build_control_surface_summary(**summary) +def _transition_identity(value: Mapping[str, Any]) -> tuple[Any, ...]: + dimensions = value["dimensions"] + metrics = value["metrics"] + return ( + value["repo_slug"], + value["provider"], + *( + dimensions.get(key) + for key in ( + "session", + "state", + "outcome", + "owner_action", + "pr_number", + "head_sha", + "pr_state", + ) + ), + *( + metrics.get(f"{key}_count") + for key in ("dispatch", "message", "cancel", "collect") + ), + ( + metrics.get("elapsed_seconds") + if dimensions["state"] in {"complete", "failed", "terminated", "archived"} + else None + ), + ( + metrics.get("usage_acu") + if dimensions["state"] in {"complete", "failed", "terminated", "archived"} + else None + ), + ) + + +def gated_control_surface_transition( + capability: object, + previous: Mapping[str, Any] | None = None, + **summary: Any, +) -> dict[str, Any] | None: + """Emit only a meaningful lifecycle/count/PR change after capability acceptance.""" + + current = gated_control_surface_summary(capability, **summary) + if current is None: + return None + if previous is None: + return current + try: + validate_control_surface_summary(previous) + except ValueError: + return current + return None if _transition_identity(previous) == _transition_identity(current) else current + + def slack_board_run( *, logical_session: str, diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py index 7e30d879..a3b292fc 100644 --- a/tests/test_control_surface_summary.py +++ b/tests/test_control_surface_summary.py @@ -5,11 +5,13 @@ import json from datetime import UTC, datetime from pathlib import Path +from unittest import mock import pytest from code_mower.cloud_client.errors import CloudBundleError from code_mower.cloud_client.events import validate_cloud_event +from code_mower.cloud_client.endpoints import probe_cloud_service from code_mower.board_local_observation import WorkBinding from code_mower.control_surface_summary import ( CAPABILITY_SCHEMA, @@ -18,8 +20,10 @@ SUMMARY_SCHEMA, build_control_surface_summary, capability_accepts_summary, + capability_from_health, fixture_manifest_digest, gated_control_surface_summary, + gated_control_surface_transition, opaque_session, slack_board_run, validate_control_surface_summary, @@ -139,6 +143,9 @@ def test_capability_gate_requires_exact_installed_contract_identity() -> None: } assert capability_accepts_summary(capability) + assert capability_from_health( + {"capabilities": {EVENT_TYPE: capability}} + ) == capability for changed in ( {**capability, "accepting": False}, {**capability, "capability_version": 2}, @@ -147,6 +154,7 @@ def test_capability_gate_requires_exact_installed_contract_identity() -> None: None, ): assert not capability_accepts_summary(changed) + assert capability_from_health({"capabilities": {EVENT_TYPE: changed}}) is None def test_summary_builder_is_retry_stable_and_capability_gated() -> None: @@ -185,6 +193,62 @@ def test_summary_builder_is_retry_stable_and_capability_gated() -> None: } assert gated_control_surface_summary(capability, **values) == first + later = {**values, "observed_at": datetime(2026, 9, 21, 7, 1, tzinfo=UTC)} + assert gated_control_surface_transition(capability, first, **later) is None + changed = { + **later, + "lifecycle": { + **lifecycle, + "counts": {**lifecycle["counts"], "message": 2}, + }, + } + assert gated_control_surface_transition(capability, first, **changed) is not None + assert gated_control_surface_transition(capability, {"invalid": True}, **later) is not None + + +def test_health_probe_exposes_only_the_exact_summary_capability() -> None: + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, + } + + class FakeResponse: + def __init__(self, body: dict[str, object]) -> None: + self.body = body + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.body).encode() + + def getcode(self) -> int: + return 200 + + accepted = {"capabilities": {EVENT_TYPE: capability}} + with mock.patch( + "urllib.request.urlopen", return_value=FakeResponse(accepted) + ): + check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) + assert check["detail"][EVENT_TYPE] == capability + + mismatched = { + "capabilities": { + EVENT_TYPE: {**capability, "fixture_manifest_sha256": "0" * 64} + } + } + with mock.patch( + "urllib.request.urlopen", return_value=FakeResponse(mismatched) + ): + check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) + assert EVENT_TYPE not in check["detail"] + def test_slack_lifecycle_uses_existing_local_board_adapter() -> None: binding = WorkBinding( From e09683af7fee00a304d037907f270bc1356d5f92 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 00:28:28 -0700 Subject: [PATCH 6/8] Run summary contract tests under unittest --- tests/test_control_surface_summary.py | 475 +++++++++++++------------- 1 file changed, 237 insertions(+), 238 deletions(-) diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py index a3b292fc..e6ad7774 100644 --- a/tests/test_control_surface_summary.py +++ b/tests/test_control_surface_summary.py @@ -5,9 +5,7 @@ import json from datetime import UTC, datetime from pathlib import Path -from unittest import mock - -import pytest +from unittest import TestCase, mock from code_mower.cloud_client.errors import CloudBundleError from code_mower.cloud_client.events import validate_cloud_event @@ -40,240 +38,241 @@ def _fixture(name: str) -> dict: return json.loads((RESOURCE_ROOT / f"{PREFIX}.{name}.json").read_text()) -def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries() -> None: - rows = _fixture("accepted")["events"] - - assert len(rows) == 10 - assert {row["event"]["dimensions"]["state"] for row in rows} == { - "archived", - "complete", - "failed", - "pending", - "running", - "suspended", - "terminated", - "uncertain", - "waiting_for_approval", - "waiting_for_user", - } - for row in rows: - validate_control_surface_summary(row["event"]) - assert validate_cloud_event(row["event"]) == row["event"] - - -def test_all_canonical_rejected_events_fail_closed() -> None: - rows = _fixture("rejected")["events"] - - assert len(rows) == 16 - for row in rows: - with pytest.raises(CloudBundleError): +class ControlSurfaceSummaryTests(TestCase): + def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries(self) -> None: + rows = _fixture("accepted")["events"] + + assert len(rows) == 10 + assert {row["event"]["dimensions"]["state"] for row in rows} == { + "archived", + "complete", + "failed", + "pending", + "running", + "suspended", + "terminated", + "uncertain", + "waiting_for_approval", + "waiting_for_user", + } + for row in rows: validate_control_surface_summary(row["event"]) - with pytest.raises(CloudBundleError): - validate_cloud_event(row["event"]) - - -def test_fixture_manifest_binds_exact_source_and_installed_resource_bytes() -> None: - manifest_path = RESOURCE_ROOT / f"{PREFIX}.fixture-manifest.json" - manifest = json.loads(manifest_path.read_text()) - - assert manifest["contract_schema"] == SUMMARY_SCHEMA - assert manifest["digest_scope"] == "exact_file_bytes" - assert [row["path"] for row in manifest["files"]] == sorted( - row["path"] for row in manifest["files"] - ) - package_root = importlib.resources.files("code_mower") - for row in manifest["files"]: - source = (RESOURCE_ROOT / row["path"]).read_bytes() - installed = package_root.joinpath(row["path"]).read_bytes() - assert installed == source - assert len(source) == row["bytes"] - assert hashlib.sha256(source).hexdigest() == row["sha256"] - - -def test_contract_resources_are_part_of_materialized_packages() -> None: - targets = {target for _source, target, _kind in PACKAGE_FILES} - required = { - "src/code_mower/control_surface_summary.py", - *{ - f"src/code_mower/{PREFIX}.{suffix}.json" - for suffix in ( - "schema", - "accepted", - "rejected", - "expectations", - "fixture-manifest", - ) - }, - } - - assert required <= targets - - -def test_schema_and_expectations_pin_capability_and_hosted_data_controls() -> None: - schema = _fixture("schema") - expectations = _fixture("expectations") - - assert schema["$id"] == SUMMARY_SCHEMA - assert schema["properties"]["event_type"]["const"] == EVENT_TYPE - assert ( - schema["$defs"]["dimensions"]["properties"]["capability_version"]["const"] - == CAPABILITY_VERSION - ) - names = {row["name"] for row in expectations["expectations"]} - assert { - "capability_absent", - "capability_mismatch", - "rollback", - "tenant_isolation", - "export", - "deletion", - "retention", - "token_revocation", - "aggregate_reconciliation", - } <= names - - -def test_capability_gate_requires_exact_installed_contract_identity() -> None: - capability = { - "schema": CAPABILITY_SCHEMA, - "summary_schema": SUMMARY_SCHEMA, - "capability_version": CAPABILITY_VERSION, - "fixture_manifest_sha256": fixture_manifest_digest(), - "accepting": True, - } - - assert capability_accepts_summary(capability) - assert capability_from_health( - {"capabilities": {EVENT_TYPE: capability}} - ) == capability - for changed in ( - {**capability, "accepting": False}, - {**capability, "capability_version": 2}, - {**capability, "fixture_manifest_sha256": "0" * 64}, - {**capability, "unknown": "field"}, - None, - ): - assert not capability_accepts_summary(changed) - assert capability_from_health({"capabilities": {EVENT_TYPE: changed}}) is None - - -def test_summary_builder_is_retry_stable_and_capability_gated() -> None: - lifecycle = { - "schema": "code_mower.remote_session.v1", - "state": "complete", - "reason": "none", - "next_action": "none", - "counts": {"dispatch": 1, "message": 1, "cancel": 0, "collect": 1}, - } - values = { - "logical_session": "private-slack-session-reference", - "repo_slug": "example/project", - "provider": "devin", - "lifecycle": lifecycle, - "observed_at": datetime(2026, 9, 21, 7, 0, tzinfo=UTC), - "pr_number": 921, - "head_sha": "a" * 40, - "pr_state": "open", - "elapsed_seconds": 60.0, - "usage_acu": 0.25, - } - first = build_control_surface_summary(**values) - second = build_control_surface_summary(**values) - - assert first == second - assert first["dimensions"]["session"] == opaque_session(values["logical_session"]) - assert values["logical_session"] not in json.dumps(first) - assert gated_control_surface_summary(None, **values) is None - capability = { - "schema": CAPABILITY_SCHEMA, - "summary_schema": SUMMARY_SCHEMA, - "capability_version": CAPABILITY_VERSION, - "fixture_manifest_sha256": fixture_manifest_digest(), - "accepting": True, - } - assert gated_control_surface_summary(capability, **values) == first - - later = {**values, "observed_at": datetime(2026, 9, 21, 7, 1, tzinfo=UTC)} - assert gated_control_surface_transition(capability, first, **later) is None - changed = { - **later, - "lifecycle": { - **lifecycle, - "counts": {**lifecycle["counts"], "message": 2}, - }, - } - assert gated_control_surface_transition(capability, first, **changed) is not None - assert gated_control_surface_transition(capability, {"invalid": True}, **later) is not None - - -def test_health_probe_exposes_only_the_exact_summary_capability() -> None: - capability = { - "schema": CAPABILITY_SCHEMA, - "summary_schema": SUMMARY_SCHEMA, - "capability_version": CAPABILITY_VERSION, - "fixture_manifest_sha256": fixture_manifest_digest(), - "accepting": True, - } - - class FakeResponse: - def __init__(self, body: dict[str, object]) -> None: - self.body = body - - def __enter__(self) -> "FakeResponse": - return self - - def __exit__(self, *args: object) -> None: - return None - - def read(self) -> bytes: - return json.dumps(self.body).encode() - - def getcode(self) -> int: - return 200 - - accepted = {"capabilities": {EVENT_TYPE: capability}} - with mock.patch( - "urllib.request.urlopen", return_value=FakeResponse(accepted) - ): - check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) - assert check["detail"][EVENT_TYPE] == capability - - mismatched = { - "capabilities": { - EVENT_TYPE: {**capability, "fixture_manifest_sha256": "0" * 64} + assert validate_cloud_event(row["event"]) == row["event"] + + + def test_all_canonical_rejected_events_fail_closed(self) -> None: + rows = _fixture("rejected")["events"] + + assert len(rows) == 16 + for row in rows: + with self.assertRaises(CloudBundleError): + validate_control_surface_summary(row["event"]) + with self.assertRaises(CloudBundleError): + validate_cloud_event(row["event"]) + + + def test_fixture_manifest_binds_exact_source_and_installed_resource_bytes(self) -> None: + manifest_path = RESOURCE_ROOT / f"{PREFIX}.fixture-manifest.json" + manifest = json.loads(manifest_path.read_text()) + + assert manifest["contract_schema"] == SUMMARY_SCHEMA + assert manifest["digest_scope"] == "exact_file_bytes" + assert [row["path"] for row in manifest["files"]] == sorted( + row["path"] for row in manifest["files"] + ) + package_root = importlib.resources.files("code_mower") + for row in manifest["files"]: + source = (RESOURCE_ROOT / row["path"]).read_bytes() + installed = package_root.joinpath(row["path"]).read_bytes() + assert installed == source + assert len(source) == row["bytes"] + assert hashlib.sha256(source).hexdigest() == row["sha256"] + + + def test_contract_resources_are_part_of_materialized_packages(self) -> None: + targets = {target for _source, target, _kind in PACKAGE_FILES} + required = { + "src/code_mower/control_surface_summary.py", + *{ + f"src/code_mower/{PREFIX}.{suffix}.json" + for suffix in ( + "schema", + "accepted", + "rejected", + "expectations", + "fixture-manifest", + ) + }, + } + + assert required <= targets + + + def test_schema_and_expectations_pin_capability_and_hosted_data_controls(self) -> None: + schema = _fixture("schema") + expectations = _fixture("expectations") + + assert schema["$id"] == SUMMARY_SCHEMA + assert schema["properties"]["event_type"]["const"] == EVENT_TYPE + assert ( + schema["$defs"]["dimensions"]["properties"]["capability_version"]["const"] + == CAPABILITY_VERSION + ) + names = {row["name"] for row in expectations["expectations"]} + assert { + "capability_absent", + "capability_mismatch", + "rollback", + "tenant_isolation", + "export", + "deletion", + "retention", + "token_revocation", + "aggregate_reconciliation", + } <= names + + + def test_capability_gate_requires_exact_installed_contract_identity(self) -> None: + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, + } + + assert capability_accepts_summary(capability) + assert capability_from_health( + {"capabilities": {EVENT_TYPE: capability}} + ) == capability + for changed in ( + {**capability, "accepting": False}, + {**capability, "capability_version": 2}, + {**capability, "fixture_manifest_sha256": "0" * 64}, + {**capability, "unknown": "field"}, + None, + ): + assert not capability_accepts_summary(changed) + assert capability_from_health({"capabilities": {EVENT_TYPE: changed}}) is None + + + def test_summary_builder_is_retry_stable_and_capability_gated(self) -> None: + lifecycle = { + "schema": "code_mower.remote_session.v1", + "state": "complete", + "reason": "none", + "next_action": "none", + "counts": {"dispatch": 1, "message": 1, "cancel": 0, "collect": 1}, + } + values = { + "logical_session": "private-slack-session-reference", + "repo_slug": "example/project", + "provider": "devin", + "lifecycle": lifecycle, + "observed_at": datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + "pr_number": 921, + "head_sha": "a" * 40, + "pr_state": "open", + "elapsed_seconds": 60.0, + "usage_acu": 0.25, + } + first = build_control_surface_summary(**values) + second = build_control_surface_summary(**values) + + assert first == second + assert first["dimensions"]["session"] == opaque_session(values["logical_session"]) + assert values["logical_session"] not in json.dumps(first) + assert gated_control_surface_summary(None, **values) is None + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, + } + assert gated_control_surface_summary(capability, **values) == first + + later = {**values, "observed_at": datetime(2026, 9, 21, 7, 1, tzinfo=UTC)} + assert gated_control_surface_transition(capability, first, **later) is None + changed = { + **later, + "lifecycle": { + **lifecycle, + "counts": {**lifecycle["counts"], "message": 2}, + }, + } + assert gated_control_surface_transition(capability, first, **changed) is not None + assert gated_control_surface_transition(capability, {"invalid": True}, **later) is not None + + + def test_health_probe_exposes_only_the_exact_summary_capability(self) -> None: + capability = { + "schema": CAPABILITY_SCHEMA, + "summary_schema": SUMMARY_SCHEMA, + "capability_version": CAPABILITY_VERSION, + "fixture_manifest_sha256": fixture_manifest_digest(), + "accepting": True, } - } - with mock.patch( - "urllib.request.urlopen", return_value=FakeResponse(mismatched) - ): - check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) - assert EVENT_TYPE not in check["detail"] - - -def test_slack_lifecycle_uses_existing_local_board_adapter() -> None: - binding = WorkBinding( - session_id="a" * 32, - work_id="work1", - repository="example/project", - worktree_id="sha256:" + "b" * 64, - ) - lifecycle = { - "schema": "code_mower.remote_session.v1", - "state": "waiting_for_user", - "reason": "user_input_required", - "next_action": "none", - "counts": {"dispatch": 1, "message": 0, "cancel": 0, "collect": 0}, - } - - run = slack_board_run( - logical_session="private-slack-session-reference", - binding=binding, - provider="codex", - observed_at=datetime(2026, 9, 21, 7, 0, tzinfo=UTC), - lifecycle=lifecycle, - ) - - assert run.binding == binding - assert run.phase == "waiting_for_user" - assert run.source_kind == "remote_session" - assert run.lifecycle == lifecycle + + class FakeResponse: + def __init__(self, body: dict[str, object]) -> None: + self.body = body + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.body).encode() + + def getcode(self) -> int: + return 200 + + accepted = {"capabilities": {EVENT_TYPE: capability}} + with mock.patch( + "urllib.request.urlopen", return_value=FakeResponse(accepted) + ): + check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) + assert check["detail"][EVENT_TYPE] == capability + + mismatched = { + "capabilities": { + EVENT_TYPE: {**capability, "fixture_manifest_sha256": "0" * 64} + } + } + with mock.patch( + "urllib.request.urlopen", return_value=FakeResponse(mismatched) + ): + check = probe_cloud_service("https://codemower.com/api/ingest", timeout=1) + assert EVENT_TYPE not in check["detail"] + + + def test_slack_lifecycle_uses_existing_local_board_adapter(self) -> None: + binding = WorkBinding( + session_id="a" * 32, + work_id="work1", + repository="example/project", + worktree_id="sha256:" + "b" * 64, + ) + lifecycle = { + "schema": "code_mower.remote_session.v1", + "state": "waiting_for_user", + "reason": "user_input_required", + "next_action": "none", + "counts": {"dispatch": 1, "message": 0, "cancel": 0, "collect": 0}, + } + + run = slack_board_run( + logical_session="private-slack-session-reference", + binding=binding, + provider="codex", + observed_at=datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + lifecycle=lifecycle, + ) + + assert run.binding == binding + assert run.phase == "waiting_for_user" + assert run.source_kind == "remote_session" + assert run.lifecycle == lifecycle From 3ff4a3f5437cf7395e1a5bac3347ea3c34367ac2 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 01:15:30 -0700 Subject: [PATCH 7/8] Close control summary validation gaps --- docs/control-surface-telemetry.md | 4 +- ...trol_surface_session_summary.accepted.json | 2 +- ...face_session_summary.fixture-manifest.json | 12 ++-- ...trol_surface_session_summary.rejected.json | 56 +++++++++++++++ ...ontrol_surface_session_summary.schema.json | 69 ++++++++++++++++++- src/code_mower/control_surface_summary.py | 53 ++++++++++++++ tests/test_control_surface_summary.py | 39 ++++++++++- 7 files changed, 225 insertions(+), 10 deletions(-) diff --git a/docs/control-surface-telemetry.md b/docs/control-surface-telemetry.md index f63c2903..777463d7 100644 --- a/docs/control-surface-telemetry.md +++ b/docs/control-surface-telemetry.md @@ -26,7 +26,9 @@ available elapsed-time or Devin ACU measurements. The producer hashes its local logical session into a 32-character key before building the event. It never forwards a Slack identity or provider session reference. -The closed validator rejects unknown root, dimension, and metric fields. Task +The closed validator rejects unknown root, dimension, metric, and tool +provenance fields. Tool provenance is limited to the fixed Code Mower reporter +identity, a bounded package version, and a coarse local/CI runtime category. Task text, messages, answers, prompts, response URLs, Slack identities, source, diffs, transcripts, tokens, paths, context or graph data, provider references, and raw output cannot enter this event. diff --git a/src/code_mower/control_surface_session_summary.accepted.json b/src/code_mower/control_surface_session_summary.accepted.json index 47e13039..91e5b6e0 100644 --- a/src/code_mower/control_surface_session_summary.accepted.json +++ b/src/code_mower/control_surface_session_summary.accepted.json @@ -453,7 +453,7 @@ "control_surface": "slack", "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "outcome": "unknown", - "owner_action": "none", + "owner_action": "inspect_provider", "pr_number": "921", "pr_state": "open", "privacy_classification": "metadata_only", diff --git a/src/code_mower/control_surface_session_summary.fixture-manifest.json b/src/code_mower/control_surface_session_summary.fixture-manifest.json index 2d51b1cc..8eed0aa3 100644 --- a/src/code_mower/control_surface_session_summary.fixture-manifest.json +++ b/src/code_mower/control_surface_session_summary.fixture-manifest.json @@ -4,10 +4,10 @@ "encoding": "utf-8", "files": [ { - "bytes": 16933, + "bytes": 16945, "path": "control_surface_session_summary.accepted.json", "role": "accepted", - "sha256": "f6629a2561068fd1ca495cc7e028bc527840fb6ed13ef4f182593ca2a4b0ab38" + "sha256": "62fa33e2df67106096baf0e017a85f8eaeb47a7ccbc0396425b6a3a937e45735" }, { "bytes": 1869, @@ -16,16 +16,16 @@ "sha256": "01c8e9bb26f60fb8163a69801070ab6eabeadf20c271a41455634063966c7a21" }, { - "bytes": 27228, + "bytes": 29149, "path": "control_surface_session_summary.rejected.json", "role": "rejected", - "sha256": "0a3f0293bab4b45a08d6f6a2559b0a3e5cafd3c457dde4297b896af10ddbb6fe" + "sha256": "9c40cf77d056ae1665bed3366cd7297739fc27ff3b4533ee89493b0c684825bc" }, { - "bytes": 6001, + "bytes": 7583, "path": "control_surface_session_summary.schema.json", "role": "schema", - "sha256": "88823fc7fd523411b5abeb3a6c38ba741672f99d16c6db7e133b274d6de7c2c2" + "sha256": "a3b75f6f229a709ad0c225d217436e025856dee948b40120d3d2f92109c395fd" } ], "schema": "code_mower.controlSurfaceSessionFixtureManifest.v1" diff --git a/src/code_mower/control_surface_session_summary.rejected.json b/src/code_mower/control_surface_session_summary.rejected.json index 704cf600..09851b40 100644 --- a/src/code_mower/control_surface_session_summary.rejected.json +++ b/src/code_mower/control_surface_session_summary.rejected.json @@ -807,6 +807,62 @@ } }, "name": "remote_provider_reference_instead_of_opaque_session" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "details": { + "answer": "private answer", + "message": "private Slack message", + "path": "sensitive/worktree", + "prompt": "private prompt", + "response_url": "https://hooks.slack.com/private" + }, + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "forbidden_nested_tool_content" } ], "schema": "code_mower.controlSurfaceSessionSummaryRejectedFixtures.v1" diff --git a/src/code_mower/control_surface_session_summary.schema.json b/src/code_mower/control_surface_session_summary.schema.json index 607ae16b..61973c67 100644 --- a/src/code_mower/control_surface_session_summary.schema.json +++ b/src/code_mower/control_surface_session_summary.schema.json @@ -231,6 +231,73 @@ "const": "" }, "tool": { + "additionalProperties": false, + "properties": { + "integration": { + "const": "code-mower slack lifecycle" + }, + "lens": { + "const": "" + }, + "model": { + "const": "" + }, + "model_source": { + "const": "not_applicable" + }, + "model_version_raw": { + "const": "" + }, + "prompt_pack_version": { + "const": "" + }, + "provider": { + "const": "code-mower" + }, + "role": { + "const": "reporter" + }, + "runtime_environment": { + "enum": [ + "ci", + "github-actions", + "local" + ] + }, + "schema": { + "const": "code_mower.toolProvenance.v1" + }, + "source": { + "const": "code-mower slack lifecycle" + }, + "tool_name": { + "const": "code-mower" + }, + "tool_version": { + "maxLength": 64, + "pattern": "^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$", + "type": "string" + }, + "version_source": { + "const": "package_version" + } + }, + "required": [ + "integration", + "lens", + "model", + "model_source", + "model_version_raw", + "prompt_pack_version", + "provider", + "role", + "runtime_environment", + "schema", + "source", + "tool_name", + "tool_version", + "version_source" + ], "type": "object" } }, @@ -255,7 +322,7 @@ "x-code-mower-invariants": [ "status equals dimensions.state", "complete=>succeeded, failed=>failed, terminated=>cancelled, every other state=>unknown", - "owner_action is derived from the validated local lifecycle reason", + "owner_action is derived from the validated local lifecycle reason; archived may require inspect_provider when result is not ready", "head_sha and pr_state require pr_number", "usage_acu is allowed only for devin and only when already observed" ] diff --git a/src/code_mower/control_surface_summary.py b/src/code_mower/control_surface_summary.py index d52d53d5..128ac4dc 100644 --- a/src/code_mower/control_surface_summary.py +++ b/src/code_mower/control_surface_summary.py @@ -107,12 +107,46 @@ "terminated": "cancelled", } OWNER_ACTIONS_BY_STATE = { + "archived": frozenset({"none", "inspect_provider"}), "waiting_for_user": frozenset({"answer_question"}), "waiting_for_approval": frozenset({"respond_to_approval"}), "uncertain": frozenset({"inspect_provider"}), "failed": frozenset({"inspect_failure"}), "suspended": frozenset({"inspect_failure"}), } +TOOL_FIELDS = frozenset( + { + "schema", + "role", + "tool_name", + "tool_version", + "provider", + "model", + "model_version_raw", + "model_source", + "version_source", + "integration", + "lens", + "runtime_environment", + "prompt_pack_version", + "source", + } +) +TOOL_CONSTANTS = { + "schema": "code_mower.toolProvenance.v1", + "role": "reporter", + "tool_name": "code-mower", + "provider": "code-mower", + "model": "", + "model_version_raw": "", + "model_source": "not_applicable", + "version_source": "package_version", + "integration": SOURCE, + "lens": "", + "prompt_pack_version": "", + "source": SOURCE, +} +TOOL_RUNTIME_ENVIRONMENTS = frozenset({"local", "ci", "github-actions"}) PROVIDERS = frozenset( { "antigravity", @@ -135,6 +169,7 @@ _SESSION = re.compile(r"[0-9a-f]{32}") _HEAD_SHA = re.compile(r"[0-9a-f]{40}") _PR_NUMBER = re.compile(r"[1-9][0-9]{0,9}") +_TOOL_VERSION = re.compile(r"[0-9A-Za-z][0-9A-Za-z.+-]{0,63}") _CAPABILITY_FIELDS = frozenset( { "schema", @@ -195,6 +230,23 @@ def _optional_metric(value: object, field: str) -> None: raise _error(f"metrics.{field} must be a finite non-negative number") +def _validate_tool(value: object) -> None: + """Validate the closed, metadata-only Code Mower provenance object.""" + + if not isinstance(value, Mapping): + raise _error("tool must be an object") + _closed(value, TOOL_FIELDS, "tool") + if any(not isinstance(item, str) for item in value.values()): + raise _error("tool fields must be strings") + for field, expected in TOOL_CONSTANTS.items(): + if value.get(field) != expected: + raise _error(f"tool.{field} does not match the v1 producer contract") + if value.get("runtime_environment") not in TOOL_RUNTIME_ENVIRONMENTS: + raise _error("tool.runtime_environment is not a supported coarse category") + if _TOOL_VERSION.fullmatch(value.get("tool_version", "")) is None: + raise _error("tool.tool_version must be a bounded package version") + + def validate_control_surface_summary(value: Mapping[str, Any]) -> None: """Validate the new event's complete closed shape and cross-field invariants.""" @@ -217,6 +269,7 @@ def validate_control_surface_summary(value: Mapping[str, Any]) -> None: raise _error("tenant identity must come from authenticated ingest") if value.get("source") != SOURCE or value.get("lens") != "": raise _error("producer provenance does not match the v1 contract") + _validate_tool(value.get("tool")) provider = value.get("provider") if not isinstance(provider, str) or ( provider not in PROVIDERS and _CUSTOM_PROVIDER.fullmatch(provider) is None diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py index e6ad7774..21eae915 100644 --- a/tests/test_control_surface_summary.py +++ b/tests/test_control_surface_summary.py @@ -63,7 +63,7 @@ def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries( def test_all_canonical_rejected_events_fail_closed(self) -> None: rows = _fixture("rejected")["events"] - assert len(rows) == 16 + assert len(rows) == 17 for row in rows: with self.assertRaises(CloudBundleError): validate_control_surface_summary(row["event"]) @@ -205,6 +205,43 @@ def test_summary_builder_is_retry_stable_and_capability_gated(self) -> None: assert gated_control_surface_transition(capability, {"invalid": True}, **later) is not None + def test_archived_result_not_ready_is_a_valid_provider_owner_action(self) -> None: + lifecycle = { + "schema": "code_mower.remote_session.v1", + "state": "archived", + "reason": "result_not_ready", + "next_action": "inspect_provider", + "counts": {"dispatch": 1, "message": 0, "cancel": 0, "collect": 0}, + } + + event = build_control_surface_summary( + logical_session="archived-session", + repo_slug="example/project", + provider="devin", + lifecycle=lifecycle, + observed_at=datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + ) + + assert event["dimensions"]["state"] == "archived" + assert event["dimensions"]["owner_action"] == "inspect_provider" + validate_control_surface_summary(event) + assert validate_cloud_event(event) == event + + + def test_tool_provenance_is_closed_and_rejects_forbidden_nested_content(self) -> None: + event = _fixture("accepted")["events"][0]["event"] + for tool in ( + {**event["tool"], "message": "private Slack message"}, + {**event["tool"], "details": {"prompt": "private prompt"}}, + {**event["tool"], "runtime_environment": "developer-laptop"}, + ): + changed = {**event, "tool": tool} + with self.assertRaises(CloudBundleError): + validate_control_surface_summary(changed) + with self.assertRaises(CloudBundleError): + validate_cloud_event(changed) + + def test_health_probe_exposes_only_the_exact_summary_capability(self) -> None: capability = { "schema": CAPABILITY_SCHEMA, From 99b657ae9822a689b46d21c41695a4cfb28a177d Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 01:33:21 -0700 Subject: [PATCH 8/8] Close control summary lifecycle matrix --- docs/control-surface-telemetry.md | 18 +- ...trol_surface_session_summary.accepted.json | 691 ++++++++++++++++-- ...face_session_summary.fixture-manifest.json | 12 +- ...trol_surface_session_summary.rejected.json | 116 +++ ...ontrol_surface_session_summary.schema.json | 662 ++++++++++++++++- src/code_mower/control_surface_summary.py | 65 +- tests/test_control_surface_summary.py | 104 ++- 7 files changed, 1570 insertions(+), 98 deletions(-) diff --git a/docs/control-surface-telemetry.md b/docs/control-surface-telemetry.md index 777463d7..1046e7d8 100644 --- a/docs/control-surface-telemetry.md +++ b/docs/control-surface-telemetry.md @@ -21,10 +21,20 @@ packaged schema and fixtures are the normative machine-readable contract: The summary allows an opaque Code Mower correlation key, repository slug, categorical provider, lifecycle state and outcome, observation time, operation -counts, bounded owner action, optional pull-request metadata, and already -available elapsed-time or Devin ACU measurements. The producer hashes its -local logical session into a 32-character key before building the event. It -never forwards a Slack identity or provider session reference. +counts, closed lifecycle reason, bounded owner action, optional pull-request +metadata, and already available elapsed-time or Devin ACU measurements. The +producer hashes its local logical session into a 32-character key before +building the event. It never forwards a Slack identity or provider session +reference. + +The lifecycle state, reason, and owner action form one closed policy triple. +Routine status observations use `none`; owner waits use `answer_question` or +`respond_to_approval`; failures and suspensions use `inspect_failure`. +`collect` observations with `result_not_ready` or `result_unavailable` use +`inspect_provider` regardless of whether the provider is still pending, +running, owner-waiting, or terminal. This preserves both facts: the provider +state remains truthful, and the missing result remains visible as the current +owner action. Unsupported combinations fail closed. The closed validator rejects unknown root, dimension, metric, and tool provenance fields. Tool provenance is limited to the fixed Code Mower reporter diff --git a/src/code_mower/control_surface_session_summary.accepted.json b/src/code_mower/control_surface_session_summary.accepted.json index 91e5b6e0..d7ad665c 100644 --- a/src/code_mower/control_surface_session_summary.accepted.json +++ b/src/code_mower/control_surface_session_summary.accepted.json @@ -2,15 +2,19 @@ "events": [ { "event": { - "created_at": "2026-09-20T12:01:00Z", + "created_at": "2026-09-20T12:10:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", + "pr_number": "921", + "pr_state": "open", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000001", - "state": "pending", + "state": "archived", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000001", @@ -28,7 +32,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "pending", + "status": "archived", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -47,19 +51,23 @@ "version_source": "package_version" } }, - "name": "pending_no_pr" + "name": "archived_none_none" }, { "event": { - "created_at": "2026-09-20T12:02:00Z", + "created_at": "2026-09-20T12:10:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "result_not_ready", "outcome": "unknown", - "owner_action": "none", + "owner_action": "inspect_provider", + "pr_number": "921", + "pr_state": "open", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000002", - "state": "running", + "state": "archived", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000002", @@ -77,7 +85,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "running", + "status": "archived", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -96,19 +104,23 @@ "version_source": "package_version" } }, - "name": "running_no_pr" + "name": "archived_result_not_ready_inspect_provider" }, { "event": { - "created_at": "2026-09-20T12:03:00Z", + "created_at": "2026-09-20T12:07:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "unknown", - "owner_action": "answer_question", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "none", + "outcome": "succeeded", + "owner_action": "none", + "pr_number": "921", + "pr_state": "open", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000003", - "state": "waiting_for_user", + "state": "complete", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000003", @@ -117,8 +129,9 @@ "lens": "", "metrics": { "cancel_count": 0, - "collect_count": 0, + "collect_count": 1, "dispatch_count": 1, + "elapsed_seconds": 420.0, "message_count": 0, "summary_count": 1 }, @@ -126,7 +139,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "waiting_for_user", + "status": "complete", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -145,19 +158,23 @@ "version_source": "package_version" } }, - "name": "waiting_for_user" + "name": "complete_none_none" }, { "event": { - "created_at": "2026-09-20T12:04:00Z", + "created_at": "2026-09-20T12:07:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "unknown", - "owner_action": "respond_to_approval", + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "result_unavailable", + "outcome": "succeeded", + "owner_action": "inspect_provider", + "pr_number": "921", + "pr_state": "open", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000004", - "state": "waiting_for_approval", + "state": "complete", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000004", @@ -166,8 +183,9 @@ "lens": "", "metrics": { "cancel_count": 0, - "collect_count": 0, + "collect_count": 1, "dispatch_count": 1, + "elapsed_seconds": 420.0, "message_count": 0, "summary_count": 1 }, @@ -175,7 +193,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "waiting_for_approval", + "status": "complete", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -194,19 +212,20 @@ "version_source": "package_version" } }, - "name": "waiting_for_approval" + "name": "complete_result_unavailable_inspect_provider" }, { "event": { - "created_at": "2026-09-20T12:05:00Z", + "created_at": "2026-09-20T12:08:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "unknown", + "lifecycle_reason": "session_failed", + "outcome": "failed", "owner_action": "inspect_failure", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000005", - "state": "suspended", + "state": "failed", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000005", @@ -224,7 +243,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "suspended", + "status": "failed", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -243,19 +262,20 @@ "version_source": "package_version" } }, - "name": "suspended_no_pr" + "name": "failed_session_failed_inspect_failure" }, { "event": { - "created_at": "2026-09-20T12:06:00Z", + "created_at": "2026-09-20T12:08:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "unknown", + "lifecycle_reason": "result_not_ready", + "outcome": "failed", "owner_action": "inspect_provider", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000006", - "state": "uncertain", + "state": "failed", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000006", @@ -273,7 +293,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "uncertain", + "status": "failed", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -292,22 +312,20 @@ "version_source": "package_version" } }, - "name": "uncertain_inspect_provider" + "name": "failed_result_not_ready_inspect_provider" }, { "event": { - "created_at": "2026-09-20T12:07:00Z", + "created_at": "2026-09-20T12:01:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "outcome": "succeeded", + "lifecycle_reason": "none", + "outcome": "unknown", "owner_action": "none", - "pr_number": "921", - "pr_state": "open", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000007", - "state": "complete", + "state": "pending", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000007", @@ -316,9 +334,8 @@ "lens": "", "metrics": { "cancel_count": 0, - "collect_count": 1, + "collect_count": 0, "dispatch_count": 1, - "elapsed_seconds": 420.0, "message_count": 0, "summary_count": 1 }, @@ -326,7 +343,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "complete", + "status": "pending", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -345,19 +362,20 @@ "version_source": "package_version" } }, - "name": "complete_with_open_pr" + "name": "pending_none_none" }, { "event": { - "created_at": "2026-09-20T12:08:00Z", + "created_at": "2026-09-20T12:01:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "failed", - "owner_action": "inspect_failure", + "lifecycle_reason": "result_not_ready", + "outcome": "unknown", + "owner_action": "inspect_provider", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000008", - "state": "failed", + "state": "pending", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000008", @@ -375,7 +393,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "failed", + "status": "pending", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -394,19 +412,20 @@ "version_source": "package_version" } }, - "name": "failed_without_pr" + "name": "pending_result_not_ready_inspect_provider" }, { "event": { - "created_at": "2026-09-20T12:09:00Z", + "created_at": "2026-09-20T12:02:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "outcome": "cancelled", + "lifecycle_reason": "none", + "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", "session": "00000000000000000000000000000009", - "state": "terminated", + "state": "running", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-000000000009", @@ -414,7 +433,7 @@ "install_id": "", "lens": "", "metrics": { - "cancel_count": 1, + "cancel_count": 0, "collect_count": 0, "dispatch_count": 1, "message_count": 0, @@ -424,7 +443,7 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "terminated", + "status": "running", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -443,22 +462,20 @@ "version_source": "package_version" } }, - "name": "terminated_without_pr" + "name": "running_none_none" }, { "event": { - "created_at": "2026-09-20T12:10:00Z", + "created_at": "2026-09-20T12:02:00Z", "dimensions": { "capability_version": 1, "control_surface": "slack", - "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "result_not_ready", "outcome": "unknown", "owner_action": "inspect_provider", - "pr_number": "921", - "pr_state": "open", "privacy_classification": "metadata_only", "session": "0000000000000000000000000000000a", - "state": "archived", + "state": "running", "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" }, "event_id": "00000000-0000-4000-8000-00000000000a", @@ -476,7 +493,557 @@ "repo_slug": "example/project", "schema": "code_mower.benchmarkEvent.v1", "source": "code-mower slack lifecycle", - "status": "archived", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "running_result_not_ready_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:05:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "session_suspended", + "outcome": "unknown", + "owner_action": "inspect_failure", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000b", + "state": "suspended", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000b", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "suspended", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "suspended_session_suspended_inspect_failure" + }, + { + "event": { + "created_at": "2026-09-20T12:05:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "result_not_ready", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000c", + "state": "suspended", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000c", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "suspended", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "suspended_result_not_ready_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:09:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "none", + "outcome": "cancelled", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000d", + "state": "terminated", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000d", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 1, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "terminated", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "terminated_none_none" + }, + { + "event": { + "created_at": "2026-09-20T12:09:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "result_not_ready", + "outcome": "cancelled", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000e", + "state": "terminated", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000e", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 1, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "terminated", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "terminated_result_not_ready_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:06:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "provider_unavailable", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "0000000000000000000000000000000f", + "state": "uncertain", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-00000000000f", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "uncertain", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "uncertain_provider_unavailable_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:06:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "reconcile_dispatch", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000010", + "state": "uncertain", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000010", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "uncertain", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "uncertain_reconcile_dispatch_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:06:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "inspect_provider_then_acknowledge", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000011", + "state": "uncertain", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000011", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "uncertain", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "uncertain_inspect_provider_then_acknowledge_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:04:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "approval_required", + "outcome": "unknown", + "owner_action": "respond_to_approval", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000012", + "state": "waiting_for_approval", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000012", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_approval", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "waiting_for_approval_approval_required_respond_to_approval" + }, + { + "event": { + "created_at": "2026-09-20T12:04:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "result_not_ready", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000013", + "state": "waiting_for_approval", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000013", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_approval", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "waiting_for_approval_result_not_ready_inspect_provider" + }, + { + "event": { + "created_at": "2026-09-20T12:03:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "user_input_required", + "outcome": "unknown", + "owner_action": "answer_question", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000014", + "state": "waiting_for_user", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000014", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_user", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "waiting_for_user_user_input_required_answer_question" + }, + { + "event": { + "created_at": "2026-09-20T12:03:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "result_not_ready", + "outcome": "unknown", + "owner_action": "inspect_provider", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000015", + "state": "waiting_for_user", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000015", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "waiting_for_user", "team_id": "", "tool": { "integration": "code-mower slack lifecycle", @@ -495,7 +1062,7 @@ "version_source": "package_version" } }, - "name": "archived_with_retained_pr" + "name": "waiting_for_user_result_not_ready_inspect_provider" } ], "schema": "code_mower.controlSurfaceSessionSummaryAcceptedFixtures.v1" diff --git a/src/code_mower/control_surface_session_summary.fixture-manifest.json b/src/code_mower/control_surface_session_summary.fixture-manifest.json index 8eed0aa3..4649049b 100644 --- a/src/code_mower/control_surface_session_summary.fixture-manifest.json +++ b/src/code_mower/control_surface_session_summary.fixture-manifest.json @@ -4,10 +4,10 @@ "encoding": "utf-8", "files": [ { - "bytes": 16945, + "bytes": 36946, "path": "control_surface_session_summary.accepted.json", "role": "accepted", - "sha256": "62fa33e2df67106096baf0e017a85f8eaeb47a7ccbc0396425b6a3a937e45735" + "sha256": "17b64ce2ae10baae2f3cf84a4a39f580ef912ce436387dcebfa98724477ec486" }, { "bytes": 1869, @@ -16,16 +16,16 @@ "sha256": "01c8e9bb26f60fb8163a69801070ab6eabeadf20c271a41455634063966c7a21" }, { - "bytes": 29149, + "bytes": 33146, "path": "control_surface_session_summary.rejected.json", "role": "rejected", - "sha256": "9c40cf77d056ae1665bed3366cd7297739fc27ff3b4533ee89493b0c684825bc" + "sha256": "418180e76d3c90aebb108ec9caa38258a0f28c380d85e898e419313756dece5c" }, { - "bytes": 7583, + "bytes": 22845, "path": "control_surface_session_summary.schema.json", "role": "schema", - "sha256": "a3b75f6f229a709ad0c225d217436e025856dee948b40120d3d2f92109c395fd" + "sha256": "2215ce1f085525de3320377f0e26e970a7db811afc6129fc5529a5cd67760c4e" } ], "schema": "code_mower.controlSurfaceSessionFixtureManifest.v1" diff --git a/src/code_mower/control_surface_session_summary.rejected.json b/src/code_mower/control_surface_session_summary.rejected.json index 09851b40..a3b73bc1 100644 --- a/src/code_mower/control_surface_session_summary.rejected.json +++ b/src/code_mower/control_surface_session_summary.rejected.json @@ -6,6 +6,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -57,6 +58,7 @@ "capability_version": 1, "control_surface": "slack", "extra": "x", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -106,6 +108,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -156,6 +159,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -212,6 +216,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -274,6 +279,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -323,6 +329,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -372,6 +379,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -421,6 +429,7 @@ "dimensions": { "capability_version": 1, "control_surface": "email", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -470,6 +479,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -519,6 +529,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -568,6 +579,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -617,6 +629,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "answer_question", "privacy_classification": "metadata_only", @@ -666,6 +679,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -716,6 +730,7 @@ "capability_version": 1, "control_surface": "slack", "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -765,6 +780,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -814,6 +830,7 @@ "dimensions": { "capability_version": 1, "control_surface": "slack", + "lifecycle_reason": "none", "outcome": "unknown", "owner_action": "none", "privacy_classification": "metadata_only", @@ -863,6 +880,105 @@ } }, "name": "forbidden_nested_tool_content" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "lifecycle_reason": "session_failed", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "lifecycle_reason_state_mismatch" + }, + { + "event": { + "created_at": "2026-09-20T12:02:00Z", + "dimensions": { + "capability_version": 1, + "control_surface": "slack", + "outcome": "unknown", + "owner_action": "none", + "privacy_classification": "metadata_only", + "session": "00000000000000000000000000000002", + "state": "running", + "summary_schema": "code_mower.controlSurfaceSessionSummary.v1" + }, + "event_id": "00000000-0000-4000-8000-000000000002", + "event_type": "control_surface_session_summary", + "install_id": "", + "lens": "", + "metrics": { + "cancel_count": 0, + "collect_count": 0, + "dispatch_count": 1, + "message_count": 0, + "summary_count": 1 + }, + "provider": "codex", + "repo_slug": "example/project", + "schema": "code_mower.benchmarkEvent.v1", + "source": "code-mower slack lifecycle", + "status": "running", + "team_id": "", + "tool": { + "integration": "code-mower slack lifecycle", + "lens": "", + "model": "", + "model_source": "not_applicable", + "model_version_raw": "", + "prompt_pack_version": "", + "provider": "code-mower", + "role": "reporter", + "runtime_environment": "local", + "schema": "code_mower.toolProvenance.v1", + "source": "code-mower slack lifecycle", + "tool_name": "code-mower", + "tool_version": "1.6.0", + "version_source": "package_version" + } + }, + "name": "missing_lifecycle_reason" } ], "schema": "code_mower.controlSurfaceSessionSummaryRejectedFixtures.v1" diff --git a/src/code_mower/control_surface_session_summary.schema.json b/src/code_mower/control_surface_session_summary.schema.json index 61973c67..c1fd477f 100644 --- a/src/code_mower/control_surface_session_summary.schema.json +++ b/src/code_mower/control_surface_session_summary.schema.json @@ -31,6 +31,394 @@ }, "dimensions": { "additionalProperties": false, + "anyOf": [ + { + "properties": { + "lifecycle_reason": { + "const": "none" + }, + "owner_action": { + "const": "none" + }, + "state": { + "const": "archived" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "archived" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "none" + }, + "owner_action": { + "const": "none" + }, + "state": { + "const": "complete" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_unavailable" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "complete" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "session_failed" + }, + "owner_action": { + "const": "inspect_failure" + }, + "state": { + "const": "failed" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "failed" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "none" + }, + "owner_action": { + "const": "none" + }, + "state": { + "const": "pending" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "pending" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "none" + }, + "owner_action": { + "const": "none" + }, + "state": { + "const": "running" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "running" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "session_suspended" + }, + "owner_action": { + "const": "inspect_failure" + }, + "state": { + "const": "suspended" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "suspended" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "none" + }, + "owner_action": { + "const": "none" + }, + "state": { + "const": "terminated" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "terminated" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "provider_unavailable" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "uncertain" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "reconcile_dispatch" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "uncertain" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "inspect_provider_then_acknowledge" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "uncertain" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "approval_required" + }, + "owner_action": { + "const": "respond_to_approval" + }, + "state": { + "const": "waiting_for_approval" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "waiting_for_approval" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "user_input_required" + }, + "owner_action": { + "const": "answer_question" + }, + "state": { + "const": "waiting_for_user" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + }, + { + "properties": { + "lifecycle_reason": { + "const": "result_not_ready" + }, + "owner_action": { + "const": "inspect_provider" + }, + "state": { + "const": "waiting_for_user" + } + }, + "required": [ + "state", + "lifecycle_reason", + "owner_action" + ] + } + ], + "dependentRequired": { + "head_sha": [ + "pr_number" + ], + "pr_state": [ + "pr_number" + ] + }, "properties": { "capability_version": { "const": 1 @@ -42,6 +430,20 @@ "pattern": "^[0-9a-f]{40}$", "type": "string" }, + "lifecycle_reason": { + "enum": [ + "approval_required", + "inspect_provider_then_acknowledge", + "none", + "provider_unavailable", + "reconcile_dispatch", + "result_not_ready", + "result_unavailable", + "session_failed", + "session_suspended", + "user_input_required" + ] + }, "outcome": { "enum": [ "unknown", @@ -102,6 +504,7 @@ "session", "privacy_classification", "state", + "lifecycle_reason", "outcome", "owner_action" ], @@ -155,10 +558,267 @@ "$id": "code_mower.controlSurfaceSessionSummary.v1", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "archived" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "archived" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "succeeded" + }, + "state": { + "const": "complete" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "complete" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "failed" + }, + "state": { + "const": "failed" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "failed" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "pending" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "pending" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "running" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "running" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "suspended" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "suspended" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "cancelled" + }, + "state": { + "const": "terminated" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "terminated" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "uncertain" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "uncertain" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "waiting_for_approval" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "waiting_for_approval" + } + }, + "required": [ + "status", + "dimensions" + ] + }, + { + "properties": { + "dimensions": { + "properties": { + "outcome": { + "const": "unknown" + }, + "state": { + "const": "waiting_for_user" + } + }, + "required": [ + "state", + "outcome" + ] + }, + "status": { + "const": "waiting_for_user" + } + }, + "required": [ + "status", + "dimensions" + ] + } + ] + } + ], "description": "Closed metadata-only Slack lifecycle event inside code_mower.benchmarkEvent.v1.", "properties": { "created_at": { "format": "date-time", + "pattern": "(?:Z|\\+00:00)$", "type": "string" }, "dimensions": { @@ -322,7 +982,7 @@ "x-code-mower-invariants": [ "status equals dimensions.state", "complete=>succeeded, failed=>failed, terminated=>cancelled, every other state=>unknown", - "owner_action is derived from the validated local lifecycle reason; archived may require inspect_provider when result is not ready", + "dimensions.state, lifecycle_reason, and owner_action must match one exact producer policy triple", "head_sha and pr_state require pr_number", "usage_acu is allowed only for devin and only when already observed" ] diff --git a/src/code_mower/control_surface_summary.py b/src/code_mower/control_surface_summary.py index 128ac4dc..1e8641c0 100644 --- a/src/code_mower/control_surface_summary.py +++ b/src/code_mower/control_surface_summary.py @@ -48,6 +48,7 @@ "session", "privacy_classification", "state", + "lifecycle_reason", "outcome", "owner_action", "pr_number", @@ -63,6 +64,7 @@ "session", "privacy_classification", "state", + "lifecycle_reason", "outcome", "owner_action", } @@ -106,13 +108,33 @@ "failed": "failed", "terminated": "cancelled", } -OWNER_ACTIONS_BY_STATE = { - "archived": frozenset({"none", "inspect_provider"}), - "waiting_for_user": frozenset({"answer_question"}), - "waiting_for_approval": frozenset({"respond_to_approval"}), - "uncertain": frozenset({"inspect_provider"}), - "failed": frozenset({"inspect_failure"}), - "suspended": frozenset({"inspect_failure"}), +LIFECYCLE_POLICY = { + "archived": {"none": "none", "result_not_ready": "inspect_provider"}, + "complete": {"none": "none", "result_unavailable": "inspect_provider"}, + "failed": { + "session_failed": "inspect_failure", + "result_not_ready": "inspect_provider", + }, + "pending": {"none": "none", "result_not_ready": "inspect_provider"}, + "running": {"none": "none", "result_not_ready": "inspect_provider"}, + "suspended": { + "session_suspended": "inspect_failure", + "result_not_ready": "inspect_provider", + }, + "terminated": {"none": "none", "result_not_ready": "inspect_provider"}, + "uncertain": { + "provider_unavailable": "inspect_provider", + "reconcile_dispatch": "inspect_provider", + "inspect_provider_then_acknowledge": "inspect_provider", + }, + "waiting_for_approval": { + "approval_required": "respond_to_approval", + "result_not_ready": "inspect_provider", + }, + "waiting_for_user": { + "user_input_required": "answer_question", + "result_not_ready": "inspect_provider", + }, } TOOL_FIELDS = frozenset( { @@ -304,9 +326,12 @@ def validate_control_surface_summary(value: Mapping[str, Any]) -> None: expected_outcome = OUTCOME_BY_STATE.get(state, "unknown") if dimensions.get("outcome") != expected_outcome: raise _error("outcome is inconsistent with lifecycle state") - allowed_actions = OWNER_ACTIONS_BY_STATE.get(state, frozenset({"none"})) - if dimensions.get("owner_action") not in allowed_actions: - raise _error("owner_action is inconsistent with lifecycle state") + reason = dimensions.get("lifecycle_reason") + expected_action = LIFECYCLE_POLICY[state].get(reason) + if expected_action is None: + raise _error("lifecycle_reason is inconsistent with lifecycle state") + if dimensions.get("owner_action") != expected_action: + raise _error("owner_action is inconsistent with lifecycle state and reason") pr_fields = {"pr_number", "head_sha", "pr_state"} & set(dimensions) if pr_fields and "pr_number" not in pr_fields: @@ -392,19 +417,11 @@ def _stamp(value: dt.datetime) -> str: def _owner_action(lifecycle: Mapping[str, Any]) -> str: - reason = lifecycle["reason"] - return { - "user_input_required": "answer_question", - "approval_required": "respond_to_approval", - "provider_unavailable": "inspect_provider", - "reconcile_dispatch": "inspect_provider", - "inspect_provider_then_acknowledge": "inspect_provider", - "result_not_ready": "inspect_provider", - "result_unavailable": "inspect_provider", - "session_failed": "inspect_failure", - "session_suspended": "inspect_failure", - "none": "none", - }[reason] + state, reason = lifecycle["state"], lifecycle["reason"] + try: + return LIFECYCLE_POLICY[state][reason] + except KeyError: + raise _error("lifecycle state and reason are not a supported producer pair") from None def build_control_surface_summary( @@ -437,6 +454,7 @@ def build_control_surface_summary( "session": opaque_session(logical_session), "privacy_classification": PRIVACY_CLASSIFICATION, "state": state, + "lifecycle_reason": projected["reason"], "outcome": OUTCOME_BY_STATE.get(state, "unknown"), "owner_action": _owner_action(projected), } @@ -504,6 +522,7 @@ def _transition_identity(value: Mapping[str, Any]) -> tuple[Any, ...]: for key in ( "session", "state", + "lifecycle_reason", "outcome", "owner_action", "pr_number", diff --git a/tests/test_control_surface_summary.py b/tests/test_control_surface_summary.py index 21eae915..ef64c1bf 100644 --- a/tests/test_control_surface_summary.py +++ b/tests/test_control_surface_summary.py @@ -3,6 +3,7 @@ import hashlib import importlib.resources import json +import tempfile from datetime import UTC, datetime from pathlib import Path from unittest import TestCase, mock @@ -15,6 +16,8 @@ CAPABILITY_SCHEMA, CAPABILITY_VERSION, EVENT_TYPE, + LIFECYCLE_POLICY, + OUTCOME_BY_STATE, SUMMARY_SCHEMA, build_control_surface_summary, capability_accepts_summary, @@ -26,6 +29,7 @@ slack_board_run, validate_control_surface_summary, ) +from code_mower.remote_session import FakeProvider, RemoteSessions, _key from code_mower.package_manifest import PACKAGE_FILES @@ -42,7 +46,7 @@ class ControlSurfaceSummaryTests(TestCase): def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries(self) -> None: rows = _fixture("accepted")["events"] - assert len(rows) == 10 + assert len(rows) == 21 assert {row["event"]["dimensions"]["state"] for row in rows} == { "archived", "complete", @@ -63,7 +67,7 @@ def test_all_canonical_accepted_events_satisfy_specialized_and_cloud_boundaries( def test_all_canonical_rejected_events_fail_closed(self) -> None: rows = _fixture("rejected")["events"] - assert len(rows) == 17 + assert len(rows) == 19 for row in rows: with self.assertRaises(CloudBundleError): validate_control_surface_summary(row["event"]) @@ -223,11 +227,107 @@ def test_archived_result_not_ready_is_a_valid_provider_owner_action(self) -> Non ) assert event["dimensions"]["state"] == "archived" + assert event["dimensions"]["lifecycle_reason"] == "result_not_ready" assert event["dimensions"]["owner_action"] == "inspect_provider" validate_control_surface_summary(event) assert validate_cloud_event(event) == event + def test_real_collect_lifecycles_cover_every_result_availability_state(self) -> None: + cases = ( + ("pending", "", "pending", "result_not_ready"), + ("running", "", "running", "result_not_ready"), + ("owner_action", "waiting_for_owner", "waiting_for_user", "result_not_ready"), + ("owner_action", "approval_required", "waiting_for_approval", "result_not_ready"), + ("complete", "", "complete", "result_unavailable"), + ("failed", "", "failed", "result_not_ready"), + ("suspended", "", "suspended", "result_not_ready"), + ("terminated", "", "terminated", "result_not_ready"), + ("archived", "", "archived", "result_not_ready"), + ) + for raw_state, raw_reason, state, reason in cases: + with self.subTest(raw_state=raw_state, raw_reason=raw_reason), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + provider = FakeProvider(root / "provider") + sessions = RemoteSessions(root / "sessions", provider) + logical_session = "summary-matrix" + sessions.run( + "dispatch", + logical_session, + prose="metadata-only lifecycle test", + repo="example/project", + apply=True, + ) + record = sessions.store.read_only(_key(logical_session)) + provider.set_state(record["binding"], raw_state, reason=raw_reason) + + lifecycle = sessions.run("collect", logical_session, apply=True) + event = build_control_surface_summary( + logical_session=logical_session, + repo_slug="example/project", + provider="codex", + lifecycle=lifecycle, + observed_at=datetime(2026, 9, 21, 7, 0, tzinfo=UTC), + ) + + assert (lifecycle["state"], lifecycle["reason"]) == (state, reason) + assert event["dimensions"]["lifecycle_reason"] == reason + assert event["dimensions"]["owner_action"] == "inspect_provider" + assert validate_cloud_event(event) == event + + + def test_schema_and_semantic_validators_enforce_the_exact_lifecycle_policy_matrix(self) -> None: + schema = _fixture("schema") + schema_policy = { + ( + row["properties"]["state"]["const"], + row["properties"]["lifecycle_reason"]["const"], + row["properties"]["owner_action"]["const"], + ) + for row in schema["$defs"]["dimensions"]["anyOf"] + } + semantic_policy = { + (state, reason, action) + for state, reasons in LIFECYCLE_POLICY.items() + for reason, action in reasons.items() + } + assert schema_policy == semantic_policy + base = next( + row["event"] + for row in _fixture("accepted")["events"] + if row["event"]["provider"] == "codex" + ) + reasons = sorted({reason for policy in LIFECYCLE_POLICY.values() for reason in policy}) + actions = ( + "none", + "answer_question", + "respond_to_approval", + "inspect_provider", + "inspect_failure", + ) + for state in sorted(LIFECYCLE_POLICY): + for reason in reasons: + for action in actions: + with self.subTest(state=state, reason=reason, action=action): + event = json.loads(json.dumps(base)) + event["status"] = state + event["dimensions"].update( + state=state, + lifecycle_reason=reason, + outcome=OUTCOME_BY_STATE.get(state, "unknown"), + owner_action=action, + ) + expected = LIFECYCLE_POLICY[state].get(reason) == action + if expected: + validate_control_surface_summary(event) + assert validate_cloud_event(event) == event + else: + with self.assertRaises(CloudBundleError): + validate_control_surface_summary(event) + with self.assertRaises(CloudBundleError): + validate_cloud_event(event) + + def test_tool_provenance_is_closed_and_rejects_forbidden_nested_content(self) -> None: event = _fixture("accepted")["events"][0]["event"] for tool in (