diff --git a/AGENTS.md b/AGENTS.md index 17e35a9..e063e51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - Never edit `.benchwork/` state files directly. - Use Benchwork MCP tools for canonical research changes. - Use native Codex tools for repository inspection, patches, shell, Git, and review. +- Request network permission before using `gh` commands that access the GitHub API or other remote resources; local-only `git` inspection does not require it. - Keep review local by default. Do not disclose a diff or source to an external reviewer without explicit approval for the exact Review Request. - Run the smallest relevant tests after code changes. - Run `/review` or an equivalent independent review before completing a core change. diff --git a/README.md b/README.md index 08b9231..b404e15 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ for repository and execution work, while the Benchwork plugin's MCP server controls canonical research state. Alpha Grimoire installation remains local and data-only; publisher signatures and remote distribution remain future work. +Phase 3 includes published execution contracts and an additional experimental +in-memory Job/Lease conformance aid. The aid does not execute processes or +containers, schedule remote or GPU work, provide production Artifact Storage, +or promote patches automatically. See the +[Phase 3 local framework](docs/en/architecture/PHASE3_LOCAL_FRAMEWORK.md). + ## Integrity model Benchwork fails closed when it detects an invalid schema, broken event chain, diff --git a/docs/en/README.md b/docs/en/README.md index ae44df1..42dfc6e 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -51,6 +51,7 @@ English is the canonical source for all Benchwork documentation. - [Command Surface and Agent Handoff](architecture/COMMAND_SURFACE.md) - [Open Grimoire](architecture/OPEN_GRIMOIRE.md) - [Integrity Repair](architecture/INTEGRITY_REPAIR.md) +- [Phase 3 local framework](architecture/PHASE3_LOCAL_FRAMEWORK.md) ## Project diff --git a/docs/en/ROADMAP.md b/docs/en/ROADMAP.md index d452513..bd3bae0 100644 --- a/docs/en/ROADMAP.md +++ b/docs/en/ROADMAP.md @@ -199,6 +199,10 @@ Task Capsule Remote Workers, cluster scheduling, automatic Provider invocation, and broad GPU support are non-goals for `0.4`. +`LocalSanctumRuntime` is an additional in-memory conformance aid for a bounded +Job/Lease lifecycle. Its `sanctum-local-*/0.1` records do not replace the +RFC-0012 execution contracts or grant canonical scientific authority. + ### Exit gate All five RFCs, executable Schemas and examples, threat-model review, diff --git a/docs/en/architecture/PHASE3_LOCAL_FRAMEWORK.md b/docs/en/architecture/PHASE3_LOCAL_FRAMEWORK.md new file mode 100644 index 0000000..222f9bd --- /dev/null +++ b/docs/en/architecture/PHASE3_LOCAL_FRAMEWORK.md @@ -0,0 +1,41 @@ +--- +language: en +canonical: true +--- + +# Phase 3 local framework + +Status: **experimental scaffold, not Phase 3 acceptance**. + +`LocalSanctumRuntime` is an in-memory conformance aid that models a bounded +Job/Lease lifecycle without executing commands. It uses the independent +`sanctum-local-*/0.1` records, not the RFC-0012 `execution-*/1.0` contracts or +the production-facing local-execution contracts. It can issue a bounded +`sanctum-local-worker-result/0.1` Proposal, but it cannot create a scientific +Run, Artifact, Assessment, or Decision. Only a separately authorised Athanor +transition may do that. + +## Implemented framework boundary + +- Task Capsule and Circle Sigils are bound when a Job is submitted. +- A Job has a fenced, expiring Lease and a monotonic local lifecycle. +- Expired Leases fail closed; stale completion is rejected. +- Worker outputs are schema-bounded Proposals. +- `sanctum-local-patch-proposal/0.1` records a patch's base and validation + evidence; it does not apply or promote the patch. + +## Explicitly deferred + +The following are intentionally `DEFERRED`, not implied by this framework: + +| Capability | Status | +| --- | --- | +| Process, container, filesystem, and network execution | `DEFERRED` | +| Remote Workers and transport | `DEFERRED` | +| Queueing, scheduling, quotas, and GPU allocation | `DEFERRED` | +| Persistent or production Artifact Storage | `DEFERRED` | +| Automatic patch application or promotion | `DEFERRED` | +| Automatic conversion of a Worker Result into science | `FORBIDDEN` | + +These features require the corresponding Phase 3 RFC, threat model, and +conformance evidence before implementation. diff --git a/schemas/sanctum-local-job-0.1.json b/schemas/sanctum-local-job-0.1.json new file mode 100644 index 0000000..188df4d --- /dev/null +++ b/schemas/sanctum-local-job-0.1.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://benchwork.dev/schemas/sanctum-local-job/0.1", + "title": "Sanctum Local Job", + "description": "Experimental in-memory conformance record; not the RFC-0012 execution-job/1.0 contract.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "job_id", "task_capsule_sigil", "circle_sigil", "status", "attempt", "created_at"], + "properties": { + "schema_version": {"const": "sanctum-local-job/0.1"}, + "job_id": {"type": "string", "pattern": "^JB-[A-Za-z0-9][A-Za-z0-9_-]*$"}, + "task_capsule_sigil": {"$ref": "https://benchwork.dev/schemas/common-definitions/1.0#/$defs/sigil"}, + "circle_sigil": {"$ref": "https://benchwork.dev/schemas/common-definitions/1.0#/$defs/sigil"}, + "status": {"enum": ["PENDING", "CLAIMED", "RUNNING", "COMPLETED", "FAILED", "CANCELLED", "EXPIRED"]}, + "attempt": {"type": "integer", "minimum": 0}, + "created_at": {"type": "string", "format": "date-time"}, + "worker_id": {"type": ["string", "null"]}, + "lease_id": {"type": ["string", "null"]} + } +} diff --git a/schemas/sanctum-local-lease-0.1.json b/schemas/sanctum-local-lease-0.1.json new file mode 100644 index 0000000..a783247 --- /dev/null +++ b/schemas/sanctum-local-lease-0.1.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://benchwork.dev/schemas/sanctum-local-lease/0.1", + "title": "Sanctum Local Lease", + "description": "Experimental in-memory conformance record; not the RFC-0012 execution-lease/1.0 contract.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "lease_id", "job_id", "worker_id", "fencing_token", "status", "expires_at"], + "properties": { + "schema_version": {"const": "sanctum-local-lease/0.1"}, + "lease_id": {"type": "string", "pattern": "^LS-[A-Za-z0-9][A-Za-z0-9_-]*$"}, + "job_id": {"type": "string", "pattern": "^JB-[A-Za-z0-9][A-Za-z0-9_-]*$"}, + "worker_id": {"type": "string", "pattern": "^WK-[A-Za-z0-9][A-Za-z0-9_-]*$"}, + "fencing_token": {"type": "integer", "minimum": 1}, + "status": {"enum": ["ACTIVE", "RELEASED", "EXPIRED"]}, + "expires_at": {"type": "string", "format": "date-time"} + } +} diff --git a/schemas/sanctum-local-patch-proposal-0.1.json b/schemas/sanctum-local-patch-proposal-0.1.json new file mode 100644 index 0000000..838495b --- /dev/null +++ b/schemas/sanctum-local-patch-proposal-0.1.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://benchwork.dev/schemas/sanctum-local-patch-proposal/0.1", + "title": "Sanctum Local Patch Proposal", + "description": "An untrusted proposal from the experimental in-memory Sanctum conformance aid. Applying or promoting a patch requires separate explicit human authorization.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "proposal_id", "job_id", "base_sigil", "patch_sigil", "summary", "changed_files", "validation", "created_at"], + "properties": { + "schema_version": {"const": "sanctum-local-patch-proposal/0.1"}, + "proposal_id": {"type": "string", "pattern": "^PP-[A-Za-z0-9][A-Za-z0-9_-]*$"}, + "job_id": {"type": "string", "pattern": "^JB-"}, + "base_sigil": {"$ref": "https://benchwork.dev/schemas/common-definitions/1.0#/$defs/sigil"}, + "patch_sigil": {"$ref": "https://benchwork.dev/schemas/common-definitions/1.0#/$defs/sigil"}, + "summary": {"type": "string", "minLength": 1}, + "changed_files": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, + "validation": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "created_at": {"type": "string", "format": "date-time"} + } +} diff --git a/schemas/sanctum-local-worker-result-0.1.json b/schemas/sanctum-local-worker-result-0.1.json new file mode 100644 index 0000000..eb37a57 --- /dev/null +++ b/schemas/sanctum-local-worker-result-0.1.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://benchwork.dev/schemas/sanctum-local-worker-result/0.1", + "title": "Sanctum Local Worker Result Proposal", + "description": "An untrusted output from the experimental in-memory Sanctum conformance aid. Athanor acceptance is separate.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "job_id", "attempt", "worker_id", "lease_id", "status", "summary", "outputs", "produced_at"], + "properties": { + "schema_version": {"const": "sanctum-local-worker-result/0.1"}, + "job_id": {"type": "string", "pattern": "^JB-"}, + "attempt": {"type": "integer", "minimum": 1}, + "worker_id": {"type": "string", "pattern": "^WK-"}, + "lease_id": {"type": "string", "pattern": "^LS-"}, + "status": {"enum": ["COMPLETED", "FAILED", "CANCELLED"]}, + "summary": {"type": "string", "minLength": 1}, + "outputs": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["schema", "uri", "blob_sigil"], "properties": {"schema": {"type": "string", "minLength": 1}, "uri": {"type": "string", "minLength": 1}, "blob_sigil": {"$ref": "https://benchwork.dev/schemas/common-definitions/1.0#/$defs/sigil"}}}}, + "produced_at": {"type": "string", "format": "date-time"} + }, + "allOf": [{"if": {"properties": {"status": {"const": "COMPLETED"}}}, "then": {"properties": {"outputs": {"minItems": 1}}}}] +} diff --git a/src/benchwork/sanctum.py b/src/benchwork/sanctum.py new file mode 100644 index 0000000..40f22c5 --- /dev/null +++ b/src/benchwork/sanctum.py @@ -0,0 +1,287 @@ +"""Experimental, local-only execution-plane state for Phase 3. + +This module deliberately does not launch processes, access a network, mutate a +project, or append Chronicle events. It models the operational Job and Lease +boundary so a future Executor can be tested without confusing its outcome with +scientific state. Worker results remain untrusted proposals. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import StrEnum +import re +from typing import Any + +from .athanor import SIGIL + + +class SanctumError(ValueError): + """Raised when an operational Job or Lease transition is invalid.""" + + +class JobState(StrEnum): + PENDING = "PENDING" + CLAIMED = "CLAIMED" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + EXPIRED = "EXPIRED" + + +class LeaseState(StrEnum): + ACTIVE = "ACTIVE" + RELEASED = "RELEASED" + EXPIRED = "EXPIRED" + + +_TERMINAL_JOB_STATES = { + JobState.COMPLETED, + JobState.FAILED, + JobState.CANCELLED, + JobState.EXPIRED, +} +_OPERATIONAL_IDENTIFIER = re.compile(r"^[A-Z]+-[A-Za-z0-9][A-Za-z0-9_-]*$") + + +def _timestamp(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _require_sigil(name: str, value: str) -> None: + if not isinstance(value, str) or not SIGIL.fullmatch(value): + raise SanctumError(f"{name} must be a SHA-256 Sigil") + + +def _require_identifier(prefix: str, name: str, value: str) -> None: + if ( + not isinstance(value, str) + or not value.startswith(prefix) + or not _OPERATIONAL_IDENTIFIER.fullmatch(value) + ): + raise SanctumError(f"{name} must start with {prefix}") + + +@dataclass +class _Lease: + lease_id: str + job_id: str + worker_id: str + fencing_token: int + expires_at: datetime + status: LeaseState = LeaseState.ACTIVE + + def record(self) -> dict[str, Any]: + return { + "schema_version": "sanctum-local-lease/0.1", + "lease_id": self.lease_id, + "job_id": self.job_id, + "worker_id": self.worker_id, + "fencing_token": self.fencing_token, + "status": self.status, + "expires_at": _timestamp(self.expires_at), + } + + +@dataclass +class _Job: + job_id: str + task_capsule_sigil: str + circle_sigil: str + created_at: datetime + status: JobState = JobState.PENDING + attempt: int = 0 + worker_id: str | None = None + lease_id: str | None = None + result: dict[str, Any] | None = None + + def record(self) -> dict[str, Any]: + return { + "schema_version": "sanctum-local-job/0.1", + "job_id": self.job_id, + "task_capsule_sigil": self.task_capsule_sigil, + "circle_sigil": self.circle_sigil, + "status": self.status, + "attempt": self.attempt, + "created_at": _timestamp(self.created_at), + "worker_id": self.worker_id, + "lease_id": self.lease_id, + } + + +class LocalSanctumRuntime: + """An in-memory Phase 3 reference state machine, not an Executor. + + The caller supplies timestamps to make expiry and recovery scenarios fully + deterministic. All returned mappings conform to the published execution + schemas; no mapping is a canonical research object. + """ + + def __init__(self) -> None: + self._jobs: dict[str, _Job] = {} + self._leases: dict[str, _Lease] = {} + self._next_fencing_token = 1 + + def submit( + self, + job_id: str, + task_capsule_sigil: str, + circle_sigil: str, + *, + now: datetime, + ) -> dict[str, Any]: + _require_identifier("JB-", "job_id", job_id) + _require_sigil("task_capsule_sigil", task_capsule_sigil) + _require_sigil("circle_sigil", circle_sigil) + if job_id in self._jobs: + raise SanctumError(f"Job already exists: {job_id}") + job = _Job(job_id, task_capsule_sigil, circle_sigil, now) + self._jobs[job_id] = job + return job.record() + + def claim( + self, + job_id: str, + lease_id: str, + worker_id: str, + duration_seconds: int, + *, + now: datetime, + ) -> dict[str, Any]: + job = self._job(job_id) + _require_identifier("LS-", "lease_id", lease_id) + _require_identifier("WK-", "worker_id", worker_id) + if duration_seconds <= 0: + raise SanctumError("duration_seconds must be positive") + if lease_id in self._leases: + raise SanctumError(f"Lease already exists: {lease_id}") + if job.status is not JobState.PENDING: + raise SanctumError(f"Job cannot be claimed from {job.status}") + lease = _Lease( + lease_id=lease_id, + job_id=job_id, + worker_id=worker_id, + fencing_token=self._next_fencing_token, + expires_at=now + timedelta(seconds=duration_seconds), + ) + self._next_fencing_token += 1 + self._leases[lease_id] = lease + job.status = JobState.CLAIMED + job.attempt += 1 + job.worker_id = worker_id + job.lease_id = lease_id + return lease.record() + + def start(self, job_id: str, lease_id: str, *, now: datetime) -> dict[str, Any]: + job, _ = self._active_lease(job_id, lease_id, now) + if job.status is not JobState.CLAIMED: + raise SanctumError(f"Job cannot start from {job.status}") + job.status = JobState.RUNNING + return job.record() + + def heartbeat( + self, + job_id: str, + lease_id: str, + duration_seconds: int, + *, + now: datetime, + ) -> dict[str, Any]: + job, lease = self._active_lease(job_id, lease_id, now) + if job.status is not JobState.RUNNING: + raise SanctumError(f"Job cannot heartbeat from {job.status}") + if duration_seconds <= 0: + raise SanctumError("duration_seconds must be positive") + lease.expires_at = now + timedelta(seconds=duration_seconds) + return lease.record() + + def finish( + self, + job_id: str, + lease_id: str, + status: JobState, + summary: str, + outputs: list[dict[str, str]], + *, + now: datetime, + ) -> dict[str, Any]: + job, lease = self._active_lease(job_id, lease_id, now) + if job.status is not JobState.RUNNING: + raise SanctumError(f"Job cannot finish from {job.status}") + if status not in {JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED}: + raise SanctumError("finish status must be COMPLETED, FAILED, or CANCELLED") + if not isinstance(summary, str) or not summary: + raise SanctumError("summary is required") + if status is JobState.COMPLETED and not outputs: + raise SanctumError("a completed Worker Result requires at least one output") + for output in outputs: + if set(output) != {"schema", "uri", "blob_sigil"}: + raise SanctumError("each output has only schema, uri, and blob_sigil") + _require_sigil("output.blob_sigil", output.get("blob_sigil", "")) + if ( + not isinstance(output.get("uri"), str) + or not output["uri"] + or not isinstance(output.get("schema"), str) + or not output["schema"] + ): + raise SanctumError("each output requires uri, schema, and blob_sigil") + job.status = status + lease.status = LeaseState.RELEASED + job.result = { + "schema_version": "sanctum-local-worker-result/0.1", + "job_id": job_id, + "attempt": job.attempt, + "worker_id": lease.worker_id, + "lease_id": lease_id, + "status": status, + "summary": summary, + "outputs": outputs, + "produced_at": _timestamp(now), + } + return job.result.copy() + + def cancel(self, job_id: str, reason: str) -> dict[str, Any]: + job = self._job(job_id) + if job.status in _TERMINAL_JOB_STATES: + raise SanctumError(f"Job is already terminal: {job.status}") + if not reason: + raise SanctumError("reason is required") + job.status = JobState.CANCELLED + if job.lease_id is not None: + self._leases[job.lease_id].status = LeaseState.RELEASED + return job.record() + + def expire_leases(self, *, now: datetime) -> list[dict[str, Any]]: + """Expire active leases and fail closed their non-terminal Jobs.""" + expired: list[dict[str, Any]] = [] + for lease in self._leases.values(): + if lease.status is LeaseState.ACTIVE and now >= lease.expires_at: + lease.status = LeaseState.EXPIRED + job = self._jobs[lease.job_id] + if job.status not in _TERMINAL_JOB_STATES: + job.status = JobState.EXPIRED + expired.append(job.record()) + return expired + + def job(self, job_id: str) -> dict[str, Any]: + return self._job(job_id).record() + + def _job(self, job_id: str) -> _Job: + try: + return self._jobs[job_id] + except KeyError as error: + raise SanctumError(f"Unknown Job: {job_id}") from error + + def _active_lease(self, job_id: str, lease_id: str, now: datetime) -> tuple[_Job, _Lease]: + job = self._job(job_id) + if job.lease_id != lease_id: + raise SanctumError("Lease is not bound to Job") + lease = self._leases[lease_id] + if lease.status is not LeaseState.ACTIVE or now >= lease.expires_at: + if lease.status is LeaseState.ACTIVE: + lease.status = LeaseState.EXPIRED + job.status = JobState.EXPIRED + raise SanctumError("Lease is no longer active") + return job, lease diff --git a/tests/test_sanctum.py b/tests/test_sanctum.py new file mode 100644 index 0000000..fe6147b --- /dev/null +++ b/tests/test_sanctum.py @@ -0,0 +1,54 @@ +import unittest +from datetime import UTC, datetime, timedelta + +from benchwork.sanctum import JobState, LocalSanctumRuntime, SanctumError +from benchwork.schema_validation import validate_instance + + +SIGIL = "sha256:" + "a" * 64 + + +class LocalSanctumRuntimeTest(unittest.TestCase): + def setUp(self) -> None: + self.runtime = LocalSanctumRuntime() + self.now = datetime(2026, 8, 14, tzinfo=UTC) + + def test_local_lifecycle_yields_a_result_proposal_not_a_run(self) -> None: + job = self.runtime.submit("JB-001", SIGIL, SIGIL, now=self.now) + validate_instance("sanctum-local-job-0.1.json", job) + lease = self.runtime.claim("JB-001", "LS-001", "WK-001", 30, now=self.now) + validate_instance("sanctum-local-lease-0.1.json", lease) + self.runtime.start("JB-001", "LS-001", now=self.now) + result = self.runtime.finish( + "JB-001", + "LS-001", + JobState.COMPLETED, + "Produced a bounded proposal.", + [{"schema": "code-modification-result/1.0", "uri": "proposal.json", "blob_sigil": SIGIL}], + now=self.now + timedelta(seconds=1), + ) + validate_instance("sanctum-local-worker-result-0.1.json", result) + self.assertEqual(self.runtime.job("JB-001")["status"], "COMPLETED") + self.assertNotIn("run_id", result) + + def test_expired_lease_rejects_a_stale_completion(self) -> None: + self.runtime.submit("JB-001", SIGIL, SIGIL, now=self.now) + self.runtime.claim("JB-001", "LS-001", "WK-001", 1, now=self.now) + self.runtime.start("JB-001", "LS-001", now=self.now) + expired = self.runtime.expire_leases(now=self.now + timedelta(seconds=1)) + self.assertEqual(expired[0]["status"], "EXPIRED") + with self.assertRaisesRegex(SanctumError, "no longer active"): + self.runtime.finish( + "JB-001", "LS-001", JobState.FAILED, "stale", [], now=self.now + timedelta(seconds=2) + ) + + def test_completed_result_requires_a_bounded_output(self) -> None: + self.runtime.submit("JB-001", SIGIL, SIGIL, now=self.now) + self.runtime.claim("JB-001", "LS-001", "WK-001", 30, now=self.now) + self.runtime.start("JB-001", "LS-001", now=self.now) + with self.assertRaisesRegex(SanctumError, "requires at least one output"): + self.runtime.finish("JB-001", "LS-001", JobState.COMPLETED, "nothing", [], now=self.now) + + def test_runtime_rejects_records_that_cannot_match_the_contract(self) -> None: + with self.assertRaisesRegex(SanctumError, "job_id"): + self.runtime.submit("JB-unsafe!", SIGIL, SIGIL, now=self.now)