diff --git a/docs/2026_05_SUPERVAIZER_v2.md b/docs/2026_05_SUPERVAIZER_v2.md index 7a09daa..670294e 100644 --- a/docs/2026_05_SUPERVAIZER_v2.md +++ b/docs/2026_05_SUPERVAIZER_v2.md @@ -375,12 +375,19 @@ 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. | 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 518a583..c27c284 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,22 @@ 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. + +### 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/__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..08827a5 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -804,6 +804,34 @@ 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: Literal["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 + + @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 or "").strip() + ): + raise ValueError( + "mission_id is required when the assignment contains mission-scoped items" + ) + return self + + class V2SurfaceRequest(ContractModel): request_id: str actor: V2ActorContext diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 5cbe2d2..844321b 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,88 @@ 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 + + +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_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=[ + 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( + ref="supervaize.context.mission.january-brief", + version=3, + scope="missions", + title="January brief", + )