Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/plans/volatile-context-native-hook-pilot.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/reference/vuoro-work-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [

[project.scripts]
sprintctl = "sprintctl.cli:cli"
sprintctl-volatile-hook = "sprintctl.volatile_hook:main"

[project.optional-dependencies]
dev = ["pytest>=8"]
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions sprintctl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
"remote-backend/v1",
"remote-schema-compatibility/v1",
"sprintctl-repository-ingest-cursor/v1",
"volatile-context-item-projection/v1",
)
34 changes: 34 additions & 0 deletions sprintctl/served.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
118 changes: 118 additions & 0 deletions sprintctl/volatile_context.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading