feat: reconcile granted ops run closes

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06bfe-2a55-7ed3-bacd-879977b099bf
This commit is contained in:
tegwick 2026-09-04 12:57:12 +02:00
parent b72fdb5452
commit b63131e863
21 changed files with 1240 additions and 43 deletions

View file

@ -536,6 +536,11 @@ async def evaluate_instructions(payload: dict) -> dict:
"approach_hint": instruction.approach_hint,
"harness_profile_ref": instruction.harness_profile_ref,
"execution_refs": instruction.execution_refs,
"repository_grant": (
instruction.repository_grant.payload()
if instruction.repository_grant is not None
else None
),
})
return {"task_specs": task_specs, "reports": reports}
@ -579,6 +584,10 @@ async def emit_tasks(payload: dict) -> list[str]:
normalise_execution_refs,
resolve_execution_selector,
)
from activity_core.repository_grant import (
RepositoryGrantError,
validate_repository_grant,
)
validated_specs: list[dict] = []
for index, raw_spec in enumerate(task_specs_raw):
@ -604,6 +613,19 @@ async def emit_tasks(payload: dict) -> list[str]:
spec_dict["execution_refs"] = normalise_execution_refs(
spec_dict.get("execution_refs")
)
raw_grant = spec_dict.get("repository_grant")
if raw_grant is not None:
try:
spec_dict["repository_grant"] = validate_repository_grant(raw_grant)
except RepositoryGrantError as exc:
source = (
f"{spec_dict.get('source_type', 'rule')}:"
f"{spec_dict.get('source_id', '')}"
)
raise ApplicationError(
f"repository grant refused for {source}: {exc}",
non_retryable=True,
) from exc
validated_specs.append(spec_dict)
sink = get_issue_sink()
@ -640,6 +662,7 @@ async def emit_tasks(payload: dict) -> list[str]:
approach_hint=spec_dict.get("approach_hint"),
harness_profile_ref=spec_dict.get("harness_profile_ref"),
execution_refs=spec_dict.get("execution_refs"),
repository_grant=spec_dict.get("repository_grant"),
)
if ops_id is not None:
activity.logger.info(

View file

@ -23,6 +23,10 @@ from activity_core.glas_profile import (
require_harness_profile,
validate_profile_ref,
)
from activity_core.repository_grant import (
RepositoryGrantError,
validate_repository_grant,
)
class ParseError(Exception):
@ -122,6 +126,19 @@ def _validate_execution_declarations(
f"{location} execution_refs must be a YAML mapping",
)
repository_grant = declaration.get("repository_grant")
if repository_grant is not None:
try:
declaration["repository_grant"] = validate_repository_grant(
repository_grant
)
except RepositoryGrantError as exc:
raise ParseError(
file,
None,
f"{location} has invalid repository_grant: {exc}",
) from exc
def _normalise_review_advisory(
instructions: list[dict[str, Any]], file: Path

View file

@ -156,6 +156,137 @@ def _normalise_artifact_urls(raw: Any) -> list[dict[str, str]]:
return urls
def _normalise_repository_baseline(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in (
"repo_id",
"repo_name",
"head",
"branch",
"status_digest",
"index_diff_digest",
"worktree_diff_digest",
"upstream_ref",
"upstream_oid",
"remote_refs_digest",
"protected_git_metadata_digest",
):
value = raw.get(key)
if isinstance(value, str):
result[key] = value[:500]
elif value is None and key in {"branch", "upstream_ref", "upstream_oid"}:
result[key] = None
for key in ("detached", "clean"):
value = raw.get(key)
if isinstance(value, bool):
result[key] = value
for key in ("dirty_entries", "remote_ref_count"):
value = raw.get(key)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
result[key] = min(value, 1_000_000)
return result
def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in (
"grant_id",
"acceptance_policy_id",
"version",
"allowed_paths_digest",
):
value = raw.get(key)
if isinstance(value, str):
result[key] = value[:500]
for key in ("allowed_path_count", "min_commits", "max_commits"):
value = raw.get(key)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
result[key] = min(value, 1_000_000)
publish = raw.get("publish")
if isinstance(publish, bool):
result["publish"] = publish
return result
def _normalise_repository_acceptance(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in (
"policy_id",
"head",
"branch",
"changed_paths_digest",
):
value = raw.get(key)
if isinstance(value, str):
result[key] = value[:500]
elif value is None and key == "branch":
result[key] = None
for key in (
"accepted",
"changed_paths_truncated",
"clean_post_state",
"remote_refs_unchanged",
"protected_git_metadata_unchanged",
):
value = raw.get(key)
if isinstance(value, bool):
result[key] = value
for key in ("commit_count", "changed_path_count"):
value = raw.get(key)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
result[key] = min(value, 1_000_000)
for key, limit in (("commits", 32), ("changed_paths", 100)):
value = raw.get(key)
if isinstance(value, list):
result[key] = [
item[:500] for item in value[:limit] if isinstance(item, str)
]
return result
def _normalise_repository_metrics(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in ("storage", "session_id"):
value = raw.get(key)
if isinstance(value, str):
result[key] = value[:500]
projection_ready = raw.get("projection_ready")
if isinstance(projection_ready, bool):
result["projection_ready"] = projection_ready
return result
def normalise_repository_transaction(raw: Any) -> dict[str, Any]:
"""Allowlist bounded rein repository transaction/acceptance evidence."""
if not isinstance(raw, dict):
return {}
result: dict[str, Any] = {}
for key in ("transaction_id", "correlation_id"):
value = raw.get(key)
if isinstance(value, str):
result[key] = value[:500]
for key, normalizer in (
("baseline", _normalise_repository_baseline),
("repository_grant", _normalise_repository_grant_evidence),
("acceptance", _normalise_repository_acceptance),
("metrics", _normalise_repository_metrics),
):
value = normalizer(raw.get(key))
if value:
result[key] = value
return result
def normalise_ops_result(raw: Any) -> dict[str, Any]:
"""Allowlist legacy completion facts plus normalized Glas evidence."""
if not isinstance(raw, dict):
@ -177,4 +308,11 @@ def normalise_ops_result(raw: Any) -> dict[str, Any]:
evidence = normalise_execution_evidence(raw_evidence)
if evidence:
result["execution_evidence"] = evidence
raw_transaction = raw.get("repository_transaction")
if not isinstance(raw_transaction, dict):
raw_transaction = raw.get("transaction")
transaction = normalise_repository_transaction(raw_transaction)
if transaction:
result["repository_transaction"] = transaction
return result

View file

@ -11,6 +11,8 @@ from uuid import UUID
from pydantic import BaseModel, Field, model_validator
from activity_core.repository_grant import RepositoryGrant
# ── EventEnvelope (T40) ───────────────────────────────────────────────────────
@ -102,6 +104,7 @@ class ActionDef(BaseModel):
approach_hint: str | None = Field(default=None)
harness_profile_ref: str | None = Field(default=None)
execution_refs: dict[str, Any] = Field(default_factory=dict)
repository_grant: RepositoryGrant | None = Field(default=None)
class RuleDef(BaseModel):
@ -152,6 +155,7 @@ class InstructionDef(BaseModel):
approach_hint: str | None = Field(default=None)
harness_profile_ref: str | None = Field(default=None)
execution_refs: dict[str, Any] = Field(default_factory=dict)
repository_grant: RepositoryGrant | None = Field(default=None)
@model_validator(mode="before")
@classmethod

View file

@ -2,8 +2,11 @@
from __future__ import annotations
import hashlib
import json
import os
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
@ -11,18 +14,47 @@ from sqlalchemy import Select, and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from activity_core.glas_evidence import normalise_ops_result
from activity_core.glas_profile import (
normalise_execution_refs,
resolve_execution_selector,
)
from activity_core.glas_evidence import normalise_ops_result
from activity_core.orm import OpsRun
from activity_core.repository_grant import (
RepositoryGrant,
validate_repository_grant,
)
from activity_core.rules.models import TaskSpec
OPS_RUN_STATES = frozenset(
{"open", "claimed", "succeeded", "failed", "expired"}
)
CLOSE_DISPOSITIONS = frozenset(
{
"applied",
"reconciled",
"not_found",
"wrong_owner",
"expired_lease",
"state_conflict",
"terminal_conflict",
"evidence_conflict",
}
)
@dataclass(frozen=True)
class CloseOpsRunOutcome:
"""Explicit result of a row-locked terminal/reopen mutation decision."""
disposition: str
row: OpsRun | None
@property
def accepted(self) -> bool:
return self.disposition in {"applied", "reconciled"}
def ops_run_queue_enabled() -> bool:
raw = (os.environ.get("OPS_RUN_QUEUE_ENABLED") or "true").strip().lower()
@ -113,7 +145,11 @@ def ops_run_to_dict(row: OpsRun) -> dict[str, Any]:
"approach_hint": row.approach_hint,
"harness_profile_ref": row.harness_profile_ref,
"execution_refs": dict(row.execution_refs or {}),
"repository_grant": (
dict(row.repository_grant) if row.repository_grant is not None else None
),
"result": normalise_ops_result(row.result),
"close_intent_digest": row.close_intent_digest,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@ -126,6 +162,7 @@ async def create_ops_run_from_spec(
approach_hint: str | None = None,
harness_profile_ref: str | None = None,
execution_refs: dict | None = None,
repository_grant: dict | None = None,
) -> uuid.UUID | None:
"""Insert ops_run if queue enabled; return id or None if disabled/duplicate."""
if not ops_run_queue_enabled():
@ -163,7 +200,13 @@ async def create_ops_run_from_spec(
approach_hint=hint,
harness_profile_ref=profile_ref,
execution_refs=normalise_execution_refs(execution_refs),
repository_grant=(
validate_repository_grant(repository_grant)
if repository_grant is not None
else None
),
result={},
close_intent_digest=None,
created_at=now,
updated_at=now,
)
@ -287,19 +330,14 @@ async def complete_ops_run(
worker_id: str,
result: dict[str, Any] | None = None,
) -> OpsRun | None:
row = await session.get(OpsRun, run_id, with_for_update=True)
if row is None:
return None
if row.state != "claimed" or row.claim_owner != worker_id:
return None
now = _utcnow()
if not _has_active_lease(row, now=now):
return None
row.state = "succeeded"
row.lease_until = None
row.result = normalise_ops_result(result)
row.updated_at = now
return row
outcome = await close_ops_run(
session,
run_id,
worker_id=worker_id,
action="complete",
result=result,
)
return outcome.row if outcome.accepted else None
async def fail_ops_run(
@ -311,27 +349,142 @@ async def fail_ops_run(
reopen: bool = False,
result: dict[str, Any] | None = None,
) -> OpsRun | None:
outcome = await close_ops_run(
session,
run_id,
worker_id=worker_id,
action="fail",
error=error,
reopen=reopen,
result=result,
)
return outcome.row if outcome.accepted else None
async def close_ops_run(
session: AsyncSession,
run_id: uuid.UUID,
*,
worker_id: str,
action: str,
error: str = "",
reopen: bool = False,
result: dict[str, Any] | None = None,
) -> CloseOpsRunOutcome:
"""Apply or reconcile one completion/failure under a row lock.
A first close still requires the authenticated owner and an active lease.
Once terminal, only an exact repeat of the persisted close intent by that
same owner is accepted, making a lost HTTP response safely reconcilable.
"""
if action not in {"complete", "fail"}:
raise ValueError("action must be 'complete' or 'fail'")
row = await session.get(OpsRun, run_id, with_for_update=True)
if row is None:
return None
if row.state != "claimed" or row.claim_owner != worker_id:
return None
return CloseOpsRunOutcome("not_found", None)
payload = normalise_ops_result(result)
if action == "fail" and error:
payload["error"] = error[:2000]
intent_digest = _close_intent_digest(
action=action,
result=payload,
reopen=reopen,
)
terminal_state = "succeeded" if action == "complete" else "failed"
if row.state in {"succeeded", "failed"}:
if row.claim_owner != worker_id:
return CloseOpsRunOutcome("wrong_owner", row)
if not _granted_close_evidence_matches(row, action=action, result=payload):
return CloseOpsRunOutcome("evidence_conflict", row)
if (
row.state == terminal_state
and row.close_intent_digest == intent_digest
and normalise_ops_result(row.result) == payload
):
return CloseOpsRunOutcome("reconciled", row)
return CloseOpsRunOutcome("terminal_conflict", row)
if row.state != "claimed":
return CloseOpsRunOutcome("state_conflict", row)
if row.claim_owner != worker_id:
return CloseOpsRunOutcome("wrong_owner", row)
now = _utcnow()
if not _has_active_lease(row, now=now):
return None
payload = normalise_ops_result(result)
if error:
payload["error"] = error[:2000]
return CloseOpsRunOutcome("expired_lease", row)
if not _granted_close_evidence_matches(row, action=action, result=payload):
return CloseOpsRunOutcome("evidence_conflict", row)
row.result = payload
row.updated_at = now
if reopen and int(row.attempt or 0) < max_attempts():
if action == "fail" and reopen and int(row.attempt or 0) < max_attempts():
row.state = "open"
row.claim_owner = None
row.lease_until = None
row.close_intent_digest = None
else:
row.state = "failed"
row.state = terminal_state
row.lease_until = None
return row
row.close_intent_digest = intent_digest
return CloseOpsRunOutcome("applied", row)
def _close_intent_digest(
*,
action: str,
result: dict[str, Any],
reopen: bool,
) -> str:
payload = json.dumps(
{"action": action, "reopen": reopen, "result": result},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _granted_close_evidence_matches(
row: OpsRun,
*,
action: str,
result: dict[str, Any],
) -> bool:
"""Bind granted close evidence to the authority stored on the queue row."""
raw_grant = row.repository_grant
if raw_grant is None:
return True
try:
grant = RepositoryGrant.model_validate(raw_grant)
except (TypeError, ValueError):
return False
transaction = result.get("repository_transaction")
if not isinstance(transaction, dict):
return action == "fail"
transaction_id = transaction.get("transaction_id")
grant_evidence = transaction.get("repository_grant")
if not isinstance(transaction_id, str) or not transaction_id:
return False
if not isinstance(grant_evidence, dict):
return False
if grant_evidence.get("grant_id") != grant.grant_id:
return False
if grant_evidence.get("acceptance_policy_id") != grant.acceptance_policy_id:
return False
if action == "fail":
return True
acceptance = transaction.get("acceptance")
return bool(
isinstance(acceptance, dict)
and acceptance.get("accepted") is True
and acceptance.get("policy_id") == grant.acceptance_policy_id
)
async def list_ops_runs(

View file

@ -5,10 +5,11 @@ from __future__ import annotations
import hmac
import os
import uuid
from collections.abc import Callable
from datetime import datetime
from typing import Any, Callable
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@ -20,10 +21,10 @@ from activity_core.ops_auth import (
require_operator,
)
from activity_core.ops_run_queue import (
CloseOpsRunOutcome,
claim_ops_runs,
complete_ops_run,
close_ops_run,
default_lease_seconds,
fail_ops_run,
heartbeat_ops_run,
list_ops_runs,
ops_run_counts,
@ -186,6 +187,35 @@ class FailBody(BaseModel):
result: dict[str, Any] | None = None
_CLOSE_MESSAGES = {
"not_found": "ops_run not found",
"wrong_owner": "ops_run is not owned by this worker",
"expired_lease": "ops_run lease is missing or expired",
"state_conflict": "ops_run is not actively claimed",
"terminal_conflict": "terminal ops_run has a different close intent",
"evidence_conflict": "repository evidence does not match the queued grant",
}
def _close_response(outcome: CloseOpsRunOutcome) -> dict[str, Any]:
"""Return an applied/reconciled row or a machine-distinct refusal."""
if outcome.accepted and outcome.row is not None:
response = ops_run_to_dict(outcome.row)
response["close_disposition"] = outcome.disposition
return response
code = outcome.disposition
status_code = 404 if code == "not_found" else 409
raise HTTPException(
status_code=status_code,
detail={
"code": code,
"message": _CLOSE_MESSAGES.get(code, "ops_run close refused"),
"state": getattr(outcome.row, "state", None),
},
)
@router.get("")
async def get_ops_runs(
request: Request,
@ -329,18 +359,14 @@ async def post_complete(
Session = _db()
async with Session() as session:
async with session.begin():
row = await complete_ops_run(
outcome = await close_ops_run(
session,
run_id,
worker_id=worker_id,
action="complete",
result=body.result,
)
if row is None:
raise HTTPException(
status_code=409,
detail="not actively leased by this worker or not found",
)
return ops_run_to_dict(row)
return _close_response(outcome)
@router.post("/{run_id}/fail")
@ -360,20 +386,16 @@ async def post_fail(
Session = _db()
async with Session() as session:
async with session.begin():
row = await fail_ops_run(
outcome = await close_ops_run(
session,
run_id,
worker_id=worker_id,
action="fail",
error=body.error,
reopen=body.reopen,
result=body.result,
)
if row is None:
raise HTTPException(
status_code=409,
detail="not actively leased by this worker or not found",
)
return ops_run_to_dict(row)
return _close_response(outcome)
@router.post("/expire-leases")

View file

@ -156,7 +156,14 @@ class OpsRun(Base):
# Attribution refs carried through from workforce/leadership vocabulary;
# activity-core does not author or interpret them.
execution_refs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# Explicit repository mutation authority. This is not attribution and must
# remain separate from execution_refs.
repository_grant: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# Canonical digest of the accepted terminal close request. It enables a
# response-lost caller to reconcile an exact repeat without weakening the
# active-lease requirement for a first mutation.
close_intent_digest: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View file

@ -0,0 +1,133 @@
"""Typed repository mutation authority carried by Activity Core.
The v1 schema mirrors the accepted rein-aharness repository-grant contract,
but Activity Core owns admission and unchanged queue carriage at its boundary.
It deliberately does not infer grants from prose, labels, profiles, or
organizational attribution references.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
StrictBool,
StrictInt,
field_validator,
model_validator,
)
class RepositoryGrantError(ValueError):
"""A repository grant is malformed, unsafe, or unsupported."""
class CommitCount(BaseModel):
"""Allowed number of descendant local commits for repository-grant v1."""
model_config = ConfigDict(extra="forbid", frozen=True)
min: StrictInt = Field(ge=1, le=32)
max: StrictInt = Field(ge=1, le=32)
@model_validator(mode="after")
def _ordered_bounds(self) -> CommitCount:
if self.min > self.max:
raise ValueError("commit bounds must satisfy 1 <= min <= max <= 32")
return self
class RepositoryGrant(BaseModel):
"""Versioned, local-only repository authority envelope."""
model_config = ConfigDict(extra="forbid", frozen=True)
version: Literal["1"]
allowed_paths: list[str] = Field(min_length=1, max_length=100)
commit_count: CommitCount
publish: StrictBool
@field_validator("allowed_paths")
@classmethod
def _validate_allowed_paths(cls, value: list[str]) -> list[str]:
if len(set(value)) != len(value):
raise ValueError("allowed_paths must not contain duplicates")
for pattern in value:
_validate_path_pattern(pattern)
return value
@field_validator("publish")
@classmethod
def _local_only(cls, value: bool) -> bool:
if value:
raise ValueError(
"version 1 does not grant publication; publish must be false"
)
return value
def payload(self) -> dict[str, Any]:
"""Return the admitted field without semantic rewriting or inference."""
return self.model_dump(mode="python")
@property
def grant_id(self) -> str:
"""Match rein-aharness's canonical, path-order-independent grant id."""
return _digest(
{
"allowed_paths": sorted(self.allowed_paths),
"commit_count": {
"max": self.commit_count.max,
"min": self.commit_count.min,
},
"publish": self.publish,
"version": self.version,
}
)[:32]
@property
def acceptance_policy_id(self) -> str:
"""Match the rein-aharness v1 acceptance-policy identity."""
return _digest(
{
"allowed_paths": sorted(self.allowed_paths),
"max_commits": self.commit_count.max,
"max_evidence_paths": 100,
"min_commits": self.commit_count.min,
}
)[:32]
def validate_repository_grant(value: Any) -> dict[str, Any]:
"""Validate and return an unchanged-shape repository-grant v1 mapping."""
try:
return RepositoryGrant.model_validate(value).payload()
except (TypeError, ValueError) as exc:
raise RepositoryGrantError(str(exc)) from exc
def _validate_path_pattern(pattern: str) -> None:
if not pattern or len(pattern) > 256:
raise ValueError(
"path patterns must be non-empty strings of at most 256 characters"
)
normalized = pattern.rstrip("/")
parts = tuple(normalized.split("/"))
if (
pattern.startswith("/")
or "\\" in pattern
or "\0" in pattern
or any(part in {"", ".", ".."} for part in parts)
):
raise ValueError(f"path pattern must be repository-relative: {pattern!r}")
if parts and parts[0] == ".git":
raise ValueError("path patterns cannot grant protected .git metadata")
def _digest(value: dict[str, Any]) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()

View file

@ -7,6 +7,7 @@ activity_core.* module outside rules/.
from __future__ import annotations
import re
from copy import deepcopy
from dataclasses import asdict
from typing import Any
@ -89,6 +90,10 @@ def _task_spec_for_rule(rule: dict, event: Any, context: dict) -> dict:
result["execution_refs"] = _render_execution_refs(
action.get("execution_refs"), event, context
)
if "repository_grant" in action:
# Authority is static definition data. It must never be templated from
# event/context values or inferred from another declaration field.
result["repository_grant"] = deepcopy(action.get("repository_grant"))
result["condition"] = rule.get("condition", "")
return result

View file

@ -173,6 +173,9 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]:
}
if execution_evidence:
compact_result["execution_evidence"] = execution_evidence
repository_transaction = dict(result.get("repository_transaction") or {})
if repository_transaction:
compact_result["repository_transaction"] = repository_transaction
return {
"id": str(row.id),
"state": row.state,
@ -185,7 +188,12 @@ def _ops_summary(row: OpsRun) -> dict[str, Any]:
"approach_hint": row.approach_hint,
"harness_profile_ref": row.harness_profile_ref,
"execution_refs": dict(row.execution_refs or {}),
"repository_grant": (
dict(row.repository_grant) if row.repository_grant is not None else None
),
"execution_evidence": execution_evidence,
"repository_transaction": repository_transaction,
"close_intent_digest": row.close_intent_digest,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"result": compact_result,