diff --git a/docs/plans/volatile-context-native-hook-pilot.md b/docs/plans/volatile-context-native-hook-pilot.md new file mode 100644 index 0000000..318275e --- /dev/null +++ b/docs/plans/volatile-context-native-hook-pilot.md @@ -0,0 +1,67 @@ +# Sprintctl volatile-context native-hook pilot + +Status: implementation pilot (not deployed) + +Governing mapping: Agentops +`docs/plans/agentops/volatile-context-native-runtime-integration-mapping-2026-08-20.md`. +The imported bundle is historical input; its dispatcher binding and exclusive +claim assumptions are not implemented here. + +## Current path inventory + +The inventory below is against Sprintctl `origin/main` `15afc87` (v0.3.0). + +| Concern | Current owner path | Revision/precondition | Pilot disposition | +| --- | --- | --- | --- | +| Direct item status | `commands/work.py` -> `db.set_work_item_status` | required `--expected-revision`; SQLite CAS | projected and recognized by the pilot | +| Served item status | `work.lifecycle.arbitrate` -> authority command | immutable command basis revision; owner arbitration and PostgreSQL row lock | projected and recognized by the pilot | +| Direct sprint status | `commands/work.py` -> `db.set_sprint_status` | required `--expected-revision` | unchanged; outside item pilot | +| Item description | direct `item edit` and served `work.item.edit` | description revision CAS; served precondition required | exposed by existing item read; outside status pilot | +| Item create, refs, deps, notes, events | direct CLI and matching served operations | creation or append/remove identity; no universal item-status precondition | not recognized by hook; no claim of coverage | +| Advisory reservations | direct `commands/reservation.py`; served `work.reservation.*` | authenticated attribution/row identity, not mutation authority | may be read elsewhere; never used as projection proof | +| Maintenance lifecycle | `maintenance_capability.py`, served maintenance operations | owner capability revision and request idempotency | unchanged; separate resource projection already exists | +| Maintenance/recovery commands | owner-specific CLI/application paths | operation-specific | unchanged; no hook interception | +| Direct item reads | `item show`, `item list`, `usage --context` | backend snapshot plus explicit cached-projection freshness disclosure where supported | unchanged | +| Served reads | `work.read.*` application operations | repository-scoped identity and application snapshots | adds bounded `work.read.item-projection` | +| Cached ingestion projection | `projection.py`, `sync.py`, guarded `projection_reads.py` | ingestion watermark; read fallback only | not reused as item authority or hook cursor | +| MCP | no Sprintctl-owned MCP mutation registration in this baseline | none assumed | only the explicit structured pilot tool names are recognizable; transport must still call the owner operation | +| Native hooks | no installed Sprintctl native hook on this baseline | n/a | adds opt-in `sprintctl-volatile-hook`; no settings are installed automatically | +| ActionQ | no call from Sprintctl item read/CAS paths | federation contract pending | no dependency or fabricated execution binding | + +No write path is silently classified as covered. The pilot recognizes only +the structured `sprintctl.item_status` and +`mcp__sprintctl__item_status` tool names. Bash and unknown tool forms fail +open at the hook and still encounter Sprintctl's authoritative CAS if they +reach the owner. + +## Contracts + +`work.read.item` now includes the existing opaque `status_revision` beside its +description `edit_revision`. `work.read.item-projection` returns an allowlisted +`work-item-context/v1` object capped at 4,096 UTF-8 bytes. The title is +semantically truncated at a UTF-8 boundary and the projection labels domain +values as untrusted data. It includes no descriptions, refs, events, +credentials, environment, worktree path, or raw logs. + +`work.validate.item-status-mutation` is a read-only early-feedback operation. +It reports missing, malformed, matching, or stale expected revisions with the +current bounded projection. It never grants authority and never substitutes +for `set_work_item_status` / `work.lifecycle.arbitrate`, which compare again at +the owner boundary. + +The local adapter requires an explicitly bound `SPRINTCTL_CONTEXT_ITEM_ID` and +the existing corroborated served-backend configuration. Its cursor is a +disposable per-repository/item/harness/session/subagent revision file. Cursor +loss can cause reinjection only. Session/subagent projections and changed +deltas fail open; recognized status prechecks fail closed on missing context, +stale revision, mismatched item, or served API outage. The adapter writes no +authority state and is not installed into Claude or Codex settings by this +change. + +## Rollout and rollback + +This commit and its package entry point are inert until an operator adds a +native hook configuration and binds one item. Rollback is removal of that hook +configuration. Sprintctl CAS remains enabled and requires no schema rollback. +Appservice configuration, credentials, cluster reconciliation, and deployment +are separate operator-owned work and are not part of this pilot. diff --git a/docs/reference/vuoro-work-adapter.md b/docs/reference/vuoro-work-adapter.md index a7b7ca4..b256926 100644 --- a/docs/reference/vuoro-work-adapter.md +++ b/docs/reference/vuoro-work-adapter.md @@ -20,6 +20,7 @@ no migration or DDL. | --- | --- | --- | | Reads | `work.read.sprints`, `work.read.item`, `work.read.context`, `work.read.context-candidates`, `work.read.next-work`, `work.read.records`, `work.read.decisions` | key forbidden | | Item edit | `work.item.edit` | key forbidden; required `expected_revision` compare-and-swap | +| Volatile item context | `work.read.item-projection`, `work.validate.item-status-mutation` | read-only, key forbidden; validation is advisory and owner CAS remains final | | Reservation start | `work.claim.start` | key forbidden; one-shot create plus activation flow — **retired in v2** | | Durable reservations | `work.claim.arbitrate` | key equals immutable command `event_id` — **retired in v2** | | Lifecycle | `work.lifecycle.arbitrate` | key equals immutable command `event_id` | @@ -46,6 +47,14 @@ old/new revisions and descriptions. Existing events and item identity are never rewritten. A stale revision is rejected as `item-edit-conflict`, and an unchanged description is rejected without creating another revision. +`work.read.item` also exposes the opaque item `status_revision` already used by +direct and served lifecycle CAS. `work.read.item-projection` emits a bounded, +field-allowlisted status projection for native runtime context. The companion +`work.validate.item-status-mutation` operation only gives early feedback; it +does not reserve, mutate, or authorize an item, and the lifecycle owner repeats +the comparison atomically. See +[`volatile-context-native-hook-pilot.md`](../plans/volatile-context-native-hook-pilot.md). + `work.read.context` is the server-side aggregate for `usage --context`. It returns the ContextContract v1 itself (rather than adding an envelope field), and PostgreSQL evaluates all of its sprint, reservation, item, dependency, stale diff --git a/pyproject.toml b/pyproject.toml index 04db348..c055bf4 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ [project.scripts] sprintctl = "sprintctl.cli:cli" +sprintctl-volatile-hook = "sprintctl.volatile_hook:main" [project.optional-dependencies] dev = ["pytest>=8"] @@ -37,6 +38,7 @@ capabilities = [ "remote-backend/v1", "remote-schema-compatibility/v1", "sprintctl-repository-ingest-cursor/v1", + "volatile-context-item-projection/v1", ] sqlite-schema-version = 22 remote-schema-version = 12 diff --git a/sprintctl/__init__.py b/sprintctl/__init__.py index 04aaaef..852931a 100755 --- a/sprintctl/__init__.py +++ b/sprintctl/__init__.py @@ -9,4 +9,5 @@ "remote-backend/v1", "remote-schema-compatibility/v1", "sprintctl-repository-ingest-cursor/v1", + "volatile-context-item-projection/v1", ) diff --git a/sprintctl/served.py b/sprintctl/served.py index b823c31..e438a44 100644 --- a/sprintctl/served.py +++ b/sprintctl/served.py @@ -156,6 +156,40 @@ def read_item( ) +def read_item_projection( + served_profile: ServedProfile, *, repo_id: str, item_id: int +) -> dict[str, Any]: + """Read one bounded, revision-bearing item projection.""" + + return asyncio.run( + _invoke_operation( + served_profile, + "work.read.item-projection", + {"item_id": item_id}, + repo_id=repo_id, + ) + ) + + +def validate_item_status_mutation( + served_profile: ServedProfile, + *, + repo_id: str, + item_id: int, + expected_revision: str | None, +) -> dict[str, Any]: + """Run the read-only early-feedback check for a status mutation.""" + + return asyncio.run( + _invoke_operation( + served_profile, + "work.validate.item-status-mutation", + {"item_id": item_id, "expected_revision": expected_revision}, + repo_id=repo_id, + ) + ) + + def read_items(served_profile: ServedProfile, *, repo_id: str, sprint_id: int | None = None, track_name: str | None = None, status: str | None = None) -> dict[str, Any]: return asyncio.run(_invoke_operation(served_profile, "work.read.items", { diff --git a/sprintctl/volatile_context.py b/sprintctl/volatile_context.py new file mode 100644 index 0000000..38491e3 --- /dev/null +++ b/sprintctl/volatile_context.py @@ -0,0 +1,118 @@ +"""Bounded, read-only work-item context for native runtime adapters. + +This module deliberately owns no cursor, hook, or mutation state. It derives +one allowlisted projection from Sprintctl's authoritative item row and exposes +the same opaque status revision used by the owner's compare-and-swap path. +""" + +from __future__ import annotations + +import json +from typing import Any + + +PROJECTION_CONTRACT = "work-item-context/v1" +PROVIDER_ID = "sprintctl.work-item" +MAX_PROJECTION_BYTES = 4_096 +MAX_TITLE_BYTES = 1_024 + + +def _truncate_utf8(value: str, limit: int) -> tuple[str, bool]: + encoded = value.encode("utf-8") + if len(encoded) <= limit: + return value, False + suffix = "…" + available = limit - len(suffix.encode("utf-8")) + truncated = encoded[:available].decode("utf-8", errors="ignore") + suffix + return truncated, True + + +def project_work_item( + backend: Any, store: Any, *, repo_id: str, item_id: int +) -> dict[str, Any] | None: + """Return an allowlisted projection, or ``None`` for an unknown item.""" + + item = backend.get_work_item(store, item_id) + if item is None: + return None + title, truncated = _truncate_utf8(str(item["title"]), MAX_TITLE_BYTES) + revision = backend.item_status_revision(item) + projection = { + "contract_version": PROJECTION_CONTRACT, + "provider_id": PROVIDER_ID, + "resource_id": f"{repo_id}#{item_id}", + "revision": revision, + "data_class": "untrusted-work-state", + "item": { + "id": item_id, + "title": title, + "status": item["status"], + "priority": item.get("priority"), + "assignee": item.get("assignee"), + }, + "truncated": truncated, + } + encoded = json.dumps( + projection, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + if len(encoded) > MAX_PROJECTION_BYTES: # defensive if allowlisted fields grow + raise ValueError("work-item projection exceeds its hard byte budget") + return projection + + +def validate_status_mutation( + backend: Any, + store: Any, + *, + repo_id: str, + item_id: int, + expected_revision: str | None, +) -> dict[str, Any] | None: + """Advisory precheck using the owner's status revision. + + This is intentionally read-only. The status mutation performs the same + comparison again while holding the owner transaction/lock. + """ + + projection = project_work_item( + backend, store, repo_id=repo_id, item_id=item_id + ) + if projection is None: + return None + current = projection["revision"] + if expected_revision is None: + return { + "allowed": False, + "reason": "expected revision is required", + "current_revision": current, + "projection": projection, + } + try: + backend.validate_item_status_revision(expected_revision) + except ValueError: + return { + "allowed": False, + "reason": "expected revision is malformed", + "current_revision": current, + "projection": projection, + } + return { + "allowed": expected_revision == current, + "reason": ( + "revision matches" + if expected_revision == current + else "item status revision changed" + ), + "current_revision": current, + "projection": projection, + } + + +__all__ = [ + "MAX_PROJECTION_BYTES", + "MAX_TITLE_BYTES", + "PROJECTION_CONTRACT", + "PROVIDER_ID", + "project_work_item", + "validate_status_mutation", +] diff --git a/sprintctl/volatile_hook.py b/sprintctl/volatile_hook.py new file mode 100644 index 0000000..7065242 --- /dev/null +++ b/sprintctl/volatile_hook.py @@ -0,0 +1,256 @@ +"""Small command adapter for the Sprintctl work-item context pilot. + +Projection hooks fail open. A recognized item-status mutation precheck fails +closed when validation is unavailable. Neither path performs a mutation. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +import sys +from pathlib import Path +from typing import Any, Callable, Mapping, Protocol + +from . import backend, served + + +_FULL_EVENTS = frozenset({"SessionStart", "SubagentStart"}) +_DELTA_EVENTS = frozenset({"UserPromptSubmit", "PostToolUse"}) +_RECOGNIZED_STATUS_TOOLS = frozenset( + {"mcp__sprintctl__item_status", "sprintctl.item_status"} +) + + +class Cursor(Protocol): + def get(self, key: str) -> str | None: ... + def put(self, key: str, revision: str) -> None: ... + + +class MemoryCursor: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self.values.get(key) + + def put(self, key: str, revision: str) -> None: + self.values[key] = revision + + +class FileCursor: + """Disposable, per-consumer revision file; never an authority.""" + + def __init__(self, root: Path) -> None: + self.root = root + + def _path(self, key: str) -> Path: + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return self.root / f"{digest}.revision" + + def _safe_root(self, *, create: bool) -> bool: + try: + if create: + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + metadata = self.root.lstat() + except OSError: + return False + return ( + stat.S_ISDIR(metadata.st_mode) + and not self.root.is_symlink() + and metadata.st_uid == os.getuid() + and metadata.st_mode & 0o077 == 0 + ) + + def get(self, key: str) -> str | None: + if not self._safe_root(create=False): + return None + try: + path = self._path(key) + metadata = path.lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.getuid() + or metadata.st_mode & 0o077 != 0 + ): + return None + return path.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + def put(self, key: str, revision: str) -> None: + try: + if not self._safe_root(create=True): + return + path = self._path(key) + temporary = path.with_suffix(f".{os.getpid()}.tmp") + temporary.write_text(revision + "\n", encoding="utf-8") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + except OSError: + # Cursor loss may duplicate a later projection; it cannot authorize. + return + + +def _additional_context(event: str, context: str) -> dict[str, Any]: + return { + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context, + } + } + + +def _denied(reason: str, context: str | None = None) -> dict[str, Any]: + output: dict[str, Any] = { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + if context: + output["additionalContext"] = context + return {"hookSpecificOutput": output} + + +def _context(projection: dict[str, Any]) -> str: + return json.dumps( + {"volatile_context": projection}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +class VolatileContextHookAdapter: + def __init__( + self, + *, + repo_id: str, + item_id: int, + project: Callable[[int], dict[str, Any]], + validate: Callable[[int, str | None], dict[str, Any]], + cursor: Cursor, + ) -> None: + self.repo_id = repo_id + self.item_id = item_id + self.project = project + self.validate = validate + self.cursor = cursor + + def _cursor_key(self, event: Mapping[str, Any]) -> str: + return ":".join( + ( + self.repo_id, + str(self.item_id), + str(event.get("harness") or "native"), + str(event.get("session_id") or "unknown-session"), + str(event.get("agent_id") or "root"), + ) + ) + + def _projection(self, event: Mapping[str, Any], *, full: bool) -> dict[str, Any]: + try: + response = self.project(self.item_id) + projection = response["projection"] + revision = projection["revision"] + key = self._cursor_key(event) + if not full and self.cursor.get(key) == revision: + return {} + self.cursor.put(key, revision) + return _additional_context( + str(event["hook_event_name"]), _context(projection) + ) + except Exception: + return {} # context enrichment is non-blocking by contract + + def handle(self, event: Mapping[str, Any]) -> dict[str, Any]: + name = event.get("hook_event_name") + if name in _FULL_EVENTS: + return self._projection(event, full=True) + if name in _DELTA_EVENTS: + return self._projection(event, full=False) + if name != "PreToolUse": + return {} + + tool_name = event.get("tool_name") + if tool_name not in _RECOGNIZED_STATUS_TOOLS: + return {} + tool_input = event.get("tool_input") + if not isinstance(tool_input, Mapping): + return _denied("recognized Sprintctl status mutation has no structured input") + item_id = tool_input.get("item_id") + if item_id != self.item_id: + return _denied( + "recognized Sprintctl status mutation does not match the bound item" + ) + expected_revision = tool_input.get("expected_revision") + if expected_revision is not None and not isinstance(expected_revision, str): + expected_revision = None + try: + result = self.validate(self.item_id, expected_revision) + except Exception: + return _denied("Sprintctl mutation precheck is unavailable") + if result["allowed"]: + return {} + return _denied(result["reason"], _context(result["projection"])) + + +def _adapter(environ: Mapping[str, str], cwd: Path) -> VolatileContextHookAdapter: + item_id = int(environ["SPRINTCTL_CONTEXT_ITEM_ID"]) + config = backend.load_backend_config(cwd=cwd, environ=environ) + if config.mode != "served" or config.served_profile is None or config.repo_id is None: + raise ValueError("volatile context pilot requires a resolved served backend") + profile = config.served_profile + repo_id = config.repo_id + cursor_root = Path( + environ.get( + "SPRINTCTL_VOLATILE_CURSOR_DIR", + f"/tmp/sprintctl-volatile-context-{os.getuid()}", + ) + ) + return VolatileContextHookAdapter( + repo_id=repo_id, + item_id=item_id, + project=lambda selected: served.read_item_projection( + profile, repo_id=repo_id, item_id=selected + ), + validate=lambda selected, revision: served.validate_item_status_mutation( + profile, + repo_id=repo_id, + item_id=selected, + expected_revision=revision, + ), + cursor=FileCursor(cursor_root), + ) + + +def main() -> None: + try: + event = json.load(sys.stdin) + except (json.JSONDecodeError, OSError): + event = {} + if not isinstance(event, dict): + event = {} + try: + adapter = _adapter(os.environ, Path(event.get("cwd") or Path.cwd())) + output = adapter.handle(event) + except Exception: + recognized = ( + event.get("hook_event_name") == "PreToolUse" + and event.get("tool_name") in _RECOGNIZED_STATUS_TOOLS + ) + output = ( + _denied("Sprintctl mutation precheck is unavailable") if recognized else {} + ) + if output: + json.dump(output, sys.stdout, sort_keys=True, separators=(",", ":")) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() + + +__all__ = ["FileCursor", "MemoryCursor", "VolatileContextHookAdapter", "main"] diff --git a/sprintctl/vuoro_adapter.py b/sprintctl/vuoro_adapter.py index 787b761..5d1e16a 100644 --- a/sprintctl/vuoro_adapter.py +++ b/sprintctl/vuoro_adapter.py @@ -283,6 +283,63 @@ def _result_schema( "read", "not-allowed", ), + WorkOperationContract( + "work.read.item-projection", + _object_schema( + {"item_id": {"type": "integer", "minimum": 1}}, required=("item_id",) + ), + _result_schema( + ("repo_id", "projection"), + { + "repo_id": {"type": "string"}, + "projection": { + "type": "object", + "required": [ + "contract_version", "provider_id", "resource_id", + "revision", "data_class", "item", "truncated", + ], + "properties": { + "contract_version": {"const": "work-item-context/v1"}, + "provider_id": {"const": "sprintctl.work-item"}, + "resource_id": {"type": "string", "minLength": 3}, + "revision": {"type": "string", "minLength": 1}, + "data_class": {"const": "untrusted-work-state"}, + "item": {"type": "object"}, + "truncated": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + }, + ), + "work:read", + "read", + "not-allowed", + ), + WorkOperationContract( + "work.validate.item-status-mutation", + _object_schema( + { + "item_id": {"type": "integer", "minimum": 1}, + "expected_revision": {"type": ["string", "null"]}, + }, + required=("item_id", "expected_revision"), + ), + _result_schema( + ( + "repo_id", "allowed", "reason", "current_revision", "projection", + ), + { + "repo_id": {"type": "string"}, + "allowed": {"type": "boolean"}, + "reason": {"type": "string"}, + "current_revision": {"type": "string"}, + "projection": {"type": "object"}, + }, + ), + "work:read", + "read", + "not-allowed", + ), WorkOperationContract( "work.read.items", _object_schema({"sprint_id": {"type": ["integer", "null"], "minimum": 1}, "track_name": {"type": ["string", "null"]}, "status": {"type": ["string", "null"]}}), diff --git a/sprintctl/work_application.py b/sprintctl/work_application.py index 078a376..254b94f 100644 --- a/sprintctl/work_application.py +++ b/sprintctl/work_application.py @@ -8,6 +8,7 @@ from .application_common import * from . import reservation as _reservation +from . import volatile_context as _volatile_context @dataclass(slots=True) @@ -238,6 +239,8 @@ def invoke( "work.identity.current": target._identity_current, "work.read.sprints": target._read_sprints, "work.read.item": target._read_item, + "work.read.item-projection": target._read_item_projection, + "work.validate.item-status-mutation": target._validate_item_status_mutation, "work.read.items": target._read_items, "work.read.reservations": target._read_reservations, "work.read.reservation": target._read_reservation, @@ -356,7 +359,11 @@ def _read_item( item, edit_revision = current return { "repo_id": self.repo_id, - "item": {**item, "edit_revision": edit_revision}, + "item": { + **item, + "edit_revision": edit_revision, + "status_revision": self.backend.item_status_revision(item), + }, "events": [ event for event in self.backend.list_events(self.store, item["sprint_id"]) @@ -372,6 +379,41 @@ def _read_item( }, } + def _read_item_projection( + self, arguments: dict[str, Any], _context: InvocationContext + ) -> dict[str, Any]: + item_id = _positive_int(arguments.get("item_id"), "item_id") + projection = _volatile_context.project_work_item( + self.backend, self.store, repo_id=self.repo_id, item_id=item_id + ) + if projection is None: + raise ApplicationRejection( + "item-not-found", f"Item #{item_id} not found", 404 + ) + return {"repo_id": self.repo_id, "projection": projection} + + def _validate_item_status_mutation( + self, arguments: dict[str, Any], _context: InvocationContext + ) -> dict[str, Any]: + item_id = _positive_int(arguments.get("item_id"), "item_id") + expected_revision = arguments.get("expected_revision") + if expected_revision is not None and not isinstance(expected_revision, str): + raise ApplicationRejection( + "invalid-arguments", "expected_revision must be a string or null", 422 + ) + result = _volatile_context.validate_status_mutation( + self.backend, + self.store, + repo_id=self.repo_id, + item_id=item_id, + expected_revision=expected_revision, + ) + if result is None: + raise ApplicationRejection( + "item-not-found", f"Item #{item_id} not found", 404 + ) + return {"repo_id": self.repo_id, **result} + def _read_items(self, arguments: dict[str, Any], _context: InvocationContext) -> dict[str, Any]: sprint_id = _optional_positive_int(arguments.get("sprint_id"), "sprint_id") track_name = _optional_text(arguments.get("track_name"), "track_name") @@ -1387,4 +1429,3 @@ def _require_batch_key( "idempotency key must equal the canonical batch digest", 422, ) - diff --git a/tests/pg/test_work_item.py b/tests/pg/test_work_item.py index 19f00f7..7bd16ed 100644 --- a/tests/pg/test_work_item.py +++ b/tests/pg/test_work_item.py @@ -8,6 +8,8 @@ import pytest +from sprintctl import volatile_context + from tests.pg._shared import ( contracts, db, @@ -209,6 +211,34 @@ def test_status_cas_rejects_stale_basis_without_row_or_event_effect( assert pg.get_work_item(store, iid)["status"] == "active" assert pg.list_events(store, sprint_id) == events_after_accept + def test_status_projection_and_precheck_match_postgres_owner_cas( + self, store, sprint_id, track_id + ): + iid = pg.create_work_item(store, sprint_id, track_id, f"Projection-{_uid()}") + projected = volatile_context.project_work_item( + pg, store, repo_id=store.repo_id, item_id=iid + ) + assert projected is not None + basis = projected["revision"] + assert volatile_context.validate_status_mutation( + pg, + store, + repo_id=store.repo_id, + item_id=iid, + expected_revision=basis, + )["allowed"] is True + + pg.set_work_item_status(store, iid, "active", expected_revision=basis) + assert volatile_context.validate_status_mutation( + pg, + store, + repo_id=store.repo_id, + item_id=iid, + expected_revision=basis, + )["allowed"] is False + with pytest.raises(db.StatusConflict): + pg.set_work_item_status(store, iid, "done", expected_revision=basis) + def test_two_connections_accept_exactly_one_status_cas_writer( self, store, sprint_id, track_id ): diff --git a/tests/test_adapter_kit_migration.py b/tests/test_adapter_kit_migration.py index 4b4ad52..7122341 100644 --- a/tests/test_adapter_kit_migration.py +++ b/tests/test_adapter_kit_migration.py @@ -79,8 +79,8 @@ def test_resource_schema_gate_removes_exactly_the_three_owner_operations() -> No "work.maintenance.resource.changes", } - assert len(available) == 47 - assert len(unavailable) == 44 + assert len(available) == 49 + assert len(unavailable) == 46 assert {spec["name"] for spec in available} - { spec["name"] for spec in unavailable } == resource_names diff --git a/tests/test_served_operation_surface.py b/tests/test_served_operation_surface.py index 6f97caf..ed0eabc 100644 --- a/tests/test_served_operation_surface.py +++ b/tests/test_served_operation_surface.py @@ -55,6 +55,7 @@ "work.read.events", "work.read.handoff", "work.read.item", + "work.read.item-projection", "work.read.items", "work.read.maintenance-capability", "work.read.next-work", @@ -70,6 +71,7 @@ "work.reservation.reserve", "work.reservation.touch", "work.sprint.create", + "work.validate.item-status-mutation", ) diff --git a/tests/test_volatile_context.py b/tests/test_volatile_context.py new file mode 100644 index 0000000..fa3cbf9 --- /dev/null +++ b/tests/test_volatile_context.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json + +import pytest + +from sprintctl import db, volatile_context +from sprintctl.volatile_hook import FileCursor, MemoryCursor, VolatileContextHookAdapter + + +def _item(conn, active_sprint, *, title="Projected item") -> int: + track = db.get_or_create_track(conn, active_sprint["id"], "volatile-context") + return db.create_work_item(conn, active_sprint["id"], track, title) + + +def test_projection_is_bounded_allowlisted_and_uses_owner_status_revision( + conn, active_sprint +): + item_id = _item(conn, active_sprint, title="x" * 2_000) + + result = volatile_context.project_work_item( + db, conn, repo_id="repo-a", item_id=item_id + ) + + assert result is not None + assert result["revision"] == db.item_status_revision(db.get_work_item(conn, item_id)) + assert result["data_class"] == "untrusted-work-state" + assert result["truncated"] is True + assert set(result["item"]) == {"id", "title", "status", "priority", "assignee"} + encoded = json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + assert len(encoded) <= volatile_context.MAX_PROJECTION_BYTES + + +def test_precheck_is_read_only_and_authority_still_rejects_hook_bypass( + conn, active_sprint +): + item_id = _item(conn, active_sprint) + before = db.get_work_item(conn, item_id) + basis = db.item_status_revision(before) + assert volatile_context.validate_status_mutation( + db, conn, repo_id="repo-a", item_id=item_id, expected_revision=basis + )["allowed"] is True + + db.set_work_item_status(conn, item_id, "active", expected_revision=basis) + with pytest.raises(db.StatusConflict): + db.set_work_item_status(conn, item_id, "done", expected_revision=basis) + + stale = volatile_context.validate_status_mutation( + db, conn, repo_id="repo-a", item_id=item_id, expected_revision=basis + ) + assert stale["allowed"] is False + assert stale["current_revision"] != basis + + +def _adapter(project, validate, cursor=None): + return VolatileContextHookAdapter( + repo_id="repo-a", + item_id=7, + project=project, + validate=validate, + cursor=cursor or MemoryCursor(), + ) + + +def _projection(revision="item:00000000-0000-4000-8000-000000000007@status:pending"): + return { + "projection": { + "contract_version": "work-item-context/v1", + "provider_id": "sprintctl.work-item", + "resource_id": "repo-a#7", + "revision": revision, + "data_class": "untrusted-work-state", + "item": {"id": 7, "title": "Task", "status": "pending"}, + "truncated": False, + } + } + + +def test_hook_reads_fail_open_and_delta_cursor_is_per_session(): + cursor = MemoryCursor() + adapter = _adapter(lambda _item_id: _projection(), lambda *_args: {}, cursor) + event = { + "hook_event_name": "UserPromptSubmit", + "session_id": "session-a", + "agent_id": None, + } + assert "additionalContext" in adapter.handle(event)["hookSpecificOutput"] + assert adapter.handle(event) == {} + assert "additionalContext" in adapter.handle({**event, "session_id": "session-b"})[ + "hookSpecificOutput" + ] + + unavailable = _adapter( + lambda _item_id: (_ for _ in ()).throw(ConnectionError("offline")), + lambda *_args: {}, + ) + assert unavailable.handle({"hook_event_name": "SessionStart"}) == {} + + +def test_hook_recognized_mutation_fails_closed_but_unknown_tool_fails_open(): + unavailable = _adapter( + lambda _item_id: _projection(), + lambda *_args: (_ for _ in ()).throw(ConnectionError("offline")), + ) + recognized = { + "hook_event_name": "PreToolUse", + "tool_name": "mcp__sprintctl__item_status", + "tool_input": {"item_id": 7, "expected_revision": "revision"}, + } + denied = unavailable.handle(recognized)["hookSpecificOutput"] + assert denied["permissionDecision"] == "deny" + assert "unavailable" in denied["permissionDecisionReason"] + assert unavailable.handle({**recognized, "tool_name": "Bash"}) == {} + + +def test_hook_stale_precheck_returns_current_bounded_projection(): + response = _projection() + adapter = _adapter( + lambda _item_id: response, + lambda _item_id, _revision: { + "allowed": False, + "reason": "item status revision changed", + "current_revision": response["projection"]["revision"], + "projection": response["projection"], + }, + ) + denied = adapter.handle( + { + "hook_event_name": "PreToolUse", + "tool_name": "sprintctl.item_status", + "tool_input": {"item_id": 7, "expected_revision": "stale"}, + } + )["hookSpecificOutput"] + assert denied["permissionDecision"] == "deny" + assert "volatile_context" in denied["additionalContext"] + + +def test_file_cursor_is_disposable_private_and_rejects_unsafe_root(tmp_path): + root = tmp_path / "cursor" + cursor = FileCursor(root) + cursor.put("consumer", "revision-1") + assert cursor.get("consumer") == "revision-1" + assert root.stat().st_mode & 0o777 == 0o700 + assert next(root.iterdir()).stat().st_mode & 0o777 == 0o600 + + root.chmod(0o755) + assert cursor.get("consumer") is None + cursor.put("consumer", "revision-2") + assert next(root.iterdir()).read_text(encoding="utf-8").strip() == "revision-1" diff --git a/tests/test_vuoro_work_adapter_integration.py b/tests/test_vuoro_work_adapter_integration.py index 841adda..ce9d231 100644 --- a/tests/test_vuoro_work_adapter_integration.py +++ b/tests/test_vuoro_work_adapter_integration.py @@ -34,26 +34,25 @@ def anyio_backend() -> str: # change to what clients see, so they must be updated in the same commit as # the catalog change and never re-pinned to make a red test pass. # -# Last re-pinned for the claim -> reservation cutover: aeace4d added six -# reservation operations and 1a06d1e removed five claim operations, a net +1 -# at both schema versions. Schema 7 still gates exactly the three -# work.maintenance.resource.* operations. +# Re-pinned for the bounded volatile-context pilot, which adds exactly two +# read-only owner operations at both schema versions. Schema 7 still gates +# exactly the three work.maintenance.resource.* operations. @pytest.mark.parametrize( ("remote_schema_version", "operation_count", "byte_count", "operations_sha", "revision"), [ ( 6, - 44, - 50_749, - "e7774625cb825b30cbb614d3c3684e2fcd213f7b251bb58c1ce1b0a6cca3c17a", - "e7774625cb825b30cbb614d3c3684e2fcd213f7b251bb58c1ce1b0a6cca3c17a", + 46, + 53_243, + "2e1100b582060a283100ef6d2f47be49031d104a4f783a6b32b87a7daf609937", + "2e1100b582060a283100ef6d2f47be49031d104a4f783a6b32b87a7daf609937", ), ( 7, - 47, - 55_864, - "c3cfc5e173fa5d09932840097c263734feb2220d5fa24ce87c9085471ff4b774", - "b9f0d734441c414e8d0d9ef75d18fc9759576def9d2143e2b4fc54e9d5e67143", + 49, + 58_358, + "54d630b66f0869b074ec057b0708f3ef9f08c366db5a2b3b1913898af6e7357e", + "d1994bf90b9506955a39cf2ceacae1f89468aa04a7e8519a990db29f869e8504", ), ], ) diff --git a/tests/test_work_application.py b/tests/test_work_application.py index 39931d2..9163137 100644 --- a/tests/test_work_application.py +++ b/tests/test_work_application.py @@ -950,6 +950,30 @@ def test_next_work_explain_is_one_application_aggregate(conn, active_sprint): assert payload["recommended_command_bundle"]["bundle_version"] == "1" +def test_item_projection_and_status_precheck_are_owner_reads(conn, active_sprint): + track = db.get_or_create_track(conn, active_sprint["id"], "context") + item_id = db.create_work_item(conn, active_sprint["id"], track, "Context item") + app = _application(store=conn, backend=db) + + read = app.invoke("work.read.item", {"item_id": item_id}, _context()) + projected = app.invoke( + "work.read.item-projection", {"item_id": item_id}, _context() + ) + revision = read["item"]["status_revision"] + + assert projected["projection"]["revision"] == revision + assert app.invoke( + "work.validate.item-status-mutation", + {"item_id": item_id, "expected_revision": revision}, + _context(), + )["allowed"] is True + assert app.invoke( + "work.validate.item-status-mutation", + {"item_id": item_id, "expected_revision": None}, + _context(), + )["allowed"] is False + + def test_authority_handlers_enforce_actor_basis_and_idempotency_before_backend(): calls = [] app = _application(calls=calls)