From 5f0d8e126fb633e782b629054a7b11f51b9c6741 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 2 Jul 2026 12:56:56 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(contracts):=20add=20V2Cont?= =?UTF-8?q?extAssignment=20for=20context.assign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2026_05_SUPERVAIZER_v2.md | 1 + docs/CHANGELOG.md | 4 ++++ src/supervaizer/__init__.py | 9 +++++++++ src/supervaizer/contracts.py | 17 +++++++++++++++++ tests/test_contracts.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/docs/2026_05_SUPERVAIZER_v2.md b/docs/2026_05_SUPERVAIZER_v2.md index 7a09daa..3a9d377 100644 --- a/docs/2026_05_SUPERVAIZER_v2.md +++ b/docs/2026_05_SUPERVAIZER_v2.md @@ -375,6 +375,7 @@ Common action IDs: | `job.stop` | Stop or cancel agent work. | | `job.sync` | Return a convergent state snapshot for Studio. | | `step.awaiting.submit` | Submit operator input for a HITL step. | +| `context.assign` | Assign a frozen Studio context selection to a job; agent fetches content via `ContextClient` and stores provenance (`ref`, `version`, `hash`, `synced_at`). | | `resource..` | Run a resource operation. | | `dataset..query` | Query an agent-owned dataset. | | `artifact.get` | Load artifact content by reference. | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 518a583..178d7b3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **`context.assign` contract** — New typed wire contract for assigning a frozen Studio context selection to a job: `V2ContextAssignment` (items, `mission_id`, `job_id`, `assigned_at`), `V2ContextAssignmentItem` (`ref`, `version`, `scope`, `title`), and the `V2_ACTION_CONTEXT_ASSIGN` action id in `supervaizer.contracts`, exported at package level. Additive only; dispatch uses the existing generic `Server.v2_action` machinery. + ## [1.2.0] - 2026-05-27 ### Changed diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index be2024a..115f12b 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -103,6 +103,10 @@ "supervaizer.contracts", "WORKSPACE_BINDING_OPTIONS_ACTION", ), + "V2_ACTION_CONTEXT_ASSIGN": ( + "supervaizer.contracts", + "V2_ACTION_CONTEXT_ASSIGN", + ), "AgentMethodContract": ("supervaizer.contracts", "AgentMethodContract"), "AgentMethodsContract": ("supervaizer.contracts", "AgentMethodsContract"), "AgentRegistrationContract": ("supervaizer.contracts", "AgentRegistrationContract"), @@ -150,6 +154,11 @@ "V2AwaitingState": ("supervaizer.contracts", "V2AwaitingState"), "V2CaseLaneDefinition": ("supervaizer.contracts", "V2CaseLaneDefinition"), "V2CaseSnapshot": ("supervaizer.contracts", "V2CaseSnapshot"), + "V2ContextAssignment": ("supervaizer.contracts", "V2ContextAssignment"), + "V2ContextAssignmentItem": ( + "supervaizer.contracts", + "V2ContextAssignmentItem", + ), "V2DashboardDefinition": ("supervaizer.contracts", "V2DashboardDefinition"), "V2DashboardWidgetDataRef": ( "supervaizer.contracts", diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 8c821a5..81c54c4 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -804,6 +804,23 @@ class V2ActionRequest(ContractModel): workspace_authorization: V2VerifiedWorkspaceContext | None = None +V2_ACTION_CONTEXT_ASSIGN = "context.assign" + + +class V2ContextAssignmentItem(ContractModel): + ref: str # Studio ContextItem ref, e.g. "supervaize.context.mission.january-brief" + version: int # ContextItem version at selection time + scope: str # "workspace" | "mission" + title: str + + +class V2ContextAssignment(ContractModel): + items: list[V2ContextAssignmentItem] # empty list = explicit "no context" + mission_id: str | None = None + job_id: str + assigned_at: str # ISO-8601, stamped by Studio + + class V2SurfaceRequest(ContractModel): request_id: str actor: V2ActorContext diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 5cbe2d2..b4c5853 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -32,6 +32,8 @@ V2AgentMethods, V2AwaitingState, V2CaseSnapshot, + V2ContextAssignment, + V2ContextAssignmentItem, V2DashboardWidgetDataRef, V2DashboardWidgetDefinition, V2DashboardWidgetVisualization, @@ -45,6 +47,7 @@ V2VerifiedWorkspaceContext, V2WorkspaceAuthorizationSettings, V2WorkspaceBindingDefinition, + V2_ACTION_CONTEXT_ASSIGN, build_data_resource_context_headers, build_v2_agent_registration, controller_contract_info, @@ -730,3 +733,29 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: ) assert supervaizer.V2WorkspaceBindingDefinition is V2WorkspaceBindingDefinition assert supervaizer.build_v2_agent_registration is build_v2_agent_registration + + +def test_v2_context_assignment_parses_items() -> None: + assignment = V2ContextAssignment( + items=[ + V2ContextAssignmentItem( + ref="supervaize.context.mission.january-brief", + version=3, + scope="mission", + title="January brief", + ) + ], + mission_id="mis_1", + job_id="job_1", + assigned_at="2026-07-02T09:00:00+00:00", + ) + assert assignment.items[0].version == 3 + assert V2_ACTION_CONTEXT_ASSIGN == "context.assign" + + +def test_v2_context_assignment_allows_empty_items() -> None: + assignment = V2ContextAssignment( + items=[], job_id="job_1", assigned_at="2026-07-02T09:00:00+00:00" + ) + assert assignment.items == [] + assert assignment.mission_id is None From 228d674bf3ab9a6bd6fe6e5a378afeb180636347 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 2 Jul 2026 14:59:46 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(contracts):=20constrain=20?= =?UTF-8?q?context=20scope=20and=20document=20assignment=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #71 review: V2ContextAssignmentItem.scope is now Literal["workspace", "mission"] (matching other closed v2 vocabularies) with a ValidationError regression test, and the v2 doc gains explicit context.assign semantics: refs-not-content payload, freeze-on-pull with provenance, mandatory fetched-vs-assigned version check that fails the assignment on mismatch, and no live context reads during execution. Co-Authored-By: Claude Fable 5 --- docs/2026_05_SUPERVAIZER_v2.md | 6 ++++++ docs/CHANGELOG.md | 12 ++++++++++++ src/supervaizer/contracts.py | 2 +- tests/test_contracts.py | 10 ++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/2026_05_SUPERVAIZER_v2.md b/docs/2026_05_SUPERVAIZER_v2.md index 3a9d377..670294e 100644 --- a/docs/2026_05_SUPERVAIZER_v2.md +++ b/docs/2026_05_SUPERVAIZER_v2.md @@ -382,6 +382,12 @@ Common action IDs: Action requests include `actor`, `workspace`, `mission_id`, `agent_slug`, `surface`, `action`, `input`, and optional correlation fields such as `job_id`, `case_id`, `step_id`, `draft_session_id`, and `idempotency_key`. +### Context assignment semantics + +`context.assign` carries a `V2ContextAssignment` payload: the selected items (`ref`, `version`, `scope`, `title`), the `job_id`, an optional `mission_id`, and a Studio-stamped `assigned_at`. An empty `items` list is an explicit "no context" assignment, not an error. + +The payload intentionally contains references, not content. On receipt the agent fetches each item once through `ContextClient.open()` and freezes the returned content as its own snapshot with provenance (`ref`, `version`, content `hash`, `synced_at`). `ContextClient.open()` returns the item's current `version`; the agent MUST compare it against the assigned `version` and fail the whole assignment with an explicit error when they differ — the item changed between selection and sync, and the operator should re-assign. Agents must not silently store content under a version it does not match, and must not re-read Studio context during job execution; a new `context.assign` is the only refresh path. + ## Sync and Offline Semantics Studio owns operator-facing job lifecycle, but the agent owns actual business state. `job.sync` is the reconciliation point between the two. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 178d7b3..c27c284 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,18 @@ All notable changes to this project will be documented in this file. - **`context.assign` contract** — New typed wire contract for assigning a frozen Studio context selection to a job: `V2ContextAssignment` (items, `mission_id`, `job_id`, `assigned_at`), `V2ContextAssignmentItem` (`ref`, `version`, `scope`, `title`), and the `V2_ACTION_CONTEXT_ASSIGN` action id in `supervaizer.contracts`, exported at package level. Additive only; dispatch uses the existing generic `Server.v2_action` machinery. +### Tests + +- `tests/test_common.py` — structured JSON log output for API access-denial records +- `just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 677 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 65s | + ## [1.2.0] - 2026-05-27 ### Changed diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 81c54c4..cfb0dc1 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -810,7 +810,7 @@ class V2ActionRequest(ContractModel): class V2ContextAssignmentItem(ContractModel): ref: str # Studio ContextItem ref, e.g. "supervaize.context.mission.january-brief" version: int # ContextItem version at selection time - scope: str # "workspace" | "mission" + scope: Literal["workspace", "mission"] title: str diff --git a/tests/test_contracts.py b/tests/test_contracts.py index b4c5853..34e0c31 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -759,3 +759,13 @@ def test_v2_context_assignment_allows_empty_items() -> None: ) assert assignment.items == [] assert assignment.mission_id is None + + +def test_v2_context_assignment_item_rejects_unknown_scope() -> None: + with pytest.raises(ValidationError): + V2ContextAssignmentItem( + ref="supervaize.context.mission.january-brief", + version=3, + scope="missions", + title="January brief", + ) From 0a05820fa2725e1aba8fda19cd91730f14e6c4c3 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 2 Jul 2026 15:26:30 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20fix(contracts):=20require=20?= =?UTF-8?q?mission=5Fid=20for=20mission-scoped=20context=20assignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #71 follow-up review: a V2ContextAssignment containing scope="mission" items but no mission_id passed validation while the agent-side ContextClient.open() would have no mission context to fetch with. A model_validator now rejects that combination; workspace-only assignments still allow a null mission_id. Co-Authored-By: Claude Fable 5 --- src/supervaizer/contracts.py | 8 ++++++++ tests/test_contracts.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index cfb0dc1..425261f 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -820,6 +820,14 @@ class V2ContextAssignment(ContractModel): job_id: str assigned_at: str # ISO-8601, stamped by Studio + @model_validator(mode="after") + def _require_mission_id_for_mission_items(self) -> "V2ContextAssignment": + if any(item.scope == "mission" for item in self.items) and not self.mission_id: + raise ValueError( + "mission_id is required when the assignment contains mission-scoped items" + ) + return self + class V2SurfaceRequest(ContractModel): request_id: str diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 34e0c31..3ce8be0 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -761,6 +761,38 @@ def test_v2_context_assignment_allows_empty_items() -> None: assert assignment.mission_id is None +def test_v2_context_assignment_requires_mission_id_for_mission_items() -> None: + with pytest.raises(ValidationError, match="mission_id is required"): + V2ContextAssignment( + items=[ + V2ContextAssignmentItem( + ref="supervaize.context.mission.january-brief", + version=3, + scope="mission", + title="January brief", + ) + ], + job_id="job_1", + assigned_at="2026-07-02T09:00:00+00:00", + ) + + +def test_v2_context_assignment_workspace_items_do_not_require_mission_id() -> None: + assignment = V2ContextAssignment( + items=[ + V2ContextAssignmentItem( + ref="supervaize.context.workspace.brand-voice", + version=1, + scope="workspace", + title="Brand voice", + ) + ], + job_id="job_1", + assigned_at="2026-07-02T09:00:00+00:00", + ) + assert assignment.mission_id is None + + def test_v2_context_assignment_item_rejects_unknown_scope() -> None: with pytest.raises(ValidationError): V2ContextAssignmentItem( From 763a08b2540d4751abb535c73a4ddad404f135aa Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 2 Jul 2026 15:36:10 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20fix(contracts):=20reject=20w?= =?UTF-8?q?hitespace-only=20mission=5Fid=20for=20mission-scoped=20assignme?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/supervaizer/contracts.py | 5 ++++- tests/test_contracts.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 425261f..08827a5 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -822,7 +822,10 @@ class V2ContextAssignment(ContractModel): @model_validator(mode="after") def _require_mission_id_for_mission_items(self) -> "V2ContextAssignment": - if any(item.scope == "mission" for item in self.items) and not self.mission_id: + if ( + any(item.scope == "mission" for item in self.items) + and not (self.mission_id or "").strip() + ): raise ValueError( "mission_id is required when the assignment contains mission-scoped items" ) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 3ce8be0..844321b 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -777,6 +777,23 @@ def test_v2_context_assignment_requires_mission_id_for_mission_items() -> None: ) +def test_v2_context_assignment_rejects_blank_mission_id_for_mission_items() -> None: + with pytest.raises(ValidationError, match="mission_id is required"): + V2ContextAssignment( + items=[ + V2ContextAssignmentItem( + ref="supervaize.context.mission.january-brief", + version=3, + scope="mission", + title="January brief", + ) + ], + mission_id=" ", + job_id="job_1", + assigned_at="2026-07-02T09:00:00+00:00", + ) + + def test_v2_context_assignment_workspace_items_do_not_require_mission_id() -> None: assignment = V2ContextAssignment( items=[