From afe9cfb77988908104cbaaaa6e0cce2e1e73a5f3 Mon Sep 17 00:00:00 2001 From: Peter van Tol Date: Thu, 1 Oct 2026 19:37:52 +0200 Subject: [PATCH] Project verified legacy Action correlation safely --- ...l3-serial-correlation-compat-20261001.json | 16 ++++ .../PARALLEL_ACTION_DURABLE_SLOTS_V1.md | 16 ++++ forge/operations_read_api.py | 2 + forge/serial_correlation_projection.py | 68 ++++++++++++++ product-version.json | 2 +- tests/test_operations_read_api.py | 26 ++++++ tests/test_serial_correlation_projection.py | 89 +++++++++++++++++++ 7 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 .github/product-version-operations/l3-serial-correlation-compat-20261001.json create mode 100644 forge/serial_correlation_projection.py create mode 100644 tests/test_serial_correlation_projection.py diff --git a/.github/product-version-operations/l3-serial-correlation-compat-20261001.json b/.github/product-version-operations/l3-serial-correlation-compat-20261001.json new file mode 100644 index 00000000..d4e13448 --- /dev/null +++ b/.github/product-version-operations/l3-serial-correlation-compat-20261001.json @@ -0,0 +1,16 @@ +{ + "baseline_version": "2.7.42", + "classification_rationale": "L3-FORGE-SERIAL-CORRELATION-COMPAT-V1-20261001", + "component": "product", + "event_lineage": "L3-FORGE-SERIAL-CORRELATION-COMPAT-V1-20261001", + "expected_head": "08ad4f89f1d888747662910ad450c8070e5937c1", + "operation_id": "l3-serial-correlation-compat-20261001", + "policy_revision": "forge-bootstrap-release-cadence-v2", + "product": "forge", + "projection_paths": "product-version.json", + "release_class": "PATCH", + "requested_bump": "patch", + "requested_version": null, + "schema_version": "1", + "target_version": "2.7.43" +} diff --git a/docs/architecture/PARALLEL_ACTION_DURABLE_SLOTS_V1.md b/docs/architecture/PARALLEL_ACTION_DURABLE_SLOTS_V1.md index 7b0b9897..b3893049 100644 --- a/docs/architecture/PARALLEL_ACTION_DURABLE_SLOTS_V1.md +++ b/docs/architecture/PARALLEL_ACTION_DURABLE_SLOTS_V1.md @@ -26,3 +26,19 @@ and always reports `target_verification: UNVERIFIED` and `dispatch_authorized: false`. A slot never supplies correlation, admission, or execution evidence. Installed artifact qualification and full PA-F1/F2/F3 remain separate roadmap work. + +## Legacy serial correlation compatibility + +Authenticated Mission detail also reports `serial_correlation` with contract +version `serial-action-correlation-compat/v1`. It reads the existing durable +singleton correlation without rewriting it. A bound view requires one +unambiguous relevant stored Action and matching direct or request-nested +Mission/Action/correlation identity. Only the Action ID and status, +correlation ID and optional host run ID are projected; prompt and +credential-bearing request fields are omitted. Missing correlation is +`UNAVAILABLE`; malformed, contradictory or multi-active records are +`UNSUPPORTED` with a reason and no selected Action. The view never implies a +new per-Action slot, EP admission, target verification, or host effect. A +future PA-F1 migration must still prove lossless persistence and +reconciliation of actual serial execution state before claiming full +multi-slot support. diff --git a/forge/operations_read_api.py b/forge/operations_read_api.py index a5d072c3..e5526613 100644 --- a/forge/operations_read_api.py +++ b/forge/operations_read_api.py @@ -28,6 +28,7 @@ from .models.producer import redact_action_summary from .parallel_action_contract import ParallelActionContractError, validate_peer_graph from .runtime.data_root import DataRootResolver +from .serial_correlation_projection import project_serial_correlation API_VERSION = "1" @@ -286,6 +287,7 @@ def mission_detail(self, mission_id: str) -> dict[str, Any]: "criteria": [_safe_text(item) for item in state.mission.get("acceptance_criteria", ())], "actions": [_project_action(item) for item in state.actions], "planning_slots": planning_slots, + "serial_correlation": project_serial_correlation(state), "evidence_lineage": { "execution_evidence": _project_execution_evidence(state.execution_evidence), "execution_attempts": [_project_execution_evidence(item) for item in state.execution_history], diff --git a/forge/serial_correlation_projection.py b/forge/serial_correlation_projection.py new file mode 100644 index 00000000..e92bd922 --- /dev/null +++ b/forge/serial_correlation_projection.py @@ -0,0 +1,68 @@ +"""Read-only compatibility view of one legacy Mission execution correlation. + +This never migrates state or infers an EP admission, target or execution grant. +""" +from __future__ import annotations + +from collections.abc import Mapping +import re +from typing import Any + + +CONTRACT_VERSION = "serial-action-correlation-compat/v1" +_IN_FLIGHT = frozenset({"ACTIVE", "WAITING_FOR_RESULT"}) +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z") + + +def _safe_identifier(value: object) -> bool: + return isinstance(value, str) and _IDENTIFIER.fullmatch(value) is not None + + +def project_serial_correlation(state: Any) -> dict[str, Any]: + """Bind a singleton correlation only when its Action identity is provable.""" + base = {"contract_version": CONTRACT_VERSION, "mission_revision": state.revision, + "read_only": True, "binding_status": "UNAVAILABLE", "reason": "NO_CORRELATION"} + correlation = state.execution_correlation + if correlation is None: + return base + if not isinstance(correlation, Mapping): + return {**base, "binding_status": "UNSUPPORTED", "reason": "MALFORMED_CORRELATION"} + actions = state.actions + if (not isinstance(actions, (tuple, list)) or not actions + or any(not isinstance(action, Mapping) or not isinstance(action.get("id"), str) + or not action["id"] for action in actions) + or len({action["id"] for action in actions}) != len(actions)): + return {**base, "binding_status": "UNSUPPORTED", "reason": "AMBIGUOUS_ACTION_SET"} + in_flight = [action for action in actions if action.get("status") in _IN_FLIGHT] + if len(in_flight) > 1: + return {**base, "binding_status": "UNSUPPORTED", "reason": "MULTIPLE_IN_FLIGHT_ACTIONS"} + if len(in_flight) == 1: + expected_action = in_flight[0] + elif len(actions) == 1: + expected_action = actions[0] + else: + return {**base, "binding_status": "UNSUPPORTED", "reason": "AMBIGUOUS_ACTION_SET"} + request = correlation.get("request") + if request is not None and not isinstance(request, Mapping): + return {**base, "binding_status": "UNSUPPORTED", "reason": "MALFORMED_REQUEST"} + if request is not None and any(not _safe_identifier(request.get(key)) + for key in ("mission_id", "action_id", "correlation_id")): + return {**base, "binding_status": "UNSUPPORTED", "reason": "MALFORMED_REQUEST"} + request = request or {} + for key in ("mission_id", "action_id", "correlation_id"): + direct, nested = correlation.get(key), request.get(key) + if direct is not None and nested is not None and direct != nested: + return {**base, "binding_status": "UNSUPPORTED", "reason": "IDENTITY_CONFLICT"} + mission_id = correlation.get("mission_id") or request.get("mission_id") + action_id = correlation.get("action_id") or request.get("action_id") + correlation_id = correlation.get("correlation_id") or request.get("correlation_id") + host_run_id = correlation.get("host_run_id") + if (mission_id != state.mission_id or not _safe_identifier(mission_id) + or action_id != expected_action["id"] + or not _safe_identifier(action_id) + or not _safe_identifier(correlation_id) + or host_run_id is not None and not _safe_identifier(host_run_id)): + return {**base, "binding_status": "UNSUPPORTED", "reason": "IDENTITY_UNVERIFIED"} + return {**base, "binding_status": "BOUND", "reason": None, + "action_id": action_id, "action_status": expected_action.get("status"), + "correlation_id": correlation_id, "host_run_id": host_run_id} diff --git a/product-version.json b/product-version.json index be37a67a..f21e1588 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "2.7.42" + "version": "2.7.43" } diff --git a/tests/test_operations_read_api.py b/tests/test_operations_read_api.py index 1ab604e7..8993ad64 100644 --- a/tests/test_operations_read_api.py +++ b/tests/test_operations_read_api.py @@ -247,6 +247,32 @@ def test_schema40_migration_keeps_serial_mission_without_invented_slots(self) -> self.assertEqual(response.status, 200) self.assertIsNone(response.body["mission"]["planning_slots"]) + def test_installed_mission_readback_binds_only_one_legacy_correlation(self) -> None: + self.create_mission() + document = self.database.get_document("mission_state", "MISSION-0042") + document["actions"][0]["status"] = "WAITING_FOR_RESULT" + document["execution_correlation"] = { + "request": {"mission_id": "MISSION-0042", "action_id": "ACTION-0042-A", + "correlation_id": "corr-0042", "runtime_prompt": "synthetic-private-prompt"}, + "host_run_id": "run-0042", + } + self.database.save_mission_state(document) + before = self.snapshot() + response = self.api.handle("GET", "/v1/missions/MISSION-0042", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 200) + serial = response.body["mission"]["serial_correlation"] + self.assertEqual((serial["binding_status"], serial["action_id"], serial["correlation_id"]), + ("BOUND", "ACTION-0042-A", "corr-0042")) + self.assertNotIn("synthetic-private-prompt", json.dumps(response.body)) + self.assertEqual(self.snapshot(), before) + document["actions"][1]["status"] = "ACTIVE" + self.database.save_mission_state(document) + ambiguous = self.api.handle("GET", "/v1/missions/MISSION-0042", "Bearer " + CREDENTIAL) + self.assertEqual(ambiguous.status, 200) + self.assertEqual(ambiguous.body["mission"]["serial_correlation"]["reason"], + "MULTIPLE_IN_FLIGHT_ACTIONS") + self.assertNotIn("action_id", ambiguous.body["mission"]["serial_correlation"]) + @staticmethod def _slot_graph() -> dict: return { diff --git a/tests/test_serial_correlation_projection.py b/tests/test_serial_correlation_projection.py new file mode 100644 index 00000000..3df5e345 --- /dev/null +++ b/tests/test_serial_correlation_projection.py @@ -0,0 +1,89 @@ +"""Legacy singleton correlation is never inferred from an ambiguous Action set.""" +from __future__ import annotations + +from types import SimpleNamespace +import unittest + +from forge.serial_correlation_projection import project_serial_correlation + + +def state(actions=None, correlation=None): + return SimpleNamespace( + mission_id="MISSION-1", revision=7, + actions=({"id": "ACTION-A", "status": "WAITING_FOR_RESULT"},) if actions is None else actions, + execution_correlation=correlation, + ) + + +class SerialCorrelationProjectionTests(unittest.TestCase): + def test_absent_correlation_is_unavailable(self) -> None: + result = project_serial_correlation(state()) + self.assertEqual((result["binding_status"], result["reason"]), ("UNAVAILABLE", "NO_CORRELATION")) + self.assertEqual(result["mission_revision"], 7) + + def test_direct_and_nested_request_bind_only_safe_identifiers(self) -> None: + direct = project_serial_correlation(state(correlation={ + "mission_id": "MISSION-1", "action_id": "ACTION-A", + "correlation_id": "corr-a", "host_run_id": "run-a", + "secret_token": "sensitive-value", + })) + self.assertEqual(direct["binding_status"], "BOUND") + self.assertEqual(direct["action_id"], "ACTION-A") + self.assertEqual(direct["host_run_id"], "run-a") + self.assertNotIn("sensitive-value", repr(direct)) + nested = project_serial_correlation(state(correlation={ + "request": {"mission_id": "MISSION-1", "action_id": "ACTION-A", + "correlation_id": "corr-a", "runtime_prompt": "sensitive-prompt"}, + "host_run_id": None, + })) + self.assertEqual(nested["binding_status"], "BOUND") + self.assertIsNone(nested["host_run_id"]) + self.assertNotIn("sensitive-prompt", repr(nested)) + + def test_one_active_action_among_two_is_exactly_bound(self) -> None: + actions = ({"id": "ACTION-A", "status": "COMPLETE"}, + {"id": "ACTION-B", "status": "ACTIVE"}) + result = project_serial_correlation(state(actions, { + "mission_id": "MISSION-1", "action_id": "ACTION-B", "correlation_id": "corr-b", + })) + self.assertEqual((result["binding_status"], result["action_id"]), ("BOUND", "ACTION-B")) + + def test_multiple_or_unselected_actions_are_unsupported(self) -> None: + two = ({"id": "ACTION-A", "status": "ACTIVE"}, + {"id": "ACTION-B", "status": "WAITING_FOR_RESULT"}) + result = project_serial_correlation(state(two, {"action_id": "ACTION-A", "correlation_id": "corr"})) + self.assertEqual(result["reason"], "MULTIPLE_IN_FLIGHT_ACTIONS") + idle = ({"id": "ACTION-A", "status": "READY"}, {"id": "ACTION-B", "status": "READY"}) + self.assertEqual(project_serial_correlation(state(idle, {"action_id": "ACTION-A"}))["reason"], + "AMBIGUOUS_ACTION_SET") + self.assertEqual(project_serial_correlation(state((), {"action_id": "ACTION-A"}))["reason"], + "AMBIGUOUS_ACTION_SET") + + def test_malformed_and_conflicting_identities_fail_closed(self) -> None: + self.assertEqual(project_serial_correlation(state(correlation=[]))["reason"], "MALFORMED_CORRELATION") + self.assertEqual(project_serial_correlation(state(correlation={"request": []}))["reason"], + "MALFORMED_REQUEST") + self.assertEqual(project_serial_correlation(state(correlation={"action_id": "ACTION-A", + "correlation_id": "corr-a", "request": {}}))["reason"], + "MALFORMED_REQUEST") + conflict = {"action_id": "ACTION-A", "correlation_id": "corr-a", + "request": {"mission_id": "MISSION-1", "action_id": "ACTION-B", + "correlation_id": "corr-a"}} + self.assertEqual(project_serial_correlation(state(correlation=conflict))["reason"], "IDENTITY_CONFLICT") + for correlation in ( + {"mission_id": "MISSION-X", "action_id": "ACTION-A", "correlation_id": "corr"}, + {"action_id": "ACTION-A", "correlation_id": "corr"}, + {"action_id": "ACTION-X", "correlation_id": "corr"}, + {"action_id": "ACTION-A", "correlation_id": ""}, + {"action_id": "ACTION-A", "correlation_id": "bad value"}, + {"action_id": "ACTION-A", "correlation_id": "corr", "host_run_id": []}, + ): + with self.subTest(correlation=correlation): + self.assertEqual(project_serial_correlation(state(correlation=correlation))["reason"], + "IDENTITY_UNVERIFIED") + duplicate = ({"id": "ACTION-A", "status": "ACTIVE"}, {"id": "ACTION-A", "status": "READY"}) + self.assertEqual(project_serial_correlation(state(duplicate, {}))["reason"], "AMBIGUOUS_ACTION_SET") + + +if __name__ == "__main__": + unittest.main()