Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
540e28a724 | ||
|
|
e33664f033 | ||
|
|
83b77a53d2 | ||
|
|
30fff31145 | ||
|
|
765f337b82 | ||
|
|
034a07a05e | ||
|
|
5f65e45895 | ||
|
|
d841948c21 | ||
|
|
5d821096ce | ||
|
|
e87c70778a | ||
|
|
396ea98111 | ||
|
|
a6851c0a7e | ||
|
|
7b0d387221 | ||
|
|
faf6eb2289 | ||
|
|
d54f832668 | ||
|
|
b915386ccb | ||
|
|
2af1dfbc40 | ||
|
|
3b36619487 | ||
|
|
3e432fe42c | ||
|
|
7dbe51de39 | ||
|
|
b707f8a05f | ||
|
|
83c41c8f2e | ||
|
|
a96901ce52 | ||
|
|
084fbc9930 | ||
|
|
581e2d3c0c | ||
|
|
7ffe99a0b5 | ||
|
|
6582e8a5e1 | ||
|
|
8fd457f8f5 | ||
|
|
4e66d483e4 | ||
|
|
a887646377 | ||
|
|
1227714ce3 | ||
|
|
757de6b8ed | ||
|
|
eff070bc37 | ||
|
|
3b1c3a1b60 | ||
|
|
e55392184d | ||
|
|
7e0acaae4c | ||
|
|
3bd736db7f | ||
|
|
7022801a2d | ||
|
|
f9253dd28b | ||
|
|
c24ed5ee8e | ||
|
|
db18ca306d | ||
|
|
8f3bd0d11f | ||
|
|
7a64e326c2 | ||
|
|
3609f45d69 | ||
|
|
41df96a46a | ||
|
|
4c17ce3c98 | ||
|
|
5d0f9970f6 | ||
|
|
7837f67119 | ||
|
|
4a5e3cbfec | ||
|
|
64eed3b119 | ||
|
|
cd0a90bc37 | ||
|
|
6c1dcb829a | ||
|
|
fb8a0f3e47 | ||
|
|
d2ad00139d | ||
|
|
b5c27fec1d | ||
|
|
e43209ebdd | ||
|
|
ab149d0a57 | ||
|
|
dbefdcda07 | ||
|
|
536a2af0f0 | ||
|
|
7cc1730025 | ||
|
|
30f501c77e | ||
|
|
58987681fe | ||
|
|
440f1aedc9 | ||
|
|
9e7024ba63 | ||
|
|
e5f8209f57 | ||
|
|
4420b2afcb | ||
|
|
057e915664 | ||
|
|
89ed795e33 | ||
|
|
f18c7f1b51 | ||
|
|
9d2843c6b0 | ||
|
|
da0474ce96 | ||
|
|
e48c93ca28 | ||
|
|
97f8340d9a | ||
|
|
11944ef3f4 |
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "cat >/dev/null || true; CRG=\"C:/Users/ImRuY/.workbuddy-ai/binaries/python/envs/default/Scripts/code-review-graph.exe\"; [ -x \"$CRG\" ] || exit 0; git rev-parse --git-dir >/dev/null 2>&1 && PYTHONUTF8=1 \"$CRG\" update --skip-flows --repo \"$(git rev-parse --show-toplevel 2>/dev/null)\" || true",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "cat >/dev/null || true; CRG=\"C:/Users/ImRuY/.workbuddy-ai/binaries/python/envs/default/Scripts/code-review-graph.exe\"; [ -x \"$CRG\" ] || exit 0; git rev-parse --git-dir >/dev/null 2>&1 && PYTHONUTF8=1 \"$CRG\" status --repo \"$(git rev-parse --show-toplevel 2>/dev/null)\" || echo 'Not a git repo, skipping'",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: debug-issue
|
||||
description: Systematically debug issues using graph-powered code navigation
|
||||
---
|
||||
|
||||
## Debug Issue
|
||||
|
||||
Use the knowledge graph to systematically trace and debug issues.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `semantic_search_nodes_tool` to find code related to the issue.
|
||||
2. Use `query_graph_tool` with `callers_of` and `callees_of` to trace call chains.
|
||||
3. Use `get_flow_tool` to see full execution paths through suspected areas.
|
||||
4. Run `detect_changes_tool` to check if recent changes caused the issue.
|
||||
5. Use `get_impact_radius_tool` on suspected files to see what else is affected.
|
||||
|
||||
### Tips
|
||||
|
||||
- Check both callers and callees to understand the full context.
|
||||
- Look at affected flows to find the entry point that triggers the bug.
|
||||
- Recent changes are the most common source of new issues.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- Start with `get_minimal_context_tool(task="<your task>")` before other graph tools.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
- Read the implementation and its tests before changing code. The graph narrows scope; it does not replace the source.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: explore-codebase
|
||||
description: Navigate and understand codebase structure using the knowledge graph
|
||||
---
|
||||
|
||||
## Explore Codebase
|
||||
|
||||
Use the code-review-graph MCP tools to explore and understand the codebase.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `list_graph_stats_tool` to see overall codebase metrics.
|
||||
2. Run `get_architecture_overview_tool` for high-level community structure.
|
||||
3. Use `list_communities_tool` to find major modules, then `get_community_tool` for details.
|
||||
4. Use `semantic_search_nodes_tool` to find specific functions or classes.
|
||||
5. Use `query_graph_tool` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
|
||||
6. Use `list_flows_tool` and `get_flow_tool` to understand execution paths.
|
||||
|
||||
### Tips
|
||||
|
||||
- Start broad (stats, architecture) then narrow down to specific areas.
|
||||
- Use `children_of` on a file to see all its functions and classes.
|
||||
- Use `find_large_functions_tool` to identify complex code.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- Start with `get_minimal_context_tool(task="<your task>")` before other graph tools.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
- Read the implementation and its tests before changing code. The graph narrows scope; it does not replace the source.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: refactor-safely
|
||||
description: Plan and execute safe refactoring using dependency analysis
|
||||
---
|
||||
|
||||
## Refactor Safely
|
||||
|
||||
Use the knowledge graph to plan and execute refactoring with confidence.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
|
||||
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
|
||||
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
|
||||
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
|
||||
5. After changes, run `detect_changes_tool` to verify the refactoring impact.
|
||||
|
||||
### Safety Checks
|
||||
|
||||
- Always preview before applying (rename mode gives you an edit list).
|
||||
- Check `get_impact_radius_tool` before major refactors.
|
||||
- Use `get_affected_flows_tool` to ensure no critical paths are broken.
|
||||
- Run `find_large_functions_tool` to identify decomposition targets.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- Start with `get_minimal_context_tool(task="<your task>")` before other graph tools.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
- Read the implementation and its tests before changing code. The graph narrows scope; it does not replace the source.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: review-changes
|
||||
description: Perform a structured code review using change detection and impact
|
||||
---
|
||||
|
||||
## Review Changes
|
||||
|
||||
Perform a thorough, risk-aware code review using the knowledge graph.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `detect_changes_tool` to get risk-scored change analysis.
|
||||
2. Run `get_affected_flows_tool` to find impacted execution paths.
|
||||
3. For each high-risk function, run `query_graph_tool` with pattern="tests_for" to check test coverage.
|
||||
4. Run `get_impact_radius_tool` to understand the blast radius.
|
||||
5. For any untested changes, suggest specific test cases.
|
||||
|
||||
### Output Format
|
||||
|
||||
Provide findings grouped by risk level (high/medium/low) with:
|
||||
- What changed and why it matters
|
||||
- Test coverage status
|
||||
- Suggested improvements
|
||||
- Overall merge recommendation
|
||||
|
||||
## Token Efficiency Rules
|
||||
- Start with `get_minimal_context_tool(task="<your task>")` before other graph tools.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
- Read the implementation and its tests before changing code. The graph narrows scope; it does not replace the source.
|
||||
@@ -11,5 +11,3 @@ runtime/tmp/
|
||||
runtime/cache/
|
||||
logs/
|
||||
config/test_write_perms
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "C:\\Users\\ImRuY\\.workbuddy-ai\\binaries\\python\\envs\\default\\Scripts\\code-review-graph.exe",
|
||||
"args": [
|
||||
"serve"
|
||||
],
|
||||
"cwd": "C:\\Users\\ImRuY\\aios-main-clone",
|
||||
"env": {
|
||||
"PYTHONUTF8": "1"
|
||||
},
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**This project has a knowledge graph. Start with the code-review-graph
|
||||
MCP tools to narrow scope, then read the source.** The graph is cheaper than scanning files and
|
||||
gives you structural context (callers, dependents, test coverage) that file search cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
|
||||
|
||||
### Verify in the source
|
||||
|
||||
- Narrow scope with the graph, then read the source. Do not change code from graph output alone.
|
||||
- For any non-trivial change, read the implementation and the relevant tests before concluding.
|
||||
- Verify the exact source when touching behavior, database logic, migrations, retries, fallbacks,
|
||||
recovery, or compatibility code.
|
||||
- When the graph and the source disagree, the source wins. The graph may be stale or may not
|
||||
model that relationship.
|
||||
- An empty graph result can mean "not indexed" or "not statically visible", not "does not exist".
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
| ------ | ---------- |
|
||||
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context_tool` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius_tool` | Understanding blast radius of a change |
|
||||
| `get_affected_flows_tool` | Finding which execution paths are impacted |
|
||||
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes_tool` for code review.
|
||||
3. Use `get_affected_flows_tool` to understand impact.
|
||||
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
|
||||
<!-- /code-review-graph MCP tools -->
|
||||
@@ -1,40 +1,79 @@
|
||||
"""API adapter for agent context routing and visibility."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
APIRouter = HTTPException = None
|
||||
|
||||
from .agent_context_dashboard import AgentContextDashboard
|
||||
from .agent_context_rbac import AgentContextRBAC
|
||||
from .agent_context_resolver import AgentContextResolver
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
resolver = AgentContextResolver()
|
||||
rbac = AgentContextRBAC()
|
||||
dashboard = AgentContextDashboard()
|
||||
_AUTHORITY_KEYS = frozenset({"user_id", "group_id", "role", "owner", "owner_policy", "group_policy", "agent_default"})
|
||||
|
||||
|
||||
def resolve_agent_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
|
||||
principal = authenticated_principal()
|
||||
user_id = principal["user_id"]
|
||||
group_id = principal.get("group_id")
|
||||
role = principal["role"]
|
||||
if role == "group_admin" and (not isinstance(group_id, str) or not group_id):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
route = rbac.route(user_id, group_id, role)
|
||||
|
||||
def server_policy(name: str) -> dict[str, Any] | None:
|
||||
value = principal.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_POLICY")
|
||||
return dict(value)
|
||||
|
||||
def client_overlay(name: str) -> dict[str, Any] | None:
|
||||
value = payload.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_CONTEXT_OVERLAY")
|
||||
return {key: item for key, item in value.items() if key not in _AUTHORITY_KEYS}
|
||||
|
||||
defaults = server_policy("agent_default") or {}
|
||||
defaults.update(user_id=user_id, group_id=group_id or "", role=role)
|
||||
|
||||
context = resolver.resolve(
|
||||
user_id=payload["user_id"],
|
||||
group_id=payload.get("group_id", ""),
|
||||
session=payload.get("session"),
|
||||
user_profile=payload.get("user_profile"),
|
||||
group_policy=payload.get("group_policy"),
|
||||
agent_default=payload.get("agent_default"),
|
||||
owner_policy=payload.get("owner_policy"),
|
||||
owner=payload.get("role") == "owner",
|
||||
user_id=user_id,
|
||||
group_id=group_id or "",
|
||||
session=client_overlay("session"),
|
||||
user_profile=client_overlay("user_profile"),
|
||||
group_policy=server_policy("group_policy"),
|
||||
agent_default=defaults,
|
||||
owner_policy=server_policy("owner_policy") if role == "owner" else None,
|
||||
owner=role == "owner",
|
||||
)
|
||||
return {"routing": route.to_dict(), "context": context.to_dict()}
|
||||
|
||||
|
||||
def get_context_dashboard() -> dict[str, Any]:
|
||||
authenticated_principal()
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def _http(call):
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -42,15 +81,15 @@ def build_router():
|
||||
|
||||
@router.post("/resolve", operation_id="resolveAgentContext")
|
||||
def resolve(payload: dict[str, Any]):
|
||||
return resolve_agent_context(payload)
|
||||
return _http(lambda: resolve_agent_context(payload))
|
||||
|
||||
@router.get("/overview", operation_id="getAgentContextDashboard")
|
||||
def overview():
|
||||
return get_context_dashboard()
|
||||
return _http(get_context_dashboard)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
|
||||
__all__ = ["router", "set_authenticator", "resolve_agent_context", "get_context_dashboard"]
|
||||
|
||||
@@ -1,47 +1,129 @@
|
||||
"""Actions API for unified Agent Event Bus."""
|
||||
|
||||
"""Authenticated loopback API for the unified Agent Event Bus."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = Request = None
|
||||
|
||||
from .agent_event_bus import AgentEventBus
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
bus = AgentEventBus()
|
||||
_CREATE_FIELDS = frozenset({"kind", "source", "target", "summary", "payload", "correlation_id"})
|
||||
|
||||
|
||||
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.publish(**payload)}
|
||||
def _authorize(client_host: str | None) -> dict[str, Any]:
|
||||
try:
|
||||
address = ipaddress.ip_address(client_host or "")
|
||||
except ValueError as exc:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED") from exc
|
||||
if not address.is_loopback:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED")
|
||||
return authenticated_principal()
|
||||
|
||||
|
||||
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
|
||||
return {"status": "ok", "events": bus.list_events(kind, status)}
|
||||
def _event_scope(principal: Mapping[str, Any]) -> tuple[str, frozenset[str]]:
|
||||
tenant_id = principal.get("tenant_id")
|
||||
targets = principal.get("allowed_event_targets")
|
||||
if not isinstance(tenant_id, str) or not tenant_id:
|
||||
raise ApiSecurityError(403, "EVENT_TENANT_SCOPE_REQUIRED")
|
||||
if (not isinstance(targets, (list, tuple, set, frozenset)) or not targets
|
||||
or any(not isinstance(target, str) or not target for target in targets)):
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_REQUIRED")
|
||||
return tenant_id, frozenset(targets)
|
||||
|
||||
|
||||
def get_agent_event(event_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.get(event_id)}
|
||||
def _authorized_event(
|
||||
event: Mapping[str, Any] | None, tenant_id: str, targets: frozenset[str],
|
||||
) -> Mapping[str, Any] | None:
|
||||
if event is None:
|
||||
return None
|
||||
if event.get("tenant_id") != tenant_id or event.get("target") not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_SCOPE_DENIED")
|
||||
return event
|
||||
|
||||
|
||||
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
|
||||
def create_agent_event(payload: Mapping[str, Any], *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
if set(payload) - _CREATE_FIELDS:
|
||||
raise ApiSecurityError(403, "EVENT_FIELDS_FORBIDDEN")
|
||||
required = ("kind", "target", "summary", "payload")
|
||||
if any(name not in payload for name in required):
|
||||
raise ApiSecurityError(403, "INVALID_EVENT")
|
||||
normalized = {name: payload[name] for name in required}
|
||||
if not isinstance(normalized["target"], str) or normalized["target"] not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_DENIED")
|
||||
normalized["source"] = principal["user_id"]
|
||||
normalized["correlation_id"] = payload.get("correlation_id")
|
||||
normalized["tenant_id"] = tenant_id
|
||||
return {"status": "ok", "event": bus.publish(**normalized)}
|
||||
|
||||
|
||||
def agent_event_stats() -> dict[str, Any]:
|
||||
return {"status": "ok", "stats": bus.stats()}
|
||||
def list_agent_events(
|
||||
kind: str | None = None, status: str = "pending", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "events": bus.list_events(
|
||||
kind, status, tenant_id=tenant_id, targets=targets,
|
||||
)}
|
||||
|
||||
|
||||
# Compatibility adapters
|
||||
|
||||
def list_gpt_inbox_messages():
|
||||
return list_agent_events(kind="gpt_inbox")
|
||||
def get_agent_event(event_id: str, *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "event": _authorized_event(bus.get(event_id), tenant_id, targets)}
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str):
|
||||
return transition_agent_event(event_id, "completed", "custom-gpt")
|
||||
def transition_agent_event(
|
||||
event_id: str, status: str, actor: str = "system", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del actor
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
_authorized_event(bus.get(event_id), tenant_id, targets)
|
||||
return {"status": "ok", "event": bus.transition(
|
||||
event_id, status, principal["user_id"], expected_tenant_id=tenant_id,
|
||||
expected_targets=targets,
|
||||
)}
|
||||
|
||||
|
||||
def agent_event_stats(*, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "stats": bus.stats(tenant_id=tenant_id, targets=targets)}
|
||||
|
||||
|
||||
def list_gpt_inbox_messages(*, client_host: str | None = None):
|
||||
return list_agent_events(kind="gpt_inbox", client_host=client_host)
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str, *, client_host: str | None = None):
|
||||
return transition_agent_event(event_id, "completed", client_host=client_host)
|
||||
|
||||
|
||||
def _host(request: Any) -> str | None:
|
||||
client = getattr(request, "client", None)
|
||||
return getattr(client, "host", None)
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -50,28 +132,32 @@ def build_router():
|
||||
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
|
||||
|
||||
@router.post("", operation_id="createAgentEvent")
|
||||
def create(payload: dict[str, Any]):
|
||||
return create_agent_event(payload)
|
||||
def create(payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: create_agent_event(payload, client_host=_host(request)))
|
||||
|
||||
@router.get("", operation_id="listAgentEvents")
|
||||
def listing(kind: str | None = None, status: str = "pending"):
|
||||
return list_agent_events(kind, status)
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str):
|
||||
return get_agent_event(event_id)
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any]):
|
||||
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
|
||||
def listing(request: Request, kind: str | None = None, status: str = "pending"):
|
||||
return _http(lambda: list_agent_events(kind, status, client_host=_host(request)))
|
||||
|
||||
@router.get("/stats/overview", operation_id="getAgentEventStats")
|
||||
def stats():
|
||||
return agent_event_stats()
|
||||
def stats(request: Request):
|
||||
return _http(lambda: agent_event_stats(client_host=_host(request)))
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str, request: Request):
|
||||
return _http(lambda: get_agent_event(event_id, client_host=_host(request)))
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: transition_agent_event(
|
||||
event_id, payload.get("status", ""), payload.get("actor", "system"),
|
||||
client_host=_host(request),
|
||||
))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
|
||||
__all__ = [
|
||||
"router", "set_authenticator", "create_agent_event", "list_agent_events",
|
||||
"get_agent_event", "transition_agent_event", "agent_event_stats",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Unified Agent Event Bus.
|
||||
|
||||
Unifies commands, GPT messages, replies, approvals, audit and reports into one
|
||||
state machine. Compatibility layers can filter events by kind instead of keeping
|
||||
separate queues.
|
||||
"""
|
||||
|
||||
"""Filesystem-backed Agent Event Bus with strict path contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
@@ -12,8 +6,12 @@ from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from .security_path_guards import contained_path, safe_segment, write_text_no_follow
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -27,6 +25,7 @@ class AgentEvent:
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
status: str = "pending"
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
@@ -39,87 +38,223 @@ class AgentEvent:
|
||||
|
||||
class AgentEventBus:
|
||||
STATES = ("pending", "running", "completed", "failed")
|
||||
_DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
|
||||
def __init__(self, root: str = "agents/events"):
|
||||
self.root = Path(root)
|
||||
for state in self.STATES:
|
||||
(self.root / state).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "audit").mkdir(parents=True, exist_ok=True)
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("symlink event root forbidden")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
self, kind: str, source: str, target: str, summary: str,
|
||||
payload: dict[str, Any], correlation_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
if not all(isinstance(value, str) and value for value in (kind, source, target, summary)):
|
||||
raise ValueError("event text fields must be nonempty strings")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("event payload must be an object")
|
||||
if tenant_id is not None and (not isinstance(tenant_id, str) or not tenant_id):
|
||||
raise ValueError("invalid event tenant")
|
||||
identity = json.dumps(
|
||||
{"kind": kind, "source": source, "target": target, "summary": summary,
|
||||
"payload": payload, "correlation_id": correlation_id, "tenant_id": tenant_id},
|
||||
sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":"),
|
||||
)
|
||||
event_id = "evt_" + sha256(identity.encode("utf-8")).hexdigest()[:16]
|
||||
safe_segment(event_id)
|
||||
event = AgentEvent(
|
||||
event_id, kind, source, target, summary, payload,
|
||||
tenant_id=tenant_id, correlation_id=correlation_id,
|
||||
)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
return event.to_dict()
|
||||
|
||||
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted((self.root / status).glob("*.json")):
|
||||
event = self._read(path)
|
||||
def list_events(
|
||||
self, kind: str | None = None, status: str = "pending", limit: int = 100,
|
||||
*, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._state(status)
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0 or limit > 1000:
|
||||
raise ValueError("invalid event limit")
|
||||
if limit == 0:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for name in sorted(self._names(status)):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
event_id = safe_segment(name[:-5])
|
||||
event = self._read(status, event_id)
|
||||
if kind and event.get("kind") != kind:
|
||||
continue
|
||||
if tenant_id is not None and event.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
if targets is not None and event.get("target") not in targets:
|
||||
continue
|
||||
result.append(event)
|
||||
return result[:limit]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def get(self, event_id: str) -> dict[str, Any] | None:
|
||||
safe_segment(event_id)
|
||||
for state in self.STATES:
|
||||
path = self.root / state / f"{event_id}.json"
|
||||
if path.exists():
|
||||
return self._read(path)
|
||||
if f"{event_id}.json" in self._names(state):
|
||||
return self._read(state, event_id)
|
||||
return None
|
||||
|
||||
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
|
||||
def transition(
|
||||
self, event_id: str, status: str, actor: str | None = None,
|
||||
*, expected_tenant_id: str | None = None,
|
||||
expected_targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
self._state(status) # validate before any lookup or mutation
|
||||
event = self.get(event_id)
|
||||
if not event:
|
||||
return {"status": "not_found", "id": event_id}
|
||||
if status not in self.STATES:
|
||||
return {"status": "invalid_state"}
|
||||
old = event.get("status", "pending")
|
||||
old_path = self.root / old / f"{event_id}.json"
|
||||
event["status"] = status
|
||||
event["actor"] = actor
|
||||
event["updated_at"] = utc_now()
|
||||
if expected_tenant_id is not None and event.get("tenant_id") != expected_tenant_id:
|
||||
raise ValueError("event tenant mismatch")
|
||||
if expected_targets is not None and event.get("target") not in expected_targets:
|
||||
raise ValueError("event target mismatch")
|
||||
old = event.get("status")
|
||||
self._state(old)
|
||||
event.update(status=status, actor=actor, updated_at=utc_now())
|
||||
self._write(status, event)
|
||||
if old_path.exists() and old != status:
|
||||
old_path.unlink()
|
||||
if old != status:
|
||||
self._unlink(old, event_id)
|
||||
self._audit("transition", event, {"from": old, "to": status})
|
||||
return event
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
def stats(
|
||||
self, *, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if tenant_id is not None:
|
||||
events = {
|
||||
state: len(self.list_events(status=state, limit=1000, tenant_id=tenant_id, targets=targets))
|
||||
for state in self.STATES
|
||||
}
|
||||
audit_events = 0
|
||||
for name in self._names("audit"):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
audit = self._read("audit", safe_segment(name[:-5]))
|
||||
event = audit.get("event") if isinstance(audit, dict) else None
|
||||
if (isinstance(event, dict) and event.get("tenant_id") == tenant_id
|
||||
and (targets is None or event.get("target") in targets)):
|
||||
audit_events += 1
|
||||
return {"events": events, "audit_events": audit_events, "status": "healthy"}
|
||||
return {
|
||||
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
|
||||
"audit_events": len(list((self.root / "audit").glob("*.json"))),
|
||||
"events": {state: sum(name.endswith(".json") for name in self._names(state)) for state in self.STATES},
|
||||
"audit_events": sum(name.endswith(".json") for name in self._names("audit")),
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]):
|
||||
(self.root / state / f"{data['id']}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
def _state(self, state: Any) -> str:
|
||||
if state not in self.STATES:
|
||||
raise ValueError("invalid event state")
|
||||
return str(state)
|
||||
|
||||
def _ensure_root(self) -> None:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("invalid event root")
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]) -> None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not isinstance(data.get("id"), str):
|
||||
raise ValueError("event id must be a string")
|
||||
event_id = safe_segment(data["id"])
|
||||
self._ensure_root()
|
||||
write_text_no_follow(
|
||||
self.root, f"{state}/{event_id}.json",
|
||||
json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
|
||||
)
|
||||
|
||||
def _read(self, path: Path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
def _open_state(self, state: str, *, create: bool = False) -> tuple[int, int] | None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not self.root.exists():
|
||||
if not create:
|
||||
return None
|
||||
self._ensure_root()
|
||||
contained_path(self.root, state)
|
||||
root_fd = os.open(self.root, self._DIR_FLAGS)
|
||||
try:
|
||||
try:
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
os.close(root_fd)
|
||||
return None
|
||||
os.mkdir(state, mode=0o750, dir_fd=root_fd)
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
return root_fd, state_fd
|
||||
except Exception:
|
||||
try:
|
||||
os.close(root_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
|
||||
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
|
||||
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
def _names(self, state: str) -> set[str]:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
if not self.root.exists():
|
||||
return set()
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return set()
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
return set(os.listdir(state_fd))
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _read(self, state: str, event_id: str) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
raise FileNotFoundError(event_id)
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
fd = os.open(f"{event_id}.json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=state_fd)
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
os.close(fd)
|
||||
raise ValueError("unsafe event file")
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
||||
value = json.load(stream)
|
||||
if (not isinstance(value, dict) or value.get("id") != event_id
|
||||
or (state != "audit" and value.get("status") != state)):
|
||||
raise ValueError("invalid event record")
|
||||
return value
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _unlink(self, state: str, event_id: str) -> None:
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
info = os.stat(f"{event_id}.json", dir_fd=state_fd, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("unsafe event file")
|
||||
os.unlink(f"{event_id}.json", dir_fd=state_fd)
|
||||
os.fsync(state_fd)
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None) -> None:
|
||||
safe_segment(action)
|
||||
audit = {"id": f"{safe_segment(event['id'])}-{action}", "action": action,
|
||||
"event": event, "extra": extra or {}, "time": utc_now()}
|
||||
self._write("audit", audit)
|
||||
|
||||
|
||||
__all__ = ["AgentEvent", "AgentEventBus"]
|
||||
|
||||
@@ -1,77 +1,178 @@
|
||||
"""API for agent orchestration observability."""
|
||||
|
||||
"""Authenticated API for agent orchestration observability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
||||
store: AgentObservabilityStore | None = None
|
||||
dashboard = AgentObservabilityDashboard()
|
||||
_ROLE_WHITELIST = frozenset({"user", "group_admin", "owner"})
|
||||
_RECORD_FIELDS = frozenset(AgentExecutionRecord.__dataclass_fields__)
|
||||
_authenticator: Callable[[], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the querying tenant.
|
||||
class ApiSecurityError(PermissionError):
|
||||
def __init__(self, status_code: int, code: str):
|
||||
super().__init__(code)
|
||||
self.status_code, self.code = status_code, code
|
||||
|
||||
The callable returns the authenticated principal (dict with "user_id" and
|
||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
||||
user/group ids are NOT trusted to escalate scope.
|
||||
"""
|
||||
|
||||
def _storage() -> AgentObservabilityStore:
|
||||
global store
|
||||
if store is None:
|
||||
store = AgentObservabilityStore()
|
||||
return store
|
||||
|
||||
|
||||
def set_authenticator(fn: Callable[[], Mapping[str, Any] | None] | None) -> None:
|
||||
"""Install the host authenticator; its result is fetched for every call."""
|
||||
if fn is not None and not callable(fn):
|
||||
raise TypeError("authenticator must be callable or None")
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def _principal():
|
||||
if _authenticator is not None:
|
||||
p = _authenticator()
|
||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
||||
return p
|
||||
return None
|
||||
def _group_id(principal: Mapping[str, Any]) -> str | None:
|
||||
value = principal.get("group_id")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
def authenticated_principal() -> dict[str, Any]:
|
||||
"""Return a request-scoped copy of the server principal, or fail closed."""
|
||||
if _authenticator is None:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
try:
|
||||
raw = _authenticator()
|
||||
except ApiSecurityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_FAILED") from exc
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
principal = dict(raw)
|
||||
user_id, role = principal.get("user_id"), principal.get("role")
|
||||
if not isinstance(user_id, str) or not user_id.strip() or role not in _ROLE_WHITELIST:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
principal["user_id"] = user_id.strip()
|
||||
if principal.get("group_id") is not None and _group_id(principal) is None:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
if role == "group_admin" and not _group_id(principal):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
return principal
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
# The authenticated principal (tenant) is authoritative; the request params
|
||||
# role/user_id/group_id can never choose owner or another tenant.
|
||||
principal = _principal()
|
||||
pr_user = principal.get("user_id") if principal else None
|
||||
pr_role = principal.get("role", "user") if principal else "user"
|
||||
def _scope_set(principal: Mapping[str, Any], name: str) -> frozenset[str]:
|
||||
raw = principal.get(name, ())
|
||||
if not isinstance(raw, (list, tuple, set, frozenset)) or any(
|
||||
not isinstance(item, str) or not item for item in raw
|
||||
):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_SCOPE")
|
||||
return frozenset(raw)
|
||||
|
||||
if pr_role == "owner":
|
||||
# Server-verified owner may browse a requested scope.
|
||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
||||
summary_scope = None
|
||||
elif pr_user:
|
||||
# Authenticated non-owner: strictly scoped to own tenant.
|
||||
q_user, q_group, q_role = pr_user, None, "user"
|
||||
summary_scope = pr_user
|
||||
else:
|
||||
# Anonymous: nothing scoped to them.
|
||||
q_user, q_group, q_role = None, None, "user"
|
||||
summary_scope = None
|
||||
|
||||
records = store.query(q_user, q_group, q_role)
|
||||
def _owner_user(principal: Mapping[str, Any], requested: str | None) -> str:
|
||||
own, target = str(principal["user_id"]), requested or str(principal["user_id"])
|
||||
if target != own and target not in _scope_set(principal, "allowed_user_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_USER_SCOPE_DENIED")
|
||||
return target
|
||||
|
||||
|
||||
def _owner_group(principal: Mapping[str, Any], requested: str | None) -> str | None:
|
||||
if requested is None:
|
||||
return None
|
||||
if requested != _group_id(principal) and requested not in _scope_set(principal, "allowed_group_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_GROUP_SCOPE_DENIED")
|
||||
return requested
|
||||
|
||||
|
||||
def _summary(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
count = len(records)
|
||||
hermes = sum(1 for row in records if row.get("used_hermes"))
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(summary_scope),
|
||||
"total_executions": count,
|
||||
"openclaw_independent_loops": count - hermes,
|
||||
"hermes_orchestrated_loops": hermes,
|
||||
"average_duration_ms": (
|
||||
sum(int(row.get("duration_ms") or 0) for row in records) / count if count else 0
|
||||
),
|
||||
"token_usage": {
|
||||
"input": sum(int(row.get("input_tokens") or 0) for row in records),
|
||||
"output": sum(int(row.get("output_tokens") or 0) for row in records),
|
||||
"total": sum(int(row.get("total_tokens") or 0) for row in records),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def record_agent_execution(payload: Mapping[str, Any]):
|
||||
principal = authenticated_principal()
|
||||
if set(payload) - _RECORD_FIELDS:
|
||||
raise ApiSecurityError(403, "EXECUTION_FIELDS_FORBIDDEN")
|
||||
normalized, role = dict(payload), principal["role"]
|
||||
if role == "owner":
|
||||
requested_user = payload.get("user_id")
|
||||
if requested_user is not None and not isinstance(requested_user, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
target_user = _owner_user(principal, requested_user)
|
||||
requested_scope = payload.get("visible_scope")
|
||||
if requested_scope is not None and not isinstance(requested_scope, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
allowed_group = _owner_group(principal, requested_scope) if requested_scope and requested_scope != target_user else None
|
||||
normalized["user_id"], normalized["visible_scope"] = target_user, allowed_group or target_user
|
||||
else:
|
||||
normalized["user_id"] = principal["user_id"]
|
||||
normalized["visible_scope"] = _group_id(principal) or principal["user_id"]
|
||||
try:
|
||||
record = AgentExecutionRecord(**normalized)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_RECORD") from exc
|
||||
return {"status": "ok", "record": _storage().append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(
|
||||
user_id: str | None = None, group_id: str | None = None,
|
||||
role: str = "user",
|
||||
):
|
||||
del role # wire compatibility only; server principal is authoritative
|
||||
principal, pr_role = authenticated_principal(), None
|
||||
pr_role = principal["role"]
|
||||
if pr_role == "user":
|
||||
if user_id not in (None, principal["user_id"]) or group_id is not None:
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif pr_role == "group_admin":
|
||||
own_group = _group_id(principal)
|
||||
if group_id not in (None, own_group) or user_id not in (None, principal["user_id"]):
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], own_group, "group_admin"
|
||||
elif user_id is None and group_id is None:
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif group_id is not None:
|
||||
q_user = _owner_user(principal, user_id) if user_id is not None else principal["user_id"]
|
||||
q_group, q_role = _owner_group(principal, group_id), "group_admin"
|
||||
else:
|
||||
q_user, q_group, q_role = _owner_user(principal, user_id), None, "user"
|
||||
records = _storage().query(q_user, q_group, q_role)
|
||||
return {"status": "ok", "records": records, "summary": _summary(records)}
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -79,15 +180,13 @@ def build_router():
|
||||
|
||||
@router.post("/execution", operation_id="recordAgentExecution")
|
||||
def record(payload: dict[str, Any]):
|
||||
return record_agent_execution(payload)
|
||||
return _http(lambda: record_agent_execution(payload))
|
||||
|
||||
@router.get("/overview", operation_id="getAgentObservabilityOverview")
|
||||
def overview(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
return get_agent_observability(user_id, group_id, role)
|
||||
|
||||
return _http(lambda: get_agent_observability(user_id, group_id, role))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
|
||||
__all__ = ["ApiSecurityError", "authenticated_principal", "set_authenticator", "router", "record_agent_execution", "get_agent_observability"]
|
||||
|
||||
@@ -0,0 +1,940 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import (
|
||||
CAPABILITY_VOCABULARY_DIGEST,
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
)
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
ACTIVE_LEASE_STATES = frozenset({"CLAIMED", "RUNNING"})
|
||||
TASK_STATES = frozenset({
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
})
|
||||
VERIFY_DECISIONS = frozenset({"SUCCEEDED", "NEEDS_REWORK", "FAILED"})
|
||||
|
||||
|
||||
class TaskProtocolError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
kind: str
|
||||
identifier: str
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _string_list(value: Iterable[object] | None, field: str, *, max_items: int = 128) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > 160:
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result.append(text)
|
||||
if len(result) > max_items:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 400)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def _json_object(value: object | None, field: str, *, max_bytes: int = 128 * 1024) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result = dict(value)
|
||||
if len(canonical_json(result).encode("utf-8")) > max_bytes:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 413)
|
||||
return result
|
||||
|
||||
|
||||
def _criteria(value: object | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_INVALID", 400)
|
||||
if len(value) > 128:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
if len(canonical_json(value).encode("utf-8")) > 128 * 1024:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
return list(value)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""SQLite-backed durable task store.
|
||||
|
||||
SQLite BEGIN IMMEDIATE serializes claims so two agents cannot successfully
|
||||
claim the same queued task. WAL mode allows concurrent readers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
now: Callable[[], datetime] = utcnow,
|
||||
token_pepper: str = "",
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.now = now
|
||||
self.token_pepper = token_pepper
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=10000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
capability_vocabulary_digest TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
capability_vocabulary_digest TEXT,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(claimed_by) REFERENCES agents(agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_queue
|
||||
ON tasks(status, priority DESC, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lease
|
||||
ON tasks(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "agents", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "tasks", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_additive_column(
|
||||
connection: sqlite3.Connection, table: str, column: str, definition: str
|
||||
) -> None:
|
||||
"""Add C6 handshake storage without rewriting historical task rows."""
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
}
|
||||
if column not in columns:
|
||||
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_capability_vocabulary_digest(value: object | None) -> str | None:
|
||||
"""Validate an opt-in formal vocabulary handshake.
|
||||
|
||||
An omitted digest is the explicit C5-compatible legacy mode. A
|
||||
supplied digest has no downgrade path: it must name this exact formal
|
||||
vocabulary before any durable state is changed.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or value.strip().lower() != CAPABILITY_VOCABULARY_DIGEST:
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
return CAPABILITY_VOCABULARY_DIGEST
|
||||
|
||||
@staticmethod
|
||||
def _negotiated_capability_vocabulary_matches(
|
||||
*,
|
||||
agent_digest: str | None,
|
||||
task_digest: str | None,
|
||||
requested_digest: str | None,
|
||||
) -> bool:
|
||||
"""Match only an explicitly negotiated formal task vocabulary.
|
||||
|
||||
Historical agents and tasks carry ``NULL`` and keep their C5 behavior.
|
||||
A task admitted with a digest is never offered to or claimed by an
|
||||
agent that did not register the same current vocabulary.
|
||||
"""
|
||||
if task_digest is None:
|
||||
return True
|
||||
return (
|
||||
task_digest == CAPABILITY_VOCABULARY_DIGEST
|
||||
and agent_digest == task_digest
|
||||
and (requested_digest is None or requested_digest == task_digest)
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row_task(row: sqlite3.Row, *, include_result: bool = True) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
if include_result:
|
||||
value["result"] = json.loads(row["result_json"]) if row["result_json"] else None
|
||||
value["verification"] = (
|
||||
json.loads(row["verification_json"]) if row["verification_json"] else None
|
||||
)
|
||||
return value
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
event_type: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(task_id, agent_id, event_type, canonical_json(dict(payload or {})), iso(self.now())),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
agent_type: str,
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
agent_version = agent_version.strip()
|
||||
if not agent_id or len(agent_id) > 160 or not agent_type or not agent_version:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"capability_vocabulary_digest,metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
vocabulary_digest,
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"AGENT_REGISTERED",
|
||||
agent_id=agent_id,
|
||||
payload={"agent_type": agent_type, "agent_version": agent_version},
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("AGENT_ALREADY_EXISTS", 409) from exc
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
|
||||
def authenticate_agent(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
token_hash = self._token_hash(token)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM agents WHERE token_hash=? AND status='ACTIVE'",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
connection.execute(
|
||||
"UPDATE agents SET last_seen_at=? WHERE agent_id=?",
|
||||
(iso(self.now()), row["agent_id"]),
|
||||
)
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(scope, key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not hmac.compare_digest(row["payload_digest"], payload_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
def _idempotent_store(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(scope, key, payload_digest, canonical_json(dict(response)), iso(self.now())),
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
priority: int = 0,
|
||||
max_attempts: int = 3,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = (task_id or ("task-" + uuid.uuid4().hex)).strip()
|
||||
goal = goal.strip()
|
||||
instructions = instructions.strip()
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not task_id or len(task_id) > 180 or not goal or not instructions:
|
||||
raise TaskProtocolError("TASK_BINDING_INVALID", 400)
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
if not isinstance(priority, int) or isinstance(priority, bool) or not -100 <= priority <= 100:
|
||||
raise TaskProtocolError("PRIORITY_INVALID", 400)
|
||||
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) or not 1 <= max_attempts <= 20:
|
||||
raise TaskProtocolError("MAX_ATTEMPTS_INVALID", 400)
|
||||
parsed_deadline = parse_time(deadline)
|
||||
if parsed_deadline and parsed_deadline <= self.now():
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
normalized_required = normalize_required_capabilities(required)
|
||||
if any(value.startswith("exact:") for value in normalized_required):
|
||||
raise TaskProtocolError("TASK_CAPABILITY_UNKNOWN", 403)
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
"priority": priority,
|
||||
"max_attempts": max_attempts,
|
||||
}
|
||||
payload_digest = digest_json(payload)
|
||||
now = iso(self.now())
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = self._idempotent_lookup(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,capability_vocabulary_digest,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
vocabulary_digest,
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
priority,
|
||||
max_attempts,
|
||||
0,
|
||||
"QUEUED",
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("TASK_ID_CONFLICT", 409) from exc
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
result = self._row_task(row)
|
||||
self._idempotent_store(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
response=result,
|
||||
)
|
||||
self._event(connection, "TASK_CREATED", task_id=task_id, payload={"priority": priority})
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _expire_locked(self, connection: sqlite3.Connection) -> None:
|
||||
now_dt = self.now()
|
||||
now = iso(now_dt)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('QUEUED','CLAIMED','RUNNING')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
deadline = parse_time(row["deadline"])
|
||||
if deadline is not None and deadline <= now_dt:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='EXPIRED',claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(now, row["task_id"]),
|
||||
)
|
||||
self._event(connection, "TASK_EXPIRED", task_id=row["task_id"])
|
||||
continue
|
||||
if row["status"] in ACTIVE_LEASE_STATES:
|
||||
lease_expires = parse_time(row["lease_expires_at"])
|
||||
if lease_expires is not None and lease_expires <= now_dt:
|
||||
if row["attempt"] >= row["max_attempts"]:
|
||||
next_state = "FAILED"
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
if next_state == "FAILED":
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"LEASE_EXPIRED",
|
||||
task_id=row["task_id"],
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"next_state": next_state, "attempt": row["attempt"]},
|
||||
)
|
||||
|
||||
def expire_leases(self) -> int:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
before = connection.total_changes
|
||||
self._expire_locked(connection)
|
||||
changed = connection.total_changes - before
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
limit: int = 20,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
self._expire_locked(connection)
|
||||
capabilities = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if (
|
||||
self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
)
|
||||
and has_capabilities(capabilities, required)
|
||||
):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
if not self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
):
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = set(json.loads(agent["capabilities_json"]))
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_VERIFIED",
|
||||
task_id=task_id,
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"decision": decision, "next_state": next_state},
|
||||
)
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def cancel_task(self, *, task_id: str, reason: str = "") -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] in TERMINAL_STATES:
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='CANCELLED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(iso(self.now()), task_id),
|
||||
)
|
||||
self._event(connection, "TASK_CANCELLED", task_id=task_id, payload={"reason": reason})
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.expire_leases()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
return self._row_task(row)
|
||||
|
||||
def get_events(self, task_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY event_id DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 1000))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"task_id": row["task_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
@@ -6,7 +6,7 @@ The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget,EvidenceEnvelope
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,6 +69,7 @@ class BaselineService:
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
self.store.evidence_attestation_verifier=self._verify_task_evidence
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
@@ -379,11 +380,39 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
with self.store._transaction():
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
evidence=EvidenceEnvelope.create(task_id=task.task_id,claim_id=claim.claim_id,
|
||||
kind='diagnostic',worker_identity=claim.worker_identity,payload=envelope,
|
||||
previous_evidence_hash=self.store._last_evidence_hash(task.task_id),
|
||||
attestation_ref='baseline-machine:'+self.signer.key_id)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':self.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(self.signer.private_key.sign(proof)).decode())
|
||||
self.store.complete_claim_with_evidence_and_event(task.task_id,claim.claim_id,
|
||||
'COMPLETED',envelope,[evidence])
|
||||
|
||||
def _verify_task_evidence(self,evidence):
|
||||
"""Verify the TaskPlane wrapper and its separately signed operation receipt."""
|
||||
try:
|
||||
if not isinstance(evidence,EvidenceEnvelope) or evidence.kind!='diagnostic': return False
|
||||
prefix='baseline-machine:'
|
||||
if not evidence.attestation_ref or not evidence.attestation_ref.startswith(prefix): return False
|
||||
key_id=evidence.attestation_ref[len(prefix):]
|
||||
key=self.signer.trusted_public_keys.get(key_id)
|
||||
if key is None or not evidence.signature: return False
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':key_id,'evidence':evidence.digest_payload()})
|
||||
key.verify(base64.b64decode(evidence.signature,validate=True),proof)
|
||||
claim=self.store.get_claim(evidence.claim_id)
|
||||
task=self.store.get_task(evidence.task_id)
|
||||
if claim.task_id!=task.task_id or claim.worker_identity!=evidence.worker_identity: return False
|
||||
if claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
return self._receipt_valid(task,evidence.payload)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Canonical fail-closed capability semantics for formal AgentTask eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
# These aliases are the verified C4 contract captured in
|
||||
# ``qa/capability-contract.txt``. Unknown task names become exact requirements;
|
||||
# unknown worker names are never granted.
|
||||
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"repository_read": frozenset({"repo.read"}),
|
||||
"git.read": frozenset({"repo.read"}),
|
||||
"repo.read": frozenset({"repo.read"}),
|
||||
"test_execution": frozenset({"test.execute"}),
|
||||
"git.test": frozenset({"test.execute"}),
|
||||
"test.execute": frozenset({"test.execute"}),
|
||||
"governed_git_write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.git.write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||
"shell": frozenset({"runtime.shell"}),
|
||||
"git": frozenset({"runtime.git"}),
|
||||
"python": frozenset({"runtime.python"}),
|
||||
"code.read": frozenset({"legacy.code.read"}),
|
||||
"code.review": frozenset({"legacy.code.review"}),
|
||||
"code.write": frozenset({"legacy.code.write"}),
|
||||
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||
"test.python": frozenset({"legacy.test.python"}),
|
||||
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||
"git.commit": frozenset({"legacy.git.commit"}),
|
||||
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||
"review.security": frozenset({"legacy.review.security"}),
|
||||
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
}
|
||||
|
||||
_WORKER_ALIASES = {
|
||||
key: value for key, value in _TASK_ALIASES.items()
|
||||
if key not in {"release:candidate", "governed_git_write", "governed.git.write"}
|
||||
}
|
||||
_WORKER_ALIASES.update({
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"governed.git.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.git.write.file": frozenset({"governed.git.write.file"}),
|
||||
})
|
||||
|
||||
_EXACT_LIFECYCLE_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"production.plan.apply",
|
||||
"production.promote",
|
||||
"production.rollback",
|
||||
"task.principal.issue",
|
||||
"testing",
|
||||
"verification",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
for _capability in _EXACT_LIFECYCLE_CAPABILITIES:
|
||||
_TASK_ALIASES[_capability] = frozenset({_capability})
|
||||
_WORKER_ALIASES[_capability] = frozenset({_capability})
|
||||
_UNKNOWN_PREFIX = "exact:"
|
||||
CAPABILITY_VOCABULARY_SCHEMA = "aios.agent-task.capability-vocabulary.v1"
|
||||
|
||||
|
||||
def capability_vocabulary_descriptor() -> dict[str, object]:
|
||||
"""Return the immutable inputs to the formal capability vocabulary digest.
|
||||
|
||||
The generic Task Plane has deliberately different wildcard semantics. This
|
||||
descriptor names only the formal AgentTask vocabulary used in this module.
|
||||
"""
|
||||
return {
|
||||
"schema": CAPABILITY_VOCABULARY_SCHEMA,
|
||||
"task_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_TASK_ALIASES.items())
|
||||
},
|
||||
"worker_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_WORKER_ALIASES.items())
|
||||
},
|
||||
"unknown_required_prefix": _UNKNOWN_PREFIX,
|
||||
"unknown_required": "exact-and-deny",
|
||||
"unknown_worker": "never-granted",
|
||||
"wildcard": "formal-deny",
|
||||
}
|
||||
|
||||
|
||||
def capability_vocabulary_digest() -> str:
|
||||
"""Return a deterministic digest for the formal capability vocabulary."""
|
||||
encoded = json.dumps(
|
||||
capability_vocabulary_descriptor(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
# A constant lets registration, admission, poll, and claim compare one exact
|
||||
# formal vocabulary without importing or interpreting the generic Task Plane.
|
||||
CAPABILITY_VOCABULARY_DIGEST = capability_vocabulary_digest()
|
||||
|
||||
|
||||
def _clean(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("capability must be a string")
|
||||
text = value.strip().lower()
|
||||
if not text:
|
||||
raise ValueError("capability must not be empty")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
continue
|
||||
mapped = _WORKER_ALIASES.get(text)
|
||||
if mapped is not None:
|
||||
result.update(mapped)
|
||||
if {"governed.git.write.branch", "governed.git.write.file"}.issubset(result):
|
||||
result.add("governed.git.write")
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def has_capabilities(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> bool:
|
||||
required = normalize_required_capabilities(required_capabilities)
|
||||
effective = normalize_worker_capabilities(worker_capabilities)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in required):
|
||||
return False
|
||||
return required.issubset(effective)
|
||||
|
||||
|
||||
def required_capabilities_subset(granted_requirements: Iterable[object], required_requirements: Iterable[object]) -> bool:
|
||||
granted = normalize_required_capabilities(granted_requirements)
|
||||
required = normalize_required_capabilities(required_requirements)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in granted | required):
|
||||
return False
|
||||
return required.issubset(granted)
|
||||
|
||||
|
||||
def normalized_capability_matrix(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> dict[str, object]:
|
||||
worker_values = tuple(worker_capabilities)
|
||||
required_values = tuple(required_capabilities)
|
||||
worker = normalize_worker_capabilities(worker_values)
|
||||
required = normalize_required_capabilities(required_values)
|
||||
return {
|
||||
"worker_effective": sorted(worker),
|
||||
"task_required": sorted(required),
|
||||
"eligible": has_capabilities(worker_values, required_values),
|
||||
}
|
||||
@@ -158,7 +158,10 @@ def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
||||
from security_path_guards import safe_segment, relative_parts, write_text_no_follow
|
||||
|
||||
def safe(s: str, n=160) -> str:
|
||||
return safe_segment(s, max_length=n)
|
||||
def sh(args, check=False):
|
||||
p = subprocess.run(['git'] + args, cwd=str(WS), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git failed')
|
||||
@@ -206,7 +209,10 @@ def relpath(path: str) -> str:
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
return str(Path(x))
|
||||
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
||||
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
b = name or f'{prefix}-{uid("br")}'
|
||||
if len(b) > 180: raise ValueError('branch name too long')
|
||||
relative_parts(b)
|
||||
if '..' in b or '@{' in b or b.endswith('.lock'): raise ValueError('invalid branch name')
|
||||
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
||||
return b
|
||||
def _json_has_plain_secret(obj: Any) -> bool:
|
||||
@@ -254,8 +260,12 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -284,7 +294,7 @@ def checkout(branch, base='main', create=False):
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
r=relpath(path); write_text_no_follow(WS, str(r).replace('\\','/'), content); return r
|
||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
@@ -835,7 +845,7 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
|
||||
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
r=write_json(f'patches/{safe(b.replace("/","_"))}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
@@ -942,7 +952,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if not name or not base: return self.sendj({'error':'provider_name/base_url/model_name required'},400)
|
||||
owner=safe(data.get('username') or a['account']); secret_ref=data.get('api_key_secret_ref') or data.get('secret_ref')
|
||||
if data.get('api_key'):
|
||||
d=SECRETS/'llm'/owner; d.mkdir(parents=True, exist_ok=True); sid=uid('llmkey'); p=d/f'{sid}.secret'; p.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(p,0o600); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
sid=uid('llmkey'); write_text_no_follow(SECRETS, f'llm/{owner}/{sid}.secret', str(data['api_key'])); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
if not secret_ref: return self.sendj({'error':'api_key_secret_ref or api_key required'},400)
|
||||
mid=data.get('model_id') or f'custom.{owner}.{hashlib.sha1((prov+base+name).encode()).hexdigest()[:10]}'; b=checkout(data.get('branch') or f'patch/model-{safe(mid)}','main',True)
|
||||
doc=sanitize({k:v for k,v in data.items() if k!='api_key'}); doc.update({'model_id':mid,'provider_name':prov,'base_url':base,'model_name':name,'api_key_secret_ref':secret_ref,'created_by':a['account'],'created_at':now()})
|
||||
@@ -1080,6 +1090,7 @@ def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
|
||||
@@ -216,6 +216,7 @@ class ControlPlaneReleaseManifest:
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
trusted_runtime_identity: Mapping[str, object] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -229,6 +230,35 @@ class ControlPlaneReleaseManifest:
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def runtime_identity(self) -> dict[str, object]:
|
||||
"""Return only a host-injected, already trusted comparison result."""
|
||||
identity = self.trusted_runtime_identity
|
||||
required = {
|
||||
"status", "declared_identity", "observed_identity", "manifest_digest",
|
||||
"config_digest", "observed_at", "expires_at", "evidence_refs", "reason",
|
||||
"mismatch_fields",
|
||||
}
|
||||
if (
|
||||
not isinstance(identity, Mapping)
|
||||
or identity.get("status") != "PASS"
|
||||
or not required.issubset(identity)
|
||||
or not isinstance(identity.get("declared_identity"), Mapping)
|
||||
or not isinstance(identity.get("observed_identity"), Mapping)
|
||||
or not isinstance(identity.get("evidence_refs"), list)
|
||||
or not identity["evidence_refs"]
|
||||
):
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"declared_identity": None,
|
||||
"observed_identity": None,
|
||||
"observed_at": None,
|
||||
"expires_at": None,
|
||||
"evidence_refs": [],
|
||||
"reason": "TRUSTED_OBSERVATION_UNAVAILABLE",
|
||||
"mismatch_fields": [],
|
||||
}
|
||||
return dict(identity)
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
@@ -254,7 +284,12 @@ class ControlPlaneReleaseManifest:
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
# Artifact and path validation proves only a local declaration. The
|
||||
# composition root may inject the output of an already trusted runtime
|
||||
# comparison, but this manifest must never promote its own checks to
|
||||
# runtime-observation PASS.
|
||||
identity = self.runtime_identity()
|
||||
return {**self.as_dict(), "drift_status": identity["status"], "component_runtime_identity": identity}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
|
||||
@@ -39,6 +39,7 @@ from aios_governed_task_bootstrap import (
|
||||
)
|
||||
from governed_production_release import build_executor_config
|
||||
from aios_baseline.runtime_contract import evaluate_deployment
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
TaskPrincipalVerifier = Callable[
|
||||
@@ -104,6 +105,10 @@ class RuntimeConfig:
|
||||
health_url: str = "http://127.0.0.1:18927/health"
|
||||
release_plan_path: Path | None = None
|
||||
repository_discovery_cursor_secret: str | bytes | None = None
|
||||
component_manifest_path: Path | None = None
|
||||
component_receipt_path: Path | None = None
|
||||
component_id: str | None = None
|
||||
component_identity_comparison: Callable[[Mapping[str, Any]], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
@@ -236,6 +241,12 @@ class GovernedRuntime:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=self.config.component_id,
|
||||
manifest_path=self.config.component_manifest_path,
|
||||
receipt_path=self.config.component_receipt_path,
|
||||
trusted_comparison=self.config.component_identity_comparison,
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
@@ -246,7 +257,8 @@ class GovernedRuntime:
|
||||
"runtime_path": str(self.config.state_root),
|
||||
"artifact_sha256": os.environ.get("AIOS_CONTROL_PLANE_ARTIFACT_SHA256"),
|
||||
"deployment_state": "STAGING",
|
||||
"drift_status": "PASS",
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
"repository": self.config.repository,
|
||||
"repository_identity": self.config.repository,
|
||||
"authoritative_git_dir": str(self.config.authoritative_git_dir),
|
||||
@@ -1332,6 +1344,17 @@ def main(
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
)),
|
||||
repository_discovery_cursor_secret=discovery_cursor_secret,
|
||||
component_manifest_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_MANIFEST_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_MANIFEST_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_receipt_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_id=os.environ.get("AIOS_COMPONENT_ID", "").strip() or None,
|
||||
)
|
||||
runtime = GovernedRuntime(config)
|
||||
# Prefer a server-side TaskPrincipal provider recovered from the same
|
||||
@@ -1342,21 +1365,39 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
admission_runtime = expired_admission_runtime()
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, build_source_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
if admission_runtime is None or task_principal_verifier is not None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw),
|
||||
)
|
||||
admission_runtime = AdmissionRuntimeRouter(admission_runtime, source_runtime).bundle()
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
|
||||
@@ -11,10 +11,32 @@ class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
required_facts: Mapping[str, frozenset[str]] | None = None
|
||||
|
||||
def _readiness_reason(self) -> str:
|
||||
if self.required_facts is None:
|
||||
return "REQUIRED_FACT_INVENTORY_MISSING"
|
||||
expected_groups = {"READ_PLANE", "WRITE_PLANE", "RELEASE_PLANE"}
|
||||
if set(self.required_facts) != expected_groups:
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
planes = {
|
||||
"READ_PLANE": self.read_plane,
|
||||
"WRITE_PLANE": self.write_plane,
|
||||
"RELEASE_PLANE": self.release_plane,
|
||||
}
|
||||
for group, facts in self.required_facts.items():
|
||||
if not isinstance(facts, frozenset) or not facts or any(not isinstance(name, str) or not name for name in facts):
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
plane = planes[group]
|
||||
if set(plane) != set(facts):
|
||||
return "REQUIRED_FACT_INVENTORY_MISMATCH"
|
||||
if any(type(value) is not bool or value is not True for value in plane.values()):
|
||||
return "REQUIRED_FACT_NOT_TRUE"
|
||||
return "READY"
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
return self._readiness_reason() == "READY"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -22,4 +44,5 @@ class RuntimeStatus:
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
"readiness_reason": self._readiness_reason(),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,6 +16,25 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
||||
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
||||
|
||||
def _loopback(value):
|
||||
try: return ipaddress.ip_address(value).is_loopback
|
||||
except (ValueError, TypeError): return False
|
||||
|
||||
def validate_dev_profile():
|
||||
if DEV_ALLOW and not (os.getenv('AIOS_RUNTIME_PROFILE') == 'isolated-staging' and _loopback(HOST)):
|
||||
raise RuntimeError('DEV_ALLOW requires isolated-staging and loopback')
|
||||
|
||||
def runtime_selection(principal, data):
|
||||
if not principal.get('token_ok'): raise PermissionError('authenticated principal required')
|
||||
if any(key in data for key in ('username', 'user_id', 'role', 'tier')):
|
||||
raise ValueError('runtime identity must come from authenticated principal')
|
||||
username=principal['username']; role=principal['role']
|
||||
if role not in _ROLE_WHITELIST or re.fullmatch(r'[A-Za-z0-9_@.-]{1,120}', username) is None or username in ('.','..'):
|
||||
raise ValueError('invalid authenticated principal')
|
||||
tier='ultra' if role=='owner' else 'regular'
|
||||
return username, role, tier
|
||||
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
||||
@@ -68,6 +87,7 @@ def token_identity(token: str):
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
@@ -85,7 +105,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -163,7 +185,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'not_found','path':path},404)
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
def do_POST(self):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == '/api/runtime/select':
|
||||
if not (_loopback(HOST) and _loopback(self.client_address[0])):
|
||||
self.send_json({'error':'loopback ingress required'},403); return
|
||||
a = self.require_write()
|
||||
if not a: return
|
||||
data = parse_body(self)
|
||||
try: runtime_selection(a, data)
|
||||
except (ValueError, PermissionError):
|
||||
self.send_json({'error':'invalid runtime selection identity'},403); return
|
||||
else:
|
||||
data = parse_body(self)
|
||||
init_db()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -217,7 +251,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not m: self.send_json({'error':'model not found'},404); return
|
||||
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
||||
if path == '/api/runtime/select':
|
||||
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
||||
username,role,tier=runtime_selection(a, data); risk=data.get('risk','normal')
|
||||
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
||||
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
||||
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
||||
@@ -292,5 +326,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
|
||||
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
||||
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -486,8 +486,12 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -1303,6 +1307,7 @@ def api_spec():
|
||||
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""aios_owner_based_executor — Owner 基于授权的可执行 CLI 入口。
|
||||
|
||||
把 "owner 基于授权 → Agent 自主完成受治理写链" 从测试/装配层提升为**可直接执行的命令**。
|
||||
集成 V2R2C3 现场组合根 `field_governed_writer.py`,装配到生产入口
|
||||
`GovernedWriterAutomation.acquire_verified()`。
|
||||
|
||||
子命令:
|
||||
register <authorization_ref> 登记一条 Owner 授权到 authority(可 --policy-json 指定,
|
||||
默认用内置参考授权物 azn-owner-gw-v1)。
|
||||
acquire <authorization_ref> 装配 FieldGovernedWriterFactory,运行 acquire_verified(),
|
||||
输出 GovernedWriterSession JSON。
|
||||
|
||||
行为契约:
|
||||
- 生产入口唯一授权来源是 authority 已登记的 authorization_ref;未登记即 fail-closed。
|
||||
- authority 未装配(--no-authority 或构造时 owner_authority=None)→ AuthorizationDenied。
|
||||
- EXPIRED 任务无预签 recovery → OwnerActionRequired(OWNER_BOUNDARY,非自动恢复)。
|
||||
- status==error 输出到 stderr,退出码非 0;成功输出会话 JSON,退出码 0。
|
||||
- legacy_global_writes_enabled 默认 false = 安全不变式,不是 blocker。
|
||||
|
||||
用法示例:
|
||||
python aios_owner_based_executor.py register azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --branch-mode continue --head-oid <oid>
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --task-status EXPIRED # OwnerBoundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.field_governed_writer import (
|
||||
FieldGovernedWriterFactory,
|
||||
FieldOwnerAuthorizationAuthority,
|
||||
FieldRuntimeStatus,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
# 版本与示例授权引用
|
||||
VERSION = "V2R2C3"
|
||||
DEFAULT_AZN_REF = "azn-owner-gw-v1"
|
||||
|
||||
_AZN_CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
_AZN_OPS = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"epoch_reissue",
|
||||
"governed_commit",
|
||||
"heartbeat",
|
||||
"job_reconcile",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"successor_recovery",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"writer_lease",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 授权物 / 请求构造
|
||||
# ---------------------------------------------------------------------------
|
||||
def reference_policy(*, expires_at: float | None = None) -> OwnerAuthorizationPolicy:
|
||||
"""内置参考授权物 azn-owner-gw-v1(现场登记时 Owner 设定当下有效 expires_at)。"""
|
||||
if expires_at is None:
|
||||
expires_at = time.time() + 30 * 86400.0
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id=DEFAULT_AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
|
||||
|
||||
def _default_subject_repo_branch(task: str) -> tuple[str, str, str]:
|
||||
return ("aios-autonomous-admin", "aios/aios", f"agent/autonomy-{task}")
|
||||
|
||||
|
||||
def build_request(
|
||||
*,
|
||||
task_id: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
plan_digest: str,
|
||||
base_oid: str,
|
||||
generation: int,
|
||||
risk: str,
|
||||
caps: frozenset[str],
|
||||
files: tuple[str, ...],
|
||||
overrides: dict | None = None,
|
||||
) -> GovernedWriterRequest:
|
||||
subject, repo, branch = _default_subject_repo_branch(task_id)
|
||||
body = dict(
|
||||
subject=subject,
|
||||
task_id=task_id,
|
||||
repository=repo,
|
||||
target_branch=branch,
|
||||
risk=risk,
|
||||
generation=generation,
|
||||
base_oid=base_oid,
|
||||
required_capabilities=caps,
|
||||
file_allowlist=files,
|
||||
plan_digest=plan_digest,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if overrides:
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依据配置构造 authority(含 --no-authority fail-closed 场景)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_authority(
|
||||
*, policy_json_path: str | None, use_reference: bool, expires_at: float | None
|
||||
) -> FieldOwnerAuthorizationAuthority | None:
|
||||
if policy_json_path:
|
||||
data = json.loads(Path(policy_json_path).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(data["authorization_ref"], pol)
|
||||
return auth
|
||||
if use_reference:
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(DEFAULT_AZN_REF, reference_policy(expires_at=expires_at))
|
||||
return auth
|
||||
return None # authority 未装配 -> acquire_verified 直接 fail-closed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子命令实现
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
expires_at = float(args.expires_at) if args.expires_at else None
|
||||
if args.policy_json:
|
||||
data = json.loads(Path(args.policy_json).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
ref = data.get("authorization_ref", pol.authorization_id)
|
||||
else:
|
||||
pol = reference_policy(expires_at=expires_at)
|
||||
ref = DEFAULT_AZN_REF
|
||||
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(ref, pol)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "registered",
|
||||
"authorization_ref": ref,
|
||||
"policy_fingerprint": pol.policy_fingerprint,
|
||||
"subject": pol.subject,
|
||||
"repository": pol.repository,
|
||||
"allowed_branch_prefixes": list(pol.allowed_branch_prefixes),
|
||||
"risk_ceiling": pol.risk_ceiling,
|
||||
"expires_at": pol.expires_at,
|
||||
"note": "registered in-memory authority; field authority must persist",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_acquire(args: argparse.Namespace) -> int:
|
||||
from app.task_plane.governance_control_kernel import StaleManifest
|
||||
|
||||
# 使用独立工作目录构造 manifest + ledger,避免污染运行目录
|
||||
workdir = Path(args.workdir) if args.workdir else None
|
||||
if workdir is None:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="gw_exec_"))
|
||||
|
||||
store = ManifestStore(str(workdir / "manifest.db"))
|
||||
ledger = ActionLedger(str(workdir / "ledger.db"))
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(
|
||||
DiscoveryObservation.build(
|
||||
worker="W1",
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
activation_digest="activation-exec",
|
||||
epoch_id="epoch-exec-prov",
|
||||
policy_digest="policy-exec",
|
||||
plan_provider_root="/plans/exec",
|
||||
task_id=args.task,
|
||||
grant_id="grant-exec",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
)
|
||||
manifest_id = store.freeze(barrier.freeze())
|
||||
|
||||
auth = build_authority(
|
||||
policy_json_path=args.policy_json,
|
||||
use_reference=args.reference,
|
||||
expires_at=float(args.expires_at) if args.expires_at else None,
|
||||
)
|
||||
|
||||
task_status = args.task_status.upper()
|
||||
task = TaskState(task_id=args.task, status=task_status, revision=1)
|
||||
|
||||
if args.branch_mode == "create":
|
||||
branch_exists, head_oid = False, None
|
||||
elif args.branch_mode == "continue":
|
||||
branch_exists, head_oid = True, args.head_oid
|
||||
else: # auto: 由 read 决定(默认新分支)
|
||||
branch_exists, head_oid = (True, args.head_oid) if args.head_oid else (False, None)
|
||||
|
||||
runtime_status = FieldRuntimeStatus(
|
||||
legacy_global_writes_enabled=args.legacy_global,
|
||||
task_principal_writes_enabled=args.principal_writes,
|
||||
task_principal_verifier_ready=args.verifier_ready,
|
||||
governed_write_blockers=tuple(args.blocker or ()),
|
||||
)
|
||||
|
||||
# fail-closed authority 场景:--no-authority 显式不装配
|
||||
if args.no_authority:
|
||||
auth = None
|
||||
|
||||
adapters = _make_adapters(
|
||||
authority=auth,
|
||||
manifest_store=store,
|
||||
action_ledger=ledger,
|
||||
task=task,
|
||||
branch_exists=branch_exists,
|
||||
head_oid=head_oid,
|
||||
repo=args.repo if args.repo else "aios/aios",
|
||||
branch=args.branch if args.branch else f"agent/autonomy-{args.task}",
|
||||
generation=args.generation,
|
||||
runtime_status=runtime_status,
|
||||
)
|
||||
if auth is None:
|
||||
adapters.owner_authority = None
|
||||
|
||||
automation = _automation(store, ledger)
|
||||
request = build_request(
|
||||
task_id=args.task,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
plan_digest=args.plan_digest,
|
||||
base_oid=args.base_oid,
|
||||
generation=args.generation,
|
||||
risk=args.risk,
|
||||
caps=set(_AZN_CAPS),
|
||||
files=tuple(args.files),
|
||||
)
|
||||
|
||||
try:
|
||||
session = automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=args.authorization_ref,
|
||||
readiness=runtime_status_to_readiness(runtime_status),
|
||||
epoch=None,
|
||||
adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
print(json.dumps({"status": "ok", "version": VERSION, "session": session.to_dict()},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except OwnerActionRequired as exc:
|
||||
print(json.dumps({"status": "owner_boundary", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 3
|
||||
except AuthorizationDenied as exc:
|
||||
print(json.dumps({"status": "auth_denied", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 - CLI 边界统一收口
|
||||
print(json.dumps({"status": "error", "version": VERSION,
|
||||
"error": f"{type(exc).__name__}: {exc}"},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
_cleanup_workdir(workdir)
|
||||
|
||||
|
||||
def _make_adapters(
|
||||
*,
|
||||
authority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task,
|
||||
branch_exists,
|
||||
head_oid,
|
||||
repo,
|
||||
branch,
|
||||
generation,
|
||||
runtime_status,
|
||||
):
|
||||
from app.task_plane.field_governed_writer import _DefaultExecutionPlane, _DefaultGit, \
|
||||
_DefaultJobs, _DefaultPrincipalIssuer, _DefaultTrustedSigner, _DefaultWriterLease
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAdapters
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(repository=repo, target_branch=branch, generation=generation),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
|
||||
def runtime_status_to_readiness(rs: FieldRuntimeStatus):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=rs.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=rs.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=rs.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(rs.governed_write_blockers),
|
||||
)
|
||||
|
||||
|
||||
def _automation(store, ledger):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAutomation
|
||||
|
||||
return GovernedWriterAutomation(manifest_store=store, action_ledger=ledger)
|
||||
|
||||
|
||||
def _cleanup_workdir(workdir: Path) -> None:
|
||||
import gc
|
||||
import shutil
|
||||
|
||||
try:
|
||||
gc.collect()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
except Exception: # pragma: no cover - 清理失败不影响结果
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数与入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
import tempfile # noqa: F401 (确保模块可见)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AIOS Owner 基于授权执行器 (V2R2C3)",
|
||||
prog="aios_owner_based_executor",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_register = sub.add_parser("register", help="登记 Owner 授权到 authority")
|
||||
p_register.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_register.add_argument("--policy-json", help="policy JSON 文件(含 authorization_ref + policy)")
|
||||
p_register.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_register.set_defaults(func=cmd_register)
|
||||
|
||||
p_acquire = sub.add_parser("acquire", help="运行 acquire_verified() 完成受治理写链")
|
||||
p_acquire.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_acquire.add_argument("--no-authority", action="store_true",
|
||||
help="显式不装配 authority -> fail-closed")
|
||||
p_acquire.add_argument("--policy-json", help="从文件装载授权物(替代内置参考)")
|
||||
p_acquire.add_argument("--reference", action="store_true",
|
||||
help="使用内置参考授权物 azn-owner-gw-v1")
|
||||
p_acquire.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_acquire.add_argument("--task", default="task-gw-exec-0001")
|
||||
p_acquire.add_argument("--task-status", default="QUEUED",
|
||||
choices=["QUEUED", "CLAIMED", "RUNNING", "EXPIRED"])
|
||||
p_acquire.add_argument("--branch-mode", default="create", choices=["create", "continue", "auto"])
|
||||
p_acquire.add_argument("--head-oid", help="继续分支的 live HEAD oid")
|
||||
p_acquire.add_argument("--base-oid", default="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3")
|
||||
p_acquire.add_argument("--repo", help="仓库(默认 aios/aios)")
|
||||
p_acquire.add_argument("--branch", help="目标分支(默认 agent/autonomy-<task>)")
|
||||
p_acquire.add_argument("--generation", type=int, default=1)
|
||||
p_acquire.add_argument("--risk", default="medium")
|
||||
p_acquire.add_argument("--runtime", default="runtime-gw-exec")
|
||||
p_acquire.add_argument("--process-start", default="pid:gw:exec:1")
|
||||
p_acquire.add_argument("--plan-digest", default="sha256:plan-exec")
|
||||
p_acquire.add_argument("--files", nargs="*", default=[
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
"active-runtime/app/task_plane/field_governed_writer.py",
|
||||
])
|
||||
p_acquire.add_argument("--workdir", help="manifest/ledger 工作目录(默认临时)")
|
||||
# 运行时状态(W-RUNTIME 只读线)
|
||||
p_acquire.add_argument("--legacy-global", action="store_true",
|
||||
help="legacy_global_writes_enabled(默认 false=不变式)")
|
||||
p_acquire.add_argument("--principal-writes", action="store_true", default=True,
|
||||
help="task_principal_writes_enabled(默认 true)")
|
||||
p_acquire.add_argument("--verifier-ready", action="store_true", default=True,
|
||||
help="task_principal_verifier_ready(默认 true)")
|
||||
p_acquire.add_argument("--blocker", action="append",
|
||||
help="governed_write_blockers 追加")
|
||||
p_acquire.set_defaults(func=cmd_acquire)
|
||||
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile # noqa: F401
|
||||
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
@@ -51,15 +51,30 @@ AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
||||
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
||||
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
||||
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA = "aios.source-publication-policy.v1"
|
||||
SOURCE_PUBLICATION_POLICY_ID = "C5_SOURCE_PUBLICATION_ONLY"
|
||||
SOURCE_PUBLICATION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
||||
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
# ``engineering`` is deliberately between medium and high. Adding this rank
|
||||
# does not raise any configured ceiling; existing policy files keep their
|
||||
# existing value (the normal local-owner policy is ``medium``).
|
||||
_RISK = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -691,6 +706,30 @@ class OwnerAuthorizationBundle:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePublicationPolicyRequest:
|
||||
"""One exact, source-only unattended publication authorization request."""
|
||||
|
||||
policy_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
source_oid: str
|
||||
source_tree_oid: str
|
||||
target_agent: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
exact_files: tuple[str, ...]
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
risk: str = "engineering"
|
||||
production_impact: str = "none"
|
||||
renewal: str = "none"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "exact_files", tuple(self.exact_files))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCapabilityRequest:
|
||||
capability_id: str
|
||||
@@ -755,20 +794,28 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
key_path: Path | None,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path,
|
||||
key_path or Path("/dev/null"),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.policy = policy
|
||||
self.clock = clock
|
||||
self.registered_signer = registered_signer
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
if self.registered_signer is not None:
|
||||
return str(self.registered_signer.fingerprint)
|
||||
return super().fingerprint
|
||||
|
||||
@staticmethod
|
||||
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
||||
@@ -831,6 +878,111 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
||||
if self.registered_signer is None:
|
||||
return super()._sign_canonical(value)
|
||||
payload = canonical_json_bytes(dict(value))
|
||||
signature = self.registered_signer._sign_canonical_payload(
|
||||
self.namespace, payload
|
||||
)
|
||||
return SignedArtifact(
|
||||
payload=dict(value),
|
||||
canonical_payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.fingerprint,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def issue_source_publication_policy(
|
||||
self, request: SourcePublicationPolicyRequest
|
||||
) -> SignedArtifact:
|
||||
"""Sign one exact C5 source-publication policy.
|
||||
|
||||
This is an issuance operation only. It cannot change the configured
|
||||
owner policy, its ceiling, branch/file allowlists, or trust domain.
|
||||
"""
|
||||
now = int(self.clock())
|
||||
files = tuple(request.exact_files)
|
||||
def source_scope_narrows(value: str, allowed: Sequence[str]) -> bool:
|
||||
base = value.rstrip("/")
|
||||
return any(
|
||||
base == prefix.rstrip("/")
|
||||
or base.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in allowed
|
||||
)
|
||||
if (
|
||||
request.policy_id != SOURCE_PUBLICATION_POLICY_ID
|
||||
or request.repository != self.policy.repository
|
||||
or not _HEX40.fullmatch(request.authority_base_oid)
|
||||
or not _HEX40.fullmatch(request.source_oid)
|
||||
or not _HEX40.fullmatch(request.source_tree_oid)
|
||||
or not _ID.fullmatch(request.target_agent)
|
||||
or not request.target_agent.startswith("agent/")
|
||||
or not source_scope_narrows(
|
||||
request.target_branch, self.policy.allowed_branch_prefixes
|
||||
)
|
||||
or not request.target_branch.startswith("agent/")
|
||||
or "\\" in request.target_branch
|
||||
or ".." in PurePosixPath(request.target_branch).parts
|
||||
or request.target_branch.endswith(("/", "."))
|
||||
or type(request.generation) is not int
|
||||
or request.generation < 1
|
||||
or not _HEX64.fullmatch(request.execution_digest)
|
||||
or not files
|
||||
or any(
|
||||
not item
|
||||
or PurePosixPath(item).is_absolute()
|
||||
or "\\" in item
|
||||
or ".." in PurePosixPath(item).parts
|
||||
or item.startswith(".git")
|
||||
or not source_scope_narrows(item, self.policy.allowed_file_prefixes)
|
||||
for item in files
|
||||
)
|
||||
or not SOURCE_PUBLICATION_CAPABILITIES.issubset(self.policy.allowed_capabilities)
|
||||
or len(set(files)) != len(files)
|
||||
or not _ID.fullmatch(request.nonce)
|
||||
or request.risk != "engineering"
|
||||
or request.risk not in _RISK
|
||||
or _RISK[request.risk] > _RISK[self.policy.risk_ceiling]
|
||||
or request.production_impact != "none"
|
||||
or request.renewal != "none"
|
||||
or type(request.ttl_seconds) is not int
|
||||
or not 1 <= request.ttl_seconds <= 3600
|
||||
or request.ttl_seconds > self.policy.maximum_epoch_ttl_seconds
|
||||
):
|
||||
_fail("SOURCE_PUBLICATION_REQUEST_WIDENS_POLICY")
|
||||
value: dict[str, Any] = {
|
||||
"schema": SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
"policy_id": SOURCE_PUBLICATION_POLICY_ID,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": request.repository,
|
||||
"trust_domain": self.policy.trust_domain,
|
||||
"policy_digest": self.policy.digest,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"source_oid": request.source_oid,
|
||||
"source_tree_oid": request.source_tree_oid,
|
||||
"target_agent": request.target_agent,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"exact_files": list(files),
|
||||
"capabilities": sorted(SOURCE_PUBLICATION_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": request.nonce,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 30),
|
||||
"expires_at": now + request.ttl_seconds,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.fingerprint,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
issue_source_only_policy = issue_source_publication_policy
|
||||
|
||||
def issue_authorization_bundle(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,8 @@ from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1"
|
||||
SCHEMA_GRANT = "aios.task-admission-grant.v1"
|
||||
SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"
|
||||
SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"
|
||||
SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -142,9 +144,30 @@ class OwnerDelegationEpoch:
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
production_scope: str | None = None
|
||||
destructive_policy: str | None = None
|
||||
# Provenance retained by the consumer. These fields are populated by the
|
||||
# signed-directory provider for v2 (and left at their v1-compatible
|
||||
# defaults when reading legacy epochs); they are never used to translate or
|
||||
# re-sign the original artifact.
|
||||
status: str = ""
|
||||
signature_namespace: str = ""
|
||||
signature_key_id: str = ""
|
||||
canonical_payload_digest: str = ""
|
||||
canonical_payload: bytes = b""
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
"""The signed epoch issuer, retained under the legacy identity name."""
|
||||
return self.owner_identity
|
||||
|
||||
@property
|
||||
def canonical_payload_bytes(self) -> bytes:
|
||||
return self.canonical_payload
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value.pop("canonical_payload", None)
|
||||
value["allowed_capabilities"] = sorted(self.allowed_capabilities)
|
||||
value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes)
|
||||
value["allowed_file_prefixes"] = list(self.allowed_file_prefixes)
|
||||
@@ -309,7 +332,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -838,7 +861,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
|
||||
@@ -26,6 +26,8 @@ import urllib.request
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
OwnerDelegationEpoch,
|
||||
SCHEMA_EPOCH,
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
canonical_json_bytes,
|
||||
@@ -44,6 +46,19 @@ from aios_gitops_job_identity import (
|
||||
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"
|
||||
_EPOCH_V1_FIELDS = frozenset({
|
||||
"epoch_id", "owner_identity", "trust_domain", "repository",
|
||||
"allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",
|
||||
"risk_ceiling", "issued_at", "not_before", "expires_at",
|
||||
"revocation_epoch", "policy_digest", "schema",
|
||||
})
|
||||
_EPOCH_V2_FIELDS = frozenset({
|
||||
*_EPOCH_V1_FIELDS,
|
||||
"business_goal", "production_scope", "destructive_policy",
|
||||
"signature_namespace", "signature_key_id", "canonical_payload_digest",
|
||||
"status",
|
||||
})
|
||||
_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
||||
|
||||
|
||||
class OfficialTaskTransport(Protocol):
|
||||
@@ -463,6 +478,7 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -622,11 +638,15 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_SCHEMA_INVALID",
|
||||
403,
|
||||
)
|
||||
expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS
|
||||
if set(value) != expected_fields:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)
|
||||
if value.get("epoch_id") != epoch_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_ID_MISMATCH",
|
||||
@@ -642,7 +662,34 @@ class SignedEpochDirectoryProvider:
|
||||
if epoch_id in self._revoked_ids():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
|
||||
# v2 is a producer-authored envelope. Validate its self-digest and
|
||||
# signature binding before the cryptographic verifier sees the exact
|
||||
# bytes from disk. The digest is over the payload with only the digest
|
||||
# member removed; no schema translation is permitted.
|
||||
if schema == SCHEMA_EPOCH_V2:
|
||||
if (
|
||||
value.get("status") != "ACTIVE"
|
||||
or not isinstance(value.get("business_goal"), str)
|
||||
or not value["business_goal"]
|
||||
or not isinstance(value.get("production_scope"), str)
|
||||
or not value["production_scope"]
|
||||
or not isinstance(value.get("destructive_policy"), str)
|
||||
or value["destructive_policy"] not in {"forbidden", "rollback-only"}
|
||||
or value.get("signature_namespace") != OWNER_NAMESPACE
|
||||
or not isinstance(value.get("signature_key_id"), str)
|
||||
or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])
|
||||
or not isinstance(value.get("canonical_payload_digest"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)
|
||||
digest_value = dict(value)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if sha256_json(digest_value) != supplied_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)
|
||||
|
||||
key_id = key_id_file.read_text(encoding="utf-8").strip()
|
||||
if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)
|
||||
armored = signature.read_text(encoding="utf-8")
|
||||
if self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
@@ -659,6 +706,23 @@ class SignedEpochDirectoryProvider:
|
||||
not_before = value.get("not_before")
|
||||
expires_at = value.get("expires_at")
|
||||
if (
|
||||
not isinstance(value.get("epoch_id"), str)
|
||||
or not value["epoch_id"]
|
||||
or not isinstance(value.get("owner_identity"), str)
|
||||
or not value["owner_identity"]
|
||||
or not isinstance(value.get("trust_domain"), str)
|
||||
or not value["trust_domain"]
|
||||
or not isinstance(value.get("repository"), str)
|
||||
or not value["repository"]
|
||||
or not isinstance(value.get("risk_ceiling"), str)
|
||||
or value["risk_ceiling"] not in {"low", "medium", "engineering", "high"}
|
||||
or not isinstance(value.get("issued_at"), int)
|
||||
or isinstance(value.get("issued_at"), bool)
|
||||
or not isinstance(value.get("revocation_epoch"), int)
|
||||
or isinstance(value.get("revocation_epoch"), bool)
|
||||
or not isinstance(value.get("policy_digest"), str)
|
||||
or not value["policy_digest"]
|
||||
or
|
||||
not isinstance(not_before, int)
|
||||
or isinstance(not_before, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
@@ -672,13 +736,16 @@ class SignedEpochDirectoryProvider:
|
||||
files = value.get("allowed_file_prefixes")
|
||||
if (
|
||||
not isinstance(branches, list)
|
||||
or not branches
|
||||
or not all(isinstance(item, str) and item for item in branches)
|
||||
or not isinstance(capabilities, list)
|
||||
or not capabilities
|
||||
or not all(
|
||||
isinstance(item, str) and item
|
||||
for item in capabilities
|
||||
)
|
||||
or not isinstance(files, list)
|
||||
or not files
|
||||
or not all(isinstance(item, str) and item for item in files)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
@@ -701,6 +768,19 @@ class SignedEpochDirectoryProvider:
|
||||
revocation_epoch=int(value["revocation_epoch"]),
|
||||
policy_digest=str(value["policy_digest"]),
|
||||
schema=str(value["schema"]),
|
||||
production_scope=(
|
||||
str(value["production_scope"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
destructive_policy=(
|
||||
str(value["destructive_policy"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
status=str(value.get("status", "")),
|
||||
signature_namespace=str(value.get("signature_namespace", "")),
|
||||
signature_key_id=str(value.get("signature_key_id", "")),
|
||||
canonical_payload_digest=str(value.get("canonical_payload_digest", "")),
|
||||
canonical_payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -915,6 +995,7 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
PARAMETERIZED_RECOVERY_NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
@@ -199,6 +200,31 @@ def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Call
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
def _parameterized_recovery_nonce_reserved(
|
||||
connection: sqlite3.Connection, nonce: str
|
||||
) -> bool:
|
||||
"""Share the existing authority nonce domain when C6 NOOP support exists.
|
||||
|
||||
Historical lineage databases can predate TaskStore's idempotency table;
|
||||
their established lineage-only operation remains readable. The C6 NOOP
|
||||
adapter refuses to activate on those schemas, so an absent table cannot
|
||||
create a cross-operation uniqueness gap.
|
||||
"""
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if "idempotency" not in tables:
|
||||
return False
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM idempotency WHERE scope=? AND key=?",
|
||||
(PARAMETERIZED_RECOVERY_NONCE_SCOPE, nonce),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
@@ -251,6 +277,8 @@ class TaskLineageSupersessionController:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
if _parameterized_recovery_nonce_reserved(connection, decision["nonce"]):
|
||||
_fail("PARAMETERIZED_RECOVERY_NONCE_CONSUMED")
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Narrow, terminal-only parameterized recovery over the formal Task store.
|
||||
|
||||
This adapter deliberately does *not* create a generic recovery authority. It
|
||||
can only record an Owner-signed, exact-CAS terminal NOOP. A non-terminal
|
||||
recovery remains the existing formal lineage/supersession operation.
|
||||
|
||||
The receipt uses the existing TaskStore ``idempotency`` uniqueness constraint
|
||||
in the same authoritative database. Two fixed owner-authority scopes bind a
|
||||
decision id and a nonce atomically; this is not a new nonce database or table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_agent_task_protocol import TaskStore, canonical_json
|
||||
from aios_task_lineage_supersession import (
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
REPOSITORY,
|
||||
)
|
||||
|
||||
|
||||
DECISION_SCHEMA = "aios.task-parameterized-recovery-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-parameterized-recovery-receipt.v1"
|
||||
ACTION_TERMINAL_NOOP = "TERMINAL_NOOP"
|
||||
TERMINAL_STATUSES = frozenset({"COMPLETED", "CANCELLED", "FAILED_FINAL"})
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
DECISION_SCOPE = "aios.task-parameterized-recovery.v1:owner-decision"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
_BRANCH = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "decision_id", "nonce", "action", "task_id",
|
||||
"task_identity_sha256", "expected_status", "expected_attempt",
|
||||
"expected_generation", "expected_revision", "expected_claimed_by",
|
||||
"expected_lease_token_hash", "expected_lease_expires_at", "repository",
|
||||
"target_branch", "owner_identity", "signature_namespace",
|
||||
"signature_principal", "key_id", "issued_at", "not_before",
|
||||
"expires_at", "one_time_use", "signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ParameterizedRecoveryError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ParameterizedRecoveryError(code, status)
|
||||
|
||||
|
||||
def _utc(value: object, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ParameterizedRecoveryError(f"{field.upper()}_INVALID", 400) from exc
|
||||
if parsed.tzinfo is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: object, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def canonical_decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
def _identity_digest(row: sqlite3.Row) -> str:
|
||||
value = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
}
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != _FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"action": ACTION_TERMINAL_NOOP,
|
||||
"repository": REPOSITORY,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(f"{field.upper()}_MISMATCH", 403)
|
||||
for field in ("task_id", "decision_id", "nonce"):
|
||||
if _SAFE_ID.fullmatch(_text(raw[field], field, maximum=512)) is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
if not isinstance(raw["task_identity_sha256"], str) or not _HEX64.fullmatch(raw["task_identity_sha256"]):
|
||||
_fail("TASK_IDENTITY_SHA256_INVALID", 400)
|
||||
if not isinstance(raw["target_branch"], str) or not _BRANCH.fullmatch(raw["target_branch"]):
|
||||
_fail("TARGET_BRANCH_INVALID", 400)
|
||||
for field, minimum in (("expected_attempt", 0), ("expected_generation", 1), ("expected_revision", 1)):
|
||||
if isinstance(raw[field], bool) or not isinstance(raw[field], int) or raw[field] < minimum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
for field in ("expected_claimed_by", "expected_lease_token_hash", "expected_lease_expires_at"):
|
||||
if raw[field] is not None:
|
||||
_text(raw[field], field, maximum=512)
|
||||
if raw["expected_status"] not in TERMINAL_STATUSES:
|
||||
_fail("EXPECTED_STATUS_NOT_TERMINAL", 409)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > not_before or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at <= issued_at or expires_at - issued_at > MAX_TTL:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
if now < not_before or now >= expires_at or issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
return dict(raw)
|
||||
|
||||
|
||||
class ParameterizedRecoveryController:
|
||||
"""Consumes a validated terminal-NOOP decision once, in the TaskStore DB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskStore,
|
||||
verifier: SignatureVerifier,
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
identity_digest: Callable[[sqlite3.Row], str] = _identity_digest,
|
||||
) -> None:
|
||||
if not isinstance(store, TaskStore) or verifier is None:
|
||||
_fail("FORMAL_TASK_AUTHORITY_REQUIRED", 503)
|
||||
self.store = store
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
tables = {str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not {"tasks", "events", "idempotency", "task_lineage_supersession_receipts"}.issubset(tables):
|
||||
_fail("FORMAL_LINEAGE_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
|
||||
def _verify_task(self, row: sqlite3.Row, decision: Mapping[str, Any]) -> None:
|
||||
if self.identity_digest(row) != decision["task_identity_sha256"]:
|
||||
_fail("TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
if (
|
||||
row["task_id"] != decision["task_id"]
|
||||
or row["status"] != decision["expected_status"]
|
||||
or int(row["revision"]) != decision["expected_revision"]
|
||||
or int(row["attempt"]) != decision["expected_attempt"]
|
||||
or row["claimed_by"] != decision["expected_claimed_by"]
|
||||
or row["lease_token_hash"] != decision["expected_lease_token_hash"]
|
||||
or row["lease_expires_at"] != decision["expected_lease_expires_at"]
|
||||
):
|
||||
_fail("TASK_EXACT_CAS_MISMATCH")
|
||||
try:
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
constraints = json.loads(row["constraints_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("TASK_BINDING_JSON_INVALID") from exc
|
||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(execution, Mapping) or constraints.get("generation") != decision["expected_generation"]:
|
||||
_fail("TASK_EXECUTION_BINDING_INVALID")
|
||||
if (
|
||||
execution.get("task_id") != decision["task_id"]
|
||||
or execution.get("repository") != decision["repository"]
|
||||
or execution.get("target_branch") != decision["target_branch"]
|
||||
):
|
||||
_fail("TASK_EXECUTION_BINDING_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def _receipt(decision: Mapping[str, Any], decision_sha: str, row: sqlite3.Row, now: datetime) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"state": "NOOP",
|
||||
"noop_reason": "TERMINAL_STATUS",
|
||||
"decision_id": decision["decision_id"],
|
||||
"nonce": decision["nonce"],
|
||||
"task_id": decision["task_id"],
|
||||
"status": row["status"],
|
||||
"revision": int(row["revision"]),
|
||||
"owner_decision_sha256": decision_sha,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": hashlib.sha256(decision["signature"].encode()).hexdigest(),
|
||||
"exact_identity_cas": True,
|
||||
"one_time_use": True,
|
||||
"observed_at": now.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_exact_replay(
|
||||
nonce_row: sqlite3.Row | None, decision_row: sqlite3.Row | None, decision: Mapping[str, Any], decision_sha: str
|
||||
) -> dict[str, Any] | None:
|
||||
if nonce_row is None and decision_row is None:
|
||||
return None
|
||||
if nonce_row is None or decision_row is None:
|
||||
present = nonce_row if nonce_row is not None else decision_row
|
||||
if present["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
_fail("RECOVERY_RECEIPT_INCOMPLETE", 500)
|
||||
if nonce_row["payload_digest"] != decision_sha or decision_row["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
try:
|
||||
nonce_receipt = json.loads(nonce_row["response_json"])
|
||||
decision_receipt = json.loads(decision_row["response_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("RECOVERY_RECEIPT_INVALID", 500) from exc
|
||||
if nonce_receipt != decision_receipt or (
|
||||
nonce_receipt.get("schema") != RECEIPT_SCHEMA
|
||||
or nonce_receipt.get("decision_id") != decision["decision_id"]
|
||||
or nonce_receipt.get("nonce") != decision["nonce"]
|
||||
or nonce_receipt.get("task_id") != decision["task_id"]
|
||||
or nonce_receipt.get("owner_decision_sha256") != decision_sha
|
||||
):
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
return dict(nonce_receipt)
|
||||
|
||||
def recover_terminal_noop(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload = canonical_decision_payload(raw)
|
||||
payload_bytes = canonical_json(payload).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
payload=payload_bytes,
|
||||
signature=decision["signature"],
|
||||
key_id=OWNER_KEY_ID,
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (decision["task_id"],)).fetchone()
|
||||
if row is None:
|
||||
_fail("TASK_NOT_FOUND", 404)
|
||||
self._verify_task(row, decision)
|
||||
nonce_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(NONCE_SCOPE, decision["nonce"]),
|
||||
).fetchone()
|
||||
decision_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(DECISION_SCOPE, decision["decision_id"]),
|
||||
).fetchone()
|
||||
lineage_row = connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if lineage_row is not None:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
replay = self._read_exact_replay(nonce_row, decision_row, decision, decision_sha)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
receipt = self._receipt(decision, decision_sha, row, now)
|
||||
receipt_json = canonical_json(receipt)
|
||||
for scope, key in ((NONCE_SCOPE, decision["nonce"]), (DECISION_SCOPE, decision["decision_id"])):
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(scope, key, decision_sha, receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(decision["task_id"], None, "TASK_PARAMETERIZED_RECOVERY_NOOP", receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
# There is intentionally no generic resume/reclaim path. The existing
|
||||
# lineage controller remains the only predecessor/successor authority.
|
||||
recover = recover_terminal_noop
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Read-only V3 issuer scope over the admitted producer's durable receipt.
|
||||
|
||||
The V1/V2 provider remains authoritative for those schemas. V3 validation
|
||||
and Job readback are delegated to the existing admission producer contract;
|
||||
this module never reserves a Job or changes the canonical plan digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_live_adapters import (
|
||||
ProducerReceiptError,
|
||||
_FLProducerReceiptReserver,
|
||||
load_v3_plan,
|
||||
validate_v3_plan,
|
||||
)
|
||||
from governed_runtime_scope import (
|
||||
GovernedScopeError,
|
||||
RuntimeScope,
|
||||
_branch,
|
||||
_generation,
|
||||
_hex64,
|
||||
_job_id,
|
||||
_normalize_caps,
|
||||
_normalize_files,
|
||||
_oid40,
|
||||
_task_id,
|
||||
_trust_digest,
|
||||
)
|
||||
|
||||
|
||||
def _deny_mutation(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise GovernedScopeError("ISSUER_PRODUCER_MUTATION_FORBIDDEN")
|
||||
|
||||
|
||||
def _read_only_bootstrap(root: Path) -> GovernedTaskBootstrap:
|
||||
"""Construct only the read-only binding view; __init__ creates directories."""
|
||||
root = Path(root)
|
||||
for path in (root, root / "task-bindings", root / "jobs"):
|
||||
metadata = path.lstat()
|
||||
if (stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode)
|
||||
or stat.S_IMODE(metadata.st_mode) & 0o022):
|
||||
raise GovernedScopeError("PRODUCER_STATE_ROOT_INVALID")
|
||||
view = object.__new__(GovernedTaskBootstrap)
|
||||
view.root = root
|
||||
view.task_meta_root = root / "task-bindings"
|
||||
view.job_root = root / "jobs"
|
||||
view.create = _deny_mutation
|
||||
view._store = _deny_mutation
|
||||
view._write = _deny_mutation
|
||||
return view
|
||||
|
||||
|
||||
class IssuerV3ReceiptScopeProvider:
|
||||
"""Delegate legacy plans, and resolve V3 with an existing Job only."""
|
||||
|
||||
def __init__(
|
||||
self, *, legacy: Any, producer_state_root: Path, plan_root: Path,
|
||||
grant_reader: Callable[..., Mapping[str, Any]],
|
||||
plan_loader: Callable[[Path, Mapping[str, Any]], Mapping[str, Any]] = load_v3_plan,
|
||||
) -> None:
|
||||
self.legacy = legacy
|
||||
self.producer_state_root = Path(producer_state_root)
|
||||
self.plan_root = Path(plan_root)
|
||||
self.grant_reader = grant_reader
|
||||
self.plan_loader = plan_loader
|
||||
|
||||
def inspect_v3_binding(
|
||||
self, *, grant_raw: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
) -> tuple[str, Mapping[str, Any]]:
|
||||
"""Validate original bytes and durable receipt without Task mutation.
|
||||
|
||||
This method can inspect an expired historical fixture. ``resolve``
|
||||
separately enforces live Task and grant state before issuing scope.
|
||||
"""
|
||||
try:
|
||||
original = self.plan_loader(self.plan_root, grant_raw)
|
||||
if dict(original) != dict(plan):
|
||||
raise GovernedScopeError("EXECUTION_PLAN_READBACK_CONFLICT")
|
||||
# Keep the main V3 validator in the issuer contract even when a
|
||||
# test supplies an isolated loader. It binds the original plan,
|
||||
# including risk, hashes, capabilities and execution digest.
|
||||
from aios_task_admission_live_adapters import canonical_json_bytes
|
||||
validate_v3_plan(grant=grant_raw, raw=canonical_json_bytes(original))
|
||||
bootstrap = _read_only_bootstrap(self.producer_state_root)
|
||||
runtime = type("ReadOnlyIssuerRuntime", (), {
|
||||
"bootstrap": bootstrap, "bindings": {},
|
||||
})()
|
||||
reserver = _FLProducerReceiptReserver(runtime)
|
||||
job_id = reserver.existing_v3_job(grant_raw, original)
|
||||
if job_id is None:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_MISSING")
|
||||
readback = reserver.readbacks.get(str(grant_raw["task_id"]))
|
||||
proof = readback.get("reservation_readback") if isinstance(readback, Mapping) else None
|
||||
if not isinstance(proof, Mapping) or proof.get("job_id") != job_id:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_BINDING_INVALID")
|
||||
return job_id, dict(proof)
|
||||
except (TaskAdmissionError, ProducerReceiptError, OSError, KeyError, TypeError, ValueError) as exc:
|
||||
raise GovernedScopeError(str(exc) or "PRODUCER_RECEIPT_INVALID") from exc
|
||||
|
||||
def resolve(
|
||||
self, *, task: Any, execution: Any, grant: Any,
|
||||
plan: Mapping[str, Any], trust: Any, now: int,
|
||||
) -> RuntimeScope:
|
||||
if plan.get("schema") != "aios.task-admission-execution-plan.v3":
|
||||
return self.legacy.resolve(
|
||||
task=task, execution=execution, grant=grant,
|
||||
plan=plan, trust=trust, now=now,
|
||||
)
|
||||
|
||||
task_id = _task_id(task.task_id)
|
||||
exec_job = _job_id(execution.external_job_id)
|
||||
exec_digest = _hex64(execution.execution_digest, "execution_digest")
|
||||
exec_branch = _branch(execution.target_branch)
|
||||
exec_generation = _generation(execution.generation)
|
||||
grant_raw = self.grant_reader(
|
||||
task_id=task_id, execution_digest=exec_digest,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, now=int(now),
|
||||
)
|
||||
producer_job, _proof = self.inspect_v3_binding(
|
||||
grant_raw=grant_raw, plan=plan,
|
||||
)
|
||||
if exec_job != producer_job:
|
||||
raise GovernedScopeError("EXECUTION_PRODUCER_JOB_MISMATCH")
|
||||
if str(task.status).upper() not in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedScopeError("TASK_NOT_CLAIMED")
|
||||
if grant.status != "ADMITTED" or int(grant.expires_at) <= int(now):
|
||||
raise GovernedScopeError("GRANT_EXPIRED_OR_NOT_ADMITTED")
|
||||
expected = {
|
||||
"task_id": task_id,
|
||||
"repository": execution.repository,
|
||||
"target_branch": exec_branch,
|
||||
"generation": exec_generation,
|
||||
"execution_digest": exec_digest,
|
||||
"authority_base_oid": _oid40(grant.authority_base_oid, "authority_base_oid"),
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if (execution.repository != "aios/aios"
|
||||
or execution.task_id != task_id
|
||||
or any(grant_raw.get(key) != value for key, value in expected.items())):
|
||||
raise GovernedScopeError("V3_GRANT_EXECUTION_BINDING_MISMATCH")
|
||||
caps = _normalize_caps(grant.requested_capabilities)
|
||||
files = _normalize_files(grant.file_allowlist)
|
||||
if (caps != _normalize_caps(grant_raw["capabilities"])
|
||||
or files != _normalize_files(grant_raw["file_allowlist"])):
|
||||
raise GovernedScopeError("V3_GRANT_SCOPE_MISMATCH")
|
||||
if (not trust.issuer_id or not trust.key_id or not trust.public_key
|
||||
or "TASK_PRINCIPAL" not in str(trust.purpose).upper().replace("-", "_")
|
||||
or not str(trust.bridge_id).strip()):
|
||||
raise GovernedScopeError("TRUST_IDENTITY_INVALID")
|
||||
trust_digest = _trust_digest(trust)
|
||||
if (trust.scope_manifest_sha256 is not None
|
||||
and _hex64(trust.scope_manifest_sha256, "scope_manifest_sha256") != trust_digest):
|
||||
raise GovernedScopeError("TRUST_MANIFEST_DIGEST_MISMATCH")
|
||||
return RuntimeScope(
|
||||
scope_id=f"governed-runtime:{task_id}:g{exec_generation}:{exec_digest[:16]}",
|
||||
task_id=task_id, external_job_id=producer_job,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, execution_digest=exec_digest,
|
||||
capabilities=caps, trust_manifest_sha256=trust_digest,
|
||||
grant_id=grant.grant_id, grant_expires_at=int(grant.expires_at),
|
||||
)
|
||||
@@ -24,6 +24,19 @@ class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
C5_INTEGRATION_CONTRACT_FIELDS = frozenset({
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"publication_plan_sha256", "repair_source_commit", "repair_tree_oid",
|
||||
})
|
||||
C5_INTEGRATION_FILES = {
|
||||
"active-runtime/app/aios_capability_contract.py": ("publication_source_commit", "67d8a3a296110e20a4ea59863cd7982b6bdada71abd457c0f7cec480b06d278a"),
|
||||
"tests/test_v2r2c5_capability_contract.py": ("publication_source_commit", "f91af641528ea9a625ca45a46b6c26f5dba568e6b69cba819ca33ab5cba28de3"),
|
||||
"active-runtime/app/aios_baseline/service.py": ("repair_source_commit", "3c3918aec5503947b48ef4c73e34cac8f0d33fdfb99cfcd5ca8af699b663da72"),
|
||||
"tests/baseline_v1_1/test_loop.py": ("repair_source_commit", "f867e92b9ec9b4dff248e6ca38280848458a535c610dc6d9fea2b270c29b664a"),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
@@ -135,6 +148,7 @@ class TrustedCandidateBuilderConfig:
|
||||
policy_sha256: str
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
c5_contract: Mapping[str, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
@@ -145,16 +159,52 @@ class TrustedCandidateBuilderConfig:
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
is_integration = policy.get("schema") == "aios.trusted-candidate-builder-policy.c5-integration.v1"
|
||||
is_c5 = is_integration or policy.get("schema") == "aios.trusted-candidate-builder-policy.c5.v1"
|
||||
if set(policy) != (required | {"c5_contract"} if is_c5 else required) or policy.get("schema") not in {"aios.trusted-candidate-builder-policy.v1", "aios.trusted-candidate-builder-policy.c5.v1", "aios.trusted-candidate-builder-policy.c5-integration.v1"}:
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
expected_recipes = {"V2R2C2", "V2R2C3", "V2R2C5"} if is_c5 else {"V2R2C2", "V2R2C3"}
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != expected_recipes:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
c5_contract = None
|
||||
if is_c5:
|
||||
c5 = recipes["V2R2C5"]
|
||||
expected_mode = "governed-source-integration-only" if is_integration else "governed-source-admission-only"
|
||||
if not isinstance(c5, Mapping) or set(c5) != {"mode", "version", "purpose"} or c5.get("mode") != expected_mode or c5.get("version") != "V2R2C5" or c5.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("POLICY_V2R2C5_INVALID")
|
||||
required_c5 = {"task_id", "authority_base_oid", "accepted_base_oid", "rollback_version", "rollback_target_oid", "rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
required_c5 |= C5_INTEGRATION_CONTRACT_FIELDS
|
||||
value = policy.get("c5_contract")
|
||||
if not isinstance(value, Mapping) or set(value) != required_c5:
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if any(not isinstance(v, str) for v in value.values()):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
c5_contract = dict(value)
|
||||
oid_fields = {"authority_base_oid", "accepted_base_oid", "rollback_target_oid"}
|
||||
if is_integration:
|
||||
oid_fields |= {
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"repair_source_commit", "repair_tree_oid",
|
||||
}
|
||||
sha_fields = {"rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
sha_fields.add("publication_plan_sha256")
|
||||
if (
|
||||
not c5_contract["task_id"]
|
||||
or c5_contract["rollback_version"] != "V2R2C4"
|
||||
or c5_contract["accepted_base_oid"] != c5_contract["rollback_target_oid"]
|
||||
or any(len(c5_contract[name]) != 40 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in oid_fields)
|
||||
or any(len(c5_contract[name]) != 64 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in sha_fields)
|
||||
):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
@@ -210,6 +260,7 @@ class TrustedCandidateBuilderConfig:
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
c5_contract=c5_contract,
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
@@ -231,6 +282,36 @@ class TrustedCandidateBuilderConfig:
|
||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(version)
|
||||
if recipe is not None:
|
||||
if recipe.get("mode") in {"governed-source-admission-only", "governed-source-integration-only"}:
|
||||
if version != "V2R2C5" or self.c5_contract is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "tree_oid"}
|
||||
if set(admitted) != required or admitted.get("mode") != "governed-source-admission" or admitted.get("version") != version or admitted.get("source_commit") != source_commit or admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if (not isinstance(admitted.get("candidate_id"), str) or not admitted.get("candidate_id")
|
||||
or admitted.get("task_id") != self.c5_contract["task_id"]
|
||||
or admitted.get("base_oid") != self.c5_contract["authority_base_oid"]):
|
||||
raise BuilderError("SOURCE_ADMISSION_BINDING_INVALID")
|
||||
effective = str(candidate_id or admitted["candidate_id"])
|
||||
if effective != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit", "tree_oid"):
|
||||
value = admitted.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if recipe.get("mode") == "governed-source-integration-only" and (
|
||||
source_commit != self.c5_contract["integration_source_commit"]
|
||||
or admitted["tree_oid"] != self.c5_contract["integration_tree_oid"]
|
||||
):
|
||||
raise BuilderError("SOURCE_INTEGRATION_BINDING_INVALID")
|
||||
return {**admitted, **self.c5_contract}
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
@@ -247,6 +328,8 @@ class TrustedCandidateBuilderConfig:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
if self.c5_contract is not None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
@@ -263,8 +346,9 @@ class TrustedCandidateBuilderConfig:
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
allowed_fields = (required, required | {"tree_oid"}) if version == "V2R2C5" else (required,)
|
||||
if (
|
||||
set(admitted) != required
|
||||
set(admitted) not in allowed_fields
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
@@ -275,7 +359,7 @@ class TrustedCandidateBuilderConfig:
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit"):
|
||||
for name in ("base_oid", "source_commit", *(["tree_oid"] if "tree_oid" in admitted else [])):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
@@ -395,7 +479,44 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def _validate_integration_source(self, recipe: Mapping[str, str]) -> None:
|
||||
if "integration_source_commit" not in recipe:
|
||||
return
|
||||
|
||||
def read(*arguments: str, binary: bool = False):
|
||||
try:
|
||||
result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", *arguments],
|
||||
check=True, capture_output=True, text=not binary, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return result.stdout if binary else result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_UNAVAILABLE") from exc
|
||||
|
||||
source = recipe["source_commit"]
|
||||
if source != recipe["integration_source_commit"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
for commit_field, tree_field in (
|
||||
("integration_source_commit", "integration_tree_oid"),
|
||||
("publication_source_commit", "publication_tree_oid"),
|
||||
("repair_source_commit", "repair_tree_oid"),
|
||||
):
|
||||
if read("rev-parse", f"{recipe[commit_field]}^{{tree}}") != recipe[tree_field]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
if read("show", "-s", "--format=%P", source) != recipe["authority_base_oid"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
changed = read("diff", "--name-only", recipe["authority_base_oid"], source).splitlines()
|
||||
if sorted(changed) != sorted(C5_INTEGRATION_FILES):
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_FILE_SET_MISMATCH")
|
||||
for path, (component, digest) in C5_INTEGRATION_FILES.items():
|
||||
integrated = read("show", f"{source}:{path}", binary=True)
|
||||
original = read("show", f"{recipe[component]}:{path}", binary=True)
|
||||
if integrated != original or hashlib.sha256(integrated).hexdigest() != digest:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_BYTES_MISMATCH")
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self._validate_integration_source(recipe)
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
@@ -412,6 +533,16 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("tree_oid") is not None and recipe.get("tree_oid") != tree_oid:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("accepted_base_oid") is not None:
|
||||
accepted_ancestry = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["accepted_base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if getattr(accepted_ancestry, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
@@ -488,6 +619,13 @@ class ServerOwnedEvidenceSource:
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if recipe.get("rollback_version") is not None:
|
||||
if deployment.get("rollback_target") != {
|
||||
"version": recipe["rollback_version"],
|
||||
"target_oid": recipe["rollback_target_oid"],
|
||||
"artifact_sha256": recipe["rollback_artifact_sha256"],
|
||||
}:
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
|
||||
@@ -18,7 +18,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import fcntl
|
||||
try:
|
||||
import fcntl
|
||||
except ModuleNotFoundError: # pragma: no cover - production host is POSIX
|
||||
fcntl = None
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -376,6 +379,8 @@ class FileLock:
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "FileLock":
|
||||
if fcntl is None:
|
||||
raise ReleaseBlocked("production executor file locking requires POSIX")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
@@ -388,7 +393,7 @@ class FileLock:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
if self.handle is not None and fcntl is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Fixed registered composition of the existing shadow API and renewal store."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
ORIGINAL_SHA = 'f6393fe614239036a1d8235c98c1f455dc32b700107d88f48190c4f61dae617e'
|
||||
|
||||
|
||||
def service_type(types, renewal, public_key):
|
||||
class RegisteredService(types.SilentReleaseActionService):
|
||||
def __init__(self, root, **kwargs):
|
||||
if 'completion_approvals' in kwargs:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_DEPENDENCY_REQUIRED')
|
||||
original = types.SignedApprovalStore(Path(root) / 'approvals')
|
||||
completion = renewal.RegisteredCompletionApprovalStore(
|
||||
original, public_key, types.SignedReleasePlanApproval,
|
||||
)
|
||||
super().__init__(root, completion_approvals=completion, **kwargs)
|
||||
return RegisteredService
|
||||
|
||||
|
||||
def main():
|
||||
# Original Round3 startup and verifier remain loaded before the new root
|
||||
# is added. The C5 types are a separate registered module as in R2.
|
||||
import silent_release_hardening as original
|
||||
import hashlib
|
||||
if hashlib.sha256(Path(original.__file__).read_bytes()).hexdigest() != ORIGINAL_SHA:
|
||||
raise RuntimeError('ROUND3_HARDENING_DRIFT')
|
||||
if not getattr(original.BuilderAttestationVerifier.verify,
|
||||
'__aios_round3_rollback_contract_patch__', False):
|
||||
raise RuntimeError('ROUND3_VERIFIER_NOT_ACTIVE')
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
if Path(__file__) != root / 'c5_completion_api.py':
|
||||
raise renewal.RenewalRejected('REGISTERED_API_WRAPPER_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
keyring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if keyring.get('algorithm') != 'Ed25519' or parent['key_id'] in keyring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('REGISTERED_APPROVAL_KEY_REQUIRED')
|
||||
public_key = base64.b64decode(keyring['keys'][parent['key_id']], validate=True)
|
||||
renewal.verify_signature(parent, public_key)
|
||||
spec = importlib.util.spec_from_file_location('_registered_c5_completion_types',
|
||||
root / 'silent_release_hardening.py')
|
||||
types = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = types
|
||||
spec.loader.exec_module(types)
|
||||
loaded = runpy.run_path(str(root / 'original_api.py'), run_name='_registered_original_api')
|
||||
g = loaded['main'].__globals__
|
||||
if g['SILENT_RELEASE_EXECUTION_ENABLED'] is not False or Path(g['SILENT_RELEASE_STATE_ROOT']) != renewal.STATE_ROOT:
|
||||
raise renewal.RenewalRejected('EXISTING_SHADOW_PLANE_REQUIRED')
|
||||
g['ReleasePlan'] = types.ReleasePlan
|
||||
g['TrustedCandidateBundle'] = types.TrustedCandidateBundle
|
||||
g['SilentReleaseActionService'] = service_type(types, renewal, public_key)
|
||||
g['TRUSTED_RELEASE_POLICY'] = renewal.TRUSTED_RELEASE_POLICY_PATH
|
||||
service = g['_build_silent_release_service']()
|
||||
if service.execution_enabled or service.policy_sha256 != renewal.TRUSTED_RELEASE_POLICY_SHA256:
|
||||
raise renewal.RenewalRejected('EXACT_API_POLICY_REQUIRED')
|
||||
# Preserve the exact candidate boundary from the existing registered R2.
|
||||
def reject_candidate_creation(payload):
|
||||
raise original.CapabilityAuthorizationError('completion mode cannot create or replace candidates')
|
||||
service.create_release_candidate = reject_candidate_creation
|
||||
g['_SILENT_RELEASE_SERVICE'] = service
|
||||
g['main']()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Completion-only fixed tag and metadata adapter launcher, no runtime effects."""
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
UNIT = 'aios-c5-exact-production-host-r1.service'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('adapter')
|
||||
parser.add_argument('mode', choices=('tag', 'registry', 'baseline'))
|
||||
args = parser.parse_args()
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
from silent_release_hardening import SignedApprovalStore, SignedReleasePlanApproval
|
||||
if Path(__file__) != root / 'c5_completion_phase.py' or os.geteuid() != 0:
|
||||
raise renewal.RenewalRejected('REGISTERED_COMPLETION_PHASE_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if credentials != Path('/run/credentials') / UNIT:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_PURPOSE_UNIT_REQUIRED')
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/' + UNIT)
|
||||
for line in Path('/proc/self/cgroup').read_text().splitlines()):
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_CGROUP_REQUIRED')
|
||||
module = 'governed_tag_adapter.py' if args.mode == 'tag' else 'release_metadata_adapter.py'
|
||||
if Path(args.adapter) != ROOT / module:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_ADAPTER_REQUIRED')
|
||||
value = json.load(sys.stdin)
|
||||
fixed = {'schema': 'aios.production-executor-request.v1', 'version': 'V2R2C5',
|
||||
'target_oid': renewal.SOURCE, 'evidence_closure_oid': renewal.SOURCE,
|
||||
'artifact_sha256': renewal.ARTIFACT,
|
||||
'approval_plan_id': 'sha256:d8f64b8b92f9fdb17f5c6053b76a68ee5bcbc2f266d91438c4db7893463079f8',
|
||||
'repository_identity': 'aios/aios', 'production_service': 'aios-v2r2c2-production.service',
|
||||
'production_port': 18928}
|
||||
if args.mode != 'tag':
|
||||
fixed['tag_oid'] = renewal.TAG_OID
|
||||
if (set(value) != set(fixed) | {'operation'} or value['operation'] not in {'probe', 'apply'}
|
||||
or any(value[k] != v for k, v in fixed.items())):
|
||||
raise renewal.RenewalRejected('EXACT_COMPLETION_REQUEST_REQUIRED')
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
ring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if parent['key_id'] in ring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('APPROVAL_KEY_REVOKED')
|
||||
public_key = base64.b64decode(ring['keys'][parent['key_id']], validate=True)
|
||||
approval = renewal.RegisteredCompletionApprovalStore(
|
||||
SignedApprovalStore(renewal.STATE_ROOT / 'approvals'), public_key, SignedReleasePlanApproval,
|
||||
).load(renewal.CID)
|
||||
if approval.fingerprint == renewal.OLD_FP:
|
||||
raise renewal.RenewalRejected('FRESH_COMPLETION_SUCCESSOR_REQUIRED')
|
||||
# Loading the store verifies both actual signatures, registered fixed nonce,
|
||||
# append-only chain and current successor lifetime. The historical proof
|
||||
# intentionally remains bound to the original approval, never rewritten.
|
||||
runtime = json.load(urllib.request.urlopen('http://127.0.0.1:18928/health', timeout=10))
|
||||
if any(runtime.get(k) != v for k, v in {
|
||||
'version': 'V2R2C5', 'commit_oid': renewal.SOURCE, 'artifact_sha256': renewal.ARTIFACT,
|
||||
'deployment_state': 'ACTIVE', 'production_deployed': True}.items()):
|
||||
raise renewal.RenewalRejected('CURRENT_C5_RUNTIME_REQUIRED')
|
||||
# Exact signed copies of these historical helpers are read-only here.
|
||||
# Never call their activation, run(), or verification-phase effect methods.
|
||||
import production_verification_phase_r1 as business
|
||||
import public_business_acceptance_probe_r1 as public
|
||||
business.RegisteredVerificationPhase.business_proof(None, True)
|
||||
public.reconcile()
|
||||
os.environ.clear()
|
||||
os.environ.update(PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
PYTHONDONTWRITEBYTECODE='1', AIOS_GITOPS_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_LEGACY_GLOBAL_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_CONTROLLER_WORKSPACE=str(renewal.CONTROLLER_WORKSPACE),
|
||||
AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=str(renewal.AUTHORITATIVE_GIT_DIR),
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY='aios/aios',
|
||||
AIOS_RUNTIME_REGISTRY_ROOT=str(renewal.RUNTIME_REGISTRY_ROOT),
|
||||
AIOS_RUNTIME_BASELINE_PATH=str(renewal.RUNTIME_BASELINE_PATH))
|
||||
if args.mode == 'tag':
|
||||
os.environ.update(GIT_COMMITTER_NAME='owner', GIT_COMMITTER_EMAIL='owner@cnnetmkt.top',
|
||||
GIT_COMMITTER_DATE='1790826063 +0000', GIT_TERMINAL_PROMPT='0',
|
||||
GIT_ASKPASS=str(root / 'publisher-askpass.py'))
|
||||
sys.argv = [module, '--workspace', str(renewal.CONTROLLER_WORKSPACE),
|
||||
'--repository', str(renewal.AUTHORITATIVE_GIT_DIR), '--remote', 'gitea-http',
|
||||
'--gitea-api', 'http://127.0.0.1:3000/api/v1', '--gitea-repo', 'aios/aios',
|
||||
'--gitea-token-file', str(credentials / 'gitea-release-publisher'),
|
||||
'--state-root', str(renewal.HISTORICAL_ROOT / 'tag-publication')]
|
||||
else:
|
||||
sys.argv = [module, '--mode', args.mode, '--expected-previous-baseline', 'V2R2C4',
|
||||
'--expected-previous-baseline-sha256', 'de0c7008facc44c5061239ee490135b58e521989952918f84a57cecb3358483c',
|
||||
'--health-url', 'http://127.0.0.1:18928/health']
|
||||
sys.stdin = io.StringIO(json.dumps(value))
|
||||
runpy.run_path(str(root / module), run_name='__main__')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Append-only C5 completion renewal boundary; activation requires registration.
|
||||
|
||||
The exact Owner-registered API, signer and completion host must call this
|
||||
boundary. This module does not grant authority or perform deployment effects.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
CID = 'rc-v2r2c5-5f65e458957d'
|
||||
# Immutable release content remains compiled into the validator. Renewable
|
||||
# authority identities and filesystem locations are installed only from the
|
||||
# digest-bound, root-owned registration file (see configure_registered_bindings).
|
||||
OLD_FP = '0' * 64
|
||||
SOURCE = '5f65e458957dd51c9973cfa956a624f3cbf54990'
|
||||
TREE = 'ddd239d43d63a7fbd3cff46119c71466c9443737'
|
||||
ARTIFACT = '7f9d8409f07a5e0d36f5db7c55eb82ea70935fa731e057998f29cd1a5ca0a9ad'
|
||||
PLAN = '370521afcf9b2288f6ad3803ca3554a46ee05d2042d53b19b4f4cb80e37a9684'
|
||||
MUTABLE = {'issued_at', 'not_before', 'expires_at', 'nonce', 'fingerprint', 'signature'}
|
||||
CAPABILITIES = ['baseline_registry_finalize', 'runtime_switch', 'tag_publish']
|
||||
AUTHORITY_ROOT = Path('/unconfigured/c5-completion-authority')
|
||||
STATE_ROOT = Path('/unconfigured/c5-completion-state')
|
||||
OWNER_KEY = 'UNCONFIGURED'
|
||||
TAG_OID = '6d1745ff496103db7e721a2b2bc624fda52c5dc8'
|
||||
SAGA_HEAD = '0' * 64
|
||||
HISTORICAL_ROOT = Path('/unconfigured/c5-completion-history')
|
||||
CONTROLLER_WORKSPACE = Path('/unconfigured/c5-controller-workspace')
|
||||
AUTHORITATIVE_GIT_DIR = Path('/unconfigured/c5-authoritative-git')
|
||||
RUNTIME_REGISTRY_ROOT = Path('/unconfigured/c5-runtime-registry')
|
||||
RUNTIME_BASELINE_PATH = Path('/unconfigured/c5-runtime-baseline.json')
|
||||
TRUSTED_RELEASE_POLICY_PATH = Path('/unconfigured/c5-trusted-release-policy.json')
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = '0' * 64
|
||||
BINDINGS_PATH_ENV = 'AIOS_C5_COMPLETION_BINDINGS_PATH'
|
||||
BINDINGS_SHA256_ENV = 'AIOS_C5_COMPLETION_BINDINGS_SHA256'
|
||||
_BINDINGS_DIGEST = None
|
||||
RENEWAL_SOURCE_FILES = ('c5_completion_renewal.py', 'c5_release_execution_host.py',
|
||||
'silent_release_hardening.py', 'c5_completion_signer.py',
|
||||
'c5_completion_api.py', 'c5_completion_phase.py') # gitleaks:allow -- filenames only
|
||||
PHASE_DEPENDENCIES = ('governed_tag_adapter.py', 'release_metadata_adapter.py',
|
||||
'production_verification_phase_r1.py', 'public_business_acceptance_probe_r1.py',
|
||||
'publisher-askpass.py', 'c5_staging_installation.py',
|
||||
'aios_single_host_authority.py', 'registered_owner_signer_adapter.py')
|
||||
|
||||
|
||||
class RenewalRejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_completion_lineage(gate):
|
||||
"""Bind a current completion-only journal suffix to the frozen Saga15 head.
|
||||
|
||||
The suffix is authenticated by the enclosing registered Owner contract;
|
||||
these checks preserve its origin and exclude deployment/source transitions.
|
||||
"""
|
||||
revision = gate.get('saga_revision')
|
||||
suffix = gate.get('completion_journal_suffix', [])
|
||||
observed = gate.get('observed_at')
|
||||
if (type(revision) is not int or revision < 15 or not isinstance(suffix, list)
|
||||
or type(observed) not in {int, float} or not math.isfinite(observed)):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if revision == 15:
|
||||
if gate.get('saga_head') != SAGA_HEAD or suffix:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
return
|
||||
if not 0 < len(suffix) <= 64 or len(suffix) != revision - 15:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
allowed = {
|
||||
'owner_registered_completion_successor_tag_reconciliation': ('TAG_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'owner_registered_tag_capability_successor_verified': ('TAG_VERIFIED', {'RECONCILIATION_REQUIRED', 'TAG_PENDING'}),
|
||||
'baseline_registry_finalization_pending': ('BASELINE_REGISTRY_PENDING', {'TAG_VERIFIED'}),
|
||||
'owner_registered_completion_successor_metadata_reconciliation': ('BASELINE_REGISTRY_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'release_reconciliation_required': ('RECONCILIATION_REQUIRED', {'TAG_PENDING', 'TAG_VERIFIED', 'BASELINE_REGISTRY_PENDING'}),
|
||||
}
|
||||
previous, state = SAGA_HEAD, 'RECONCILIATION_REQUIRED'
|
||||
for offset, event in enumerate(suffix, 16):
|
||||
if (not isinstance(event, dict)
|
||||
or set(event) != {'schema_version', 'sequence', 'previous_hash', 'payload', 'event_hash'}
|
||||
or type(event['schema_version']) is not int or event['schema_version'] != 1
|
||||
or type(event['sequence']) is not int or event['sequence'] != offset + 1
|
||||
or event['previous_hash'] != previous):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
payload = event['payload']
|
||||
if (not isinstance(payload, dict)
|
||||
or set(payload) != {'candidate_id', 'release_plan_id', 'revision', 'state', 'event', 'timestamp', 'details'}
|
||||
or payload['candidate_id'] != CID
|
||||
or payload['release_plan_id'] != 'pending-' + CID
|
||||
or type(payload['revision']) is not int or payload['revision'] != offset
|
||||
or not isinstance(payload['details'], dict)
|
||||
or not isinstance(payload['event'], str) or payload['event'] not in allowed):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
target, origins = allowed[payload['event']]
|
||||
if payload['state'] != target or state not in origins:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if ('release_plan_sha256' in payload['details']
|
||||
and payload['details']['release_plan_sha256'] != PLAN):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
try:
|
||||
if timestamp(payload['timestamp']) > gate['observed_at']:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
actual = digest(canonical({key: event[key] for key in ('sequence', 'previous_hash', 'payload')}))
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as exc:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID') from exc
|
||||
if event['event_hash'] != actual:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
previous, state = actual, target
|
||||
if gate.get('saga_head') != previous:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise RenewalRejected('UTC_REQUIRED')
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def verify_signature(approval, public_key):
|
||||
payload = {k: v for k, v in approval.items() if k not in {'signature', 'fingerprint'}}
|
||||
raw = canonical(payload)
|
||||
if approval.get('fingerprint') != digest(raw):
|
||||
raise RenewalRejected('APPROVAL_DIGEST_INVALID')
|
||||
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
||||
base64.b64decode(approval['signature'], validate=True), raw
|
||||
)
|
||||
|
||||
|
||||
def validate_successor(old, new, public_key, *, now):
|
||||
"""Validate historical predecessor and fresh identical-binding successor.
|
||||
|
||||
Historical signature verification does not authorize an expired effect.
|
||||
The caller must separately verify its fresh registered Owner capability.
|
||||
"""
|
||||
verify_signature(old, public_key)
|
||||
verify_signature(new, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES}
|
||||
if any(old.get(k) != v for k, v in exact.items()):
|
||||
raise RenewalRejected('EXACT_PREDECESSOR_REQUIRED')
|
||||
if set(old) != set(new) or any(new[k] != old[k] for k in set(old) - MUTABLE):
|
||||
raise RenewalRejected('IMMUTABLE_APPROVAL_BINDING_DRIFT')
|
||||
if timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('PREDECESSOR_NOT_EXPIRED')
|
||||
issued, not_before, expires = (timestamp(new[k]) for k in ['issued_at', 'not_before', 'expires_at'])
|
||||
if not issued <= not_before <= now < expires or not 0 < expires - issued <= 900:
|
||||
raise RenewalRejected('FRESH_BOUNDED_APPROVAL_REQUIRED')
|
||||
if new['nonce'] == old['nonce'] or new['fingerprint'] == old['fingerprint']:
|
||||
raise RenewalRejected('FRESH_APPROVAL_IDENTITY_REQUIRED')
|
||||
return new['fingerprint']
|
||||
|
||||
|
||||
def secure_bytes(path, owner_uid=0):
|
||||
path = Path(path)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
with os.fdopen(descriptor, 'rb') as stream:
|
||||
metadata = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != owner_uid or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_RENEWAL_FILE')
|
||||
return stream.read()
|
||||
|
||||
|
||||
def _safe_parents(path):
|
||||
"""Reject writable or symlinked ancestors before opening authority files."""
|
||||
path = Path(path)
|
||||
if not path.is_absolute() or '..' in path.parts:
|
||||
raise RenewalRejected('ABSOLUTE_CANONICAL_PATH_REQUIRED')
|
||||
for parent in path.parents:
|
||||
info = parent.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_AUTHORITY_ANCESTOR')
|
||||
|
||||
|
||||
def _sha(value):
|
||||
return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def _oid(value):
|
||||
return isinstance(value, str) and len(value) == 40 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def configure_registered_bindings(path=None, expected_sha256=None, *, owner_uid=0):
|
||||
"""Install renewable completion bindings from one authenticated file.
|
||||
|
||||
No historical approval fingerprint, Saga head, Owner key, or authority/state
|
||||
path is a runtime default. The service registration must provide both the
|
||||
absolute file path and its SHA-256 digest.
|
||||
"""
|
||||
global _BINDINGS_DIGEST
|
||||
path = Path(path or os.environ.get(BINDINGS_PATH_ENV, ''))
|
||||
expected_sha256 = expected_sha256 or os.environ.get(BINDINGS_SHA256_ENV, '')
|
||||
if not path.is_absolute() or not _sha(expected_sha256):
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_BINDINGS_REQUIRED')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path, owner_uid=owner_uid)
|
||||
if digest(raw) != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_DIGEST_MISMATCH')
|
||||
if _BINDINGS_DIGEST is not None and _BINDINGS_DIGEST != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_REBIND_FORBIDDEN')
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_INVALID') from exc
|
||||
fields = {
|
||||
'schema', 'authority_root', 'state_root', 'owner_key_id',
|
||||
'predecessor_fingerprint', 'saga_head', 'historical_root',
|
||||
'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path',
|
||||
'trusted_release_policy_path', 'trusted_release_policy_sha256',
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != fields or value.get('schema') != 'aios.c5.completion-bindings.v1':
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_SCHEMA_INVALID')
|
||||
authority_root = Path(value['authority_root'])
|
||||
state_root = Path(value['state_root'])
|
||||
governed_paths = {
|
||||
name: Path(value[name]) for name in (
|
||||
'historical_root', 'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path', 'trusted_release_policy_path',
|
||||
)
|
||||
}
|
||||
owner_key_id = value['owner_key_id']
|
||||
try:
|
||||
owner_key_valid = (isinstance(owner_key_id, str)
|
||||
and owner_key_id.startswith('SHA256:')
|
||||
and len(base64.b64decode(owner_key_id[7:], validate=True)) == 32)
|
||||
except (ValueError, TypeError):
|
||||
owner_key_valid = False
|
||||
all_paths = (authority_root, state_root, *governed_paths.values())
|
||||
if (any(not item.is_absolute() or '..' in item.parts for item in all_paths)
|
||||
or not _sha(value['predecessor_fingerprint']) or not _sha(value['saga_head'])
|
||||
or not _sha(value['trusted_release_policy_sha256'])
|
||||
or not owner_key_valid):
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_VALUE_INVALID')
|
||||
if path.parent != authority_root:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_ROOT_MISMATCH')
|
||||
global AUTHORITY_ROOT, STATE_ROOT, OWNER_KEY, OLD_FP, SAGA_HEAD
|
||||
global HISTORICAL_ROOT, CONTROLLER_WORKSPACE, AUTHORITATIVE_GIT_DIR
|
||||
global RUNTIME_REGISTRY_ROOT, RUNTIME_BASELINE_PATH
|
||||
global TRUSTED_RELEASE_POLICY_PATH, TRUSTED_RELEASE_POLICY_SHA256
|
||||
AUTHORITY_ROOT = authority_root
|
||||
STATE_ROOT = state_root
|
||||
OWNER_KEY = value['owner_key_id']
|
||||
OLD_FP = value['predecessor_fingerprint']
|
||||
SAGA_HEAD = value['saga_head']
|
||||
HISTORICAL_ROOT = governed_paths['historical_root']
|
||||
CONTROLLER_WORKSPACE = governed_paths['controller_workspace']
|
||||
AUTHORITATIVE_GIT_DIR = governed_paths['authoritative_git_dir']
|
||||
RUNTIME_REGISTRY_ROOT = governed_paths['runtime_registry_root']
|
||||
RUNTIME_BASELINE_PATH = governed_paths['runtime_baseline_path']
|
||||
TRUSTED_RELEASE_POLICY_PATH = governed_paths['trusted_release_policy_path']
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = value['trusted_release_policy_sha256']
|
||||
_BINDINGS_DIGEST = expected_sha256
|
||||
return dict(value)
|
||||
|
||||
|
||||
class RegisteredCompletionAuthority:
|
||||
"""Fixed root registration, Owner signature and immutable evidence verifier.
|
||||
|
||||
The completed production proofs are historical facts. This fresh authority
|
||||
permits only approval renewal and remaining metadata effects; it cannot
|
||||
authorize another runtime switch or public acceptance Task.
|
||||
"""
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return AUTHORITY_ROOT
|
||||
|
||||
@property
|
||||
def state_root(self):
|
||||
return STATE_ROOT
|
||||
|
||||
def __call__(self):
|
||||
from c5_staging_installation import RegistryOwnerVerifier
|
||||
root = AUTHORITY_ROOT
|
||||
_safe_parents(root / 'contract.json')
|
||||
envelope = json.loads(secure_bytes(root / 'contract.json'))
|
||||
if set(envelope) != {'payload', 'signature'}:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_ENVELOPE_REQUIRED')
|
||||
p = envelope['payload']
|
||||
fields = {'schema', 'host', 'repository', 'candidate_id', 'source_commit', 'tree_oid',
|
||||
'artifact_sha256', 'release_plan_sha256', 'state_root', 'predecessor_fingerprint',
|
||||
'predecessor_sha256', 'predecessor_uid', 'predecessor_snapshot', 'approval_nonce',
|
||||
'issued_at', 'expires_at', 'owner_key_id', 'owner_namespace', 'allowed_effects',
|
||||
'runtime_effects_allowed', 'files', 'gate_path', 'gate_observed_at',
|
||||
'governed_source_commit', 'governed_source_tree'}
|
||||
if not isinstance(p, dict) or set(p) != fields:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_SCHEMA_REQUIRED')
|
||||
exact = {'schema': 'aios.c5.registered-completion-authority.v1', 'host': 'cn-network-mkt',
|
||||
'repository': 'aios/aios', 'candidate_id': CID, 'source_commit': SOURCE,
|
||||
'tree_oid': TREE, 'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'state_root': str(STATE_ROOT), 'predecessor_fingerprint': OLD_FP,
|
||||
'owner_key_id': OWNER_KEY, 'owner_namespace': 'aios-owner-approval-v1',
|
||||
'allowed_effects': ['approval_successor_append', 'baseline_finalize', 'registry_finalize', 'tag_publish'],
|
||||
'runtime_effects_allowed': False}
|
||||
if any(p[k] != v for k, v in exact.items()) or type(p['runtime_effects_allowed']) is not bool:
|
||||
raise RenewalRejected('EXACT_COMPLETION_AUTHORITY_REQUIRED')
|
||||
now = time.time()
|
||||
if (type(p['issued_at']) is not int or type(p['expires_at']) is not int
|
||||
or not p['issued_at'] <= now < p['expires_at']
|
||||
or not 0 < p['expires_at'] - p['issued_at'] <= 1800
|
||||
or type(p['gate_observed_at']) not in {int, float}
|
||||
or not math.isfinite(p['gate_observed_at'])
|
||||
or not p['issued_at'] - 60 <= p['gate_observed_at'] <= p['issued_at']):
|
||||
raise RenewalRejected('FRESH_OWNER_AUTHORITY_REQUIRED')
|
||||
if (not _sha(p['approval_nonce']) or not _sha(p['predecessor_sha256'])
|
||||
or type(p['predecessor_uid']) is not int or p['predecessor_uid'] not in {0, 1000}
|
||||
or any(not isinstance(p[k], str) or len(p[k]) != 40
|
||||
or any(c not in '0123456789abcdef' for c in p[k])
|
||||
for k in ('governed_source_commit', 'governed_source_tree'))):
|
||||
raise RenewalRejected('AUTHORITY_IDENTITIES_INVALID')
|
||||
verifier = RegistryOwnerVerifier(Path('/etc/aios/single-host-signer-registry.json'),
|
||||
owner_signer_id='local-owner-authority')
|
||||
if not verifier.verify(namespace=p['owner_namespace'], payload=canonical(p),
|
||||
signature=envelope['signature'], key_id=p['owner_key_id']):
|
||||
raise RenewalRejected('OWNER_SIGNATURE_REJECTED')
|
||||
payload_sha = digest(canonical(p))
|
||||
registered = json.loads(secure_bytes(root / 'registration.json'))
|
||||
if (set(registered) != {'schema', 'status', 'payload_sha256', 'approval_nonce'}
|
||||
or registered != {'schema': 'aios.c5.completion-registration.v1', 'status': 'REGISTERED',
|
||||
'payload_sha256': payload_sha, 'approval_nonce': p['approval_nonce']}):
|
||||
raise RenewalRejected('ROOT_REGISTRATION_REQUIRED')
|
||||
if (root / 'rollback.json').exists():
|
||||
raise RenewalRejected('REGISTRATION_ROLLED_BACK')
|
||||
pins = p['files']
|
||||
if not isinstance(pins, list) or not pins:
|
||||
raise RenewalRejected('SIGNED_FILE_CLOSURE_REQUIRED')
|
||||
checked = {}
|
||||
for item in pins:
|
||||
if (not isinstance(item, dict) or set(item) != {'path', 'sha256', 'uid'}
|
||||
or not _sha(item['sha256']) or type(item['uid']) is not int or item['uid'] != 0):
|
||||
raise RenewalRejected('SIGNED_FILE_PIN_INVALID')
|
||||
path = Path(item['path'])
|
||||
if path.parent != root or str(path) in checked:
|
||||
raise RenewalRejected('SIGNED_FILE_PATH_INVALID')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path)
|
||||
if digest(raw) != item['sha256']:
|
||||
raise RenewalRejected('SIGNED_FILE_BYTES_DRIFT')
|
||||
checked[str(path)] = raw
|
||||
required = {str(root / name) for name in (*RENEWAL_SOURCE_FILES, *PHASE_DEPENDENCIES, 'source-material.json',
|
||||
'historical-production-proof.json', 'predecessor-approval.json', 'current-gate.json',
|
||||
'approval-public-keyring.json', 'original_api.py')}
|
||||
if not required <= set(checked):
|
||||
raise RenewalRejected('INCOMPLETE_REGISTERED_CLOSURE')
|
||||
if Path(__file__) != root / 'c5_completion_renewal.py':
|
||||
raise RenewalRejected('UNREGISTERED_AUTHORITY_MODULE')
|
||||
if (p['predecessor_snapshot'] != str(root / 'predecessor-approval.json')
|
||||
or p['gate_path'] != str(root / 'current-gate.json')
|
||||
or digest(checked[p['predecessor_snapshot']]) != p['predecessor_sha256']):
|
||||
raise RenewalRejected('REGISTERED_SNAPSHOT_DRIFT')
|
||||
material = json.loads(checked[str(root / 'source-material.json')])
|
||||
if (material.get('status') != 'MATERIALIZED' or material.get('task_status') != 'SUCCEEDED'
|
||||
or material.get('remote_verified') is not True
|
||||
or material.get('source_commit') != p['governed_source_commit']
|
||||
or material.get('source_tree') != p['governed_source_tree']
|
||||
or material.get('file_sha256') != {
|
||||
name: digest(checked[str(root / name)])
|
||||
for name in RENEWAL_SOURCE_FILES}):
|
||||
raise RenewalRejected('GOVERNED_CODE_PROVENANCE_REQUIRED')
|
||||
historical = json.loads(checked[str(root / 'historical-production-proof.json')])
|
||||
if (historical.get('candidate_id') != CID or historical.get('approval_fingerprint') != OLD_FP
|
||||
or historical.get('release_plan_sha256') != PLAN
|
||||
or historical.get('status') != 'COMPLETED'
|
||||
or any(historical.get(k) is not True for k in (
|
||||
'public_business_verified', 'real_c4_rollback_verified', 'c5_restore_verified'))):
|
||||
raise RenewalRejected('COMPLETED_PRODUCTION_PROOF_REQUIRED')
|
||||
gate = json.loads(checked[p['gate_path']])
|
||||
expected_gate = {'candidate_id': CID, 'source_commit': SOURCE, 'artifact_sha256': ARTIFACT,
|
||||
'release_plan_sha256': PLAN,
|
||||
'local_tag_oid': TAG_OID, 'runtime_verified': True, 'business_verified': True,
|
||||
'global_writes_enabled': False, 'observed_at': p['gate_observed_at']}
|
||||
if (any(gate.get(k) != v for k, v in expected_gate.items())
|
||||
or any(type(gate.get(k)) is not bool for k in (
|
||||
'runtime_verified', 'business_verified', 'global_writes_enabled'))
|
||||
or gate.get('remote_tag_oid') not in {None, TAG_OID}):
|
||||
raise RenewalRejected('EXACT_POSTVERIFICATION_GATE_REQUIRED')
|
||||
validate_completion_lineage(gate)
|
||||
return {**p, 'payload_sha256': payload_sha}
|
||||
|
||||
def validate_host(self, *, state_root, candidate_id, source_commit, tree_oid,
|
||||
artifact_sha256, release_plan_sha256):
|
||||
self()
|
||||
if (Path(state_root), candidate_id, source_commit, tree_oid, artifact_sha256, release_plan_sha256) != (
|
||||
STATE_ROOT, CID, SOURCE, TREE, ARTIFACT, PLAN):
|
||||
raise RenewalRejected('COMPLETION_HOST_BINDING_DRIFT')
|
||||
|
||||
def deny_runtime_effect(self, **kwargs):
|
||||
self()
|
||||
raise RenewalRejected('COMPLETION_RUNTIME_EFFECT_FORBIDDEN')
|
||||
|
||||
|
||||
class CompletionRuntimeGuard:
|
||||
def __init__(self, authority):
|
||||
if type(authority) is not RegisteredCompletionAuthority:
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_AUTHORITY_REQUIRED')
|
||||
self.authority = authority
|
||||
|
||||
def execute(self, **kwargs):
|
||||
return self.authority.deny_runtime_effect(**kwargs)
|
||||
|
||||
|
||||
def append_file(path, raw):
|
||||
"""An existing record must be identical; it is never replaced."""
|
||||
path = Path(path)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
except FileExistsError:
|
||||
if secure_bytes(path, owner_uid=os.geteuid()) != raw:
|
||||
raise RenewalRejected('APPEND_ONLY_RECORD_CONFLICT')
|
||||
return False
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
|
||||
|
||||
def publish_successor(directory, old, new, public_key, *, now, registration_digest, predecessor_bytes):
|
||||
"""Append records inside the SAME plane under a verified caller lock.
|
||||
|
||||
Registration validation, current Saga CAS, production/business readbacks,
|
||||
and permission to call this function remain mandatory caller gates.
|
||||
"""
|
||||
fp = validate_successor(old, new, public_key, now=now)
|
||||
if json.loads(predecessor_bytes) != old:
|
||||
raise RenewalRejected('PREDECESSOR_BYTES_DRIFT')
|
||||
predecessor_bytes_digest = digest(predecessor_bytes)
|
||||
if len(registration_digest) != 64 or any(c not in '0123456789abcdef' for c in registration_digest):
|
||||
raise RenewalRejected('REGISTERED_DIGEST_REQUIRED')
|
||||
directory = Path(directory)
|
||||
metadata = directory.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_APPEND_DIRECTORY')
|
||||
receipt = {'schema': 'aios.c5.completion-approval-successor.v1', 'candidate_id': CID,
|
||||
'predecessor_fingerprint': OLD_FP, 'predecessor_bytes_sha256': predecessor_bytes_digest,
|
||||
'successor_fingerprint': fp, 'successor_sha256': digest(canonical(new)),
|
||||
'owner_registration_payload_sha256': registration_digest,
|
||||
'completion_only': True, 'runtime_effects_allowed': False}
|
||||
# A crash after the first append is unresolved until its exact receipt is
|
||||
# reconciled. A loader must require BOTH files; no mutable current pointer.
|
||||
append_file(directory / (fp + '.approval.json'), canonical(new))
|
||||
append_file(directory / (fp + '.receipt.json'), canonical(receipt))
|
||||
return receipt
|
||||
|
||||
|
||||
class CompletionApprovalStore:
|
||||
"""Explicit store dependency for a registered completion host/API.
|
||||
|
||||
The original store and original candidate approval remain untouched.
|
||||
No global class replacement or expired approval verification is used.
|
||||
An authenticated registration fixes ONE successor nonce and immutable
|
||||
receipt path before signing. The host must separately enforce TAG_PENDING
|
||||
and forbid runtime effects; this store does not execute a Saga.
|
||||
"""
|
||||
|
||||
def __init__(self, original, directory, public_key, authority, approval_type):
|
||||
self.original = original
|
||||
self.directory = Path(directory)
|
||||
self.public_key = public_key
|
||||
self.authority = authority
|
||||
self.approval_type = approval_type
|
||||
|
||||
def _policy(self):
|
||||
# authority must be the fixed registered verifier, not a caller-supplied
|
||||
# HTTP callback. Wiring it is outside this unregistered draft.
|
||||
policy = self.authority()
|
||||
if policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP:
|
||||
raise RenewalRejected('REGISTERED_EXACT_POLICY_REQUIRED')
|
||||
nonce = policy.get('approval_nonce', '')
|
||||
if not isinstance(nonce, str) or len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce):
|
||||
raise RenewalRejected('REGISTERED_NONCE_REQUIRED')
|
||||
return policy, self.directory / (nonce + '.activation.json')
|
||||
|
||||
def _parent(self):
|
||||
old = self.original.load(CID)
|
||||
policy, _ = self._policy()
|
||||
owner = policy.get('predecessor_uid')
|
||||
if type(owner) is not int or owner not in {0, 1000}:
|
||||
raise RenewalRejected('REGISTERED_PREDECESSOR_OWNER_REQUIRED')
|
||||
raw = secure_bytes(self.original.root / (CID + '.json'), owner_uid=owner)
|
||||
if json.loads(raw) != old.to_dict() or digest(raw) != policy.get('predecessor_sha256'):
|
||||
raise RenewalRejected('PREDECESSOR_FILE_DRIFT')
|
||||
return old.to_dict(), raw
|
||||
|
||||
def load(self, candidate_id):
|
||||
if candidate_id != CID:
|
||||
return self.original.load(candidate_id)
|
||||
policy, activation = self._policy()
|
||||
if not activation.exists():
|
||||
return self.original.load(candidate_id)
|
||||
receipt = json.loads(secure_bytes(activation, owner_uid=policy['predecessor_uid']))
|
||||
expected = {'schema', 'candidate_id', 'predecessor_fingerprint', 'predecessor_bytes_sha256',
|
||||
'successor_fingerprint', 'successor_sha256', 'owner_registration_payload_sha256',
|
||||
'completion_only', 'runtime_effects_allowed'}
|
||||
if set(receipt) != expected or receipt.get('owner_registration_payload_sha256') != policy['payload_sha256']:
|
||||
raise RenewalRejected('ACTIVATION_RECEIPT_INVALID')
|
||||
fp = receipt.get('successor_fingerprint', '')
|
||||
if len(fp) != 64 or any(c not in '0123456789abcdef' for c in fp):
|
||||
raise RenewalRejected('SUCCESSOR_FINGERPRINT_INVALID')
|
||||
raw = secure_bytes(self.directory / (fp + '.approval.json'), owner_uid=policy['predecessor_uid'])
|
||||
new = json.loads(raw)
|
||||
parent, parent_raw = self._parent()
|
||||
valid = validate_successor(parent, new, self.public_key, now=datetime.now(timezone.utc).timestamp())
|
||||
exact = {'candidate_id': CID, 'predecessor_fingerprint': OLD_FP,
|
||||
'predecessor_bytes_sha256': digest(parent_raw), 'successor_fingerprint': valid,
|
||||
'successor_sha256': digest(raw), 'completion_only': True, 'runtime_effects_allowed': False}
|
||||
if any(receipt.get(k) != v for k, v in exact.items()) or new['nonce'] != policy['approval_nonce']:
|
||||
raise RenewalRejected('SUCCESSOR_ACTIVATION_DRIFT')
|
||||
durable = json.loads(secure_bytes(self.directory / (fp + '.receipt.json'), owner_uid=policy['predecessor_uid']))
|
||||
if durable != receipt:
|
||||
raise RenewalRejected('DURABLE_RECEIPT_DRIFT')
|
||||
return self.approval_type.from_dict(new)
|
||||
|
||||
def save(self, candidate_id, approval):
|
||||
if candidate_id != CID:
|
||||
return self.original.save(candidate_id, approval)
|
||||
policy, activation = self._policy()
|
||||
parent, raw = self._parent()
|
||||
if approval.fingerprint == parent['fingerprint']:
|
||||
return self.original.save(candidate_id, approval)
|
||||
new = approval.to_dict()
|
||||
if new.get('nonce') != policy['approval_nonce']:
|
||||
raise RenewalRejected('REGISTERED_NONCE_MISMATCH')
|
||||
# The caller supplies its authenticated registered effect lock and
|
||||
# invokes the existing ApprovalVerifier before this store boundary.
|
||||
receipt = publish_successor(self.directory, parent, new, self.public_key,
|
||||
now=datetime.now(timezone.utc).timestamp(),
|
||||
registration_digest=policy['payload_sha256'], predecessor_bytes=raw)
|
||||
created = append_file(activation, canonical(receipt))
|
||||
if self.load(candidate_id).fingerprint != approval.fingerprint:
|
||||
raise RenewalRejected('SUCCESSOR_READBACK_FAILED')
|
||||
return activation, created
|
||||
|
||||
|
||||
class RegisteredCompletionApprovalStore(CompletionApprovalStore):
|
||||
"""Production dependency; no request-selected root or authority callback."""
|
||||
|
||||
def __init__(self, original, public_key, approval_type):
|
||||
if Path(original.root) != STATE_ROOT / 'approvals':
|
||||
raise RenewalRejected('SAME_APPROVAL_PLANE_REQUIRED')
|
||||
super().__init__(original, STATE_ROOT / 'approvals' / 'c5-completion-successors',
|
||||
public_key, RegisteredCompletionAuthority(), approval_type)
|
||||
|
||||
|
||||
class CompletionSignerMixin:
|
||||
"""Narrow additional method of the EXISTING credential-bound signer.
|
||||
|
||||
Activation must register this exact mixin, verifier and namespace snapshot
|
||||
pins. The encrypted private key stays in the existing signer's unit. This
|
||||
draft supplies no activation, credential access or HTTP route.
|
||||
"""
|
||||
|
||||
def sign_exact_completion_successor(self, request):
|
||||
expected = {'schema', 'operation', 'candidate_id', 'predecessor_fingerprint'}
|
||||
if (set(request) != expected or request.get('operation') != 'renew_exact_completion_approval'
|
||||
or request.get('candidate_id') != CID or request.get('predecessor_fingerprint') != OLD_FP
|
||||
or request.get('schema') != 'aios.exact-release-signer.request.v1'):
|
||||
raise RenewalRejected('EXACT_SIGNER_RENEWAL_REQUEST_REQUIRED')
|
||||
policy = self.completion_authority()
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
if (policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP
|
||||
or not policy['issued_at'] <= now < policy['expires_at']
|
||||
or not 0 <= now - policy['gate_observed_at'] <= 60):
|
||||
raise RenewalRejected('FRESH_REGISTERED_COMPLETION_GATE_REQUIRED')
|
||||
old_raw = secure_bytes(policy['predecessor_snapshot'], owner_uid=0)
|
||||
if digest(old_raw) != policy['predecessor_sha256']:
|
||||
raise RenewalRejected('REGISTERED_SIGNER_PREIMAGE_DRIFT')
|
||||
old = json.loads(old_raw)
|
||||
public_key = self.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
verify_signature(old, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES,
|
||||
'key_id': self.policy.key_id, 'owner_identity': self.policy.owner_identity,
|
||||
'trust_domain': self.policy.trust_domain,
|
||||
'owner_approval_record_oid': self.authorization_oid}
|
||||
if any(old.get(k) != v for k, v in exact.items()) or timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('EXACT_EXPIRED_SIGNER_PREDECESSOR_REQUIRED')
|
||||
nonce = policy['approval_nonce']
|
||||
if len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce) or nonce == old['nonce']:
|
||||
raise RenewalRejected('FRESH_REGISTERED_SIGNER_NONCE_REQUIRED')
|
||||
output = self.state_root / 'completion-renewals' / (nonce + '.json')
|
||||
if output.exists():
|
||||
result = json.loads(secure_bytes(output, owner_uid=0))
|
||||
if (result.get('registration_payload_sha256') != policy['payload_sha256']
|
||||
or result['approval']['nonce'] != nonce):
|
||||
raise RenewalRejected('SIGNER_REPLAY_CONFLICT')
|
||||
validate_successor(old, result['approval'], public_key, now=now)
|
||||
return {**result, 'status': 'SIGNED_IDEMPOTENT'}
|
||||
expiry = min(now + 600, policy['expires_at'] - 5)
|
||||
if expiry - now < 120:
|
||||
raise RenewalRejected('INSUFFICIENT_SIGNER_WINDOW')
|
||||
def utc(value):
|
||||
return datetime.fromtimestamp(value, timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
payload = {k:v for k,v in old.items() if k not in {'fingerprint', 'signature'}}
|
||||
payload.update(issued_at=utc(now), not_before=utc(now), expires_at=utc(expiry), nonce=nonce)
|
||||
raw = canonical(payload)
|
||||
# All candidate, plan, historical signature and registered freshness
|
||||
# checks precede this only signing operation.
|
||||
approval = {**payload, 'fingerprint': digest(raw),
|
||||
'signature': base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
validate_successor(old, approval, public_key, now=now)
|
||||
result = {'schema': 'aios.exact-release-signer.response.v1', 'status': 'SIGNED',
|
||||
'approval': approval, 'registration_payload_sha256': policy['payload_sha256'],
|
||||
'predecessor_fingerprint': OLD_FP, 'completion_only': True, 'arbitrary_signing': False}
|
||||
# This registered directory must be created by the activation consumer
|
||||
# inside the existing signer state root, never a request path.
|
||||
append_file(output, canonical(result))
|
||||
return result
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Registered fixed extension of the existing credential-bound signer unit."""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from c5_completion_renewal import (
|
||||
CompletionSignerMixin, RegisteredCompletionAuthority,
|
||||
RenewalRejected, secure_bytes, digest,
|
||||
)
|
||||
|
||||
ORIGINAL = Path('/usr/local/libexec/aios_exact_release_signer.py')
|
||||
ORIGINAL_SHA = '6e2a5a9df0edfe566175e5b556d15901b2c8fe4484388ad74af2ac64f810c930'
|
||||
|
||||
|
||||
def signer_class(original):
|
||||
class CompletionSigner(CompletionSignerMixin, original.ExactReleaseSigner):
|
||||
def completion_authority(self):
|
||||
return RegisteredCompletionAuthority()()
|
||||
|
||||
def handle(self, request):
|
||||
if request.get('operation') == 'renew_exact_completion_approval':
|
||||
return self.sign_exact_completion_successor(request)
|
||||
return super().handle(request)
|
||||
return CompletionSigner
|
||||
|
||||
|
||||
def main():
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
if Path(__file__) != renewal.AUTHORITY_ROOT / 'c5_completion_signer.py':
|
||||
raise RenewalRejected('REGISTERED_SIGNER_WRAPPER_REQUIRED')
|
||||
RegisteredCompletionAuthority()()
|
||||
if digest(secure_bytes(ORIGINAL)) != ORIGINAL_SHA:
|
||||
raise RenewalRejected('ORIGINAL_SIGNER_PREIMAGE_DRIFT')
|
||||
spec = importlib.util.spec_from_file_location('c5_original_exact_release_signer', ORIGINAL)
|
||||
original = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = original
|
||||
spec.loader.exec_module(original)
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if not credentials.is_absolute() or credentials.parent != Path('/run/credentials'):
|
||||
raise RenewalRejected('EXISTING_SIGNER_PURPOSE_CREDENTIAL_REQUIRED')
|
||||
if credentials.name != 'aios-exact-release-signer.service':
|
||||
raise RenewalRejected('EXISTING_SIGNER_UNIT_REQUIRED')
|
||||
cgroup = Path('/proc/self/cgroup').read_text()
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/aios-exact-release-signer.service')
|
||||
for line in cgroup.splitlines()):
|
||||
raise RenewalRejected('EXISTING_SIGNER_CGROUP_REQUIRED')
|
||||
signer = signer_class(original)(
|
||||
Path('/etc/aios-gitops/exact-release-signer-policy.json'),
|
||||
credentials / 'owner_release_private_key',
|
||||
)
|
||||
original.serve(Path('/run/aios-exact-release-signer/signer.sock'), signer)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Exact C5 runtime-switch adapter for the silent-release Saga.
|
||||
|
||||
The adapter owns only the production deployment phase. Tag publication and
|
||||
Registry/Baseline finalization remain separate ``ReleaseOrchestrator`` Saga
|
||||
steps. A host must register immutable executable bytes and provide the real
|
||||
HMAC-authenticated source-admission store plus the signed Owner approval
|
||||
verifier before this adapter can be constructed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_source_admission import ReleaseSourceAdmissionStore
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import (
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
ReleasePlan,
|
||||
RuntimeSwitchController,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
_canonical_json,
|
||||
)
|
||||
|
||||
|
||||
C5_VERSION = "V2R2C5"
|
||||
C5_PREPUBLICATION_SOURCE_COMMIT = "396ea9811124736aa140d2810ab53eaefb6b5c1c"
|
||||
C4_ROLLBACK_TARGET = {
|
||||
"version": "V2R2C4",
|
||||
"target_oid": "e5f8209f57b43fc39e55c4555776e6362ce326eb",
|
||||
"artifact_sha256": "0dfc0461f7f9aa25cfbfc37008dcbd2d48d5341383574163b7bba102852062c4",
|
||||
}
|
||||
C4_ROLLBACK_ARTIFACT_PATH = Path(
|
||||
"/var/lib/aios-gitops/release-only/"
|
||||
"v2r2c4-e5f8209f57b4/artifact.tar.gz"
|
||||
)
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE = Path("/etc/aios/v2r2c2.env")
|
||||
C5_PRODUCTION_SERVICE = "aios-v2r2c2-production.service"
|
||||
C5_PRODUCTION_PORT = 18928
|
||||
REQUEST_SCHEMA = "aios.c5.exact-production-deployment-request.v1"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class C5DeploymentBindingError(CapabilityAuthorizationError):
|
||||
"""C5 source, plan, approval, executor, or receipt binding is invalid."""
|
||||
|
||||
|
||||
class C5DeploymentOutcomeUnknown(RuntimeError):
|
||||
"""The deployment effect must be reconciled before any retry."""
|
||||
|
||||
|
||||
class C5DeploymentConflict(RuntimeError):
|
||||
"""The observed runtime is bound to a different release."""
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _run_registered_adapter(
|
||||
command: tuple[str, ...], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Invoke only the code-pinned process and accept one JSON object."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
input=_canonical_json(dict(request)),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_UNAVAILABLE"
|
||||
) from exc
|
||||
if completed.returncode != 0:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_FAILED"
|
||||
)
|
||||
try:
|
||||
lines = [
|
||||
line
|
||||
for line in completed.stdout.decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
value = json.loads(lines[-1]) if lines else None
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _secure_registered_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
require_root_owner: bool,
|
||||
) -> None:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID") from exc
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
if os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_WRITABLE")
|
||||
if require_root_owner:
|
||||
if not hasattr(os, "geteuid") or info.st_uid != 0:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_OWNER_INVALID")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != 0
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5DeploymentBindingError(
|
||||
"C5_EXECUTOR_PARENT_SECURITY_INVALID"
|
||||
)
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if _sha256_file(path) != expected_sha256:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_CODE_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredC5DeploymentExecutor:
|
||||
"""Pinned host configuration for one concrete process adapter.
|
||||
|
||||
``adapter_path`` is optional for a native executable. When present the
|
||||
command is exactly ``executable adapter_path``; arbitrary argv and Python
|
||||
callables are intentionally unsupported.
|
||||
"""
|
||||
|
||||
executor_id: str
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
adapter_path: Path | None = None
|
||||
adapter_sha256: str | None = None
|
||||
require_root_owner: bool = True
|
||||
execution_scope: str = "PRODUCTION"
|
||||
isolation_root: Path | None = None
|
||||
rollback_target: Mapping[str, str] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.executor_id or len(self.executor_id) > 255:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ID_INVALID")
|
||||
if HEX64.fullmatch(self.executable_sha256) is None:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if (self.adapter_path is None) != (self.adapter_sha256 is None):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ADAPTER_BINDING_INVALID")
|
||||
if (
|
||||
self.adapter_sha256 is not None
|
||||
and HEX64.fullmatch(self.adapter_sha256) is None
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if not Path(self.artifact_path).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_PATH_INVALID")
|
||||
if self.execution_scope not in {"PRODUCTION", "ISOLATED_REHEARSAL"}:
|
||||
raise C5DeploymentBindingError("C5_EXECUTION_SCOPE_INVALID")
|
||||
target = dict(self.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if target.keys() != C4_ROLLBACK_TARGET.keys() or target["version"] != C4_ROLLBACK_TARGET["version"] or target["target_oid"] != C4_ROLLBACK_TARGET["target_oid"] or HEX64.fullmatch(str(target["artifact_sha256"])) is None:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if self.execution_scope == "PRODUCTION":
|
||||
if target != C4_ROLLBACK_TARGET:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if Path(self.rollback_artifact_path) != C4_ROLLBACK_ARTIFACT_PATH:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_ARTIFACT_PATH_INVALID")
|
||||
if Path(self.production_environment_file) != C5_PRODUCTION_ENVIRONMENT_FILE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_ENVIRONMENT_FILE_INVALID")
|
||||
if self.isolation_root is not None:
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
else:
|
||||
if self.isolation_root is None or not Path(self.isolation_root).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
root = Path(self.isolation_root).resolve(strict=False)
|
||||
for bound_path in (self.rollback_artifact_path, self.production_environment_file):
|
||||
path = Path(bound_path)
|
||||
if not path.is_absolute() or path.resolve(strict=False) == root:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID")
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID") from exc
|
||||
_secure_registered_file(
|
||||
Path(self.rollback_artifact_path),
|
||||
target["artifact_sha256"],
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
object.__setattr__(self, "rollback_target", target)
|
||||
self.verify_code()
|
||||
|
||||
def verify_code(self) -> None:
|
||||
_secure_registered_file(
|
||||
Path(self.executable),
|
||||
self.executable_sha256,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.adapter_path is not None:
|
||||
_secure_registered_file(
|
||||
Path(self.adapter_path),
|
||||
str(self.adapter_sha256),
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
values = [str(Path(self.executable))]
|
||||
if self.adapter_path is not None:
|
||||
values.append(str(Path(self.adapter_path)))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
class C5ExactDeploymentAdapter:
|
||||
"""Callable runtime-switch phase with exact C5 admission and approval."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
release_plan: ReleasePlan,
|
||||
approval: SignedReleasePlanApproval,
|
||||
approval_verifier: SignedApprovalVerifier,
|
||||
source_admissions: ReleaseSourceAdmissionStore,
|
||||
executor: RegisteredC5DeploymentExecutor,
|
||||
) -> None:
|
||||
if not isinstance(approval_verifier, SignedApprovalVerifier):
|
||||
raise C5DeploymentBindingError("C5_APPROVAL_VERIFIER_REQUIRED")
|
||||
if not isinstance(source_admissions, ReleaseSourceAdmissionStore):
|
||||
raise C5DeploymentBindingError("C5_SOURCE_ADMISSION_STORE_REQUIRED")
|
||||
self.candidate = candidate
|
||||
self.release_plan = release_plan
|
||||
self.approval = approval
|
||||
self.approval_verifier = approval_verifier
|
||||
self.source_admissions = source_admissions
|
||||
self.executor = executor
|
||||
self._binding = self._verify_binding(candidate, release_plan)
|
||||
|
||||
@staticmethod
|
||||
def _global_writes_disabled() -> bool:
|
||||
return os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() == "false"
|
||||
|
||||
def _verify_binding(
|
||||
self,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
if candidate.fingerprint != self.candidate.fingerprint or plan.sha256 != self.release_plan.sha256:
|
||||
raise C5DeploymentBindingError("C5_RUNTIME_INPUT_BINDING_MISMATCH")
|
||||
plan.validate()
|
||||
if plan.tag.version != C5_VERSION or plan.finalization.version != C5_VERSION:
|
||||
raise C5DeploymentBindingError("C5_VERSION_BINDING_INVALID")
|
||||
if candidate.source_commit == C5_PREPUBLICATION_SOURCE_COMMIT:
|
||||
raise C5DeploymentBindingError("C5_PREPUBLICATION_SOURCE_FORBIDDEN")
|
||||
rollback_target = dict(self.executor.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if plan.deployment.rollback_target != rollback_target:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if plan.deployment.service != C5_PRODUCTION_SERVICE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_SERVICE_INVALID")
|
||||
if plan.deployment.writer_expected is not False:
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION,
|
||||
candidate.candidate_id,
|
||||
candidate.source_commit,
|
||||
)
|
||||
if (
|
||||
admitted.get("source_commit") != candidate.source_commit
|
||||
or admitted.get("tree_oid") != candidate.tree_oid
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_ADMITTED_SOURCE_BINDING_INVALID")
|
||||
if (
|
||||
plan.deployment.source_commit != candidate.source_commit
|
||||
or plan.deployment.tree_oid != candidate.tree_oid
|
||||
or plan.deployment.artifact_sha256 != candidate.artifact_sha256
|
||||
or plan.deployment.manifest_sha256 != candidate.manifest_sha256
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_CANDIDATE_PLAN_BINDING_INVALID")
|
||||
self.approval_verifier.verify_release_plan(
|
||||
self.approval,
|
||||
candidate,
|
||||
plan,
|
||||
plan.policy_sha256,
|
||||
)
|
||||
|
||||
artifact = Path(self.executor.artifact_path)
|
||||
_secure_registered_file(
|
||||
artifact,
|
||||
candidate.artifact_sha256,
|
||||
require_root_owner=self.executor.require_root_owner,
|
||||
)
|
||||
artifact_size = artifact.stat().st_size
|
||||
if artifact_size <= 0:
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_BYTES_MISMATCH")
|
||||
self.executor.verify_code()
|
||||
return {
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"target_oid": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"artifact_path": str(artifact),
|
||||
"artifact_size": artifact_size,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"owner_approval_record_oid": self.approval.owner_approval_record_oid,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
"systemd_unit_sha256": plan.deployment.systemd_unit_sha256,
|
||||
"dropin_sha256": plan.deployment.dropin_sha256,
|
||||
"rollback_target": rollback_target,
|
||||
"rollback_artifact_path": str(
|
||||
self.executor.rollback_artifact_path
|
||||
),
|
||||
"production_environment_file": str(
|
||||
self.executor.production_environment_file
|
||||
),
|
||||
"writer_expected": False,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"executor_executable_sha256": self.executor.executable_sha256,
|
||||
"executor_adapter_sha256": self.executor.adapter_sha256,
|
||||
}
|
||||
|
||||
def _request(self, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
**self._binding,
|
||||
}
|
||||
|
||||
def _invoke(self, operation: str) -> dict[str, Any]:
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
self.executor.verify_code()
|
||||
value = _run_registered_adapter(
|
||||
self.executor.command, self._request(operation)
|
||||
)
|
||||
for key, expected in self._binding.items():
|
||||
if value.get(key) != expected:
|
||||
raise C5DeploymentBindingError(f"C5_DEPLOYMENT_RECEIPT_MISMATCH:{key}")
|
||||
if value.get("writer_disabled") is not True:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_WRITER_STATE_INVALID")
|
||||
receipt = value.get("deployment_receipt")
|
||||
if not isinstance(receipt, Mapping) or not receipt:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_RECEIPT_REQUIRED")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _classify(value: Mapping[str, Any]) -> ClaimProbeResult:
|
||||
status = value.get("status")
|
||||
if (
|
||||
status == "ACTIVE_VERIFIED"
|
||||
and value.get("verified") is True
|
||||
and value.get("deployment_state") == "ACTIVE"
|
||||
and value.get("production_deployed") is True
|
||||
and value.get("writer_disabled") is True
|
||||
and value.get("global_legacy_writes_enabled") is False
|
||||
):
|
||||
return ClaimProbeResult.APPLIED_VERIFIED
|
||||
if status == "NOT_APPLIED":
|
||||
return ClaimProbeResult.NOT_APPLIED
|
||||
if status == "CONFLICT":
|
||||
return ClaimProbeResult.CONFLICT
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def recovery_probe(self) -> ClaimProbeResult:
|
||||
"""Read-only exact probe used by ``CapabilityLedger`` after UNKNOWN."""
|
||||
try:
|
||||
self._verify_binding(self.candidate, self.release_plan)
|
||||
return self._classify(self._invoke("probe"))
|
||||
except C5DeploymentConflict:
|
||||
return ClaimProbeResult.CONFLICT
|
||||
except Exception:
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute only the explicit Saga rollback phase and verify exact C4."""
|
||||
self._verify_binding(candidate, plan)
|
||||
value = self._invoke("rollback")
|
||||
if (
|
||||
value.get("status") != "ROLLED_BACK_VERIFIED"
|
||||
or value.get("verified") is not True
|
||||
or value.get("rollback_verified") is not True
|
||||
or value.get("rollback_version") != self.executor.rollback_target["version"]
|
||||
or value.get("rollback_target_oid")
|
||||
!= self.executor.rollback_target["target_oid"]
|
||||
or value.get("rollback_artifact_sha256")
|
||||
!= self.executor.rollback_target["artifact_sha256"]
|
||||
):
|
||||
raise C5DeploymentOutcomeUnknown("C5_ROLLBACK_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"rollback_verified": True,
|
||||
"rollback_version": self.executor.rollback_target["version"],
|
||||
"rollback_target_oid": self.executor.rollback_target["target_oid"],
|
||||
"rollback_artifact_sha256": self.executor.rollback_target["artifact_sha256"],
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
self._verify_binding(candidate, plan)
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification == ClaimProbeResult.INDETERMINATE:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
if classification == ClaimProbeResult.NOT_APPLIED:
|
||||
applied = self._invoke("apply")
|
||||
if applied.get("status") == "CONFLICT":
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if applied.get("status") not in {"APPLIED", "ACTIVE_VERIFIED"}:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"rollback_target": dict(self.executor.rollback_target),
|
||||
"writer_disabled": True,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"deployment_receipt_sha256": hashlib.sha256(
|
||||
_canonical_json(dict(probe))
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
def controller(
|
||||
self,
|
||||
ledger: CapabilityLedger,
|
||||
*,
|
||||
lease_seconds: float = 300,
|
||||
) -> RuntimeSwitchController:
|
||||
"""Bind this exact adapter to the existing capability controller."""
|
||||
return RuntimeSwitchController(
|
||||
ledger,
|
||||
self,
|
||||
recovery_probe=self.recovery_probe,
|
||||
executor_id=self.executor.executor_id,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
"""Owner-registered dispatch boundary for the fixed-scope C5 installer.
|
||||
|
||||
The registration authorizes one exact signed installation contract and one
|
||||
exact set of installed executor bytes. It is not an execution receipt; the
|
||||
wrapped :class:`Installer` remains the component that durably records and
|
||||
reconciles installation state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import c5_staging_installation as _installer_module
|
||||
from c5_staging_installation import (
|
||||
BRIDGE_EFFECTS,
|
||||
BRIDGE_TARGET,
|
||||
HOST,
|
||||
InstallationError,
|
||||
InstallationOutcomeUnknown,
|
||||
ISSUER_EFFECTS,
|
||||
ISSUER_TARGET,
|
||||
OWNER_NAMESPACE,
|
||||
R14_SOURCE_COMMIT,
|
||||
REPOSITORY,
|
||||
SUCCESSOR_SCHEMA,
|
||||
Installer,
|
||||
RegistryOwnerVerifier,
|
||||
canonical_bytes,
|
||||
verify_signed_contract,
|
||||
)
|
||||
from registered_owner_signer_adapter import RegisteredOwnerSignerAdapter
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.registered-installer-executor.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.executor-provenance.v1"
|
||||
EXECUTION_DIGEST = "0779fa615e8e7971a8dcfccf0f295b1323cd9b64787e174aea4fd2affc414339"
|
||||
BRIDGE_PORT = 18892
|
||||
ISSUER_PORT = 18946
|
||||
CAPABILITIES = frozenset({"install", "rollback", "reconcile"})
|
||||
TARGET_EFFECTS = {BRIDGE_TARGET: BRIDGE_EFFECTS, ISSUER_TARGET: ISSUER_EFFECTS}
|
||||
MAX_TTL_SECONDS = 900
|
||||
MAX_CODE_BYTES = 16 * 1024 * 1024
|
||||
DEFAULT_REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_PROVENANCE_MANIFEST = Path("/etc/aios/c5-executor-provenance.json")
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
NONCE = re.compile(r"^[A-Za-z0-9._:\-]{16,128}$")
|
||||
SCOPE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:\-]{2,127}$")
|
||||
|
||||
|
||||
class ExecutorRegistrationError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise ExecutorRegistrationError(code)
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _require_digest(value: Any, pattern: re.Pattern[str], code: str) -> str:
|
||||
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
_fail("EXECUTOR_JSON_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _secure_bytes(
|
||||
path: Path, *, require_root_owner: bool = True, maximum: int = MAX_CODE_BYTES,
|
||||
) -> bytes:
|
||||
path = Path(path)
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
if path.is_symlink() or path.resolve(strict=True) != path:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
before = path.stat()
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
try:
|
||||
current = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(current.st_mode)
|
||||
or current.st_dev != before.st_dev
|
||||
or current.st_ino != before.st_ino
|
||||
or current.st_size < 1
|
||||
or current.st_size > maximum
|
||||
):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
if require_root_owner:
|
||||
if os.name != "posix" or current.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(current.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_stat = parent.stat()
|
||||
if parent.is_symlink() or parent_stat.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(parent_stat.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
chunks: list[bytes] = []
|
||||
remaining = current.st_size
|
||||
while remaining:
|
||||
chunk = os.read(fd, min(remaining, 1024 * 1024))
|
||||
if not chunk:
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
if os.read(fd, 1):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
integration_commit: str
|
||||
integration_tree: str
|
||||
installer_module_path: Path
|
||||
installer_script_path: Path
|
||||
executor_module_path: Path
|
||||
installer_module_sha256: str
|
||||
installer_script_sha256: str
|
||||
executor_module_sha256: str
|
||||
authority_manifest_sha256: str
|
||||
|
||||
|
||||
def _actual_installer_module_path() -> Path:
|
||||
value = getattr(_installer_module, "__file__", None)
|
||||
if not isinstance(value, str):
|
||||
_fail("EXECUTOR_LOADED_INSTALLER_PATH_INVALID")
|
||||
return Path(value).resolve(strict=True)
|
||||
|
||||
|
||||
def _actual_executor_module_path() -> Path:
|
||||
return Path(__file__).resolve(strict=True)
|
||||
|
||||
|
||||
def load_code_provenance(
|
||||
manifest: Path, *, require_root_owner: bool = True,
|
||||
) -> CodeProvenance:
|
||||
"""Load identity from an authority manifest and actual loaded paths."""
|
||||
manifest = Path(manifest)
|
||||
raw = _secure_bytes(manifest, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_PROVENANCE_INVALID") from exc
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {
|
||||
"schema", "environment", "instance_id", "integration_commit",
|
||||
"integration_tree", "entries",
|
||||
}
|
||||
or value["schema"] != PROVENANCE_SCHEMA
|
||||
or not isinstance(value["entries"], dict)
|
||||
or set(value["entries"]) != {
|
||||
"installer_module", "installer_script", "executor_module",
|
||||
}
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
expected_paths = {
|
||||
"installer_module": _actual_installer_module_path(),
|
||||
"executor_module": _actual_executor_module_path(),
|
||||
}
|
||||
resolved: dict[str, Path] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for label, entry in value["entries"].items():
|
||||
if not isinstance(entry, dict) or set(entry) != {"path", "sha256"}:
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
if not isinstance(entry["path"], str):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
path = Path(entry["path"])
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
canonical = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
if path != canonical:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
if label in expected_paths and canonical != expected_paths[label]:
|
||||
_fail("EXECUTOR_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
if label == "installer_script" and canonical.name != "c5_staging_installation.py":
|
||||
_fail("EXECUTOR_INSTALLER_SCRIPT_PATH_INVALID")
|
||||
digest = _sha256_bytes(
|
||||
_secure_bytes(canonical, require_root_owner=require_root_owner)
|
||||
)
|
||||
if entry["sha256"] != digest:
|
||||
_fail("EXECUTOR_PROVENANCE_CODE_MISMATCH")
|
||||
resolved[label] = canonical
|
||||
hashes[label] = digest
|
||||
if (
|
||||
not isinstance(value["environment"], str)
|
||||
or SCOPE_ID.fullmatch(value["environment"]) is None
|
||||
or not isinstance(value["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(value["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_SCOPE_INVALID")
|
||||
return CodeProvenance(
|
||||
environment=value["environment"],
|
||||
instance_id=value["instance_id"],
|
||||
integration_commit=_require_digest(
|
||||
value["integration_commit"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_COMMIT_INVALID",
|
||||
),
|
||||
integration_tree=_require_digest(
|
||||
value["integration_tree"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_TREE_INVALID",
|
||||
),
|
||||
installer_module_path=resolved["installer_module"],
|
||||
installer_script_path=resolved["installer_script"],
|
||||
executor_module_path=resolved["executor_module"],
|
||||
installer_module_sha256=hashes["installer_module"],
|
||||
installer_script_sha256=hashes["installer_script"],
|
||||
executor_module_sha256=hashes["executor_module"],
|
||||
authority_manifest_sha256=_sha256_bytes(raw),
|
||||
)
|
||||
|
||||
|
||||
def _contract_binding(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"old_snapshot_sha256": _sha256_bytes(canonical_bytes(payload["existing"])),
|
||||
"new_manifest_sha256": _sha256_bytes(canonical_bytes({
|
||||
"release_root": payload["release_root"],
|
||||
"files": payload["files"],
|
||||
"readiness": payload["readiness"],
|
||||
})),
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
}
|
||||
|
||||
|
||||
def _issuer_dependency(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"source_commit": payload["source_commit"],
|
||||
"support_commit": payload["support_commit"],
|
||||
"owner_signer_id": payload["owner_signer_id"],
|
||||
"owner_key_id": payload["owner_key_id"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
}
|
||||
|
||||
|
||||
def _registration_payload(
|
||||
*, executor_id: Any, nonce: Any, issued_at: Any, expires_at: Any,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
provenance: CodeProvenance,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"executor_id": executor_id,
|
||||
"owner_signer_id": contract_payload["owner_signer_id"],
|
||||
"owner_key_id": contract_payload["owner_key_id"],
|
||||
"host": HOST,
|
||||
"repository": REPOSITORY,
|
||||
"environment": provenance.environment,
|
||||
"instance_id": provenance.instance_id,
|
||||
"source_commit": R14_SOURCE_COMMIT,
|
||||
"support_commit": contract_payload["support_commit"],
|
||||
"integration_commit": provenance.integration_commit,
|
||||
"integration_tree": provenance.integration_tree,
|
||||
"installer_module_sha256": provenance.installer_module_sha256,
|
||||
"installer_script_sha256": provenance.installer_script_sha256,
|
||||
"executor_module_sha256": provenance.executor_module_sha256,
|
||||
"authority_manifest_sha256": provenance.authority_manifest_sha256,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"bridge_port": BRIDGE_PORT,
|
||||
"issuer_port": ISSUER_PORT,
|
||||
"capabilities": sorted(CAPABILITIES),
|
||||
"effects": sorted(contract_payload["effects"]),
|
||||
"issuer_dependency": dict(issuer_dependency) if issuer_dependency is not None else None,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"nonce": nonce,
|
||||
**_contract_binding(contract_payload, contract_digest),
|
||||
}
|
||||
|
||||
|
||||
def _validate_issuer_dependency(
|
||||
dependency: Any, contract_payload: Mapping[str, Any],
|
||||
) -> None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if dependency is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return
|
||||
if (
|
||||
not isinstance(dependency, Mapping)
|
||||
or set(dependency) != {
|
||||
"target", "contract_digest", "source_commit", "support_commit",
|
||||
"owner_signer_id", "owner_key_id", "contract_nonce",
|
||||
"contract_issued_at", "contract_expires_at", "rollback_sha256",
|
||||
}
|
||||
or dependency["target"] != ISSUER_TARGET
|
||||
or dependency["source_commit"] != contract_payload["source_commit"]
|
||||
or dependency["support_commit"] != contract_payload["support_commit"]
|
||||
or dependency["owner_signer_id"] != contract_payload["owner_signer_id"]
|
||||
or dependency["owner_key_id"] != contract_payload["owner_key_id"]
|
||||
or type(dependency["contract_issued_at"]) is not int
|
||||
or type(dependency["contract_expires_at"]) is not int
|
||||
):
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
_require_digest(
|
||||
dependency["contract_digest"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
_require_digest(
|
||||
dependency["rollback_sha256"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
|
||||
|
||||
def _validate_payload(
|
||||
payload: Mapping[str, Any], *, now: int, provenance: CodeProvenance,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
expected = _registration_payload(
|
||||
executor_id=payload.get("executor_id"), nonce=payload.get("nonce"),
|
||||
issued_at=payload.get("issued_at"), expires_at=payload.get("expires_at"),
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
provenance=provenance, issuer_dependency=issuer_dependency,
|
||||
)
|
||||
if set(payload) != set(expected) or any(
|
||||
payload.get(key) != value for key, value in expected.items()
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_BINDING_INVALID")
|
||||
if (
|
||||
not isinstance(payload["executor_id"], str)
|
||||
or not payload["executor_id"]
|
||||
or not isinstance(payload["nonce"], str)
|
||||
or NONCE.fullmatch(payload["nonce"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_IDENTITY_INVALID")
|
||||
if (
|
||||
type(payload["issued_at"]) is not int
|
||||
or type(payload["expires_at"]) is not int
|
||||
or payload["issued_at"] > now
|
||||
or now >= payload["expires_at"]
|
||||
or not 1 <= payload["expires_at"] - payload["issued_at"] <= MAX_TTL_SECONDS
|
||||
or payload["issued_at"] < contract_payload["issued_at"]
|
||||
or payload["expires_at"] > contract_payload["expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if payload["support_commit"] != payload["integration_commit"]:
|
||||
_fail("EXECUTOR_COMMIT_BINDING_INVALID")
|
||||
if (
|
||||
issuer_dependency is not None
|
||||
and payload["expires_at"] > issuer_dependency["contract_expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if (
|
||||
not isinstance(payload["environment"], str)
|
||||
or SCOPE_ID.fullmatch(payload["environment"]) is None
|
||||
or not isinstance(payload["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(payload["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_ENVIRONMENT_BINDING_INVALID")
|
||||
if payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_SOURCE_COMMIT_INVALID")
|
||||
if (
|
||||
contract_payload["schema"] != SUCCESSOR_SCHEMA
|
||||
or contract_payload["source_commit"] != R14_SOURCE_COMMIT
|
||||
):
|
||||
_fail("EXECUTOR_CONTRACT_GENERATION_INVALID")
|
||||
effects = TARGET_EFFECTS.get(payload["target"])
|
||||
if (
|
||||
effects is None
|
||||
or not isinstance(payload["effects"], list)
|
||||
or frozenset(payload["effects"]) != effects
|
||||
or len(payload["effects"]) != len(effects)
|
||||
):
|
||||
_fail("EXECUTOR_EFFECTS_INVALID")
|
||||
if (
|
||||
not isinstance(payload["capabilities"], list)
|
||||
or frozenset(payload["capabilities"]) != CAPABILITIES
|
||||
or len(payload["capabilities"]) != len(CAPABILITIES)
|
||||
):
|
||||
_fail("EXECUTOR_CAPABILITIES_INVALID")
|
||||
_validate_issuer_dependency(issuer_dependency, contract_payload)
|
||||
for name in (
|
||||
"installer_module_sha256", "installer_script_sha256",
|
||||
"executor_module_sha256", "authority_manifest_sha256",
|
||||
"execution_digest", "contract_digest", "old_snapshot_sha256",
|
||||
"new_manifest_sha256", "rollback_sha256",
|
||||
):
|
||||
_require_digest(payload[name], HEX64, f"EXECUTOR_{name.upper()}_INVALID")
|
||||
|
||||
|
||||
def _verified_contract(
|
||||
contract: Path, installer: Installer, *, now: int,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
return verify_signed_contract(
|
||||
contract, installer.registry_path, now=now,
|
||||
require_root_owner=installer.require_root_owner,
|
||||
_layout=installer.layout,
|
||||
)
|
||||
|
||||
|
||||
def _verified_issuer_dependency(
|
||||
issuer_contract: Path | None, installer: Installer, *, now: int,
|
||||
contract_payload: Mapping[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if issuer_contract is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return None
|
||||
if issuer_contract is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
payload, digest = _verified_contract(issuer_contract, installer, now=now)
|
||||
if payload["schema"] != SUCCESSOR_SCHEMA or payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
dependency = _issuer_dependency(payload, digest)
|
||||
_validate_issuer_dependency(dependency, contract_payload)
|
||||
return dependency
|
||||
|
||||
|
||||
def issue_registration(
|
||||
*, executor_id: str, nonce: str, ttl_seconds: int, contract: Path,
|
||||
installer: Installer, provenance_manifest: Path,
|
||||
registered_signer: RegisteredOwnerSignerAdapter,
|
||||
clock: Callable[[], int], issuer_contract: Path | None = None,
|
||||
require_root_owner: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue authorization after deriving every scope field from trusted input."""
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(registered_signer, RegisteredOwnerSignerAdapter):
|
||||
_fail("REGISTERED_SIGNER_ADAPTER_REQUIRED")
|
||||
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_TTL_SECONDS:
|
||||
_fail("EXECUTOR_REGISTRATION_TTL_INVALID")
|
||||
now = int(clock())
|
||||
provenance = load_code_provenance(
|
||||
provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
contract_payload, contract_digest = _verified_contract(contract, installer, now=now)
|
||||
dependency = _verified_issuer_dependency(
|
||||
issuer_contract, installer, now=now, contract_payload=contract_payload,
|
||||
)
|
||||
payload = _registration_payload(
|
||||
executor_id=executor_id, nonce=nonce, issued_at=now,
|
||||
expires_at=now + ttl_seconds, contract_payload=contract_payload,
|
||||
contract_digest=contract_digest, provenance=provenance,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if registered_signer.fingerprint != payload["owner_key_id"]:
|
||||
_fail("EXECUTOR_SIGNER_BINDING_INVALID")
|
||||
signature = registered_signer._sign_canonical_payload(
|
||||
OWNER_NAMESPACE, canonical_bytes(payload),
|
||||
)
|
||||
return {"payload": payload, "signature": signature}
|
||||
|
||||
|
||||
def load_registration(path: Path, *, require_root_owner: bool = True) -> dict[str, Any]:
|
||||
raw = _secure_bytes(
|
||||
Path(path), require_root_owner=require_root_owner, maximum=1024 * 1024,
|
||||
)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_REGISTRATION_INVALID") from exc
|
||||
if not isinstance(value, dict) or set(value) != {"payload", "signature"}:
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
class RegisteredInstallerExecutor:
|
||||
def __init__(
|
||||
self, *, owner_verifier: RegistryOwnerVerifier, installer: Installer,
|
||||
provenance_manifest: Path, clock: Callable[[], int],
|
||||
host: Callable[[], str] = socket.gethostname,
|
||||
require_root_owner: bool = True,
|
||||
) -> None:
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(owner_verifier, RegistryOwnerVerifier):
|
||||
_fail("EXECUTOR_OWNER_VERIFIER_INVALID")
|
||||
self.owner_verifier = owner_verifier
|
||||
self.installer = installer
|
||||
self.provenance_manifest = Path(provenance_manifest)
|
||||
self.clock = clock
|
||||
self.host = host
|
||||
self.require_root_owner = require_root_owner
|
||||
self.provenance = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
|
||||
def _check_code_provenance(self) -> None:
|
||||
current = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if current != self.provenance:
|
||||
_fail("EXECUTOR_CODE_PROVENANCE_CHANGED")
|
||||
|
||||
def _verify(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
self._check_code_provenance()
|
||||
if (
|
||||
not isinstance(registration, Mapping)
|
||||
or set(registration) != {"payload", "signature"}
|
||||
or not isinstance(registration["payload"], Mapping)
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
if self.host() != HOST:
|
||||
_fail("EXECUTOR_HOST_MISMATCH")
|
||||
now = int(self.clock())
|
||||
contract_payload, contract_digest = _verified_contract(
|
||||
contract, self.installer, now=now,
|
||||
)
|
||||
payload = dict(registration["payload"])
|
||||
dependency = payload.get("issuer_dependency")
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=self.provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if payload["owner_key_id"] != self.owner_verifier.record.fingerprint:
|
||||
_fail("EXECUTOR_OWNER_KEY_BINDING_INVALID")
|
||||
signature = registration["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not self.owner_verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=canonical_bytes(payload),
|
||||
signature=signature, key_id=payload["owner_key_id"],
|
||||
)
|
||||
):
|
||||
_fail("EXECUTOR_OWNER_SIGNATURE_INVALID")
|
||||
return payload, contract_digest, contract_payload
|
||||
|
||||
def dispatch(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
artifact: Path, operation: str, *,
|
||||
issuer_contract: Path | None = None,
|
||||
issuer_artifact: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload, contract_digest, contract_payload = self._verify(registration, contract)
|
||||
capability = "install" if operation in {"preflight", "install"} else operation
|
||||
if capability not in CAPABILITIES:
|
||||
_fail("EXECUTOR_OPERATION_INVALID")
|
||||
if capability not in payload["capabilities"]:
|
||||
_fail("EXECUTOR_CAPABILITY_INVALID")
|
||||
if operation == "rollback" and contract_payload["target"] == BRIDGE_TARGET:
|
||||
if issuer_artifact is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
actual_dependency = _verified_issuer_dependency(
|
||||
issuer_contract, self.installer, now=int(self.clock()),
|
||||
contract_payload=contract_payload,
|
||||
)
|
||||
if actual_dependency != payload["issuer_dependency"]:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_MISMATCH")
|
||||
elif issuer_contract is not None or issuer_artifact is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_UNEXPECTED")
|
||||
if operation == "preflight":
|
||||
result = self.installer.preflight(contract, artifact)
|
||||
elif operation == "install":
|
||||
result = self.installer.apply(contract, artifact)
|
||||
elif operation == "rollback":
|
||||
result = self.installer.rollback_applied(
|
||||
contract, artifact, issuer_contract=issuer_contract,
|
||||
issuer_artifact=issuer_artifact,
|
||||
)
|
||||
else:
|
||||
result = self.installer.reconcile_unknown(contract, artifact)
|
||||
return {
|
||||
**result,
|
||||
"operation": operation,
|
||||
"executor_id": payload["executor_id"],
|
||||
"contract_digest": contract_digest,
|
||||
"code_binding": {
|
||||
"integration_commit": payload["integration_commit"],
|
||||
"integration_tree": payload["integration_tree"],
|
||||
"installer_module_sha256": payload["installer_module_sha256"],
|
||||
"installer_script_sha256": payload["installer_script_sha256"],
|
||||
"executor_module_sha256": payload["executor_module_sha256"],
|
||||
},
|
||||
"registration_policy_digest": _sha256_bytes(canonical_bytes(payload)),
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one Owner-registered fixed-scope C5 installer operation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"operation", choices=("preflight", "install", "rollback", "reconcile"),
|
||||
)
|
||||
parser.add_argument("--registration", type=Path, required=True)
|
||||
parser.add_argument("--contract", type=Path, required=True)
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
parser.add_argument("--issuer-contract", type=Path)
|
||||
parser.add_argument("--issuer-artifact", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Production CLI with fixed registry and provenance authority paths."""
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
registration = load_registration(args.registration)
|
||||
payload = registration.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("owner_signer_id"), str):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
installer = Installer(DEFAULT_REGISTRY)
|
||||
verifier = RegistryOwnerVerifier(
|
||||
DEFAULT_REGISTRY, owner_signer_id=payload["owner_signer_id"],
|
||||
)
|
||||
executor = RegisteredInstallerExecutor(
|
||||
owner_verifier=verifier, installer=installer,
|
||||
provenance_manifest=DEFAULT_PROVENANCE_MANIFEST,
|
||||
clock=lambda: int(time.time()),
|
||||
)
|
||||
result = executor.dispatch(
|
||||
registration, args.contract, args.artifact, args.operation,
|
||||
issuer_contract=args.issuer_contract,
|
||||
issuer_artifact=args.issuer_artifact,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except InstallationOutcomeUnknown as exc:
|
||||
print(
|
||||
json.dumps({"status": "UNKNOWN", "error": exc.code}, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
except (ExecutorRegistrationError, InstallationError, OSError, ValueError, TypeError) as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "REJECTED", "error": getattr(exc, "code", str(exc))},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,977 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-owned one-shot assembly for the exact V2R2C5 release Saga.
|
||||
|
||||
This module only assembles existing hardened Saga phases. Each mutating phase
|
||||
is a SHA-pinned process adapter; no caller-provided Python callable is accepted.
|
||||
The staging HTTP service remains shadow-only and does not import this launcher.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import approved_release_executor as approved_executor_module
|
||||
from approved_release_executor import run_adapter
|
||||
import c5_exact_deployment_adapter as exact_deployment_module
|
||||
from c5_exact_deployment_adapter import (
|
||||
C4_ROLLBACK_ARTIFACT_PATH,
|
||||
C4_ROLLBACK_TARGET,
|
||||
C5ExactDeploymentAdapter,
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE,
|
||||
C5_PRODUCTION_PORT,
|
||||
C5_PRODUCTION_SERVICE,
|
||||
C5_VERSION,
|
||||
RegisteredC5DeploymentExecutor,
|
||||
)
|
||||
from release_source_admission import open_source_admission_store
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseCandidateStore
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BaselineController,
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
GovernedFinalizationCoordinator,
|
||||
HardenedTagController,
|
||||
PreSwitchValidator,
|
||||
RegistryController,
|
||||
ReleaseOrchestrator,
|
||||
RecoverableReleaseError,
|
||||
ReleasePlan,
|
||||
ReleasePlanStore,
|
||||
ReleaseSagaStore,
|
||||
SignedApprovalStore,
|
||||
SignedApprovalVerifier,
|
||||
TagPlan,
|
||||
TagPublicationConflict,
|
||||
TagPublicationProbeEvidence,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.release-execution-host.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.release-execution-provenance.v1"
|
||||
REQUEST_SCHEMA = "aios.production-executor-request.v1"
|
||||
REPOSITORY = "aios/aios"
|
||||
SCOPES = frozenset({"PRODUCTION", "ISOLATED_REHEARSAL"})
|
||||
SHA256 = __import__("re").compile(r"^[0-9a-f]{64}$")
|
||||
OID = __import__("re").compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class C5ExecutionHostBlocked(RuntimeError):
|
||||
"""Exact host assembly or phase verification failed closed."""
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _secure_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
expected_owner_uid: int | None,
|
||||
) -> Path:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(info.st_mode) or (
|
||||
os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_SECURITY_INVALID")
|
||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_OWNER_INVALID")
|
||||
if expected_owner_uid is not None:
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PARENT_SECURITY_INVALID")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if not SHA256.fullmatch(expected_sha256) or _sha256(path) != expected_sha256:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_DIGEST_MISMATCH")
|
||||
return path
|
||||
|
||||
|
||||
def _absolute(value: Any, name: str) -> Path:
|
||||
path = Path(str(value or ""))
|
||||
if not path.is_absolute():
|
||||
raise C5ExecutionHostBlocked(f"{name}_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredPhaseCommand:
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
adapter_path: Path
|
||||
adapter_sha256: str
|
||||
arguments: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Any) -> "RegisteredPhaseCommand":
|
||||
if not isinstance(raw, Mapping) or set(raw) != {
|
||||
"executable",
|
||||
"executable_sha256",
|
||||
"adapter_path",
|
||||
"adapter_sha256",
|
||||
"arguments",
|
||||
}:
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_SCHEMA_INVALID")
|
||||
arguments = raw["arguments"]
|
||||
if not isinstance(arguments, list) or not all(
|
||||
isinstance(item, str) and item and "\0" not in item
|
||||
for item in arguments
|
||||
):
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_ARGUMENTS_INVALID")
|
||||
return cls(
|
||||
executable=_absolute(raw["executable"], "PHASE_EXECUTABLE"),
|
||||
executable_sha256=str(raw["executable_sha256"]),
|
||||
adapter_path=_absolute(raw["adapter_path"], "PHASE_ADAPTER"),
|
||||
adapter_sha256=str(raw["adapter_sha256"]),
|
||||
arguments=tuple(arguments),
|
||||
)
|
||||
|
||||
def verify(self, *, expected_owner_uid: int | None) -> None:
|
||||
_secure_file(
|
||||
self.executable,
|
||||
self.executable_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
_secure_file(
|
||||
self.adapter_path,
|
||||
self.adapter_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
return (str(self.executable), str(self.adapter_path), *self.arguments)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5HostProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
authority_commit: str
|
||||
authority_tree: str
|
||||
entries: Mapping[str, tuple[Path, str]]
|
||||
manifest_sha256: str
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5HostProvenance":
|
||||
path = _secure_file(
|
||||
Path(path), expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID") from exc
|
||||
labels = {
|
||||
"host", "exact_deployment", "deployment", "approved_executor",
|
||||
"tag", "registry", "baseline",
|
||||
}
|
||||
if (
|
||||
not isinstance(raw, Mapping)
|
||||
or set(raw) != {
|
||||
"schema", "environment", "instance_id",
|
||||
"authority_commit", "authority_tree", "entries",
|
||||
}
|
||||
or raw["schema"] != PROVENANCE_SCHEMA
|
||||
or raw["environment"] not in SCOPES
|
||||
or not isinstance(raw["entries"], Mapping)
|
||||
or set(raw["entries"]) != labels
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INSTANCE_INVALID")
|
||||
authority_commit = str(raw["authority_commit"])
|
||||
authority_tree = str(raw["authority_tree"])
|
||||
if not OID.fullmatch(authority_commit) or not OID.fullmatch(authority_tree):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_AUTHORITY_INVALID")
|
||||
actual_loaded = {
|
||||
"host": Path(__file__).resolve(strict=True),
|
||||
"exact_deployment": Path(exact_deployment_module.__file__).resolve(strict=True),
|
||||
"approved_executor": Path(approved_executor_module.__file__).resolve(strict=True),
|
||||
}
|
||||
entries: dict[str, tuple[Path, str]] = {}
|
||||
for label, item in raw["entries"].items():
|
||||
if not isinstance(item, Mapping) or set(item) != {"path", "sha256"}:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ENTRY_INVALID")
|
||||
entry_path = _absolute(item["path"], "HOST_PROVENANCE_ENTRY")
|
||||
digest = str(item["sha256"])
|
||||
_secure_file(
|
||||
entry_path, digest, expected_owner_uid=expected_owner_uid
|
||||
)
|
||||
if label in actual_loaded and entry_path != actual_loaded[label]:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
entries[label] = (entry_path, digest)
|
||||
return cls(
|
||||
environment=str(raw["environment"]),
|
||||
instance_id=instance_id,
|
||||
authority_commit=authority_commit,
|
||||
authority_tree=authority_tree,
|
||||
entries=entries,
|
||||
manifest_sha256=expected_sha256,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5ExecutionHostConfig:
|
||||
host_provenance: C5HostProvenance
|
||||
executor_authority_commit: str
|
||||
executor_authority_tree: str
|
||||
execution_scope: str
|
||||
instance_id: str
|
||||
provenance: Mapping[str, Any]
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_fingerprint: str
|
||||
state_root: Path
|
||||
trusted_policy_path: Path
|
||||
trusted_policy_sha256: str
|
||||
approval_keyring_path: Path
|
||||
approval_keyring_sha256: str
|
||||
source_admission_root: Path
|
||||
source_admission_policy_path: Path
|
||||
source_admission_policy_sha256: str
|
||||
source_admission_task_id: str
|
||||
source_admission_hmac_path: Path
|
||||
source_admission_hmac_sha256: str
|
||||
deployment_executable: Path
|
||||
deployment_executable_sha256: str
|
||||
deployment_adapter_path: Path
|
||||
deployment_adapter_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_target: Mapping[str, str]
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
authoritative_git_dir: Path
|
||||
git_executable: Path
|
||||
git_executable_sha256: str
|
||||
tag_command: RegisteredPhaseCommand
|
||||
registry_command: RegisteredPhaseCommand
|
||||
baseline_command: RegisteredPhaseCommand
|
||||
registry_record_path: Path
|
||||
baseline_record_path: Path
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
host_provenance: C5HostProvenance,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5ExecutionHostConfig":
|
||||
path = _secure_file(
|
||||
Path(path),
|
||||
expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_INVALID") from exc
|
||||
keys = {
|
||||
"schema", "version", "execution_scope", "instance_id",
|
||||
"executor_authority_commit", "executor_authority_tree",
|
||||
"provenance", "candidate_id",
|
||||
"source_commit", "tree_oid", "artifact_sha256",
|
||||
"release_plan_sha256", "approval_fingerprint", "state_root",
|
||||
"trusted_policy_path", "trusted_policy_sha256",
|
||||
"approval_keyring_path", "approval_keyring_sha256",
|
||||
"source_admission_root", "source_admission_policy_path",
|
||||
"source_admission_policy_sha256", "source_admission_task_id", "source_admission_hmac_path",
|
||||
"source_admission_hmac_sha256", "deployment_executable",
|
||||
"deployment_executable_sha256", "deployment_adapter_path",
|
||||
"deployment_adapter_sha256", "artifact_path",
|
||||
"rollback_target", "rollback_artifact_path",
|
||||
"production_environment_file", "production_service",
|
||||
"production_port", "repository_identity", "global_legacy_writes_enabled",
|
||||
"authoritative_git_dir", "git_executable", "git_executable_sha256",
|
||||
"tag_command", "registry_command", "baseline_command",
|
||||
"registry_record_path", "baseline_record_path",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != keys:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_SCHEMA_INVALID")
|
||||
rollback_artifact_path = _absolute(raw["rollback_artifact_path"], "ROLLBACK_ARTIFACT")
|
||||
production_environment_file = _absolute(raw["production_environment_file"], "PRODUCTION_ENVIRONMENT_FILE")
|
||||
exact = (
|
||||
raw["schema"] == SCHEMA
|
||||
and raw["version"] == C5_VERSION
|
||||
and raw["execution_scope"] in SCOPES
|
||||
and isinstance(raw["rollback_target"], Mapping)
|
||||
and raw["production_service"] == C5_PRODUCTION_SERVICE
|
||||
and raw["production_port"] == C5_PRODUCTION_PORT
|
||||
and raw["repository_identity"] == REPOSITORY
|
||||
and raw["global_legacy_writes_enabled"] is False
|
||||
)
|
||||
if not exact:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
provenance = raw["provenance"]
|
||||
expected_provenance = (
|
||||
{
|
||||
"environment": "ISOLATED_REHEARSAL",
|
||||
"fixture": True,
|
||||
"r14_source_receipt": False,
|
||||
}
|
||||
if raw["execution_scope"] == "ISOLATED_REHEARSAL"
|
||||
else {
|
||||
"environment": "PRODUCTION",
|
||||
"fixture": False,
|
||||
"r14_source_receipt": True,
|
||||
}
|
||||
)
|
||||
if provenance != expected_provenance:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128 or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-"
|
||||
for character in instance_id
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_INSTANCE_ID_INVALID")
|
||||
for name, pattern in (
|
||||
("executor_authority_commit", OID),
|
||||
("executor_authority_tree", OID),
|
||||
("source_commit", OID), ("tree_oid", OID),
|
||||
("artifact_sha256", SHA256),
|
||||
("release_plan_sha256", SHA256),
|
||||
("approval_fingerprint", SHA256),
|
||||
):
|
||||
if pattern.fullmatch(str(raw[name])) is None:
|
||||
raise C5ExecutionHostBlocked(f"{name.upper()}_INVALID")
|
||||
value = cls(
|
||||
host_provenance=host_provenance,
|
||||
executor_authority_commit=str(raw["executor_authority_commit"]),
|
||||
executor_authority_tree=str(raw["executor_authority_tree"]),
|
||||
execution_scope=str(raw["execution_scope"]),
|
||||
instance_id=instance_id,
|
||||
provenance=dict(provenance),
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
source_commit=str(raw["source_commit"]),
|
||||
tree_oid=str(raw["tree_oid"]),
|
||||
artifact_sha256=str(raw["artifact_sha256"]),
|
||||
release_plan_sha256=str(raw["release_plan_sha256"]),
|
||||
approval_fingerprint=str(raw["approval_fingerprint"]),
|
||||
state_root=_absolute(raw["state_root"], "STATE_ROOT"),
|
||||
trusted_policy_path=_absolute(raw["trusted_policy_path"], "TRUSTED_POLICY"),
|
||||
trusted_policy_sha256=str(raw["trusted_policy_sha256"]),
|
||||
approval_keyring_path=_absolute(raw["approval_keyring_path"], "APPROVAL_KEYRING"),
|
||||
approval_keyring_sha256=str(raw["approval_keyring_sha256"]),
|
||||
source_admission_root=_absolute(raw["source_admission_root"], "SOURCE_ADMISSION_ROOT"),
|
||||
source_admission_policy_path=_absolute(raw["source_admission_policy_path"], "SOURCE_ADMISSION_POLICY"),
|
||||
source_admission_policy_sha256=str(raw["source_admission_policy_sha256"]),
|
||||
source_admission_task_id=str(raw["source_admission_task_id"]),
|
||||
source_admission_hmac_path=_absolute(raw["source_admission_hmac_path"], "SOURCE_ADMISSION_HMAC"),
|
||||
source_admission_hmac_sha256=str(raw["source_admission_hmac_sha256"]),
|
||||
deployment_executable=_absolute(raw["deployment_executable"], "DEPLOYMENT_EXECUTABLE"),
|
||||
deployment_executable_sha256=str(raw["deployment_executable_sha256"]),
|
||||
deployment_adapter_path=_absolute(raw["deployment_adapter_path"], "DEPLOYMENT_ADAPTER"),
|
||||
deployment_adapter_sha256=str(raw["deployment_adapter_sha256"]),
|
||||
artifact_path=_absolute(raw["artifact_path"], "ARTIFACT"),
|
||||
rollback_target=dict(raw["rollback_target"]),
|
||||
rollback_artifact_path=rollback_artifact_path,
|
||||
production_environment_file=production_environment_file,
|
||||
authoritative_git_dir=_absolute(raw["authoritative_git_dir"], "AUTHORITATIVE_GIT_DIR"),
|
||||
git_executable=_absolute(raw["git_executable"], "GIT_EXECUTABLE"),
|
||||
git_executable_sha256=str(raw["git_executable_sha256"]),
|
||||
tag_command=RegisteredPhaseCommand.from_mapping(raw["tag_command"]),
|
||||
registry_command=RegisteredPhaseCommand.from_mapping(raw["registry_command"]),
|
||||
baseline_command=RegisteredPhaseCommand.from_mapping(raw["baseline_command"]),
|
||||
registry_record_path=_absolute(raw["registry_record_path"], "REGISTRY_RECORD"),
|
||||
baseline_record_path=_absolute(raw["baseline_record_path"], "BASELINE_RECORD"),
|
||||
)
|
||||
if (not value.source_admission_task_id
|
||||
or len(value.source_admission_task_id) > 160
|
||||
or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-"
|
||||
for character in value.source_admission_task_id)):
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_ID_INVALID")
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
rehearsal_root = value.state_root.resolve(strict=False)
|
||||
lowered_parts = {part.lower() for part in rehearsal_root.parts}
|
||||
if (
|
||||
"isolated-rehearsal" not in lowered_parts
|
||||
or value.instance_id not in rehearsal_root.parts
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROOT_NOT_ISOLATED")
|
||||
for fixture_path in (
|
||||
value.source_admission_root,
|
||||
value.artifact_path,
|
||||
value.rollback_artifact_path,
|
||||
value.production_environment_file,
|
||||
value.registry_record_path,
|
||||
value.baseline_record_path,
|
||||
):
|
||||
try:
|
||||
fixture_path.resolve(strict=False).relative_to(rehearsal_root)
|
||||
except ValueError as exc:
|
||||
raise C5ExecutionHostBlocked(
|
||||
"REHEARSAL_FIXTURE_OUTSIDE_ISOLATION_ROOT"
|
||||
) from exc
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
if set(value.rollback_target) != set(C4_ROLLBACK_TARGET) or value.rollback_target.get("version") != C4_ROLLBACK_TARGET["version"] or value.rollback_target.get("target_oid") != C4_ROLLBACK_TARGET["target_oid"]:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if SHA256.fullmatch(str(value.rollback_target.get("artifact_sha256", ""))) is None:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if (
|
||||
host_provenance.environment != value.execution_scope
|
||||
or host_provenance.instance_id != value.instance_id
|
||||
or host_provenance.authority_commit
|
||||
!= value.executor_authority_commit
|
||||
or host_provenance.authority_tree != value.executor_authority_tree
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_BINDING_MISMATCH")
|
||||
if value.execution_scope == "PRODUCTION" and (
|
||||
value.rollback_target != C4_ROLLBACK_TARGET
|
||||
or value.rollback_artifact_path != C4_ROLLBACK_ARTIFACT_PATH
|
||||
or value.production_environment_file != C5_PRODUCTION_ENVIRONMENT_FILE
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
expected_adapter_paths = {
|
||||
"deployment": value.deployment_adapter_path,
|
||||
"tag": value.tag_command.adapter_path,
|
||||
"registry": value.registry_command.adapter_path,
|
||||
"baseline": value.baseline_command.adapter_path,
|
||||
}
|
||||
if any(
|
||||
host_provenance.entries[label][0] != path
|
||||
for label, path in expected_adapter_paths.items()
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ADAPTER_MISMATCH")
|
||||
production_names = {
|
||||
"deployment": "c5_production_deployment_adapter.py",
|
||||
"tag": "governed_tag_adapter.py",
|
||||
"registry": "release_metadata_adapter.py",
|
||||
"baseline": "release_metadata_adapter.py",
|
||||
}
|
||||
observed_names = {
|
||||
label: path.name for label, path in expected_adapter_paths.items()
|
||||
}
|
||||
if value.execution_scope == "PRODUCTION":
|
||||
if observed_names != production_names:
|
||||
raise C5ExecutionHostBlocked("PRODUCTION_ADAPTER_IDENTITY_INVALID")
|
||||
elif any(
|
||||
observed_names[label] == production_names[label]
|
||||
for label in production_names
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_PRODUCTION_ADAPTER_FORBIDDEN")
|
||||
value.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
return value
|
||||
|
||||
def verify_files(self, *, expected_owner_uid: int | None) -> None:
|
||||
for path, digest in (
|
||||
(self.trusted_policy_path, self.trusted_policy_sha256),
|
||||
(self.approval_keyring_path, self.approval_keyring_sha256),
|
||||
(self.source_admission_policy_path, self.source_admission_policy_sha256),
|
||||
(self.source_admission_hmac_path, self.source_admission_hmac_sha256),
|
||||
(self.git_executable, self.git_executable_sha256),
|
||||
):
|
||||
_secure_file(path, digest, expected_owner_uid=expected_owner_uid)
|
||||
for command in (
|
||||
self.tag_command, self.registry_command, self.baseline_command
|
||||
):
|
||||
command.verify(expected_owner_uid=expected_owner_uid)
|
||||
|
||||
|
||||
def _load_policy(path: Path) -> TrustedReleasePolicy:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
required = {
|
||||
"services", "release_roots", "cutover_links", "dropin_paths",
|
||||
"authoritative_git_dirs", "repository_identities",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != required:
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_SCHEMA_INVALID")
|
||||
values: dict[str, frozenset[str]] = {}
|
||||
for name in required:
|
||||
items = raw[name]
|
||||
if not isinstance(items, list) or not items or not all(
|
||||
isinstance(item, str) and item for item in items
|
||||
):
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_VALUES_INVALID")
|
||||
values[name] = frozenset(items)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
def _phase_request(candidate: ReleaseCandidate, plan: ReleasePlan, approval, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
"version": C5_VERSION,
|
||||
"target_oid": candidate.source_commit,
|
||||
"evidence_closure_oid": approval.evidence_closure_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"approval_plan_id": approval.approval_plan_id,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
}
|
||||
|
||||
|
||||
def _checked_phase_response(
|
||||
value: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
for name in (
|
||||
"version", "target_oid", "evidence_closure_oid",
|
||||
"artifact_sha256", "approval_plan_id",
|
||||
):
|
||||
if value.get(name) != request[name]:
|
||||
raise C5ExecutionHostBlocked(f"PHASE_RECEIPT_MISMATCH:{name}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
class _PreSwitchProbe:
|
||||
def __init__(self, config: C5ExecutionHostConfig, source_admissions):
|
||||
self.config = config
|
||||
self.source_admissions = source_admissions
|
||||
|
||||
def _git(self, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
[str(self.config.git_executable), f"--git-dir={self.config.authoritative_git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise C5ExecutionHostBlocked("PRE_SWITCH_GIT_UNAVAILABLE")
|
||||
return completed.stdout.strip()
|
||||
|
||||
def collect(self, candidate: ReleaseCandidate, plan) -> Mapping[str, Any]:
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
tree = self._git("rev-parse", f"{candidate.source_commit}^{{tree}}")
|
||||
artifact_ok = (
|
||||
self.config.artifact_path.is_file()
|
||||
and _sha256(self.config.artifact_path) == candidate.artifact_sha256
|
||||
)
|
||||
rollback_path = (
|
||||
C4_ROLLBACK_ARTIFACT_PATH
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_artifact_path
|
||||
)
|
||||
rollback_target = (
|
||||
C4_ROLLBACK_TARGET
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_target
|
||||
)
|
||||
rollback_ok = (
|
||||
rollback_path.is_file()
|
||||
and _sha256(rollback_path) == rollback_target["artifact_sha256"]
|
||||
)
|
||||
return {
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": tree,
|
||||
"artifact_sha256": candidate.artifact_sha256 if artifact_ok else "",
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"rollback_ready": rollback_ok,
|
||||
"git_refs_consistent": (
|
||||
admitted.get("source_commit") == candidate.source_commit
|
||||
and admitted.get("tree_oid") == tree
|
||||
),
|
||||
"repository_identity": plan.repository_identity,
|
||||
"writer_disabled": os.environ.get(
|
||||
"AIOS_GITOPS_WRITES_ENABLED", "false"
|
||||
).lower() == "false",
|
||||
}
|
||||
|
||||
|
||||
class _TagPhase:
|
||||
def __init__(self, command: RegisteredPhaseCommand, candidate, plan, approval, owner_uid):
|
||||
self.command_config = command
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
return _checked_phase_response(
|
||||
run_adapter(self.command_config.command, request), request
|
||||
)
|
||||
|
||||
def probe_evidence(self, tag_plan: TagPlan) -> TagPublicationProbeEvidence:
|
||||
value = self._call("probe")
|
||||
status = value.get("status")
|
||||
listing = value.get("gitea_listing_visible")
|
||||
if status == "NOT_APPLIED":
|
||||
listing = False
|
||||
return TagPublicationProbeEvidence(
|
||||
repository_identity=tag_plan.repository_identity,
|
||||
remote_reachable=True,
|
||||
gitea_api_reachable=True,
|
||||
gitea_listing_reachable=True,
|
||||
remote_tag_oid=value.get("remote_tag_oid"),
|
||||
annotated_tag_object_oid=value.get("tag_oid"),
|
||||
remote_peeled_target_oid=value.get("remote_peeled_target_oid"),
|
||||
gitea_api_tag_oid=value.get("gitea_tag_oid"),
|
||||
gitea_api_peeled_target_oid=value.get("gitea_peeled_target_oid"),
|
||||
gitea_listing_visible=listing,
|
||||
gitea_listing_tag_oid=(value.get("gitea_tag_oid") if listing else None),
|
||||
gitea_listing_peeled_target_oid=(value.get("gitea_peeled_target_oid") if listing else None),
|
||||
observed_at=time.time(),
|
||||
)
|
||||
|
||||
def __call__(self, *, tag_plan: TagPlan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") in {"NOT_APPLIED", "PARTIAL_LOCAL"}:
|
||||
value = self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "CONFLICT":
|
||||
raise TagPublicationConflict(
|
||||
"tag publication conflicts with the exact C5 plan"
|
||||
)
|
||||
if value.get("status") != "APPLIED_VERIFIED":
|
||||
raise RecoverableReleaseError(
|
||||
"tag publication outcome is indeterminate"
|
||||
)
|
||||
proof = self.probe_evidence(tag_plan)
|
||||
if proof.remote_tag_oid != tag_plan.expected_tag_oid:
|
||||
raise C5ExecutionHostBlocked("TAG_OBJECT_BINDING_MISMATCH")
|
||||
return {
|
||||
"verified": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"repository_identity": tag_plan.repository_identity,
|
||||
"remote_tag_oid": proof.remote_tag_oid,
|
||||
"remote_peeled_target_oid": proof.remote_peeled_target_oid,
|
||||
"gitea_api_tag_oid": proof.gitea_api_tag_oid,
|
||||
"gitea_api_peeled_target_oid": proof.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": proof.gitea_listing_visible,
|
||||
}
|
||||
|
||||
|
||||
class _MetadataPhase:
|
||||
def __init__(self, *, mode: str, command: RegisteredPhaseCommand, record_path: Path,
|
||||
candidate, plan, approval, tag_phase: _TagPhase, owner_uid):
|
||||
self.mode = mode
|
||||
self.command_config = command
|
||||
self.record_path = record_path
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.tag_phase = tag_phase
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
request["tag_oid"] = self.plan.tag.expected_tag_oid
|
||||
return _checked_phase_response(run_adapter(self.command_config.command, request), request)
|
||||
|
||||
def __call__(self, *, finalization_plan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "NOT_APPLIED":
|
||||
self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") != "VERIFIED":
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_FINALIZATION_NOT_VERIFIED")
|
||||
try:
|
||||
record = json.loads(self.record_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_INVALID") from exc
|
||||
expected_record = (
|
||||
finalization_plan.registry_expected_record
|
||||
if self.mode == "registry"
|
||||
else finalization_plan.baseline_expected_record
|
||||
)
|
||||
if any(record.get(key) != item for key, item in expected_record.items()):
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_BINDING_MISMATCH")
|
||||
tag = self.tag_phase.probe_evidence(self.plan.tag)
|
||||
if (
|
||||
tag.remote_tag_oid != self.plan.tag.expected_tag_oid
|
||||
or tag.remote_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_api_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_listing_visible is not True
|
||||
):
|
||||
raise C5ExecutionHostBlocked("FINALIZATION_TAG_PROOF_INVALID")
|
||||
proof = {
|
||||
"verified": True,
|
||||
"tag_verified": True,
|
||||
"remote_tag_oid": tag.remote_tag_oid,
|
||||
"remote_peeled_target_oid": tag.remote_peeled_target_oid,
|
||||
"gitea_api_peeled_target_oid": tag.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
f"{self.mode}_target_oid": self.candidate.source_commit,
|
||||
}
|
||||
if self.mode == "baseline":
|
||||
proof.update(
|
||||
{
|
||||
"registry_record": finalization_plan.registry_expected_record,
|
||||
"baseline_record": finalization_plan.baseline_expected_record,
|
||||
"baseline_written_last": True,
|
||||
}
|
||||
)
|
||||
return proof
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class C5ExecutionHost:
|
||||
config: C5ExecutionHostConfig
|
||||
orchestrator: ReleaseOrchestrator
|
||||
|
||||
def execute(self) -> dict[str, Any]:
|
||||
result = dict(self.orchestrator.execute(self.config.candidate_id))
|
||||
production_receipt = (
|
||||
self.config.execution_scope == "PRODUCTION"
|
||||
and result.get("state") == "COMPLETED"
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"execution_scope": self.config.execution_scope,
|
||||
"instance_id": self.config.instance_id,
|
||||
"provenance": dict(self.config.provenance),
|
||||
"host_authority_commit": self.config.host_provenance.authority_commit,
|
||||
"host_authority_tree": self.config.host_provenance.authority_tree,
|
||||
"host_provenance_sha256": self.config.host_provenance.manifest_sha256,
|
||||
"fixture_evidence": self.config.execution_scope == "ISOLATED_REHEARSAL",
|
||||
"production_receipt": production_receipt,
|
||||
"public_business_e2e_verified": False,
|
||||
"production_acceptance": False,
|
||||
}
|
||||
|
||||
|
||||
def _validate_source_admission_task(config, candidate, admitted) -> None:
|
||||
if admitted.get("task_id") != config.source_admission_task_id:
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_BINDING_INVALID")
|
||||
|
||||
|
||||
def build_execution_host(
|
||||
config: C5ExecutionHostConfig,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
completion_approvals: Any = None,
|
||||
) -> C5ExecutionHost:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise C5ExecutionHostBlocked("LEGACY_GLOBAL_WRITES_ENABLED")
|
||||
config.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
candidates = ReleaseCandidateStore(config.state_root / "candidates")
|
||||
plans = ReleasePlanStore(config.state_root / "release-plans")
|
||||
approvals = SignedApprovalStore(config.state_root / "approvals")
|
||||
completion_authority = None
|
||||
if completion_approvals is not None:
|
||||
from c5_completion_renewal import (
|
||||
RegisteredCompletionApprovalStore, RegisteredCompletionAuthority,
|
||||
CompletionRuntimeGuard,
|
||||
)
|
||||
if (type(completion_approvals) is not RegisteredCompletionApprovalStore
|
||||
or type(completion_approvals.authority) is not RegisteredCompletionAuthority
|
||||
or expected_owner_uid != 0 or config.execution_scope != "PRODUCTION"
|
||||
or Path(__file__) != completion_approvals.authority.root / "c5_release_execution_host.py"
|
||||
or Path(completion_approvals.original.root) != approvals.root):
|
||||
raise C5ExecutionHostBlocked("REGISTERED_COMPLETION_DEPENDENCY_REQUIRED")
|
||||
completion_authority = completion_approvals.authority
|
||||
completion_authority.validate_host(
|
||||
state_root=config.state_root, candidate_id=config.candidate_id,
|
||||
source_commit=config.source_commit, tree_oid=config.tree_oid,
|
||||
artifact_sha256=config.artifact_sha256,
|
||||
release_plan_sha256=config.release_plan_sha256,
|
||||
)
|
||||
approvals = completion_approvals
|
||||
sagas = ReleaseSagaStore(config.state_root / "sagas")
|
||||
candidate = candidates.load(config.candidate_id)
|
||||
plan = plans.load(config.candidate_id).validate()
|
||||
approval = approvals.load(config.candidate_id)
|
||||
observed = (
|
||||
candidate.source_commit, candidate.tree_oid, candidate.artifact_sha256,
|
||||
plan.sha256, approval.fingerprint,
|
||||
)
|
||||
expected = (
|
||||
config.source_commit, config.tree_oid, config.artifact_sha256,
|
||||
config.release_plan_sha256, config.approval_fingerprint,
|
||||
)
|
||||
if observed != expected or plan.tag.version != C5_VERSION:
|
||||
raise C5ExecutionHostBlocked("HOST_DURABLE_BINDING_MISMATCH")
|
||||
if (
|
||||
plan.policy_sha256 != config.trusted_policy_sha256
|
||||
or Path(plan.deployment.authoritative_git_dir)
|
||||
!= config.authoritative_git_dir
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_ACTUAL_PLAN_BINDING_MISMATCH")
|
||||
policy = _load_policy(config.trusted_policy_path)
|
||||
policy.validate_deployment(plan.deployment)
|
||||
verifier = SignedApprovalVerifier(
|
||||
ApprovalKeyRing.from_file(config.approval_keyring_path),
|
||||
ApprovalRevocationStore(config.state_root / "revocations"),
|
||||
)
|
||||
verifier.verify_release_plan(approval, candidate, plan, plan.policy_sha256)
|
||||
hmac_key = config.source_admission_hmac_path.read_bytes().strip()
|
||||
source_admissions = open_source_admission_store(
|
||||
config.source_admission_root,
|
||||
repository=REPOSITORY,
|
||||
policy=config.source_admission_policy_path,
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=False,
|
||||
)
|
||||
admitted = source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
_validate_source_admission_task(config, candidate, admitted)
|
||||
deployment_executor = RegisteredC5DeploymentExecutor(
|
||||
executor_id="c5-production-runtime-switch-v1",
|
||||
executable=config.deployment_executable,
|
||||
executable_sha256=config.deployment_executable_sha256,
|
||||
adapter_path=config.deployment_adapter_path,
|
||||
adapter_sha256=config.deployment_adapter_sha256,
|
||||
artifact_path=config.artifact_path,
|
||||
rollback_artifact_path=config.rollback_artifact_path,
|
||||
production_environment_file=config.production_environment_file,
|
||||
execution_scope=config.execution_scope,
|
||||
isolation_root=(config.state_root if config.execution_scope == "ISOLATED_REHEARSAL" else None),
|
||||
rollback_target=config.rollback_target,
|
||||
require_root_owner=expected_owner_uid == 0,
|
||||
)
|
||||
runtime = C5ExactDeploymentAdapter(
|
||||
candidate=candidate,
|
||||
release_plan=plan,
|
||||
approval=approval,
|
||||
approval_verifier=verifier,
|
||||
source_admissions=source_admissions,
|
||||
executor=deployment_executor,
|
||||
)
|
||||
ledger = CapabilityLedger(config.state_root / "capabilities")
|
||||
tag_phase = _TagPhase(
|
||||
config.tag_command, candidate, plan, approval, expected_owner_uid
|
||||
)
|
||||
tag = HardenedTagController(
|
||||
ledger,
|
||||
tag_phase,
|
||||
expected_repository=REPOSITORY,
|
||||
publication_probe=tag_phase.probe_evidence,
|
||||
executor_id="c5-production-tag-v1",
|
||||
)
|
||||
registry_adapter = _MetadataPhase(
|
||||
mode="registry", command=config.registry_command,
|
||||
record_path=config.registry_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
baseline_adapter = _MetadataPhase(
|
||||
mode="baseline", command=config.baseline_command,
|
||||
record_path=config.baseline_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
finalizer = GovernedFinalizationCoordinator(
|
||||
RegistryController(ledger, registry_adapter, executor_id="c5-production-registry-v1"),
|
||||
BaselineController(ledger, baseline_adapter, executor_id="c5-production-baseline-v1"),
|
||||
)
|
||||
|
||||
def actual_plan_factory(actual_candidate: ReleaseCandidate) -> ReleasePlan:
|
||||
actual = plans.load(actual_candidate.candidate_id).validate()
|
||||
runtime._verify_binding(actual_candidate, actual)
|
||||
return actual
|
||||
|
||||
def runtime_verify(*, candidate: ReleaseCandidate, plan: ReleasePlan) -> Mapping[str, Any]:
|
||||
runtime._verify_binding(candidate, plan)
|
||||
result = runtime._invoke("probe")
|
||||
if runtime._classify(result) != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5ExecutionHostBlocked("RUNTIME_VERIFY_INDETERMINATE")
|
||||
return {
|
||||
"verified": True,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"authoritative_git_dir": plan.deployment.authoritative_git_dir,
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
orchestrator = ReleaseOrchestrator(
|
||||
candidates=candidates,
|
||||
plans=plans,
|
||||
approvals=approvals,
|
||||
sagas=sagas,
|
||||
approval_verifier=verifier,
|
||||
trusted_policy=policy,
|
||||
actual_plan_factory=actual_plan_factory,
|
||||
pre_switch_validator=PreSwitchValidator(
|
||||
_PreSwitchProbe(config, source_admissions)
|
||||
),
|
||||
runtime_switch=(CompletionRuntimeGuard(completion_authority) if completion_authority
|
||||
else runtime.controller(ledger)),
|
||||
runtime_verify=runtime_verify,
|
||||
runtime_rollback=(completion_authority.deny_runtime_effect if completion_authority
|
||||
else runtime.rollback),
|
||||
tag_publish=tag,
|
||||
finalizer=finalizer,
|
||||
)
|
||||
return C5ExecutionHost(config=config, orchestrator=orchestrator)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--config-sha256", required=True)
|
||||
parser.add_argument("--provenance", type=Path, required=True)
|
||||
parser.add_argument("--provenance-sha256", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
provenance = C5HostProvenance.load(
|
||||
args.provenance,
|
||||
expected_sha256=args.provenance_sha256,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = C5ExecutionHostConfig.load(
|
||||
args.config,
|
||||
expected_sha256=args.config_sha256,
|
||||
host_provenance=provenance,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
result = build_execution_host(config, expected_owner_uid=0).execute()
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (C5ExecutionHostBlocked, CapabilityAuthorizationError) as exc:
|
||||
print(json.dumps({"status": "FAIL_CLOSED", "error": type(exc).__name__}))
|
||||
raise SystemExit(2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
"""Fail-closed component deployment identity projection.
|
||||
|
||||
This module deliberately does not discover a trust root or treat process
|
||||
environment/health responses as runtime evidence. A host supplies a trusted
|
||||
comparison callback after it has performed its own authenticated observation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_COMPONENT_SCHEMA = "aios.component-manifest.v1"
|
||||
_RECEIPT_SCHEMA = "aios.component-artifact-receipt.v1"
|
||||
|
||||
|
||||
class RuntimeIdentityError(ValueError):
|
||||
"""A declared or observed identity has an invalid shape."""
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise RuntimeIdentityError("DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
if not path.is_file() or path.is_symlink() or metadata.st_size > 2 * 1024 * 1024:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_strict_object)
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("IDENTITY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def _require_sha(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_oid(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _OID.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
try:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_VALUE_INVALID") from exc
|
||||
|
||||
|
||||
def _validate_modules(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping) or not value:
|
||||
raise RuntimeIdentityError("MODULES_INVALID")
|
||||
modules: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "module_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("MODULE_PATH_INVALID")
|
||||
modules[name] = _require_sha(digest, "module_sha256")
|
||||
return dict(sorted(modules.items()))
|
||||
|
||||
|
||||
def _validate_extra_files(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("EXTRA_FILES_INVALID")
|
||||
files: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "extra_file_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("EXTRA_FILE_PATH_INVALID")
|
||||
files[name] = _require_sha(digest, "extra_file_sha256")
|
||||
return dict(sorted(files.items()))
|
||||
|
||||
|
||||
def _relative_artifact_path(value: object, field: str, *, allow_current: bool = False) -> str:
|
||||
name = _require_text(value, field)
|
||||
path = Path(name)
|
||||
if "\\" in name or path.is_absolute() or ".." in path.parts or (name == "." and not allow_current):
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return name
|
||||
|
||||
|
||||
def _validate_start_identity(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"entrypoint", "config_path", "python_path"}:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
entrypoint = _require_text(value.get("entrypoint"), "entrypoint")
|
||||
config_path = _relative_artifact_path(value.get("config_path"), "config_path")
|
||||
python_path = value.get("python_path")
|
||||
if not isinstance(python_path, list) or not python_path:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
result = {
|
||||
"entrypoint": entrypoint,
|
||||
"config_path": config_path,
|
||||
"python_path": [
|
||||
_relative_artifact_path(path, "python_path", allow_current=True)
|
||||
for path in python_path
|
||||
],
|
||||
}
|
||||
_canonical(result)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_component(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256",
|
||||
"start_identity", "dependency_contract_version", "rollback_artifact_sha256",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
rollback = value.get("rollback_artifact_sha256")
|
||||
if rollback is not None:
|
||||
rollback = _require_sha(rollback, "rollback_artifact_sha256")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"dependency_contract_version": _require_text(value.get("dependency_contract_version"), "dependency_contract_version"),
|
||||
"rollback_artifact_sha256": rollback,
|
||||
}
|
||||
|
||||
|
||||
def validate_component_manifest(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "schema_version", "components"}:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
if value.get("schema") != _COMPONENT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_SCHEMA_INVALID")
|
||||
components = value.get("components")
|
||||
if not isinstance(components, list) or not components:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_COMPONENTS_INVALID")
|
||||
normalized = [_validate_component(component) for component in components]
|
||||
ids = [component["component_id"] for component in normalized]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise RuntimeIdentityError("COMPONENT_ID_DUPLICATE")
|
||||
return {"schema": _COMPONENT_SCHEMA, "schema_version": 1, "components": normalized}
|
||||
|
||||
|
||||
def load_component_manifest(path: Path) -> tuple[dict[str, Any], str]:
|
||||
raw = path.read_bytes()
|
||||
return validate_component_manifest(_load_json(path)), hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_artifact_receipt(value: object) -> dict[str, Any]:
|
||||
required = {"schema", "schema_version", "artifact_sha256", "component_manifest_sha256", "manifest_sha256", "evidence_refs"}
|
||||
optional = {"version", "source_commit", "tree_oid", "artifact_size", "production_deployed"}
|
||||
if not isinstance(value, Mapping) or not required.issubset(value) or set(value) - required - optional:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_FIELDS_INVALID")
|
||||
if value.get("schema") != _RECEIPT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_SCHEMA_INVALID")
|
||||
references = value.get("evidence_refs")
|
||||
if not isinstance(references, list) or not references or any(not isinstance(ref, str) or not ref.strip() for ref in references):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
result = {
|
||||
"schema": _RECEIPT_SCHEMA,
|
||||
"schema_version": 1,
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"component_manifest_sha256": _require_sha(value.get("component_manifest_sha256"), "component_manifest_sha256"),
|
||||
"manifest_sha256": _require_sha(value.get("manifest_sha256"), "manifest_sha256"),
|
||||
"evidence_refs": list(references),
|
||||
}
|
||||
if "version" in value:
|
||||
result["version"] = _require_text(value["version"], "version")
|
||||
if "source_commit" in value:
|
||||
result["source_commit"] = _require_oid(value["source_commit"], "source_commit")
|
||||
if "tree_oid" in value:
|
||||
result["tree_oid"] = _require_oid(value["tree_oid"], "tree_oid")
|
||||
if "artifact_size" in value:
|
||||
size = value["artifact_size"]
|
||||
if type(size) is not int or size < 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_SIZE_INVALID")
|
||||
result["artifact_size"] = size
|
||||
if "production_deployed" in value:
|
||||
deployed = value["production_deployed"]
|
||||
if type(deployed) is not bool:
|
||||
raise RuntimeIdentityError("PRODUCTION_DEPLOYED_INVALID")
|
||||
result["production_deployed"] = deployed
|
||||
return result
|
||||
|
||||
|
||||
def load_artifact_receipt(path: Path) -> dict[str, Any]:
|
||||
return validate_artifact_receipt(_load_json(path))
|
||||
|
||||
|
||||
def _identity_result(
|
||||
status: str,
|
||||
*,
|
||||
component_id: str | None,
|
||||
declared: Mapping[str, Any] | None = None,
|
||||
observed: Mapping[str, Any] | None = None,
|
||||
reason: str,
|
||||
evidence_refs: list[str] | None = None,
|
||||
mismatches: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"component_id": component_id,
|
||||
"declared_identity": dict(declared) if declared is not None else None,
|
||||
"observed_identity": dict(observed) if observed is not None else None,
|
||||
"manifest_digest": declared.get("component_manifest_sha256") if declared else None,
|
||||
"config_digest": declared.get("config_sha256") if declared else None,
|
||||
"observed_at": observed.get("observed_at") if observed else None,
|
||||
"expires_at": observed.get("expires_at") if observed else None,
|
||||
"evidence_refs": list(evidence_refs or ()),
|
||||
"reason": reason,
|
||||
"mismatch_fields": list(mismatches or ()),
|
||||
}
|
||||
|
||||
|
||||
def declared_component_identity(manifest_path: Path | None, receipt_path: Path | None, component_id: str | None) -> dict[str, Any] | None:
|
||||
if manifest_path is None or receipt_path is None or not component_id:
|
||||
return None
|
||||
manifest, manifest_digest = load_component_manifest(manifest_path)
|
||||
receipt = load_artifact_receipt(receipt_path)
|
||||
if receipt["component_manifest_sha256"] != manifest_digest:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_DIGEST_MISMATCH")
|
||||
component = next((item for item in manifest["components"] if item["component_id"] == component_id), None)
|
||||
if component is None:
|
||||
raise RuntimeIdentityError("COMPONENT_ID_NOT_DECLARED")
|
||||
return {
|
||||
**component,
|
||||
"artifact_sha256": receipt["artifact_sha256"],
|
||||
"component_manifest_sha256": receipt["component_manifest_sha256"],
|
||||
"manifest_sha256": receipt["manifest_sha256"],
|
||||
}
|
||||
|
||||
|
||||
def _timestamp(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or float(value) < 0:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _validate_observed(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity",
|
||||
"artifact_sha256", "observed_at", "expires_at", "evidence_refs",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("OBSERVED_IDENTITY_FIELDS_INVALID")
|
||||
observed_at = _timestamp(value.get("observed_at"), "observed_at")
|
||||
expires_at = _timestamp(value.get("expires_at"), "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise RuntimeIdentityError("OBSERVATION_EXPIRY_INVALID")
|
||||
refs = value.get("evidence_refs")
|
||||
if not isinstance(refs, list) or not refs or any(not isinstance(ref, str) or not ref.strip() for ref in refs):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"observed_at": observed_at,
|
||||
"expires_at": expires_at,
|
||||
"evidence_refs": list(refs),
|
||||
}
|
||||
|
||||
|
||||
TrustedComparison = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
||||
|
||||
|
||||
def evaluate_component_runtime_identity(
|
||||
*,
|
||||
component_id: str | None,
|
||||
manifest_path: Path | None,
|
||||
receipt_path: Path | None,
|
||||
trusted_comparison: TrustedComparison | None = None,
|
||||
now: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project identity without granting trust to a declaration or self-report."""
|
||||
try:
|
||||
declared = declared_component_identity(manifest_path, receipt_path, component_id)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, reason=str(exc))
|
||||
if declared is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, reason="DECLARED_IDENTITY_UNAVAILABLE")
|
||||
if trusted_comparison is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
raw_observed = trusted_comparison(dict(declared))
|
||||
except Exception:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_COMPARISON_UNAVAILABLE")
|
||||
if raw_observed is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="OBSERVATION_MISSING")
|
||||
try:
|
||||
observed = _validate_observed(raw_observed)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, reason=str(exc))
|
||||
current = time.time() if now is None else now
|
||||
if observed["expires_at"] <= current:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, observed=observed, reason="OBSERVATION_EXPIRED", evidence_refs=observed["evidence_refs"])
|
||||
fields = ("component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity", "artifact_sha256")
|
||||
mismatches = [field for field in fields if _canonical(declared[field]) != _canonical(observed[field])]
|
||||
if mismatches:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, observed=observed, reason="DECLARED_OBSERVED_DRIFT", evidence_refs=observed["evidence_refs"], mismatches=mismatches)
|
||||
return _identity_result("PASS", component_id=component_id, declared=declared, observed=observed, reason="TRUSTED_COMPARISON_MATCH", evidence_refs=observed["evidence_refs"])
|
||||
@@ -9,6 +9,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_STATES = {"STAGED", "ACTIVE", "ROLLED_BACK"}
|
||||
@@ -25,6 +27,13 @@ class RuntimeDeploymentState:
|
||||
return self.deployment_state == "ACTIVE"
|
||||
|
||||
def health_payload(self) -> dict[str, Any]:
|
||||
# This listener owns liveness only. Its environment-derived release
|
||||
# declaration must never become runtime identity evidence by itself.
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=None,
|
||||
manifest_path=None,
|
||||
receipt_path=None,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": self.version,
|
||||
@@ -33,6 +42,8 @@ class RuntimeDeploymentState:
|
||||
"deployment_state": self.deployment_state,
|
||||
"production_deployed": self.production_deployed,
|
||||
"health_timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Concrete transport adapter for the governed source publication boundary.
|
||||
|
||||
The adapter composes the already existing live writer executor. It owns no
|
||||
leases, TaskPrincipal, credentials, retry policy, or release state machine.
|
||||
Those remain server side. ``authenticated_readback`` is deliberately an
|
||||
injected port: a local commit response is not evidence of an authenticated
|
||||
source/tree binding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from governed_unattended_release import SourcePublicationIntent
|
||||
from task_plane.governed_writer_automation import GovernedWriterRequest
|
||||
|
||||
|
||||
class GovernedPublicationError(RuntimeError):
|
||||
"""Stable fail-closed publication error."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class GovernedWriterExecutor(Protocol):
|
||||
def acquire(self, *, request: GovernedWriterRequest, job_id: str) -> Any: ...
|
||||
|
||||
def commit_and_readback(
|
||||
self, *, session: Any, request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes], expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def submit_task_result(
|
||||
self, *, session: Any, result: Mapping[str, Any], idempotency_key: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class SourceFileReader(Protocol):
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]: ...
|
||||
|
||||
|
||||
class AuthenticatedReadback(Protocol):
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent, request: GovernedWriterRequest,
|
||||
session: Any, commit_result: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class AdmissionBindingReader(Protocol):
|
||||
"""Read the server-side task/governed binding after admission."""
|
||||
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent,
|
||||
admission_receipt: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class GitTreeFileReader(Protocol):
|
||||
"""Read bytes from an immutable committed tree object, never a workspace."""
|
||||
|
||||
def __call__(self, *, tree_oid: str, path: str) -> bytes: ...
|
||||
|
||||
|
||||
class CommittedTreeSourceReader:
|
||||
"""Adapt an authoritative git-object reader to the publication port."""
|
||||
|
||||
def __init__(self, reader: GitTreeFileReader) -> None:
|
||||
if not callable(reader):
|
||||
raise ValueError("git_object_reader_required")
|
||||
self.reader = reader
|
||||
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]:
|
||||
return {
|
||||
path: self.reader(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SAFE_CODE = re.compile(r"^(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}$")
|
||||
_SAFE_OPERATION = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,63}$")
|
||||
|
||||
|
||||
def _oid(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _OID.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_OID_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _SHA.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_SHA_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
dict(value), sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_EVIDENCE_INVALID") from exc
|
||||
|
||||
|
||||
class GovernedPublicationClient:
|
||||
"""Run TaskLease -> TaskPrincipal -> WriterLease -> commit through ports.
|
||||
|
||||
``writer_executor`` is normally ``LiveGovernedWriterExecutor`` from
|
||||
``task_plane.live_governed_writer_adapters``. ``source_files`` must return
|
||||
the exact immutable bytes for the intent's allowlist; it is never allowed
|
||||
to return a path or a mutable object. The authenticated readback port
|
||||
must be backed by the authoritative provider/bridge and prove the final
|
||||
source OID, tree OID, task, branch, base, generation, and execution digest.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
writer_executor: GovernedWriterExecutor,
|
||||
source_files: SourceFileReader | None = None,
|
||||
git_tree_file: GitTreeFileReader | None = None,
|
||||
admission_binding: AdmissionBindingReader,
|
||||
authenticated_readback: AuthenticatedReadback,
|
||||
subject: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
commit_message: str = "governed source publication",
|
||||
diagnostic_sink: Callable[[Mapping[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
writer_executor is None
|
||||
or any(
|
||||
not callable(getattr(writer_executor, name, None))
|
||||
for name in (
|
||||
"acquire", "commit_and_readback", "submit_task_result",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("writer_executor_required")
|
||||
if source_files is not None:
|
||||
raise ValueError("workspace_source_reader_forbidden")
|
||||
if git_tree_file is None:
|
||||
raise ValueError("git_object_source_reader_required")
|
||||
if not callable(admission_binding) or not callable(authenticated_readback):
|
||||
raise ValueError("authenticated_ports_required")
|
||||
for name, value in (("subject", subject), ("manifest_id", manifest_id),
|
||||
("runtime_fingerprint", runtime_fingerprint),
|
||||
("process_start_identity", process_start_identity),
|
||||
("commit_message", commit_message)):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(name + "_required")
|
||||
self.writer_executor = writer_executor
|
||||
self.source_files = None
|
||||
self.git_tree_file = git_tree_file
|
||||
self.admission_binding = admission_binding
|
||||
self.authenticated_readback = authenticated_readback
|
||||
self.subject = subject
|
||||
self.manifest_id = manifest_id
|
||||
self.runtime_fingerprint = runtime_fingerprint
|
||||
self.process_start_identity = process_start_identity
|
||||
self.commit_message = commit_message
|
||||
if diagnostic_sink is not None and not callable(diagnostic_sink):
|
||||
raise ValueError("diagnostic_sink_invalid")
|
||||
self.diagnostic_sink = diagnostic_sink
|
||||
|
||||
def _record_uncertain_effect(self, *, exc: Exception, task_id: str,
|
||||
stage: str) -> None:
|
||||
"""Emit only allowlisted facts; never serialize an exception or body."""
|
||||
if self.diagnostic_sink is None:
|
||||
return
|
||||
raw_status = getattr(exc, "status", None)
|
||||
status = raw_status if type(raw_status) is int and 100 <= raw_status <= 599 else None
|
||||
raw_code = getattr(exc, "error_code", None)
|
||||
code = raw_code if isinstance(raw_code, str) and _SAFE_CODE.fullmatch(raw_code) else None
|
||||
raw_operation = getattr(exc, "operation", None)
|
||||
operation = (
|
||||
raw_operation if isinstance(raw_operation, str)
|
||||
and _SAFE_OPERATION.fullmatch(raw_operation) else stage
|
||||
)
|
||||
diagnostic = {
|
||||
"task_id": task_id, "operation": operation,
|
||||
"http_status": status,
|
||||
"error_code": (
|
||||
"transport_unknown" if status is None else (code or "issuer_error_unknown")
|
||||
),
|
||||
}
|
||||
try:
|
||||
self.diagnostic_sink(diagnostic)
|
||||
except Exception:
|
||||
# Diagnostic observation must never change the uncertainty policy.
|
||||
pass
|
||||
|
||||
def _validate_admission(self, intent: SourcePublicationIntent, receipt: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_INVALID")
|
||||
for key, expected in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid),
|
||||
("target_branch", intent.target_branch), ("generation", intent.generation),
|
||||
("execution_digest", intent.execution_digest)):
|
||||
if receipt.get(key) != expected:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
try:
|
||||
binding = self.admission_binding(intent=intent, admission_receipt=receipt)
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_UNAVAILABLE") from exc
|
||||
if not isinstance(binding, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_INVALID")
|
||||
expected = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(binding.get(key) != value for key, value in expected.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
job_id = binding.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise GovernedPublicationError("PUBLICATION_JOB_BINDING_MISSING")
|
||||
return dict(binding)
|
||||
|
||||
def _read_files(self, intent: SourcePublicationIntent) -> tuple[dict[str, bytes], dict[str, str]]:
|
||||
try:
|
||||
raw = {
|
||||
path: self.git_tree_file(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH") from exc
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_READ_FAILED") from exc
|
||||
if not isinstance(raw, Mapping) or set(raw) != set(intent.file_allowlist):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH")
|
||||
files: dict[str, bytes] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for path in intent.file_allowlist:
|
||||
data = raw.get(path)
|
||||
if isinstance(data, bytearray):
|
||||
data = bytes(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_BYTES_REQUIRED")
|
||||
files[path] = bytes(data)
|
||||
hashes[path] = hashlib.sha256(files[path]).hexdigest()
|
||||
return files, hashes
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise GovernedPublicationError("PUBLICATION_INTENT_REQUIRED")
|
||||
admission_binding = self._validate_admission(intent, admission_receipt)
|
||||
job_id = str(admission_binding["job_id"])
|
||||
files, hashes = self._read_files(intent)
|
||||
request = GovernedWriterRequest(
|
||||
subject=self.subject, task_id=intent.task_id, repository=intent.repository,
|
||||
target_branch=intent.target_branch, risk=intent.risk, generation=intent.generation,
|
||||
base_oid=intent.authority_base_oid,
|
||||
required_capabilities=frozenset(intent.requested_capabilities),
|
||||
file_allowlist=tuple(intent.file_allowlist),
|
||||
plan_digest="sha256:" + intent.execution_digest,
|
||||
manifest_id=self.manifest_id, runtime_fingerprint=self.runtime_fingerprint,
|
||||
process_start_identity=self.process_start_identity,
|
||||
)
|
||||
stage = "acquireWriterLease"
|
||||
try:
|
||||
session = self.writer_executor.acquire(request=request, job_id=job_id)
|
||||
stage = "commitFiles"
|
||||
commit_result = self.writer_executor.commit_and_readback(
|
||||
session=session, request=request, files=files,
|
||||
expected_sha256=hashes, message=self.commit_message,
|
||||
)
|
||||
if not isinstance(commit_result, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_READBACK_INVALID")
|
||||
readback = self.authenticated_readback(
|
||||
intent=intent, request=request, session=session,
|
||||
commit_result=commit_result,
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A transport exception after a mutation is indeterminate. Never
|
||||
# resubmit it here; the caller must reconcile through the existing
|
||||
# Job/recovery service.
|
||||
self._record_uncertain_effect(exc=exc, task_id=intent.task_id, stage=stage)
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
if not isinstance(readback, Mapping) or readback.get("authenticated") is not True:
|
||||
raise GovernedPublicationError("PUBLICATION_AUTHENTICATED_READBACK_REQUIRED")
|
||||
required = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(readback.get(key) != value for key, value in required.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_READBACK_BINDING_MISMATCH")
|
||||
for key in ("task_lease", "task_principal", "writer_lease"):
|
||||
if not isinstance(readback.get(key), Mapping) or readback[key].get("task_id") != intent.task_id:
|
||||
raise GovernedPublicationError("PUBLICATION_LEASE_CHAIN_MISSING")
|
||||
source_oid = _oid(readback.get("source_oid"))
|
||||
tree_oid = _oid(readback.get("tree_oid"))
|
||||
nested_commit = readback.get("commit_receipt")
|
||||
if nested_commit is not None and not isinstance(nested_commit, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
commit_oid = _oid(readback.get("commit_oid") or (nested_commit or {}).get("commit_oid"))
|
||||
if source_oid != commit_oid or tree_oid != intent.source_tree_oid:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_TREE_BINDING_MISMATCH")
|
||||
commit_receipt = readback.get("commit_receipt")
|
||||
if not isinstance(commit_receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_RECEIPT_REQUIRED")
|
||||
# The live governed receipt binds commit_oid; the independently read
|
||||
# Git tree is carried by authenticated readback. Preserve the original
|
||||
# receipt instead of inventing a tree field absent from its schema.
|
||||
if (commit_receipt.get("commit_oid") != commit_oid
|
||||
or ("tree_oid" in commit_receipt and commit_receipt["tree_oid"] != tree_oid)):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
publication = {
|
||||
**dict(readback), "authenticated": True, "task_id": intent.task_id,
|
||||
"job_id": job_id, "source_oid": source_oid, "tree_oid": tree_oid,
|
||||
"generation": intent.generation, "target_branch": intent.target_branch,
|
||||
"authority_base_oid": intent.authority_base_oid,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"commit_receipt": dict(commit_receipt),
|
||||
}
|
||||
task_result = {
|
||||
"schema": "aios.governed-source-publication-result.v1",
|
||||
"task_id": intent.task_id,
|
||||
"job_id": job_id,
|
||||
"repository": intent.repository,
|
||||
"target_branch": intent.target_branch,
|
||||
"generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"authority_base_oid": _oid(intent.authority_base_oid),
|
||||
"candidate_source_oid": _oid(intent.source_commit),
|
||||
"published_source_oid": source_oid,
|
||||
"source_tree_oid": tree_oid,
|
||||
"commit_receipt_sha256": hashlib.sha256(
|
||||
_canonical(commit_receipt)
|
||||
).hexdigest(),
|
||||
}
|
||||
result_sha256 = hashlib.sha256(_canonical(task_result)).hexdigest()
|
||||
idempotency_key = "governed-source-result-" + result_sha256
|
||||
try:
|
||||
submitted = self.writer_executor.submit_task_result(
|
||||
session=session, result=task_result,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if (
|
||||
not isinstance(submitted, Mapping)
|
||||
or submitted.get("task_id") != intent.task_id
|
||||
or submitted.get("status") != "VERIFYING"
|
||||
or submitted.get("result") != task_result
|
||||
):
|
||||
raise GovernedPublicationError(
|
||||
"PUBLICATION_TASK_RESULT_READBACK_INVALID"
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A lost result response may have changed the durable Task. Never
|
||||
# re-register, reclaim or retry here.
|
||||
self._record_uncertain_effect(
|
||||
exc=exc, task_id=intent.task_id, stage="submitTaskResult",
|
||||
)
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
publication.update(
|
||||
task_result=task_result,
|
||||
task_result_idempotency_key=idempotency_key,
|
||||
task_result_submission={
|
||||
"task_id": submitted["task_id"], "status": submitted["status"],
|
||||
"revision": submitted.get("revision"),
|
||||
"idempotent": submitted.get("idempotent", False),
|
||||
"result_sha256": result_sha256,
|
||||
},
|
||||
)
|
||||
return publication
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Narrow C5 unattended source-publication policy lifecycle.
|
||||
|
||||
The policy is an Owner-signed, exact-scope admission object. It is checked
|
||||
against the configured Owner policy at issuance and against a live revocation
|
||||
source for every request. It is not an activation/configuration mechanism
|
||||
and does not create release or production authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
SOURCE_PUBLICATION_CAPABILITIES,
|
||||
SOURCE_PUBLICATION_POLICY_ID,
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
SignedArtifact,
|
||||
SingleHostAuthorityError,
|
||||
SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
|
||||
_POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "policy_id", "owner_identity", "repository", "trust_domain",
|
||||
"policy_digest",
|
||||
"authority_base_oid", "source_oid", "source_tree_oid", "target_agent",
|
||||
"target_branch", "generation", "execution_digest", "exact_files",
|
||||
"capabilities", "risk", "production_impact", "renewal", "nonce",
|
||||
"issued_at", "not_before", "expires_at", "status", "signature_namespace",
|
||||
"signature_key_id", "canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
_REQUEST_FIELDS = frozenset(SourcePublicationPolicyRequest.__dataclass_fields__)
|
||||
|
||||
# The typed Owner epoch producer uses the configured policy's production_scope.
|
||||
# The installed C5 source-only policy contract uses ``none`` for production
|
||||
# impact; keep this exact value bound to the C5 policy rather than accepting an
|
||||
# arbitrary non-empty scope from a raw signer.
|
||||
SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE = "none"
|
||||
SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicyProjection:
|
||||
"""The only policy projection exposed after an exact request guard."""
|
||||
|
||||
max_risk: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
capabilities: tuple[str, ...]
|
||||
exact_files: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _request_dict(request: SourcePublicationPolicyRequest | Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(request, SourcePublicationPolicyRequest):
|
||||
value = asdict(request)
|
||||
elif isinstance(request, Mapping):
|
||||
value = dict(request)
|
||||
else:
|
||||
raise TypeError("request must be SourcePublicationPolicyRequest or mapping")
|
||||
if set(value) != _REQUEST_FIELDS:
|
||||
raise ValueError("SOURCE_PUBLICATION_REQUEST_FIELDS_INVALID")
|
||||
if "exact_files" in value:
|
||||
value["exact_files"] = tuple(value["exact_files"])
|
||||
return value
|
||||
|
||||
|
||||
class SourcePublicationPolicyVerifier:
|
||||
"""Strict verifier over the existing Owner registry verifier interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner_verifier: Any,
|
||||
*,
|
||||
revocation_check: Callable[[Mapping[str, Any]], bool] | None,
|
||||
expected_policy_digest: str | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.owner_verifier = owner_verifier
|
||||
self.revocation_check = revocation_check
|
||||
self.expected_policy_digest = expected_policy_digest
|
||||
self.clock = clock
|
||||
|
||||
def _deny(self, code: str) -> None:
|
||||
raise SingleHostAuthorityError(code)
|
||||
|
||||
def _validate_payload(self, artifact: SignedArtifact) -> dict[str, Any]:
|
||||
if not isinstance(artifact, SignedArtifact):
|
||||
self._deny("SOURCE_PUBLICATION_ARTIFACT_INVALID")
|
||||
payload = dict(artifact.payload)
|
||||
if set(payload) != _POLICY_FIELDS:
|
||||
self._deny("SOURCE_PUBLICATION_UNKNOWN_FIELDS")
|
||||
try:
|
||||
if artifact.canonical_payload != canonical_json_bytes(payload):
|
||||
self._deny("SOURCE_PUBLICATION_CANONICAL_BYTES_MISMATCH")
|
||||
if payload["canonical_payload_digest"] != sha256_json(
|
||||
{k: v for k, v in payload.items() if k != "canonical_payload_digest"}
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_PAYLOAD_DIGEST_INVALID")
|
||||
if payload["schema"] != SOURCE_PUBLICATION_POLICY_SCHEMA:
|
||||
self._deny("SOURCE_PUBLICATION_SCHEMA_INVALID")
|
||||
if payload["policy_id"] != SOURCE_PUBLICATION_POLICY_ID:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_ID_INVALID")
|
||||
if payload["owner_identity"] != OWNER_IDENTITY:
|
||||
self._deny("SOURCE_PUBLICATION_OWNER_INVALID")
|
||||
if not isinstance(payload["policy_digest"], str) or not _HEX64.fullmatch(payload["policy_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_INVALID")
|
||||
if self.expected_policy_digest is not None and payload["policy_digest"] != self.expected_policy_digest:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_MISMATCH")
|
||||
if payload["signature_namespace"] != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_NAMESPACE_INVALID")
|
||||
if payload["signature_key_id"] != artifact.key_id or artifact.namespace != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_ISSUER_MISMATCH")
|
||||
for name in ("authority_base_oid", "source_oid", "source_tree_oid"):
|
||||
if not isinstance(payload[name], str) or not _HEX40.fullmatch(payload[name]):
|
||||
self._deny("SOURCE_PUBLICATION_OID_INVALID")
|
||||
if not isinstance(payload["execution_digest"], str) or not _HEX64.fullmatch(payload["execution_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_EXECUTION_DIGEST_INVALID")
|
||||
branch = payload["target_branch"]
|
||||
agent = payload["target_agent"]
|
||||
if (
|
||||
not isinstance(branch, str) or not branch.startswith("agent/")
|
||||
or "\\" in branch or ".." in PurePosixPath(branch).parts
|
||||
or branch.endswith(("/", "."))
|
||||
or not isinstance(agent, str) or not agent.startswith("agent/")
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_TARGET_INVALID")
|
||||
if payload["risk"] != "engineering" or payload["production_impact"] != "none" or payload["renewal"] != "none":
|
||||
self._deny("SOURCE_PUBLICATION_SCOPE_INVALID")
|
||||
if payload["capabilities"] != sorted(SOURCE_PUBLICATION_CAPABILITIES):
|
||||
self._deny("SOURCE_PUBLICATION_CAPABILITIES_INVALID")
|
||||
if not isinstance(payload["exact_files"], list) or not payload["exact_files"] or len(set(payload["exact_files"])) != len(payload["exact_files"]):
|
||||
self._deny("SOURCE_PUBLICATION_FILES_INVALID")
|
||||
if type(payload["generation"]) is not int or payload["generation"] < 1:
|
||||
self._deny("SOURCE_PUBLICATION_GENERATION_INVALID")
|
||||
if type(payload["issued_at"]) is not int or type(payload["not_before"]) is not int or type(payload["expires_at"]) is not int:
|
||||
self._deny("SOURCE_PUBLICATION_TIME_INVALID")
|
||||
now = int(self.clock())
|
||||
if not payload["not_before"] <= payload["issued_at"] <= now < payload["expires_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_EXPIRED")
|
||||
if payload["expires_at"] <= payload["issued_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if payload["expires_at"] - payload["issued_at"] > 3600 or payload["status"] != "ACTIVE":
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if self.revocation_check is None:
|
||||
self._deny("SOURCE_PUBLICATION_REVOCATION_CHECK_MISSING")
|
||||
if self.revocation_check(payload) is not True:
|
||||
self._deny("SOURCE_PUBLICATION_REVOKED")
|
||||
except SingleHostAuthorityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_INVALID") from exc
|
||||
return payload
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self._validate_payload(artifact)
|
||||
# Verify exactly the bytes supplied by the signed artifact. Do not
|
||||
# reconstruct or reserialize them before invoking the registry API.
|
||||
if self.owner_verifier.verify(
|
||||
namespace=artifact.namespace,
|
||||
payload=artifact.canonical_payload,
|
||||
signature=artifact.signature,
|
||||
key_id=artifact.key_id,
|
||||
) is not True:
|
||||
return False
|
||||
if request is not None:
|
||||
expected = _request_dict(request)
|
||||
for field in (
|
||||
"policy_id", "repository", "authority_base_oid", "source_oid",
|
||||
"source_tree_oid", "target_agent", "target_branch", "generation",
|
||||
"execution_digest", "exact_files", "nonce", "risk",
|
||||
"production_impact", "renewal",
|
||||
):
|
||||
actual = tuple(payload[field]) if field == "exact_files" else payload[field]
|
||||
if actual != expected.get(field):
|
||||
return False
|
||||
if int(payload["expires_at"]) - int(payload["issued_at"]) != int(expected["ttl_seconds"]):
|
||||
return False
|
||||
return True
|
||||
except (SingleHostAuthorityError, TypeError, ValueError, KeyError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def require(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.verify(artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return dict(artifact.payload)
|
||||
|
||||
|
||||
def require_source_publication_epoch_scope(epoch: Any, policy_payload: Mapping[str, Any]) -> None:
|
||||
"""Require v2 epoch provenance to match the signed C5 policy exactly."""
|
||||
schema = getattr(epoch, "schema", None)
|
||||
if schema is None and isinstance(epoch, Mapping):
|
||||
schema = epoch.get("schema")
|
||||
if schema != "aios.owner-delegation-epoch.v2":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCHEMA_REQUIRED")
|
||||
|
||||
def field(name: str) -> Any:
|
||||
if isinstance(epoch, Mapping):
|
||||
return epoch.get(name)
|
||||
return getattr(epoch, name, None)
|
||||
|
||||
if (
|
||||
field("production_scope") != SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE
|
||||
or field("destructive_policy") != SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY
|
||||
or field("policy_digest") != policy_payload.get("policy_digest")
|
||||
or field("repository") != policy_payload.get("repository")
|
||||
or field("trust_domain") != policy_payload.get("trust_domain")
|
||||
):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCOPE_MISMATCH")
|
||||
|
||||
|
||||
class ScopedSourcePublicationPolicyAdmission:
|
||||
"""Rechecks a policy for every exact request before scoped admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SourcePublicationPolicyVerifier,
|
||||
*,
|
||||
bootstrap: Any | None = None,
|
||||
artifact: SignedArtifact | None = None,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
expected_agent_id: str | None = None,
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.bootstrap = bootstrap
|
||||
self.artifact = artifact
|
||||
self.request = request
|
||||
self.expected_agent_id = expected_agent_id
|
||||
|
||||
def _check_bootstrap_binding(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.bootstrap is None:
|
||||
return
|
||||
policy = getattr(self.bootstrap, "policy", None)
|
||||
digest_provider = getattr(policy, "normalized_policy_digest", None)
|
||||
digest = digest_provider() if callable(digest_provider) else None
|
||||
trust_domain = getattr(policy, "trust_domain", None)
|
||||
if not isinstance(digest, str) or digest != payload.get("policy_digest"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_POLICY_DIGEST_MISMATCH")
|
||||
if not isinstance(trust_domain, str) or trust_domain != payload.get("trust_domain"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_TRUST_DOMAIN_MISMATCH")
|
||||
if self.expected_agent_id is None or payload.get("target_agent") != self.expected_agent_id:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_TARGET_AGENT_MISMATCH")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self.verifier._validate_payload(artifact)
|
||||
if self.expected_agent_id is not None and payload.get("target_agent") != self.expected_agent_id:
|
||||
return False
|
||||
self._check_bootstrap_binding(payload)
|
||||
except SingleHostAuthorityError:
|
||||
return False
|
||||
return self.verifier.verify(artifact, request)
|
||||
|
||||
verify = authorize
|
||||
|
||||
def _request_for_raw(self, raw: Mapping[str, Any]) -> SourcePublicationPolicyRequest:
|
||||
if self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_ARTIFACT_REQUIRED")
|
||||
expected_task = getattr(self, "expected_task_id", None)
|
||||
if expected_task is not None and raw.get("task_id") != expected_task:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_PLAN_TASK_MISMATCH")
|
||||
payload = self.verifier._validate_payload(self.artifact)
|
||||
self._check_bootstrap_binding(payload)
|
||||
if self.request is not None:
|
||||
base = _request_dict(self.request)
|
||||
else:
|
||||
base = {
|
||||
"policy_id": payload["policy_id"], "repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"], "source_oid": payload["source_oid"],
|
||||
"source_tree_oid": payload["source_tree_oid"], "target_agent": payload["target_agent"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "exact_files": tuple(payload["exact_files"]),
|
||||
"nonce": payload["nonce"], "ttl_seconds": payload["expires_at"] - payload["issued_at"],
|
||||
"risk": payload["risk"], "production_impact": payload["production_impact"], "renewal": payload["renewal"],
|
||||
}
|
||||
checks = {
|
||||
"repository": base["repository"], "authority_base_oid": base["authority_base_oid"],
|
||||
"target_branch": base["target_branch"], "generation": base["generation"],
|
||||
"execution_digest": base["execution_digest"], "risk": base["risk"],
|
||||
}
|
||||
for field, expected in checks.items():
|
||||
if raw.get(field) != expected:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_REQUEST_SCOPE_MISMATCH")
|
||||
caps = tuple(raw.get("requested_capabilities", ()))
|
||||
if set(caps) != set(base["capabilities"] if "capabilities" in base else payload["capabilities"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_CAPABILITY_SCOPE_MISMATCH")
|
||||
if tuple(raw.get("file_allowlist", ())) != tuple(base["exact_files"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_FILE_SCOPE_MISMATCH")
|
||||
return SourcePublicationPolicyRequest(**base)
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
request = self._request_for_raw(raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return self.bootstrap.create_envelope(raw)
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if raw.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=str(raw.get("delegation_epoch_id") or ""),
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
return self.bootstrap.authorize_and_admit(raw)
|
||||
|
||||
def status(self, raw: Mapping[str, Any] | str) -> Any:
|
||||
"""Read status only after the same policy and epoch guard as admission."""
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if isinstance(raw, str):
|
||||
raw = {"envelope_id": raw}
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
self.verifier.require(self.artifact, request)
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
grant = self.bootstrap.store.get_grant_by_task_id(envelope.task_id)
|
||||
epoch_id = grant.parent_epoch_id if grant is not None else ""
|
||||
if not epoch_id or not hasattr(self.bootstrap, "_verify_epoch"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_STATUS_EPOCH_REQUIRED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=epoch_id,
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
if hasattr(self.bootstrap, "status"):
|
||||
return self.bootstrap.status(envelope.envelope_id)
|
||||
return envelope_raw
|
||||
|
||||
def reconcile_status(self, envelope_id: str, authorization: Mapping[str, Any]) -> Any:
|
||||
"""Carry the verified epoch identity when a Task effect has no receipt yet."""
|
||||
if authorization.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
return self.status({"envelope_id": envelope_id,
|
||||
"delegation_epoch_id": authorization.get("delegation_epoch_id")})
|
||||
|
||||
def project_task_admission_policy(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> TaskAdmissionPolicyProjection:
|
||||
payload = self.verifier.require(artifact, request)
|
||||
return TaskAdmissionPolicyProjection(
|
||||
max_risk="engineering",
|
||||
repository=payload["repository"],
|
||||
target_branch=payload["target_branch"],
|
||||
capabilities=tuple(payload["capabilities"]),
|
||||
exact_files=tuple(payload["exact_files"]),
|
||||
)
|
||||
|
||||
to_task_admission_policy = project_task_admission_policy
|
||||
|
||||
|
||||
# Short aliases for callers that prefer the lifecycle terminology.
|
||||
SourcePublicationPolicyAdmission = ScopedSourcePublicationPolicyAdmission
|
||||
SourcePublicationVerifier = SourcePublicationPolicyVerifier
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Thin unattended release runner.
|
||||
|
||||
This module is an adapter around the existing admission and silent-release
|
||||
services. It deliberately owns no release state machine and never creates
|
||||
approval evidence. The runner only binds immutable source facts, delegates
|
||||
authority, and validates durable readbacks before moving to the next phase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
RETRYABLE_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
RETRYABLE_NAMES = {
|
||||
"UNAVAILABLE", "BUSY", "TEMPORARY_FAILURE", "TASK_ADMISSION_IN_PROGRESS",
|
||||
}
|
||||
RISKS = {"low", "medium", "engineering", "high"}
|
||||
|
||||
|
||||
class UnattendedReleaseError(RuntimeError):
|
||||
"""A fail-closed runner error with a stable code."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canon(value)).hexdigest()
|
||||
|
||||
|
||||
def _require_oid(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourcePublicationIntent:
|
||||
"""Immutable, deterministic source and execution binding."""
|
||||
|
||||
repository: str
|
||||
source_commit: str
|
||||
source_tree_oid: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
requested_capabilities: tuple[str, ...]
|
||||
risk: str
|
||||
execution_digest: str
|
||||
generation: int = 1
|
||||
event_id: str = "release"
|
||||
version: str = ""
|
||||
admission_task_id: str = ""
|
||||
admission_idempotency_key: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or not isinstance(self.repository, str):
|
||||
raise UnattendedReleaseError("INVALID_REPOSITORY")
|
||||
for name in ("source_commit", "source_tree_oid", "authority_base_oid"):
|
||||
object.__setattr__(self, name, _require_oid(name, getattr(self, name)))
|
||||
if not isinstance(self.target_branch, str) or not self.target_branch or not self.target_branch.startswith("agent/") or self.target_branch.startswith("-"):
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if ".." in self.target_branch or "//" in self.target_branch or "@{" in self.target_branch:
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise UnattendedReleaseError("INVALID_GENERATION")
|
||||
object.__setattr__(self, "execution_digest", _require_sha("execution_digest", self.execution_digest))
|
||||
if not self.file_allowlist or any(not isinstance(p, str) or not p or p.startswith(("/", "\\")) or ".." in p or "\\" in p or p == ".git" or p.startswith(".git/") for p in self.file_allowlist):
|
||||
raise UnattendedReleaseError("INVALID_FILE_ALLOWLIST")
|
||||
if len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise UnattendedReleaseError("DUPLICATE_FILE_ALLOWLIST")
|
||||
if not self.requested_capabilities or any(not isinstance(c, str) or not c or "*" in c for c in self.requested_capabilities):
|
||||
raise UnattendedReleaseError("INVALID_CAPABILITIES")
|
||||
if not isinstance(self.risk, str) or self.risk.lower() not in RISKS:
|
||||
raise UnattendedReleaseError("INVALID_RISK")
|
||||
object.__setattr__(self, "risk", self.risk.lower())
|
||||
object.__setattr__(self, "file_allowlist", tuple(sorted(self.file_allowlist)))
|
||||
object.__setattr__(self, "requested_capabilities", tuple(sorted(set(self.requested_capabilities))))
|
||||
if not IDENT_RE.fullmatch(self.event_id):
|
||||
raise UnattendedReleaseError("INVALID_EVENT_ID")
|
||||
if self.version and not IDENT_RE.fullmatch(self.version):
|
||||
raise UnattendedReleaseError("INVALID_VERSION")
|
||||
if bool(self.admission_task_id) != bool(self.admission_idempotency_key):
|
||||
raise UnattendedReleaseError("INCOMPLETE_ADMISSION_IDENTITY")
|
||||
for value in (self.admission_task_id, self.admission_idempotency_key):
|
||||
if value and (not isinstance(value, str) or not IDENT_RE.fullmatch(value)):
|
||||
raise UnattendedReleaseError("INVALID_ADMISSION_IDENTITY")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = {
|
||||
"repository": self.repository, "source_commit": self.source_commit.lower(),
|
||||
"source_tree_oid": self.source_tree_oid.lower(), "authority_base_oid": self.authority_base_oid.lower(),
|
||||
"target_branch": self.target_branch, "file_allowlist": list(self.file_allowlist),
|
||||
"requested_capabilities": list(self.requested_capabilities), "risk": self.risk,
|
||||
"execution_digest": self.execution_digest.lower(), "generation": self.generation,
|
||||
"event_id": self.event_id, "version": self.version,
|
||||
}
|
||||
if self.admission_task_id:
|
||||
value.update(admission_task_id=self.admission_task_id,
|
||||
admission_idempotency_key=self.admission_idempotency_key)
|
||||
return value
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.admission_task_id or "release-task-" + _digest(self.as_dict())[:32]
|
||||
|
||||
@property
|
||||
def idempotency_key(self) -> str:
|
||||
return self.admission_idempotency_key or "release-intake-" + _digest({"task_id": self.task_id, "execution_digest": self.execution_digest.lower()})[:32]
|
||||
|
||||
def admission_request(self) -> dict[str, Any]:
|
||||
"""Return the sole normalized request accepted by Task admission."""
|
||||
return {"idempotency_key": self.idempotency_key, "task_id": self.task_id,
|
||||
"repository": self.repository, "authority_base_oid": self.authority_base_oid,
|
||||
"target_branch": self.target_branch, "generation": self.generation,
|
||||
"execution_digest": self.execution_digest, "requested_capabilities": list(self.requested_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist), "risk": self.risk}
|
||||
|
||||
|
||||
def _error_code(exc: BaseException) -> str:
|
||||
code = getattr(exc, "code", None) or getattr(exc, "status", None) or exc.__class__.__name__
|
||||
value = str(code).upper()
|
||||
return value if re.fullmatch(r"[A-Z0-9_]{1,100}", value) else "BACKEND_ERROR"
|
||||
|
||||
|
||||
def _bound_receipt(receipt: Any, intent: SourcePublicationIntent) -> bool:
|
||||
if not isinstance(receipt, Mapping):
|
||||
return False
|
||||
expected = {"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest}
|
||||
return (receipt.get("schema") == "aios.task-admission-receipt.v1"
|
||||
and all(_canon(receipt.get(k)) == _canon(v) for k, v in expected.items())
|
||||
and receipt.get("task_state") in {"QUEUED", "CLAIMED", "RUNNING"}
|
||||
and all(isinstance(receipt.get(k), str) and receipt[k] for k in ("envelope_id", "grant_id")))
|
||||
|
||||
|
||||
def _retryable(exc: BaseException) -> bool:
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if isinstance(status, int):
|
||||
return status in RETRYABLE_CODES
|
||||
code = _error_code(exc)
|
||||
return code in RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
return isinstance(exc, (TimeoutError, ConnectionError, OSError)) or _error_code(exc) in {"TASK_ADMISSION_TASK_STATE_UNKNOWN", "TIMEOUT", "TIMED_OUT", "CONNECTION_LOST"}
|
||||
|
||||
|
||||
class GovernedUnattendedReleaseRunner:
|
||||
"""Phase-separated runner; preparation has no release/deployment rights."""
|
||||
|
||||
def __init__(self, *, admission: Any, release_service: Any = None,
|
||||
publication_adapter: Any = None, owner_approval_broker: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None,
|
||||
deployment_enabled: bool = False, deployment_service: Any = None,
|
||||
max_attempts: int = 3, deadline_seconds: float = 30.0,
|
||||
environment_mode: str = "SIMULATION",
|
||||
clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep):
|
||||
if admission is None:
|
||||
raise ValueError("admission is required")
|
||||
if max_attempts < 1 or max_attempts > 10 or deadline_seconds <= 0:
|
||||
raise ValueError("invalid retry bounds")
|
||||
self.admission = admission
|
||||
self.environment_mode = str(environment_mode).upper()
|
||||
if self.environment_mode not in {"SIMULATION", "AUTHORITY"}:
|
||||
raise ValueError("environment_mode must be SIMULATION or AUTHORITY")
|
||||
self.release_service = release_service
|
||||
self.publication_adapter = publication_adapter
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.deployment_enabled = bool(deployment_enabled)
|
||||
self.deployment_service = deployment_service
|
||||
self.max_attempts = max_attempts
|
||||
self.deadline_seconds = deadline_seconds
|
||||
self.clock, self.sleep = clock, sleep
|
||||
|
||||
def intake(self, intent: SourcePublicationIntent) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise UnattendedReleaseError("INTENT_TYPE_REQUIRED")
|
||||
return {"phase": "PREPARATION", "mode": self.environment_mode, "task_id": intent.task_id,
|
||||
"idempotency_key": intent.idempotency_key, "intent": intent.as_dict(),
|
||||
"release_rights": False, "deployment_rights": False}
|
||||
|
||||
def admit(self, intent: SourcePublicationIntent, authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(authorization, Mapping):
|
||||
raise UnattendedReleaseError("AUTHORIZATION_REQUIRED")
|
||||
if self.environment_mode == "SIMULATION" and getattr(self.admission, "simulation_only", False) is not True:
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "task_id": intent.task_id, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
raw = intent.admission_request()
|
||||
deadline = self.clock() + self.deadline_seconds
|
||||
envelope_id = None
|
||||
for attempt in range(1, self.max_attempts + 1):
|
||||
if self.clock() >= deadline:
|
||||
return {"phase": "RECONCILIATION_REQUIRED" if envelope_id else "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": "ADMISSION_DEADLINE_EXCEEDED"}
|
||||
try:
|
||||
created = self.admission.create_envelope(raw)
|
||||
envelope = created.get("envelope", created) if isinstance(created, Mapping) else {}
|
||||
envelope_id = envelope.get("envelope_id") or envelope_id
|
||||
if not isinstance(envelope_id, str) or not envelope_id:
|
||||
raise UnattendedReleaseError("ENVELOPE_BINDING_MISSING")
|
||||
auth = dict(authorization)
|
||||
auth["envelope_id"] = envelope_id
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
receipt = self.admission.authorize_and_admit(auth)
|
||||
if not _bound_receipt(receipt, intent) or receipt["envelope_id"] != envelope_id:
|
||||
raise UnattendedReleaseError("ADMISSION_RECEIPT_BINDING_MISMATCH")
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
return {"phase": "ADMITTED", "mode": self.environment_mode, "attempt": attempt,
|
||||
"envelope": created, "receipt": receipt}
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if status_code == 403 or _error_code(exc) in {"403", "FORBIDDEN"}:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
# Once authorization may have run, every failure reconciles.
|
||||
# A 5xx is never sufficient proof of NOT_APPLIED.
|
||||
if envelope_id:
|
||||
return self._reconcile(envelope_id, intent, _error_code(exc), authorization)
|
||||
if not _retryable(exc) or self.clock() >= deadline or attempt >= self.max_attempts:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
self.sleep(min(0.05 * (2 ** (attempt - 1)), max(0.0, deadline - self.clock())))
|
||||
raise UnattendedReleaseError("ADMISSION_RETRY_EXHAUSTED")
|
||||
|
||||
def _reconcile(self, envelope_id: str | None, intent: SourcePublicationIntent, error: str,
|
||||
authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not envelope_id:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error}
|
||||
try:
|
||||
reconcile = getattr(self.admission, "reconcile_status", None)
|
||||
status = reconcile(envelope_id, authorization) if callable(reconcile) else self.admission.status(envelope_id)
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error, "status_error": _error_code(exc)}
|
||||
state = str(status.get("status") or status.get("task_state") or "").upper() if isinstance(status, Mapping) else ""
|
||||
if state in {"NOT_APPLIED", "ABSENT", "NOT_FOUND"}:
|
||||
return {"phase": "RETRYABLE_RECONCILIATION", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
if state == "ADMITTED" and _bound_receipt(status.get("task"), intent):
|
||||
return {"phase": "RECONCILED", "mode": self.environment_mode, "task_id": intent.task_id, "status": status, "receipt": status["task"]}
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if self.publication_adapter is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_BACKEND_MISSING"}
|
||||
required = ("task_lease", "task_principal", "writer_lease", "commit_receipt", "source_oid", "tree_oid", "authenticated")
|
||||
try:
|
||||
if not _bound_receipt(admission_receipt, intent):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "ADMISSION_RECEIPT_BINDING_MISMATCH"}
|
||||
result = self.publication_adapter.publish(intent, admission_receipt)
|
||||
if not isinstance(result, Mapping) or result.get("authenticated") is not True or any(not result.get(k) for k in required[:-1]):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_CHAIN_MISSING"}
|
||||
if any(not isinstance(result[k], Mapping) or result[k].get("task_id") != intent.task_id
|
||||
for k in ("task_lease", "task_principal", "writer_lease")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_BINDING_MISMATCH"}
|
||||
commit = result["commit_receipt"]
|
||||
# Existing continuation receipts carry remote OIDs, while the
|
||||
# authenticated task/job readback carries scope. Preserve both
|
||||
# originals; never fabricate missing fields inside a receipt.
|
||||
optional_bindings = {"task_id": intent.task_id, "tree_oid": result["tree_oid"],
|
||||
"execution_digest": intent.execution_digest,
|
||||
"target_branch": intent.target_branch,
|
||||
"base_oid": intent.authority_base_oid}
|
||||
if (not isinstance(commit, Mapping) or commit.get("commit_oid") != result["source_oid"]
|
||||
or any(key in commit and commit[key] != value for key, value in optional_bindings.items())
|
||||
or result.get("generation") != intent.generation):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_COMMIT_BINDING_MISMATCH"}
|
||||
if not OID_RE.fullmatch(str(result["source_oid"])) or str(result["tree_oid"]).lower() != intent.source_tree_oid.lower() or any(str(result.get(k, "")) != str(v) for k, v in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid), ("target_branch", intent.target_branch), ("execution_digest", intent.execution_digest))):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_RC_BINDING_MISMATCH"}
|
||||
return {"phase": "PUBLISHED", "mode": "AUTHORITY", "readback": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def postpublication_rc(self, *, event_id: str, source_commit: str, version: str = "", source_readback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if not isinstance(source_readback, Mapping) or source_readback.get("authenticated") is not True or str(source_readback.get("source_commit", "")).lower() != str(source_commit).lower():
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "SOURCE_READBACK_REQUIRED"}
|
||||
if self.release_service is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "RELEASE_BACKEND_MISSING"}
|
||||
try:
|
||||
# Only a host-injected authenticated resolver can admit a source.
|
||||
# The caller's readback object alone is never authorization.
|
||||
resolver = getattr(self.release_service, "source_admission_resolver", None)
|
||||
admitted_source = resolver(source_commit) if callable(resolver) else None
|
||||
if (not isinstance(admitted_source, Mapping) or not admitted_source.get("admission_id")
|
||||
or admitted_source.get("source_commit") != source_commit
|
||||
or admitted_source.get("tree_oid") != source_readback.get("tree_oid")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"}
|
||||
intent = self.release_service.create_release_intent({"event_id": event_id, "source_commit": source_commit, **({"version": version} if version else {})})
|
||||
candidate_id = str(intent.get("candidate_id") or intent.get("candidate", {}).get("candidate_id") or "")
|
||||
if not candidate_id:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "CANDIDATE_BINDING_MISSING"}
|
||||
readers = ("get_release_candidate", "get_release_plan", "get_acceptance")
|
||||
if any(not callable(getattr(self.release_service, name, None)) for name in readers):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BACKEND_MISSING"}
|
||||
candidate = self.release_service.get_release_candidate(candidate_id)
|
||||
plan = self.release_service.get_release_plan(candidate_id)
|
||||
acceptance = self.release_service.get_acceptance(candidate_id)
|
||||
deployment = plan.get("deployment") if isinstance(plan, Mapping) else None
|
||||
if (not isinstance(candidate, Mapping) or not isinstance(plan, Mapping) or not isinstance(deployment, Mapping) or not isinstance(acceptance, Mapping)
|
||||
or str(candidate.get("candidate_id")) != candidate_id
|
||||
or str(candidate.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(plan.get("candidate_id", candidate_id)) != candidate_id
|
||||
or str(deployment.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(deployment.get("tree_oid", "")).lower() != str(source_readback.get("tree_oid", "")).lower()
|
||||
or candidate.get("tree_oid") != deployment.get("tree_oid")
|
||||
or candidate.get("artifact_sha256") != deployment.get("artifact_sha256")
|
||||
or candidate.get("manifest_sha256") != deployment.get("manifest_sha256")
|
||||
or str(acceptance.get("candidate_id")) != candidate_id
|
||||
or acceptance.get("release_plan_sha256") != plan.get("sha256")
|
||||
or str(acceptance.get("status")) != "ACCEPTED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BINDING_MISMATCH"}
|
||||
# Public readback is redacted. Reverify original signed bytes inside
|
||||
# the existing service, not by reconstructing them from API output.
|
||||
verify_acceptance = getattr(self.release_service, "verify_release_acceptance", None)
|
||||
if not callable(verify_acceptance):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_REQUIRED"}
|
||||
verified = verify_acceptance(candidate_id)
|
||||
if (not isinstance(verified, Mapping) or verified.get("status") != "VERIFIED"
|
||||
or verified.get("candidate_id") != candidate_id
|
||||
or verified.get("release_plan_sha256") != plan.get("sha256")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_NOT_VERIFIED"}
|
||||
envelope = self.release_service.get_approval_envelope(candidate_id)
|
||||
if self.owner_approval_broker is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_BACKEND_MISSING"}
|
||||
approval = self.owner_approval_broker(dict(envelope))
|
||||
if not isinstance(approval, Mapping):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_MISSING"}
|
||||
accepted = self.release_service.submit_owner_approval(dict(approval))
|
||||
if not isinstance(accepted, Mapping) or accepted.get("status") != "VERIFIED_AND_STORED":
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "APPROVAL_NOT_VERIFIED"}
|
||||
return {"phase": "APPROVED", "mode": "AUTHORITY", "candidate_id": candidate_id,
|
||||
"readback": {"candidate": dict(candidate), "plan": dict(plan), "acceptance": dict(acceptance)},
|
||||
"approval": accepted}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def deploy(self, candidate_id: str) -> dict[str, Any]:
|
||||
if (self.environment_mode != "AUTHORITY" or not self.deployment_enabled or self.deployment_service is None
|
||||
or getattr(self.deployment_service, "execution_enabled", False) is not True):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_ENABLED"}
|
||||
try:
|
||||
result = self.deployment_service.start_deployment(candidate_id)
|
||||
saga = self.deployment_service.get_release_saga(candidate_id)
|
||||
if (not isinstance(result, Mapping) or result.get("candidate_id") != candidate_id
|
||||
or result.get("state") != "COMPLETED" or result.get("status") not in {"VERIFIED", "IDEMPOTENT"}
|
||||
or not isinstance(saga, Mapping) or saga.get("state") != "COMPLETED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_VERIFIED", "result": dict(result) if isinstance(result, Mapping) else {}}
|
||||
return {"phase": "DEPLOYED", "mode": "AUTHORITY", "result": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Local adapter for the root-owned registered Owner signer service.
|
||||
|
||||
The adapter intentionally has no public arbitrary-payload signing method.
|
||||
``LocalOwnerAuthority`` calls the private typed seam after validating and
|
||||
canonicalizing a structured Owner authorization object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_SIGNATURE_BYTES = 128 * 1024
|
||||
MAX_KEY_ID_BYTES = 512
|
||||
|
||||
|
||||
class RegisteredOwnerSignerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RegisteredOwnerSignerAdapter:
|
||||
"""Invoke the registered signer without reading or copying private keys."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: Path | str = DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
*,
|
||||
key_id: str,
|
||||
owner_verifier: Any,
|
||||
require_root: bool = True,
|
||||
test_mode: bool = False,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
self.command = str(command)
|
||||
self.key_id = key_id
|
||||
self.owner_verifier = owner_verifier
|
||||
self.require_root = require_root
|
||||
self.test_mode = test_mode
|
||||
self.runner = runner
|
||||
if not isinstance(key_id, str) or not key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_REQUIRED")
|
||||
if not test_mode:
|
||||
self._validate_command()
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.key_id
|
||||
|
||||
def _validate_command(self) -> None:
|
||||
path = Path(self.command)
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_INVALID")
|
||||
st = path.stat()
|
||||
if not hasattr(os, "geteuid") or st.st_uid != os.geteuid():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_PERMISSIONS_INVALID")
|
||||
|
||||
def _read_output(self, path: Path, maximum: int) -> bytes:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_INVALID")
|
||||
st = path.stat()
|
||||
if not self.test_mode and (not hasattr(os, "geteuid") or st.st_uid != os.geteuid()):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_OWNER_INVALID")
|
||||
if st.st_size > maximum:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_TOO_LARGE")
|
||||
return path.read_bytes()
|
||||
|
||||
def _sign_canonical_payload(self, namespace: str, payload: bytes) -> str:
|
||||
"""Private seam used only by typed ``LocalOwnerAuthority`` issuance."""
|
||||
if namespace != OWNER_NAMESPACE:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_NAMESPACE_INVALID")
|
||||
if not isinstance(payload, bytes) or not payload:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_PAYLOAD_INVALID")
|
||||
if self.require_root:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_ROOT_REQUIRED")
|
||||
if not self.test_mode:
|
||||
self._validate_command()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-owner-sign-") as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "key-id"
|
||||
input_path.write_bytes(payload)
|
||||
try:
|
||||
result = self.runner(
|
||||
[
|
||||
self.command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED") from exc
|
||||
if getattr(result, "returncode", None) != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED")
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_MISSING")
|
||||
supplied_key_id = self._read_output(key_id_path, MAX_KEY_ID_BYTES).decode("utf-8").strip()
|
||||
if supplied_key_id != self.key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_MISMATCH")
|
||||
signature = self._read_output(signature_path, MAX_SIGNATURE_BYTES).decode("utf-8")
|
||||
if not signature.startswith("-----BEGIN SSH SIGNATURE-----"):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = self.owner_verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_FAILED") from exc
|
||||
if verified is not True:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_REJECTED")
|
||||
return signature
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,7 +553,7 @@ class ReleaseSourceAdmissionStore:
|
||||
supplied_candidate = str(candidate_id or "")
|
||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
||||
return {
|
||||
result = {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
@@ -562,6 +562,9 @@ class ReleaseSourceAdmissionStore:
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
if version == "V2R2C5":
|
||||
result["tree_oid"] = record["tree_oid"]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-injected broker for short release approvals under standing authority.
|
||||
|
||||
The broker deliberately owns no business-authority ledger. Its readers must
|
||||
return verified current snapshots from the host's durable authority and
|
||||
release-lineage stores. It only derives, verifies, and persists short-lived
|
||||
``SignedReleasePlanApproval`` children for the exact local ``ReleasePlan``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from silent_release_controller import CANDIDATE_ID_RE, OID_RE, SHA256_RE, _parse_utc
|
||||
from silent_release_hardening import (
|
||||
ALLOWED_CAPABILITIES,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
_canonical_json,
|
||||
release_file_lock,
|
||||
)
|
||||
from task_plane.governance_control_kernel import (
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
ExactScopeState,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
|
||||
|
||||
_EFFECT_STATES = frozenset(
|
||||
{"KNOWN_NOT_APPLIED", "KNOWN_IN_PROGRESS", "APPLIED_VERIFIED", "UNKNOWN"}
|
||||
)
|
||||
_LINEAGE_KINDS = frozenset({"ROOT", "SUCCESSOR"})
|
||||
_BINDING_KEYS = frozenset(
|
||||
{
|
||||
"authorization_id",
|
||||
"generation",
|
||||
"authorization_sha256",
|
||||
"version",
|
||||
"repository",
|
||||
"production_scope",
|
||||
"authority_base_oid",
|
||||
"trust_root_sha256",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StandingAuthorityError(CapabilityAuthorizationError):
|
||||
"""A current standing-authority or exact-scope check failed closed."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _text(name: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
return value
|
||||
|
||||
|
||||
def _sha(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a SHA-256 digest")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a Git object id")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(name: str, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple) or not value:
|
||||
raise ValueError(f"{name} must be a non-empty tuple")
|
||||
normalized = tuple(_text(name, item) for item in value)
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError(f"{name} contains duplicates")
|
||||
return normalized
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError("clock must return a timezone-aware datetime")
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _format_utc(value: datetime) -> str:
|
||||
return _utc(value).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalPlanScope:
|
||||
version: str
|
||||
repository: str
|
||||
service: str
|
||||
release_root: str
|
||||
cutover_link: str
|
||||
authoritative_git_dir: str
|
||||
dropin_relative_path: str
|
||||
rollback_target: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"version",
|
||||
"repository",
|
||||
"service",
|
||||
"release_root",
|
||||
"cutover_link",
|
||||
"authoritative_git_dir",
|
||||
"dropin_relative_path",
|
||||
"rollback_target",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
capabilities = _tuple_of_text(
|
||||
"required_capabilities", self.required_capabilities
|
||||
)
|
||||
if tuple(sorted(capabilities)) != capabilities:
|
||||
raise ValueError("required_capabilities must be sorted")
|
||||
object.__setattr__(self, "required_capabilities", capabilities)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"cutover_link": self.cutover_link,
|
||||
"authoritative_git_dir": self.authoritative_git_dir,
|
||||
"dropin_relative_path": self.dropin_relative_path,
|
||||
"release_root": self.release_root,
|
||||
"repository": self.repository,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"rollback_target": self.rollback_target,
|
||||
"service": self.service,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
|
||||
def canonical_plan_scope(
|
||||
plan: ReleasePlan, required_capabilities: tuple[str, ...]
|
||||
) -> CanonicalPlanScope:
|
||||
"""Derive production scope only from a validated local ReleasePlan."""
|
||||
|
||||
plan.validate()
|
||||
release_path = PurePosixPath(plan.deployment.release_path)
|
||||
dropin_path = PurePosixPath(plan.deployment.dropin_path)
|
||||
try:
|
||||
dropin_relative = dropin_path.relative_to(release_path).as_posix()
|
||||
except ValueError as exc:
|
||||
raise ValueError("drop-in path is outside the candidate release path") from exc
|
||||
if dropin_relative in {"", "."}:
|
||||
raise ValueError("drop-in path must be a child of the release path")
|
||||
return CanonicalPlanScope(
|
||||
version=plan.tag.version,
|
||||
repository=plan.deployment.repository_identity,
|
||||
service=plan.deployment.service,
|
||||
release_root=plan.deployment.release_root,
|
||||
cutover_link=plan.deployment.cutover_link,
|
||||
authoritative_git_dir=plan.deployment.authoritative_git_dir,
|
||||
dropin_relative_path=dropin_relative,
|
||||
rollback_target=plan.deployment.rollback_path,
|
||||
required_capabilities=tuple(sorted(required_capabilities)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StandingReleaseAuthorization:
|
||||
authorization_id: str
|
||||
generation: int
|
||||
authorization_sha256: str
|
||||
version: str
|
||||
repository: str
|
||||
production_scope: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
trust_domain: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
rollback_target: str
|
||||
owner_identity: str
|
||||
allowed_actor_ids: tuple[str, ...]
|
||||
mission_id: str
|
||||
run_id: str
|
||||
lifecycle_state: AuthorityLifecycleState
|
||||
acceptance_complete: bool
|
||||
acceptance_evidence_ref: str | None = None
|
||||
expires_at: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"authorization_id",
|
||||
"version",
|
||||
"repository",
|
||||
"trust_domain",
|
||||
"rollback_target",
|
||||
"owner_identity",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if (
|
||||
not isinstance(self.generation, int)
|
||||
or isinstance(self.generation, bool)
|
||||
or self.generation < 1
|
||||
):
|
||||
raise ValueError("generation must be a positive integer")
|
||||
_sha("authorization_sha256", self.authorization_sha256)
|
||||
_sha("production_scope", self.production_scope)
|
||||
_oid("authority_base_oid", self.authority_base_oid)
|
||||
_sha("trust_root_sha256", self.trust_root_sha256)
|
||||
_sha(
|
||||
"lineage_root_candidate_fingerprint",
|
||||
self.lineage_root_candidate_fingerprint,
|
||||
)
|
||||
capabilities = _tuple_of_text(
|
||||
"allowed_capabilities", self.allowed_capabilities
|
||||
)
|
||||
if not set(capabilities).issubset(ALLOWED_CAPABILITIES):
|
||||
raise ValueError("allowed_capabilities contains an unsupported capability")
|
||||
object.__setattr__(self, "allowed_capabilities", capabilities)
|
||||
object.__setattr__(
|
||||
self, "allowed_actor_ids", _tuple_of_text("allowed_actor_ids", self.allowed_actor_ids)
|
||||
)
|
||||
if not isinstance(self.lifecycle_state, AuthorityLifecycleState):
|
||||
raise ValueError("lifecycle_state must be AuthorityLifecycleState")
|
||||
if type(self.acceptance_complete) is not bool:
|
||||
raise ValueError("acceptance_complete must be a boolean")
|
||||
if self.acceptance_complete:
|
||||
_text("acceptance_evidence_ref", self.acceptance_evidence_ref or "")
|
||||
elif self.acceptance_evidence_ref is not None:
|
||||
raise ValueError("acceptance evidence is only valid for completed acceptance")
|
||||
if self.expires_at is not None:
|
||||
_parse_utc(self.expires_at)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseExecutionScope:
|
||||
release_plan_id: str
|
||||
candidate_id: str
|
||||
candidate_fingerprint: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
deployment_plan_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_plan_id: str
|
||||
evidence_closure_oid: str
|
||||
owner_approval_record_oid: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
actor_id: str
|
||||
mission_id: str
|
||||
run_id: str
|
||||
effect_state: str
|
||||
lineage_kind: str
|
||||
predecessor_candidate_fingerprint: str | None = None
|
||||
successor_lineage_sha256: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"release_plan_id",
|
||||
"candidate_id",
|
||||
"approval_plan_id",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if not CANDIDATE_ID_RE.fullmatch(self.candidate_id):
|
||||
raise ValueError("candidate_id is invalid")
|
||||
for name in ("source_commit", "tree_oid", "evidence_closure_oid", "owner_approval_record_oid", "authority_base_oid"):
|
||||
_oid(name, getattr(self, name))
|
||||
for name in (
|
||||
"candidate_fingerprint",
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"deployment_plan_sha256",
|
||||
"release_plan_sha256",
|
||||
"trust_root_sha256",
|
||||
"lineage_root_candidate_fingerprint",
|
||||
):
|
||||
_sha(name, getattr(self, name))
|
||||
if self.effect_state not in _EFFECT_STATES:
|
||||
raise ValueError("effect_state is invalid")
|
||||
if self.lineage_kind not in _LINEAGE_KINDS:
|
||||
raise ValueError("lineage_kind is invalid")
|
||||
if self.lineage_kind == "ROOT":
|
||||
if self.predecessor_candidate_fingerprint is not None or self.successor_lineage_sha256 is not None:
|
||||
raise ValueError("root candidate cannot carry successor lineage")
|
||||
else:
|
||||
_sha("predecessor_candidate_fingerprint", self.predecessor_candidate_fingerprint or "")
|
||||
_sha("successor_lineage_sha256", self.successor_lineage_sha256 or "")
|
||||
if self.predecessor_candidate_fingerprint == self.candidate_fingerprint:
|
||||
raise ValueError("successor predecessor must be a different candidate")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
class RegisteredApprovalSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str: ...
|
||||
|
||||
def sign(
|
||||
self, unsigned: SignedReleasePlanApproval
|
||||
) -> SignedReleasePlanApproval | Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class StandingReleaseAuthorizationBroker:
|
||||
"""Issue and renew exact short approvals from two trusted read layers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: SilentReleaseActionService,
|
||||
authority_reader: Callable[[], StandingReleaseAuthorization],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], ReleaseExecutionScope],
|
||||
signer: RegisteredApprovalSigner,
|
||||
*,
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
):
|
||||
if not callable(authority_reader) or not callable(execution_scope_reader):
|
||||
raise TypeError("standing authority readers must be callable")
|
||||
if not callable(getattr(signer, "sign", None)):
|
||||
raise TypeError("registered approval signer must provide sign()")
|
||||
if not callable(clock):
|
||||
raise TypeError("clock must be callable")
|
||||
if not isinstance(service, SilentReleaseActionService):
|
||||
raise TypeError("service must be SilentReleaseActionService")
|
||||
if service.execution_enabled and getattr(signer, "test_only", False) is True:
|
||||
raise CapabilityAuthorizationError(
|
||||
"test-only approval signer is forbidden for production execution"
|
||||
)
|
||||
if not isinstance(execution_ttl_seconds, int) or isinstance(execution_ttl_seconds, bool) or not 1 <= execution_ttl_seconds <= 3600:
|
||||
raise ValueError("execution_ttl_seconds must be between 1 and 3600")
|
||||
if not isinstance(renewal_margin_seconds, int) or isinstance(renewal_margin_seconds, bool) or not 0 <= renewal_margin_seconds < execution_ttl_seconds:
|
||||
raise ValueError("renewal_margin_seconds must be shorter than the execution TTL")
|
||||
self.service = service
|
||||
self.authority_reader = authority_reader
|
||||
self.execution_scope_reader = execution_scope_reader
|
||||
self.signer = signer
|
||||
self.clock = clock
|
||||
self.execution_ttl_seconds = execution_ttl_seconds
|
||||
self.renewal_margin_seconds = renewal_margin_seconds
|
||||
self._lock_root = Path(service.approvals_root) / ".standing-authority-locks"
|
||||
|
||||
@staticmethod
|
||||
def _result(status: str, context: AuthorityDecisionContext, approval=None):
|
||||
# silent_release_execution imports this module during host composition.
|
||||
from silent_release_execution import StandingAuthorizationResult
|
||||
|
||||
return StandingAuthorizationResult(
|
||||
status=status, authority_context=context, approval=approval
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _context(
|
||||
authorization: StandingReleaseAuthorization | None,
|
||||
scope_state: ExactScopeState,
|
||||
*,
|
||||
lifecycle_state: AuthorityLifecycleState | None = None,
|
||||
boundary: OwnerBoundaryReason | None = None,
|
||||
) -> AuthorityDecisionContext:
|
||||
refs = () if authorization is None else authorization.evidence_refs
|
||||
return AuthorityDecisionContext(
|
||||
lifecycle_state=lifecycle_state
|
||||
or (authorization.lifecycle_state if authorization else AuthorityLifecycleState.UNKNOWN),
|
||||
scope_state=scope_state,
|
||||
owner_boundary_reason=boundary,
|
||||
evidence_refs=refs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _binding(
|
||||
authorization: StandingReleaseAuthorization, scope: ReleaseExecutionScope
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"generation": authorization.generation,
|
||||
"authorization_sha256": authorization.authorization_sha256,
|
||||
"version": authorization.version,
|
||||
"repository": authorization.repository,
|
||||
"production_scope": authorization.production_scope,
|
||||
"authority_base_oid": authorization.authority_base_oid,
|
||||
"trust_root_sha256": authorization.trust_root_sha256,
|
||||
"actor_id": scope.actor_id,
|
||||
"mission_id": authorization.mission_id,
|
||||
"run_id": authorization.run_id,
|
||||
}
|
||||
|
||||
def _read(self, envelope: Mapping[str, Any]):
|
||||
authorization = self.authority_reader()
|
||||
# The trusted scope reader receives only the locally checked release
|
||||
# identity. Caller-added envelope keys cannot become authority input.
|
||||
query_keys = (
|
||||
"approval_plan_id",
|
||||
"artifact_sha256",
|
||||
"candidate_fingerprint",
|
||||
"candidate_id",
|
||||
"deployment_plan_sha256",
|
||||
"evidence_closure_oid",
|
||||
"manifest_sha256",
|
||||
"policy_sha256",
|
||||
"release_plan_sha256",
|
||||
"required_capabilities",
|
||||
"schema",
|
||||
"source_commit",
|
||||
"tree_oid",
|
||||
)
|
||||
scope = self.execution_scope_reader(
|
||||
{name: envelope[name] for name in query_keys if name in envelope}
|
||||
)
|
||||
if not isinstance(authorization, StandingReleaseAuthorization):
|
||||
raise StandingAuthorityError("AUTHORITY_SNAPSHOT_INVALID", "authority reader returned an invalid snapshot")
|
||||
if not isinstance(scope, ReleaseExecutionScope):
|
||||
raise StandingAuthorityError("RELEASE_SCOPE_INVALID", "scope reader returned an invalid snapshot")
|
||||
return authorization, scope
|
||||
|
||||
def _load_plan_truth(self, envelope: Mapping[str, Any]):
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
if not CANDIDATE_ID_RE.fullmatch(candidate_id):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "candidate id is invalid")
|
||||
candidate = self.service.candidates.load(candidate_id)
|
||||
plan = self.service.release_plans.load(candidate_id)
|
||||
required = tuple(sorted(str(item) for item in envelope.get("required_capabilities", ())))
|
||||
if not required or len(required) != len(set(required)) or not set(required).issubset(ALLOWED_CAPABILITIES):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "required capabilities are invalid")
|
||||
plan_scope = canonical_plan_scope(plan, required)
|
||||
expected_envelope = {
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"candidate_fingerprint": candidate.fingerprint,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_plan_id": plan.tag.approval_plan_id,
|
||||
"evidence_closure_oid": plan.tag.evidence_closure_oid,
|
||||
"policy_sha256": self.service.policy_sha256,
|
||||
}
|
||||
for name, expected in expected_envelope.items():
|
||||
if envelope.get(name) != expected:
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_STALE", f"approval envelope {name} is stale")
|
||||
return candidate, plan, plan_scope, required
|
||||
|
||||
def _assert_current(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
envelope: Mapping[str, Any],
|
||||
plan_scope: CanonicalPlanScope,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if authorization.acceptance_complete:
|
||||
raise StandingAuthorityError("AUTHORITY_ACCEPTED_AND_CLOSED", "trusted release acceptance is complete")
|
||||
if authorization.lifecycle_state is not AuthorityLifecycleState.ACTIVE:
|
||||
raise StandingAuthorityError(f"AUTHORITY_{authorization.lifecycle_state.value}", "standing authorization is not active")
|
||||
if authorization.expires_at is not None and _parse_utc(authorization.expires_at) <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization has expired")
|
||||
if authorization.version != plan_scope.version or authorization.repository != plan_scope.repository:
|
||||
raise StandingAuthorityError("AUTHORITY_RELEASE_SCOPE_MISMATCH", "release identity is outside standing authority")
|
||||
if authorization.production_scope != plan_scope.sha256 or authorization.rollback_target != plan_scope.rollback_target:
|
||||
raise StandingAuthorityError("AUTHORITY_PRODUCTION_SCOPE_MISMATCH", "production or rollback scope drifted")
|
||||
if not set(required).issubset(authorization.allowed_capabilities):
|
||||
raise StandingAuthorityError("AUTHORITY_CAPABILITY_DENIED", "required capability is outside standing authority")
|
||||
exact_scope = (
|
||||
scope.candidate_id == envelope["candidate_id"]
|
||||
and scope.candidate_fingerprint == envelope["candidate_fingerprint"]
|
||||
and scope.source_commit == envelope["source_commit"]
|
||||
and scope.tree_oid == envelope["tree_oid"]
|
||||
and scope.artifact_sha256 == envelope["artifact_sha256"]
|
||||
and scope.manifest_sha256 == envelope["manifest_sha256"]
|
||||
and scope.deployment_plan_sha256 == envelope["deployment_plan_sha256"]
|
||||
and scope.release_plan_sha256 == envelope["release_plan_sha256"]
|
||||
and scope.approval_plan_id == envelope["approval_plan_id"]
|
||||
and scope.evidence_closure_oid == envelope["evidence_closure_oid"]
|
||||
and scope.authority_base_oid == authorization.authority_base_oid
|
||||
and scope.trust_root_sha256 == authorization.trust_root_sha256
|
||||
and scope.actor_id in authorization.allowed_actor_ids
|
||||
and scope.mission_id == authorization.mission_id
|
||||
and scope.run_id == authorization.run_id
|
||||
)
|
||||
if not exact_scope:
|
||||
raise StandingAuthorityError("AUTHORITY_EXACT_SCOPE_MISMATCH", "trusted release execution scope does not match")
|
||||
durable_release_plan_id = self.service.sagas.load(
|
||||
scope.candidate_id
|
||||
).release_plan_id
|
||||
if scope.release_plan_id != durable_release_plan_id:
|
||||
raise StandingAuthorityError(
|
||||
"RELEASE_PLAN_ID_MISMATCH",
|
||||
"trusted scope does not match the durable Saga release plan",
|
||||
)
|
||||
if scope.lineage_root_candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_ROOT_MISMATCH", "candidate lineage is not rooted in standing authority")
|
||||
if scope.lineage_kind == "ROOT" and scope.candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_REQUIRED", "changed candidate requires verified successor lineage")
|
||||
if scope.effect_state == "UNKNOWN":
|
||||
raise StandingAuthorityError("RELEASE_EFFECT_UNKNOWN", "release effect must be reconciled before issuance")
|
||||
|
||||
def _unsigned(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
plan: ReleasePlan,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> SignedReleasePlanApproval:
|
||||
expires = now + timedelta(seconds=self.execution_ttl_seconds)
|
||||
if authorization.expires_at is not None:
|
||||
expires = min(expires, _parse_utc(authorization.expires_at))
|
||||
if expires <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization cannot cover a new child")
|
||||
issued_at = _format_utc(now)
|
||||
return SignedReleasePlanApproval(
|
||||
release_plan_id=scope.release_plan_id,
|
||||
candidate_id=scope.candidate_id,
|
||||
source_commit=scope.source_commit,
|
||||
tree_oid=scope.tree_oid,
|
||||
artifact_sha256=scope.artifact_sha256,
|
||||
manifest_sha256=scope.manifest_sha256,
|
||||
deployment_plan_sha256=plan.deployment.sha256,
|
||||
policy_sha256=self.service.policy_sha256,
|
||||
approval_plan_id=scope.approval_plan_id,
|
||||
evidence_closure_oid=scope.evidence_closure_oid,
|
||||
owner_approval_record_oid=scope.owner_approval_record_oid,
|
||||
capabilities=required,
|
||||
expires_at=_format_utc(expires),
|
||||
nonce="standing-" + secrets.token_hex(16),
|
||||
key_id=_text("signer.key_id", self.signer.key_id),
|
||||
signature="",
|
||||
owner_identity=authorization.owner_identity,
|
||||
trust_domain=authorization.trust_domain,
|
||||
issued_at=issued_at,
|
||||
not_before=issued_at,
|
||||
release_plan_sha256=plan.sha256,
|
||||
authorization_binding=self._binding(authorization, scope),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _signed(value: SignedReleasePlanApproval | Mapping[str, Any]) -> SignedReleasePlanApproval:
|
||||
return value if isinstance(value, SignedReleasePlanApproval) else SignedReleasePlanApproval.from_dict(value)
|
||||
|
||||
def _verify_signed(self, approval, candidate, plan, now):
|
||||
verifier = self.service.approval_verifier
|
||||
if verifier is None:
|
||||
raise StandingAuthorityError("APPROVAL_VERIFIER_MISSING", "approval verifier is not configured")
|
||||
verifier.verify_release_plan(approval, candidate, plan, self.service.policy_sha256, now=now)
|
||||
|
||||
def __call__(self, envelope: Mapping[str, Any]):
|
||||
if not isinstance(envelope, Mapping):
|
||||
raise TypeError("approval envelope must be a mapping")
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
lock_name = hashlib.sha256(candidate_id.encode("utf-8")).hexdigest() + ".lock"
|
||||
lock_path = self._lock_root / lock_name
|
||||
with release_file_lock(lock_path, blocking=True):
|
||||
try:
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
# Expiry is the one expected reason an otherwise valid
|
||||
# short child needs replacement. Verify its signature,
|
||||
# immutable evidence, key and revocation state at the end
|
||||
# of its signed window; a future/not-yet-valid or malformed
|
||||
# child still fails the verifier.
|
||||
# Expiry is exclusive. When renewing an already expired
|
||||
# child, verify its signature at the final instant inside
|
||||
# its original signed window rather than at expiry itself.
|
||||
old_expiry = _parse_utc(existing.expires_at)
|
||||
old_verification_time = min(
|
||||
now, old_expiry - timedelta(microseconds=1)
|
||||
)
|
||||
self._verify_signed(existing, candidate, plan, old_verification_time)
|
||||
self._check_binding(existing, authorization, scope, required)
|
||||
if _parse_utc(existing.expires_at) - now > timedelta(seconds=self.renewal_margin_seconds):
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), existing.to_dict())
|
||||
unsigned = self._unsigned(authorization, scope, plan, required, now)
|
||||
signed = self._signed(self.signer.sign(unsigned))
|
||||
# A signer may only add its signature to the complete request.
|
||||
if signed.canonical_payload != unsigned.canonical_payload:
|
||||
raise StandingAuthorityError("SIGNER_MUTATED_APPROVAL", "registered signer changed the approval request")
|
||||
self._verify_signed(signed, candidate, plan, now)
|
||||
after_authorization, after_scope = self._read(envelope)
|
||||
after_now = _utc(self.clock())
|
||||
self._assert_current(after_authorization, after_scope, envelope, plan_scope, required, after_now)
|
||||
if after_authorization != authorization or after_scope != scope:
|
||||
raise StandingAuthorityError("AUTHORITY_CHANGED_DURING_SIGN", "authority changed while the child was signed")
|
||||
self._verify_signed(signed, candidate, plan, after_now)
|
||||
if existing is None:
|
||||
self.service.submit_owner_approval(signed.to_dict())
|
||||
else:
|
||||
self.service.renew_owner_approval(candidate_id, signed.to_dict(), expected_fingerprint=existing.fingerprint)
|
||||
persisted = self.service.approvals.load(candidate_id)
|
||||
if persisted.fingerprint != signed.fingerprint:
|
||||
raise StandingAuthorityError("APPROVAL_PERSISTENCE_MISMATCH", "persisted approval differs from signed approval")
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), persisted.to_dict())
|
||||
except StandingAuthorityError as exc:
|
||||
authorization = locals().get("authorization")
|
||||
closed_states = {
|
||||
"AUTHORITY_ACCEPTED_AND_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_EXPIRED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_REVOKED": AuthorityLifecycleState.REVOKED,
|
||||
"AUTHORITY_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_CANCELLED": AuthorityLifecycleState.CANCELLED,
|
||||
}
|
||||
if exc.code in closed_states:
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.IN_SCOPE,
|
||||
lifecycle_state=closed_states[exc.code],
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
if exc.code in {
|
||||
"AUTHORITY_UNKNOWN",
|
||||
"AUTHORITY_SNAPSHOT_INVALID",
|
||||
"AUTHORITY_CHANGED_DURING_SIGN",
|
||||
}:
|
||||
return self._result(
|
||||
"WAITING_AUTHORITY",
|
||||
self._context(
|
||||
authorization,
|
||||
ExactScopeState.UNKNOWN,
|
||||
lifecycle_state=AuthorityLifecycleState.UNKNOWN,
|
||||
),
|
||||
)
|
||||
if exc.code.startswith("AUTHORITY_") and exc.code not in {"AUTHORITY_SNAPSHOT_INVALID", "AUTHORITY_CHANGED_DURING_SIGN"}:
|
||||
goal_change = {
|
||||
"AUTHORITY_RELEASE_SCOPE_MISMATCH",
|
||||
"AUTHORITY_PRODUCTION_SCOPE_MISMATCH",
|
||||
"AUTHORITY_CAPABILITY_DENIED",
|
||||
"AUTHORITY_LINEAGE_REQUIRED",
|
||||
}
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.OUT_OF_SCOPE,
|
||||
boundary=(
|
||||
OwnerBoundaryReason.GOAL_CHANGE
|
||||
if exc.code in goal_change
|
||||
else OwnerBoundaryReason.TRUST_BOUNDARY_CHANGE
|
||||
),
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
context = self._context(authorization, ExactScopeState.UNKNOWN)
|
||||
return self._result("WAITING_AUTHORITY", context)
|
||||
except Exception:
|
||||
return self._result("WAITING_AUTHORITY", self._context(None, ExactScopeState.UNKNOWN))
|
||||
|
||||
def _check_binding(self, approval, authorization, scope, required=None):
|
||||
binding = getattr(approval, "authorization_binding", None)
|
||||
if not isinstance(binding, Mapping) or set(binding) != _BINDING_KEYS:
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_INVALID", "approval lacks an exact standing authorization binding")
|
||||
if dict(binding) != self._binding(authorization, scope):
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_STALE", "approval standing authorization binding is stale")
|
||||
if approval.owner_identity != authorization.owner_identity or approval.trust_domain != authorization.trust_domain:
|
||||
raise StandingAuthorityError("AUTHORIZATION_IDENTITY_MISMATCH", "approval owner or trust binding is stale")
|
||||
if (
|
||||
approval.release_plan_id != scope.release_plan_id
|
||||
or approval.evidence_closure_oid != scope.evidence_closure_oid
|
||||
or approval.owner_approval_record_oid != scope.owner_approval_record_oid
|
||||
):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_OPERATION_BINDING_MISMATCH",
|
||||
"approval operation identity is stale",
|
||||
)
|
||||
if required is not None and tuple(sorted(approval.capabilities)) != tuple(required):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_CAPABILITY_BINDING_MISMATCH",
|
||||
"approval capability set does not match the exact plan scope",
|
||||
)
|
||||
|
||||
def check_current(self, approval: SignedReleasePlanApproval, capability: str, operation: str) -> None:
|
||||
if not isinstance(approval, SignedReleasePlanApproval):
|
||||
raise StandingAuthorityError("APPROVAL_INVALID", "current authority guard requires a signed approval")
|
||||
_text("operation", operation)
|
||||
if capability not in approval.capabilities:
|
||||
raise StandingAuthorityError("CAPABILITY_NOT_GRANTED", "approval does not grant the requested capability")
|
||||
envelope = self.service.get_approval_envelope(approval.candidate_id)
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
self._check_binding(approval, authorization, scope, required)
|
||||
self._verify_signed(approval, candidate, plan, now)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Distinct filename/path contracts and no-follow writes at the filesystem edge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ipaddress
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
|
||||
|
||||
def require_isolated_dev_profile(host: str) -> None:
|
||||
if os.getenv("AIOS_GITOPS_DEV_ALLOW", "0") != "1":
|
||||
return
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except (TypeError, ValueError):
|
||||
loopback = False
|
||||
if os.getenv("AIOS_RUNTIME_PROFILE") != "isolated-staging" or not loopback:
|
||||
raise RuntimeError("DEV_ALLOW requires isolated-staging and loopback")
|
||||
|
||||
|
||||
def safe_segment(value: str, *, max_length: int = 160) -> str:
|
||||
if (not isinstance(value, str) or not value or len(value) > max_length
|
||||
or value in (".", "..") or value.endswith((".", " "))
|
||||
or re.fullmatch(r"[A-Za-z0-9_@.-]+", value) is None
|
||||
or value.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(1, 10)], *[f"LPT{i}" for i in range(1, 10)]}):
|
||||
raise ValueError("invalid single path segment")
|
||||
return value
|
||||
|
||||
|
||||
def relative_parts(value: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, str) or "\\" in value or ":" in value or value.startswith("/"):
|
||||
raise ValueError("invalid relative path")
|
||||
parts = tuple(value.split("/"))
|
||||
for part in parts:
|
||||
safe_segment(part, max_length=255)
|
||||
if parts[0] == ".git":
|
||||
raise ValueError("Git metadata path forbidden")
|
||||
return parts
|
||||
|
||||
|
||||
def contained_path(root: Path, segment: str) -> Path:
|
||||
safe_segment(segment)
|
||||
root = Path(root)
|
||||
if root.is_symlink():
|
||||
raise ValueError("symlink root forbidden")
|
||||
path = root / segment
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError("path escapes root")
|
||||
return path
|
||||
|
||||
|
||||
def write_text_no_follow(root: Path, relative: str, content: str) -> None:
|
||||
"""Atomic POSIX write using directory descriptors; no lexical-only fallback."""
|
||||
parts = relative_parts(relative)
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("secure directory-relative writes require POSIX no-follow support")
|
||||
root = Path(root)
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
current = os.open(root, flags)
|
||||
temporary = ".c6-" + secrets.token_hex(16)
|
||||
try:
|
||||
for part in parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o750, dir_fd=current)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child = os.open(part, flags, dir_fd=current)
|
||||
os.close(current)
|
||||
current = child
|
||||
try:
|
||||
info = os.stat(parts[-1], dir_fd=current, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("nonregular or multiply-linked target forbidden")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=current)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content); stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(temporary, parts[-1], src_dir_fd=current, dst_dir_fd=current)
|
||||
os.fsync(current)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=current)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
os.close(current)
|
||||
@@ -22,6 +22,16 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
)
|
||||
|
||||
from silent_release_controller import _sanitize, utc_now
|
||||
from task_plane.governance_control_kernel import (
|
||||
AutonomousDecisionEngine,
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
DecisionInput,
|
||||
DecisionEffectIntent,
|
||||
ExactScopeState,
|
||||
NextAction,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
from silent_release_hardening import (
|
||||
BuilderAttestation,
|
||||
CapabilityAuthorizationError,
|
||||
@@ -33,9 +43,60 @@ from silent_release_hardening import (
|
||||
_canonical_json,
|
||||
_durable_json,
|
||||
_safe_child,
|
||||
release_file_lock,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class StandingAuthorizationResult:
|
||||
"""Typed result from a current authority reader plus formal issuer.
|
||||
|
||||
This object is routing evidence, not an authorization token. A result that
|
||||
carries an approval still has to pass ``SignedApprovalVerifier`` inside the
|
||||
release service before the saga can advance.
|
||||
"""
|
||||
|
||||
status: str
|
||||
authority_context: AuthorityDecisionContext
|
||||
approval: Mapping[str, Any] | None = None
|
||||
effect_intent: DecisionEffectIntent = DecisionEffectIntent.SIDE_EFFECT
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
allowed = {
|
||||
"APPROVAL_ISSUED",
|
||||
"AUTHORIZATION_DENIED",
|
||||
"OWNER_REQUIRED",
|
||||
"WAITING_AUTHORITY",
|
||||
}
|
||||
if self.status not in allowed:
|
||||
raise ValueError("invalid standing authorization status")
|
||||
if not isinstance(self.authority_context, AuthorityDecisionContext):
|
||||
raise ValueError("authority_context must be AuthorityDecisionContext")
|
||||
if self.effect_intent is not DecisionEffectIntent.SIDE_EFFECT:
|
||||
raise ValueError("standing authorization must be a SIDE_EFFECT decision")
|
||||
if self.status == "APPROVAL_ISSUED":
|
||||
if not isinstance(self.approval, Mapping):
|
||||
raise ValueError("issued authorization requires a signed approval")
|
||||
if (
|
||||
self.authority_context.lifecycle_state
|
||||
is not AuthorityLifecycleState.ACTIVE
|
||||
or self.authority_context.scope_state is not ExactScopeState.IN_SCOPE
|
||||
or self.authority_context.owner_boundary_reason is not None
|
||||
):
|
||||
raise ValueError("issued approval is inconsistent with authority context")
|
||||
elif self.approval is not None:
|
||||
raise ValueError("non-issued authorization cannot carry an approval")
|
||||
if self.status == "OWNER_REQUIRED" and (
|
||||
self.authority_context.owner_boundary_reason is None
|
||||
):
|
||||
raise ValueError("OWNER_REQUIRED needs an explicit boundary reason")
|
||||
|
||||
|
||||
StandingAuthorizationBroker = Callable[
|
||||
[Mapping[str, Any]], StandingAuthorizationResult | Mapping[str, Any]
|
||||
]
|
||||
|
||||
|
||||
class BuilderSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
@@ -262,38 +323,56 @@ class LeaderLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
self._lock_context = release_file_lock(self.path, blocking=False)
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
self.handle = self._lock_context.__enter__()
|
||||
except BlockingIOError as exc:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
raise RuntimeError("silent release leader is already active") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self.handle is not None:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self._lock_context.__exit__(exc_type, exc, tb)
|
||||
finally:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
|
||||
class PersistentSilentReleaseController:
|
||||
"""Idempotent main-event controller with durable startup/recovery reports."""
|
||||
|
||||
_UNKNOWN_EFFECT_ACTIONS = frozenset(
|
||||
{
|
||||
"reconcile_baseline_registry",
|
||||
"reconcile_release",
|
||||
"reconcile_tag",
|
||||
"resume_rollback",
|
||||
"resume_runtime_switch",
|
||||
"verify_runtime",
|
||||
}
|
||||
)
|
||||
_RECOVERY_SIDE_EFFECT_STATES = frozenset(
|
||||
{
|
||||
"APPROVED",
|
||||
"PRE_SWITCH_REVALIDATING",
|
||||
"SWITCH_PENDING",
|
||||
"SWITCHED",
|
||||
"RUNTIME_VERIFYING",
|
||||
"RUNTIME_VERIFIED",
|
||||
"TAG_PENDING",
|
||||
"TAG_VERIFIED",
|
||||
"BASELINE_REGISTRY_PENDING",
|
||||
"ROLLBACK_PENDING",
|
||||
"RECONCILIATION_REQUIRED",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
@@ -301,14 +380,42 @@ class PersistentSilentReleaseController:
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
owner_approval_broker: StandingAuthorizationBroker | None = None,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.service = service
|
||||
self.main_probe = main_probe
|
||||
self.recovery_worker = recovery_worker
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
self.leader_path = self.root / "controller.leader.lock"
|
||||
self.status_path = self.root / "controller-status.json"
|
||||
|
||||
def run_once_safely(self) -> dict[str, Any]:
|
||||
"""Keep read-only diagnostics available when a control tick fails.
|
||||
|
||||
A dependency failure never grants write permission or repairs a corrupt
|
||||
journal. Only a later successful, fully checked tick may advance work.
|
||||
Exception text can contain credentials, so persist its type only.
|
||||
"""
|
||||
try:
|
||||
return self.run_once()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
# A failed production tick may have reached an adapter before
|
||||
# losing its acknowledgement. Never assert zero effects here.
|
||||
"side_effects_executed": (
|
||||
None if self.service.execution_enabled else False
|
||||
),
|
||||
"status": "FAIL_CLOSED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
def startup_verify(self) -> dict[str, Any]:
|
||||
checked = 0
|
||||
failed = 0
|
||||
@@ -329,6 +436,196 @@ class PersistentSilentReleaseController:
|
||||
_durable_json(self.status_path, status)
|
||||
return status
|
||||
|
||||
@classmethod
|
||||
def _unknown_effects_pending(cls, recovery: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
isinstance(item, Mapping)
|
||||
and item.get("recovery_action") in cls._UNKNOWN_EFFECT_ACTIONS
|
||||
for item in recovery.get("sagas", ())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _recovery_side_effect_candidates(
|
||||
cls, recovery: Mapping[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
str(item["candidate_id"])
|
||||
for item in recovery.get("sagas", ())
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("state") in cls._RECOVERY_SIDE_EFFECT_STATES
|
||||
and item.get("candidate_id")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _authority_context_payload(
|
||||
context: AuthorityDecisionContext,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"lifecycle_state": context.lifecycle_state.value,
|
||||
"scope_state": context.scope_state.value,
|
||||
"owner_boundary_reason": (
|
||||
context.owner_boundary_reason.value
|
||||
if context.owner_boundary_reason is not None
|
||||
else None
|
||||
),
|
||||
"evidence_refs": list(context.evidence_refs),
|
||||
}
|
||||
|
||||
def _try_standing_authorization(self, candidate_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
|
||||
# Default-off compatibility: an already accepted exact approval may be
|
||||
# revalidated, but absence of a current broker never derives new
|
||||
# authority. When a broker is configured it must be consulted on every
|
||||
# side-effect decision so a still-valid short approval cannot mask a
|
||||
# revoked/closed root authorization or an exact-scope change.
|
||||
if self.owner_approval_broker is None and existing is not None:
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(existing.to_dict())
|
||||
except Exception:
|
||||
accepted = None
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"status": "APPROVED",
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
if self.owner_approval_broker is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_BACKEND_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
envelope = self.service.get_approval_envelope(candidate_id)
|
||||
try:
|
||||
issued = self.owner_approval_broker(dict(envelope))
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
authority_context = None
|
||||
decision = None
|
||||
if isinstance(issued, StandingAuthorizationResult):
|
||||
authority_context = issued.authority_context
|
||||
context_payload = self._authority_context_payload(authority_context)
|
||||
decision = self.decision_engine.decide(
|
||||
DecisionInput(
|
||||
goal_id=candidate_id,
|
||||
admission_ready=True,
|
||||
effect_intent=DecisionEffectIntent.SIDE_EFFECT,
|
||||
authority_context=authority_context,
|
||||
evidence_refs=authority_context.evidence_refs,
|
||||
)
|
||||
)
|
||||
decision_payload = decision.to_dict()
|
||||
if decision.next_valid_action is NextAction.DENY_EFFECT:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or "STANDING_AUTHORIZATION_DENIED",
|
||||
"status": "AUTHORIZATION_DENIED",
|
||||
}
|
||||
if decision.next_valid_action is NextAction.OWNER_BOUNDARY:
|
||||
boundary_reason = decision.owner_boundary_reason
|
||||
if boundary_reason is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": "OWNER_BOUNDARY_REASON_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": boundary_reason.value,
|
||||
"status": "OWNER_REQUIRED",
|
||||
}
|
||||
if decision.next_valid_action is not NextAction.CREATE_OR_RESUME_NEXT_CHILD:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or decision.next_valid_action.value,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if issued.status != "APPROVAL_ISSUED":
|
||||
status = (
|
||||
"AUTHORIZATION_DENIED"
|
||||
if issued.status == "AUTHORIZATION_DENIED"
|
||||
else "WAITING_AUTHORITY"
|
||||
)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": issued.status,
|
||||
"status": status,
|
||||
}
|
||||
approval = dict(issued.approval or {})
|
||||
elif isinstance(issued, Mapping):
|
||||
# Backward-compatible C5 callback contract. Mapping coverage does
|
||||
# not grant authority; submit_owner_approval verifies the signed
|
||||
# object against the current exact candidate and ReleasePlan.
|
||||
approval = dict(issued)
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(approval)
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
if accepted.get("status") not in {"VERIFIED_AND_STORED", "IDEMPOTENT"}:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "APPROVAL_NOT_VERIFIED",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"idempotent": accepted.get("status") == "IDEMPOTENT",
|
||||
"saga_state": accepted.get("saga_state"),
|
||||
"status": "APPROVED",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
|
||||
def run_once(self) -> dict[str, Any]:
|
||||
with LeaderLock(self.leader_path):
|
||||
startup = self.startup_verify()
|
||||
@@ -345,26 +642,98 @@ class PersistentSilentReleaseController:
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
# ReleaseRecoveryWorker can resume an externally visible effect.
|
||||
# Re-resolve current root lifecycle and exact scope for every such
|
||||
# candidate before invoking it; executor/verifier checks remain the
|
||||
# final fence. This also prevents a cached short approval from
|
||||
# bypassing root revocation during restart recovery.
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
unknown_effect = self._unknown_effects_pending(recovery_status)
|
||||
recovery_authority_decisions: list[dict[str, Any]] = []
|
||||
recovery_candidates = (
|
||||
self._recovery_side_effect_candidates(recovery_status)
|
||||
if self.recovery_worker is not None
|
||||
else ()
|
||||
)
|
||||
for candidate_id in recovery_candidates:
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
recovery_authority_decisions.append(authorization)
|
||||
if authorization.get("status") != "APPROVED":
|
||||
notification = None
|
||||
if (
|
||||
not unknown_effect
|
||||
and authorization.get("status") == "OWNER_REQUIRED"
|
||||
):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": notification,
|
||||
"recovery": recovery_status,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": (
|
||||
"RECONCILIATION_REQUIRED"
|
||||
if unknown_effect
|
||||
else authorization.get("status")
|
||||
),
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else recovery_status
|
||||
)
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
if self._unknown_effects_pending(recovery_status):
|
||||
result = {
|
||||
"authority_decision": None,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": None,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": "RECONCILIATION_REQUIRED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
source_commit = str(self.main_probe())
|
||||
event_id = f"main-{source_commit}"
|
||||
intent = self.service.create_release_intent(
|
||||
{"event_id": event_id, "source_commit": source_commit}
|
||||
)
|
||||
notification = None
|
||||
authorization = None
|
||||
if intent.get("candidate_id"):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
str(intent["candidate_id"])
|
||||
)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else self.service.get_recovery_status()
|
||||
)
|
||||
candidate_id = str(intent["candidate_id"])
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
if authorization.get("status") == "OWNER_REQUIRED":
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": intent,
|
||||
"notification": notification,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": recovery_authority_decisions,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
@@ -373,6 +742,51 @@ class PersistentSilentReleaseController:
|
||||
return _sanitize(result)
|
||||
|
||||
|
||||
def compose_standing_release_controller(
|
||||
root: Path,
|
||||
service: SilentReleaseActionService,
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
authority_reader: Callable[[], Any],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], Any],
|
||||
signer: Any,
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
clock: Callable[[], Any] | None = None,
|
||||
) -> PersistentSilentReleaseController:
|
||||
"""Compose trusted host adapters into the existing controller and effects.
|
||||
|
||||
Readers must return the validated snapshot types defined by
|
||||
``release_standing_authority``. The signer must already belong to the
|
||||
service's release approval trust domain. This function neither registers
|
||||
keys nor performs network/authority reads during cold startup.
|
||||
"""
|
||||
from release_standing_authority import StandingReleaseAuthorizationBroker
|
||||
|
||||
options = {
|
||||
"execution_ttl_seconds": execution_ttl_seconds,
|
||||
"renewal_margin_seconds": renewal_margin_seconds,
|
||||
}
|
||||
if clock is not None:
|
||||
options["clock"] = clock
|
||||
broker = StandingReleaseAuthorizationBroker(
|
||||
service,
|
||||
authority_reader,
|
||||
execution_scope_reader,
|
||||
signer,
|
||||
**options,
|
||||
)
|
||||
service.install_current_authority_guard(broker.check_current)
|
||||
return PersistentSilentReleaseController(
|
||||
root,
|
||||
service,
|
||||
main_probe=main_probe,
|
||||
recovery_worker=recovery_worker,
|
||||
owner_approval_broker=broker,
|
||||
)
|
||||
|
||||
|
||||
class ShadowActionHandler(BaseHTTPRequestHandler):
|
||||
service: SilentReleaseActionService
|
||||
controller: PersistentSilentReleaseController
|
||||
@@ -485,24 +899,12 @@ def serve_shadow(
|
||||
service,
|
||||
main_probe=git_main_probe(repository),
|
||||
)
|
||||
controller.run_once()
|
||||
controller.run_once_safely()
|
||||
stop = threading.Event()
|
||||
|
||||
def controller_loop() -> None:
|
||||
while not stop.wait(poll_seconds):
|
||||
try:
|
||||
controller.run_once()
|
||||
except Exception as exc: # fail closed and keep the API available
|
||||
_durable_json(
|
||||
controller.status_path,
|
||||
{
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "FAIL_CLOSED",
|
||||
},
|
||||
)
|
||||
controller.run_once_safely()
|
||||
|
||||
handler = type(
|
||||
"ConfiguredShadowActionHandler",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
"""Exact source-only admission composition within the existing Bridge.
|
||||
|
||||
This module neither installs itself nor issues authority. A host activation must
|
||||
pin an Owner-signed policy and an independently verified epoch. The existing
|
||||
Task admission store, V3 producer, Task Plane and principal verifier are reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
SignedArtifact, canonical_json_bytes, _open_secure,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission, SourcePublicationPolicyVerifier,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
raw = _open_secure(path, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("SOURCE_ACTIVATION_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
class ScopedPrincipalProvider:
|
||||
"""Policy revocation also fences principals from already admitted Tasks."""
|
||||
|
||||
def __init__(self, guarded: ScopedSourcePublicationPolicyAdmission, delegate: Any):
|
||||
self.guarded = guarded
|
||||
self.delegate = delegate
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
self.guarded.verifier.require(self.guarded.artifact, self.guarded.request)
|
||||
return self.delegate.readiness()
|
||||
except Exception as exc:
|
||||
return {"ready": False, "mode": "source-publication-only", "error": str(exc)}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]) -> Any:
|
||||
grant = self.delegate.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is None or grant.parent_epoch_id != self.delegate.epoch_id:
|
||||
raise TaskAdmissionError("SOURCE_PRINCIPAL_GRANT_REQUIRED", 403)
|
||||
# status rechecks the signed policy, exact durable envelope and original
|
||||
# signed epoch. The delegate then verifies the live TaskLease/state.
|
||||
self.guarded.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
class _AdmissionContext:
|
||||
"""Metadata only for legacy hosts retaining their signed principal verifier."""
|
||||
def __init__(self, bundle):
|
||||
self.store, self.authorization = bundle.store, bundle.authorization
|
||||
self.task_port, self.epoch_id = bundle.task_port, bundle.epoch_id
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
raise TaskAdmissionError("REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED", 403)
|
||||
|
||||
|
||||
def guard_source_runtime(
|
||||
runtime: TaskAdmissionRuntimeBundle, *, artifact: SignedArtifact,
|
||||
verifier: SourcePublicationPolicyVerifier, expected_agent_id: str,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
service = runtime.service
|
||||
if service is None:
|
||||
raise ValueError("SOURCE_ACTIVATION_BOOTSTRAP_REQUIRED")
|
||||
guarded = ScopedSourcePublicationPolicyAdmission(
|
||||
verifier, bootstrap=service, artifact=artifact,
|
||||
expected_agent_id=expected_agent_id,
|
||||
)
|
||||
# Build the typed request through the same exact-envelope guard used by the
|
||||
# HTTP contract, instead of accepting a caller-supplied request projection.
|
||||
payload = artifact.payload
|
||||
request = guarded._request_for_raw({
|
||||
"repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "risk": payload["risk"],
|
||||
"file_allowlist": payload["exact_files"], "requested_capabilities": payload["capabilities"],
|
||||
})
|
||||
guarded.request = request
|
||||
verifier.require(artifact, request)
|
||||
principal = runtime.principal_verifier or _AdmissionContext(runtime)
|
||||
epoch = principal.authorization.verify_delegation_epoch(principal.epoch_id)
|
||||
# The complete per-envelope check runs again on authorization and every
|
||||
# principal use; reject a production-capable epoch already at composition.
|
||||
if (getattr(epoch, "production_scope", None) != "none"
|
||||
or getattr(epoch, "destructive_policy", None) != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(guarded).handlers(),
|
||||
principal_verifier=ScopedPrincipalProvider(guarded, principal),
|
||||
service=guarded,
|
||||
)
|
||||
|
||||
|
||||
def build_source_runtime_from_activation_file(path: Path, *, post_admission: Any,
|
||||
ordinary_activation_file: Path):
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
load_owner_approval_verifier,
|
||||
)
|
||||
_, config = _read_object(path)
|
||||
if set(config) != {"schema", "policy_artifact", "policy_sha256", "policy_revocations",
|
||||
"admission_activation", "expected_agent_id"} or config["schema"] != "aios.source-publication-host-activation.v1":
|
||||
raise ValueError("SOURCE_ACTIVATION_FIELDS_INVALID")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = config[key]
|
||||
if not isinstance(value, str) or not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise ValueError("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
raw, signed = _read_object(Path(config["policy_artifact"]))
|
||||
if hashlib.sha256(raw).hexdigest() != config["policy_sha256"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
if set(signed) != {"payload", "signature", "key_id", "namespace"}:
|
||||
raise ValueError("SOURCE_ACTIVATION_ARTIFACT_INVALID")
|
||||
artifact = SignedArtifact(canonical_payload=canonical_json_bytes(signed["payload"]), **signed)
|
||||
_, ordinary_config = _read_object(ordinary_activation_file)
|
||||
_, source_config = _read_object(Path(config["admission_activation"]))
|
||||
for field in ("state_db", "task_api_url", "task_control_token_file", "owner_verifier_module"):
|
||||
if not ordinary_config.get(field) or source_config.get(field) != ordinary_config[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH")
|
||||
|
||||
def not_revoked(payload: Mapping[str, Any]) -> bool:
|
||||
_, value = _read_object(Path(config["policy_revocations"]))
|
||||
if (set(value) != {"schema", "revoked_nonces"}
|
||||
or value["schema"] != "aios.source-publication-revocations.v1"
|
||||
or not isinstance(value["revoked_nonces"], list)
|
||||
or any(not isinstance(nonce, str) or not nonce for nonce in value["revoked_nonces"])):
|
||||
return False
|
||||
return payload["nonce"] not in value["revoked_nonces"]
|
||||
|
||||
runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(config["admission_activation"]), post_admission=post_admission)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
load_owner_approval_verifier(Path(source_config["owner_verifier_module"])),
|
||||
revocation_check=not_revoked,
|
||||
expected_policy_digest=runtime.service.policy.normalized_policy_digest(),
|
||||
)
|
||||
guarded_runtime = guard_source_runtime(runtime, artifact=artifact, verifier=verifier,
|
||||
expected_agent_id=config["expected_agent_id"])
|
||||
payload = artifact.payload
|
||||
plan_path = Path(source_config["execution_plan_dir"]) / (payload["execution_digest"] + ".json")
|
||||
plan_bytes, plan = _read_object(plan_path)
|
||||
if (hashlib.sha256(plan_bytes).hexdigest() != payload["execution_digest"]
|
||||
or canonical_json_bytes(plan) != plan_bytes):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_DIGEST_MISMATCH")
|
||||
hashes = plan.get("file_sha256")
|
||||
if not isinstance(hashes, dict) or set(hashes) != set(payload["exact_files"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_FILE_HASH_SET_MISMATCH")
|
||||
for field in ("repository", "authority_base_oid", "target_branch", "generation", "risk"):
|
||||
if plan.get(field) != payload[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_SCOPE_MISMATCH")
|
||||
if sorted(plan.get("capabilities", [])) != sorted(payload["capabilities"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if not isinstance(plan.get("task_id"), str) or not plan["task_id"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_TASK_REQUIRED")
|
||||
guarded_runtime.service.expected_file_sha256 = dict(hashes)
|
||||
guarded_runtime.service.expected_task_id = plan["task_id"]
|
||||
return guarded_runtime
|
||||
|
||||
|
||||
class AdmissionRuntimeRouter:
|
||||
"""Route an exact branch/epoch without widening the ordinary policy."""
|
||||
|
||||
def __init__(self, ordinary: TaskAdmissionRuntimeBundle, source: TaskAdmissionRuntimeBundle):
|
||||
self.ordinary = ordinary
|
||||
self.source = source
|
||||
self.guard = source.service
|
||||
self.store = self.guard.bootstrap.store
|
||||
|
||||
def _source_request(self, operation: str, request: Mapping[str, Any]) -> bool:
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return request.get("target_branch") == self.guard.artifact.payload["target_branch"]
|
||||
envelope_id = str(request.get("envelope_id") or "")
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
# Never fall through after finding a source envelope, including
|
||||
# when it has expired or been revoked.
|
||||
return False
|
||||
envelope = self.guard.bootstrap._load_envelope(envelope_id)
|
||||
return envelope.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
def dispatch(operation: str, request: Mapping[str, Any]):
|
||||
runtime = self.source if self._source_request(operation, request) else self.ordinary
|
||||
return runtime.handlers[operation](request)
|
||||
return {op: (lambda request, op=op: dispatch(op, request)) for op in self.source.handlers}
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
return {"mode": "scoped-admission-router", "ordinary": self.ordinary.principal_verifier.readiness(),
|
||||
"source_publication": self.source.principal_verifier.readiness()}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
provider = (self.source.principal_verifier if grant is not None
|
||||
and grant.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
else self.ordinary.principal_verifier)
|
||||
return provider(transport, request)
|
||||
|
||||
def bundle(self) -> TaskAdmissionRuntimeBundle:
|
||||
return TaskAdmissionRuntimeBundle(handlers=self.handlers(), principal_verifier=self)
|
||||
|
||||
|
||||
class ExistingPrincipalPolicyFence:
|
||||
"""Add a C5 policy fence while retaining the registered signed verifier.
|
||||
|
||||
Layered staging hosts use a signed TaskPrincipal verifier with their own
|
||||
storage scope. They must not be migrated to a server attestation merely to
|
||||
add source admission. Every request still reaches the original verifier.
|
||||
"""
|
||||
|
||||
def __init__(self, delegate: Any, source_runtime: TaskAdmissionRuntimeBundle):
|
||||
self.delegate = delegate
|
||||
self.guard = source_runtime.service
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.delegate, name)
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.guard.bootstrap.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is not None and grant.target_branch == self.guard.artifact.payload["target_branch"]:
|
||||
self.guard.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
if transport.get("operation") in {"commitFilesGoverned", "commitFileGoverned"}:
|
||||
expected = getattr(self.guard, "expected_file_sha256", None)
|
||||
if not isinstance(expected, dict) or not expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_HASHES_REQUIRED", 403)
|
||||
try:
|
||||
raw = request.get("files")
|
||||
if raw is None:
|
||||
raw = {request["path"]: request["content_base64"]}
|
||||
pairs = list(raw.items()) if isinstance(raw, dict) else [(item["path"], item["content_base64"]) for item in raw]
|
||||
if len({path for path, _ in pairs}) != len(pairs):
|
||||
raise ValueError("duplicate path")
|
||||
actual = {path: hashlib.sha256(base64.b64decode(data, validate=True)).hexdigest()
|
||||
for path, data in pairs}
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_PAYLOAD_INVALID", 403) from exc
|
||||
if actual != expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_CONTENT_SCOPE_MISMATCH", 403)
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
def expired_admission_runtime() -> TaskAdmissionRuntimeBundle:
|
||||
"""Keep a verified-expired ordinary epoch denied while serving other routes."""
|
||||
def deny(*_args: Any, **_kwargs: Any):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
|
||||
class ClosedPrincipal:
|
||||
__call__ = staticmethod(deny)
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "TASK_ADMISSION_EPOCH_EXPIRED"}
|
||||
|
||||
from aios_task_admission_bridge_contract import TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers={op: deny for op in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS},
|
||||
principal_verifier=ClosedPrincipal(),
|
||||
)
|
||||
@@ -1,20 +1,257 @@
|
||||
"""Task Center API."""
|
||||
"""Authenticated read-only Task Center projections and event transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
except Exception: # pragma: no cover - importable without FastAPI for model tests
|
||||
APIRouter = HTTPException = Request = StreamingResponse = None
|
||||
|
||||
from .task_center import TaskCenter
|
||||
from .task_dashboard_view import ProjectionCursorExpired, ProjectionCursorTransportLimit, TaskDashboardView
|
||||
from .task_plane.errors import ForbiddenError, UnauthorizedError
|
||||
|
||||
|
||||
class ProjectionUnavailable(RuntimeError):
|
||||
"""The host did not install the source and identity bindings required to read."""
|
||||
|
||||
|
||||
task_center = TaskCenter()
|
||||
dashboard = TaskDashboardView()
|
||||
_projection_reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None
|
||||
_principal_resolver: Callable[[Any], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def get_task_overview(tasks: list[dict[str, Any]] | None = None):
|
||||
return task_center.summarize(tasks)
|
||||
def set_task_projection_reader(reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None) -> None:
|
||||
"""Install a host-owned reader that returns already-authorized task/event data."""
|
||||
global _projection_reader
|
||||
_projection_reader = reader
|
||||
|
||||
|
||||
def set_task_projection_principal_resolver(
|
||||
resolver: Callable[[Any], Mapping[str, Any] | None] | None,
|
||||
) -> None:
|
||||
"""Install the host's authenticated-principal resolver for all projection reads."""
|
||||
global _principal_resolver
|
||||
_principal_resolver = resolver
|
||||
|
||||
|
||||
def get_task_overview(
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project provided durable records; route callers use ``_read_projection``."""
|
||||
summary = task_center.summarize(tasks)
|
||||
projection = dashboard.build(tasks, events_by_task)
|
||||
return {
|
||||
**summary,
|
||||
"tasks": projection["tasks"],
|
||||
"views": projection["views"],
|
||||
"snapshot_cursor": dashboard.snapshot_cursor(tasks, events_by_task),
|
||||
}
|
||||
|
||||
|
||||
def get_task_events(
|
||||
cursor: str | None,
|
||||
*,
|
||||
principal: Mapping[str, Any],
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
feed = dashboard.event_feed(source["tasks"], source["events_by_task"], cursor, limit=limit)
|
||||
cards = {card["task_id"]: card for card in dashboard.build(source["tasks"], source["events_by_task"])["tasks"]}
|
||||
for event in feed["events"]:
|
||||
card = cards.get(event["entity_id"])
|
||||
if card is None:
|
||||
continue
|
||||
# Event sequence and task revision have different authority domains.
|
||||
# The client fences entity snapshots by task revision, while resumable
|
||||
# transport remains governed by the per-task append sequence.
|
||||
event["event_sequence"] = event["sequence"]
|
||||
event["event_revision"] = event.get("revision")
|
||||
event["task_revision"] = card.get("revision")
|
||||
event["revision"] = card.get("revision")
|
||||
event["task"] = card
|
||||
event["related_tasks"] = _ancestor_cards(card, cards)
|
||||
return feed
|
||||
|
||||
|
||||
def _ancestor_cards(card: Mapping[str, Any], cards: Mapping[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
related: list[dict[str, Any]] = []
|
||||
parent_id = card.get("lineage", {}).get("parent_task_id") if isinstance(card.get("lineage"), Mapping) else None
|
||||
visited: set[str] = set()
|
||||
while isinstance(parent_id, str) and parent_id and parent_id not in visited:
|
||||
visited.add(parent_id)
|
||||
parent = cards.get(parent_id)
|
||||
if parent is None:
|
||||
break
|
||||
related.append(parent)
|
||||
lineage = parent.get("lineage")
|
||||
parent_id = lineage.get("parent_task_id") if isinstance(lineage, Mapping) else None
|
||||
return related
|
||||
|
||||
|
||||
def configure_task_plane_projection(service: Any, principal_resolver: Callable[[Any], Mapping[str, Any] | None]) -> None:
|
||||
"""Bind this FastAPI router to an existing authorized ``TaskPlaneService``.
|
||||
|
||||
The resolver must return a host-authenticated principal containing the
|
||||
server-derived ``task_plane_identity``. No browser-provided role or scope
|
||||
participates in these reads.
|
||||
"""
|
||||
def reader(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
actor = principal.get("task_plane_identity")
|
||||
if not isinstance(actor, str) or not actor:
|
||||
raise PermissionError("task projection identity is missing")
|
||||
return _read_task_plane_snapshot(service, actor)
|
||||
|
||||
set_task_projection_principal_resolver(principal_resolver)
|
||||
set_task_projection_reader(reader)
|
||||
|
||||
|
||||
_TASKPLANE_DISPLAY_STATUSES = {
|
||||
"CREATED": "pending",
|
||||
"QUEUED": "pending",
|
||||
"CLAIMED": "running",
|
||||
"RUNNING": "running",
|
||||
"WAITING_CHILD": "running",
|
||||
"VERIFYING": "running",
|
||||
"COMPLETED": "completed",
|
||||
"FAILED_RECOVERABLE": "blocked",
|
||||
"FAILED_FINAL": "blocked",
|
||||
"CANCELLED": "blocked",
|
||||
"MANUAL_REVIEW_REQUIRED": "waiting_approval",
|
||||
}
|
||||
|
||||
|
||||
def _read_task_plane_snapshot(service: Any, actor: str) -> Mapping[str, Any]:
|
||||
"""Read an authorized, bounded-consistency TaskPlane projection.
|
||||
|
||||
``list_tasks`` and ``get_events`` are separate authorized reads. We only
|
||||
publish their result when a second task and event-position read confirms
|
||||
that no visible task revision, membership, or per-task append position
|
||||
moved while the first snapshot was collected. A busy plane is retried
|
||||
once and then fails closed rather than emitting a mixed snapshot.
|
||||
"""
|
||||
for _attempt in range(2):
|
||||
raw_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
task_ids = [task["task_id"] for task in raw_tasks]
|
||||
events_by_task = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verification_events = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verified_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
if _task_revisions(raw_tasks) == _task_revisions(verified_tasks) and all(
|
||||
_event_positions(events_by_task[task_id]) == _event_positions(verification_events[task_id])
|
||||
for task_id in task_ids
|
||||
):
|
||||
return {"tasks": raw_tasks, "events_by_task": events_by_task}
|
||||
raise ProjectionUnavailable("task projection changed during snapshot")
|
||||
|
||||
|
||||
def _visible_tasks(records: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(records, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid tasks")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for raw in records:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("task_id"), str) or not raw["task_id"]:
|
||||
raise ProjectionUnavailable("task projection source returned invalid task")
|
||||
source_status = str(raw.get("status") or "").upper()
|
||||
if source_status not in _TASKPLANE_DISPLAY_STATUSES:
|
||||
raise ProjectionUnavailable("task projection source returned unknown TaskPlane status")
|
||||
tasks.append({
|
||||
**raw,
|
||||
"source_status": source_status or None,
|
||||
"status": _TASKPLANE_DISPLAY_STATUSES[source_status],
|
||||
})
|
||||
return tasks
|
||||
|
||||
|
||||
def _task_revisions(tasks: list[Mapping[str, Any]]) -> tuple[tuple[str, Any], ...]:
|
||||
return tuple(sorted((task["task_id"], task.get("revision")) for task in tasks))
|
||||
|
||||
|
||||
def _event_positions(events: Any) -> tuple[tuple[str, int], ...]:
|
||||
if not isinstance(events, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid events")
|
||||
positions: list[tuple[str, int]] = []
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
raise ProjectionUnavailable("task projection source returned invalid event")
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
raise ProjectionUnavailable("task projection event has no durable sequence")
|
||||
positions.append((event["event_id"], sequence))
|
||||
return tuple(positions)
|
||||
|
||||
|
||||
def _read_projection(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if _projection_reader is None:
|
||||
raise ProjectionUnavailable("task projection source is not configured")
|
||||
source = _projection_reader(principal)
|
||||
if not isinstance(source, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned invalid data")
|
||||
tasks, events = source.get("tasks"), source.get("events_by_task")
|
||||
if not isinstance(tasks, list) or not isinstance(events, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned incomplete data")
|
||||
return {"tasks": tasks, "events_by_task": events}
|
||||
|
||||
|
||||
def _principal(request: Any) -> Mapping[str, Any]:
|
||||
if _principal_resolver is None:
|
||||
raise ProjectionUnavailable("task projection authentication is not configured")
|
||||
principal = _principal_resolver(request)
|
||||
if not isinstance(principal, Mapping):
|
||||
raise PermissionError("task projection authentication failed")
|
||||
return principal
|
||||
|
||||
|
||||
def _sse(payload: Mapping[str, Any]):
|
||||
# The bounded response is deliberately resumable. The browser reconnects
|
||||
# with the returned cursor; polling remains available when it cannot.
|
||||
for event in payload["events"]:
|
||||
yield f"id: {event['cursor']}\ndata: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
yield ": end-of-batch\n\n"
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ProjectionCursorTransportLimit as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=413, detail="TASK_EVENT_CURSOR_TOO_LARGE") from exc
|
||||
except ProjectionCursorExpired as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=410, detail="EVENT_CURSOR_EXPIRED") from exc
|
||||
except ProjectionUnavailable as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=503, detail="TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
except UnauthorizedError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=401, detail="TASK_PROJECTION_UNAUTHORIZED") from exc
|
||||
except ForbiddenError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except PermissionError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _overview(principal: Mapping[str, Any]) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
return get_task_overview(source["tasks"], source["events_by_task"])
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -23,12 +260,37 @@ def build_router():
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
|
||||
|
||||
@router.get("/overview", operation_id="getTaskCenterOverview")
|
||||
def overview():
|
||||
return get_task_overview()
|
||||
def overview(request: Request):
|
||||
return _http(lambda: _overview(_principal(request)))
|
||||
|
||||
@router.get("/events", operation_id="streamTaskCenterEvents")
|
||||
def events(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
payload = _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
return StreamingResponse(_sse(payload), media_type="text/event-stream")
|
||||
|
||||
@router.get("/events/poll", operation_id="pollTaskCenterEvents")
|
||||
def poll(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
return _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_task_overview"]
|
||||
__all__ = [
|
||||
"router", "get_task_overview", "get_task_events", "set_task_projection_reader",
|
||||
"set_task_projection_principal_resolver", "configure_task_plane_projection",
|
||||
"ProjectionUnavailable",
|
||||
]
|
||||
|
||||
|
||||
def _effective_cursor(request: Any, cursor: str | None, after_cursor: str | None) -> str | None:
|
||||
header_cursor = request.headers.get("Last-Event-ID") if request is not None else None
|
||||
supplied = [value for value in (cursor, after_cursor, header_cursor) if value]
|
||||
if len(set(supplied)) > 1:
|
||||
raise ValueError("conflicting event cursors")
|
||||
return supplied[0] if supplied else None
|
||||
|
||||
@@ -1,81 +1,438 @@
|
||||
"""Task Center visualization model."""
|
||||
"""Read-only Task Center projections built from durable task and event records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import base64
|
||||
import json
|
||||
import zlib
|
||||
from collections import defaultdict
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class ProjectionCursorExpired(ValueError):
|
||||
"""The requested event cursor no longer names a visible durable event."""
|
||||
|
||||
|
||||
class ProjectionCursorTransportLimit(ValueError):
|
||||
"""The authorized per-task watermark cannot fit a request/header cursor."""
|
||||
|
||||
|
||||
_MAX_CURSOR_BYTES = 8192
|
||||
_MAX_CURSOR_PAYLOAD_BYTES = 32768
|
||||
_INITIAL_INTERACTION_KIND = "initial_request"
|
||||
_ADDITIONAL_INTERACTION_KINDS = frozenset({"additional_interaction", "follow_up"})
|
||||
|
||||
|
||||
class TaskDashboardView:
|
||||
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
"""Build the Task Center read model without maintaining another state store."""
|
||||
|
||||
def build(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_tasks = [dict(task) for task in tasks or [] if isinstance(task, Mapping)]
|
||||
by_id = {
|
||||
task_id: task for task in raw_tasks
|
||||
if isinstance((task_id := task.get("task_id")), str) and task_id
|
||||
}
|
||||
children = self._children_by_parent(by_id)
|
||||
cards = [
|
||||
self._task_card(task, by_id, children, events_by_task)
|
||||
for task in raw_tasks
|
||||
]
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS Task Center",
|
||||
"views": {
|
||||
"kanban": self._kanban(tasks),
|
||||
"timeline": self._timeline(tasks),
|
||||
"flow": self._flow(tasks),
|
||||
"statistics": self._statistics(tasks),
|
||||
"kanban": self._kanban(cards),
|
||||
"timeline": self._timeline(cards),
|
||||
"flow": self._flow(cards),
|
||||
"statistics": self._statistics(cards),
|
||||
},
|
||||
"tasks": [self._task_card(task) for task in tasks],
|
||||
"tasks": cards,
|
||||
}
|
||||
|
||||
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
def event_feed(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
cursor: str | None = None,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Return an authorization-filtered, resumable page of durable events."""
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("invalid event page limit")
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks = self._decode_cursor(cursor) if cursor else {}
|
||||
self._validate_watermarks(rows, watermarks)
|
||||
pending = [
|
||||
row for row in rows
|
||||
if row["sequence"] > watermarks.get(row["entity_id"], -1)
|
||||
]
|
||||
page = pending[:limit]
|
||||
next_watermarks = dict(watermarks)
|
||||
emitted_events: list[dict[str, Any]] = []
|
||||
for row in page:
|
||||
next_watermarks[row["entity_id"]] = max(next_watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
# ``Last-Event-ID`` holds the cursor from the final SSE frame. It
|
||||
# must therefore include every preceding entity watermark, not just
|
||||
# the frame's own task, or a reconnect would replay another task.
|
||||
emitted_events.append({**row, "cursor": self._encode_cursor(next_watermarks)})
|
||||
next_cursor = self._encode_cursor(next_watermarks) if next_watermarks else None
|
||||
return {
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"events": emitted_events,
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": len(pending) > len(page),
|
||||
}
|
||||
|
||||
def snapshot_cursor(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> str | None:
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks: dict[str, int] = {}
|
||||
for row in rows:
|
||||
watermarks[row["entity_id"]] = max(watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
return self._encode_cursor(watermarks) if watermarks else None
|
||||
|
||||
def _task_card(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
by_id: Mapping[str, dict[str, Any]],
|
||||
children: Mapping[str, list[str]],
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
task_id = task.get("task_id")
|
||||
progress = task.get("progress") if isinstance(task.get("progress"), Mapping) else {}
|
||||
parent_task_id = task.get("parent_task_id")
|
||||
event_result = self._project_events(
|
||||
task_id,
|
||||
events_by_task,
|
||||
complete=task.get("interaction_history_coverage") == "complete",
|
||||
)
|
||||
direct_children = list(children.get(task_id, ())) if isinstance(task_id, str) else []
|
||||
grandchildren = [grandchild for child in direct_children for grandchild in children.get(child, ())]
|
||||
authorization = self._authorization(task)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"title": task.get("title") or task.get("display_name"),
|
||||
"root_task_id": task.get("root_task_id"),
|
||||
"created_at": task.get("created_at"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"status": task.get("status"),
|
||||
"progress": task.get("progress", {}),
|
||||
"elapsed_seconds": task.get("elapsed_seconds", 0),
|
||||
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
|
||||
"source_status": task.get("source_status", task.get("status")),
|
||||
"revision": task.get("revision"),
|
||||
"progress": progress,
|
||||
"elapsed_seconds": self._known_nonnegative_int(task.get("elapsed_seconds")),
|
||||
"remaining_steps": progress.get("remaining_steps", []),
|
||||
"notification": task.get("notification", {}),
|
||||
"actions": [
|
||||
"start",
|
||||
"update_progress",
|
||||
"block",
|
||||
"request_approval",
|
||||
"approve",
|
||||
"reject",
|
||||
"retry",
|
||||
"complete",
|
||||
"notify",
|
||||
"close",
|
||||
],
|
||||
"lineage": {
|
||||
"parent_task_id": parent_task_id if isinstance(parent_task_id, str) and parent_task_id in by_id else None,
|
||||
"child_task_ids": direct_children,
|
||||
"grandchild_task_ids": grandchildren,
|
||||
},
|
||||
"responsibility": {
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"owner_principal_id": task.get("owner_principal_id"),
|
||||
},
|
||||
"authorization": authorization,
|
||||
"actions": self._actions(task, authorization),
|
||||
"interaction_history": event_result,
|
||||
}
|
||||
|
||||
def _kanban(self, tasks):
|
||||
@staticmethod
|
||||
def _known_nonnegative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _children_by_parent(by_id: Mapping[str, dict[str, Any]]) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = defaultdict(list)
|
||||
for task_id, task in by_id.items():
|
||||
parent_id = task.get("parent_task_id")
|
||||
if isinstance(parent_id, str) and parent_id in by_id:
|
||||
result[parent_id].append(task_id)
|
||||
return {parent: sorted(child_ids) for parent, child_ids in result.items()}
|
||||
|
||||
def _project_events(
|
||||
self,
|
||||
task_id: Any,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
*,
|
||||
complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(task_id, str) or events_by_task is None or task_id not in events_by_task:
|
||||
return self._unknown_history()
|
||||
raw_events = events_by_task[task_id]
|
||||
if not isinstance(raw_events, list):
|
||||
return self._unknown_history()
|
||||
seen: dict[str, dict[str, Any]] = {}
|
||||
semantic_ids: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
events: list[dict[str, Any]] = []
|
||||
conflicts: list[dict[str, str]] = []
|
||||
for raw in raw_events:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("event_id"), str):
|
||||
conflicts.append({"kind": "invalid_event", "event_id": ""})
|
||||
continue
|
||||
event = dict(raw)
|
||||
event_id = event["event_id"]
|
||||
previous = seen.get(event_id)
|
||||
if previous is not None:
|
||||
if previous != event:
|
||||
conflicts.append({"kind": "duplicate_event_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
seen[event_id] = event
|
||||
item = self._event_item(event)
|
||||
if self._malformed_human_interaction(event):
|
||||
conflicts.append({"kind": "invalid_human_interaction_evidence", "event_id": event_id})
|
||||
if self._malformed_metric_evidence(event):
|
||||
conflicts.append({"kind": "invalid_metric_evidence", "event_id": event_id})
|
||||
semantic_id = item.get("semantic_id")
|
||||
if semantic_id:
|
||||
semantic_namespace = "human_interaction" if item["category"] in {"initial_request", "additional_interaction"} else item["category"]
|
||||
previous_semantic = semantic_ids.get((semantic_namespace, semantic_id))
|
||||
if previous_semantic is not None:
|
||||
if self._semantic_content(previous_semantic) != self._semantic_content(item):
|
||||
conflicts.append({"kind": "duplicate_semantic_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
semantic_ids[(semantic_namespace, semantic_id)] = item
|
||||
events.append(item)
|
||||
events.sort(key=lambda event: (event.get("created_at") or "", event["event_id"]))
|
||||
interactions = sum(event["category"] in {"initial_request", "additional_interaction"} for event in events)
|
||||
is_exact = complete and not conflicts
|
||||
return {
|
||||
"history_status": "partial" if not complete else ("conflicted" if conflicts else "complete"),
|
||||
"interaction_count": interactions if is_exact else None,
|
||||
"initial_request_count": sum(event["category"] == "initial_request" for event in events) if is_exact else None,
|
||||
"additional_interaction_count": sum(event["category"] == "additional_interaction" for event in events) if is_exact else None,
|
||||
"decision_count": sum(event["category"] == "decision" for event in events) if is_exact else None,
|
||||
"offered_choice_count": sum(event.get("choice_count", 0) for event in events if event["category"] == "offered_choice") if is_exact else None,
|
||||
"selection_submission_count": sum(event["category"] == "selection_submission" for event in events) if is_exact else None,
|
||||
"events": events,
|
||||
"source_conflicts": conflicts,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _unknown_history() -> dict[str, Any]:
|
||||
return {
|
||||
"history_status": "partial",
|
||||
"interaction_count": None,
|
||||
"initial_request_count": None,
|
||||
"additional_interaction_count": None,
|
||||
"decision_count": None,
|
||||
"offered_choice_count": None,
|
||||
"selection_submission_count": None,
|
||||
"events": [],
|
||||
"source_conflicts": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _semantic_content(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value for key, value in event.items()
|
||||
if key not in {"event_id", "sequence", "revision", "created_at"}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _malformed_human_interaction(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
if source != "HUMAN_INTERACTION":
|
||||
return False
|
||||
interaction_id = data.get("interaction_id")
|
||||
kind = data.get("kind")
|
||||
return not (
|
||||
isinstance(interaction_id, str) and interaction_id
|
||||
and isinstance(kind, str)
|
||||
and (kind == _INITIAL_INTERACTION_KIND or kind in _ADDITIONAL_INTERACTION_KINDS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_metric_evidence(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
event_type = str(event.get("type") or "").upper()
|
||||
if event_type in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"}:
|
||||
return not isinstance(data.get("decision_id"), str) or not data["decision_id"]
|
||||
if event_type in {"CHOICE_OFFERED", "OPTIONS_OFFERED"}:
|
||||
choice_count = data.get("choice_count")
|
||||
return not (
|
||||
isinstance(data.get("choice_offer_id"), str) and data["choice_offer_id"]
|
||||
and isinstance(choice_count, int) and not isinstance(choice_count, bool) and choice_count >= 0
|
||||
)
|
||||
if event_type in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"}:
|
||||
return not isinstance(data.get("selection_submission_id"), str) or not data["selection_submission_id"]
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _event_item(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
event_type = str(event.get("type") or "UNKNOWN")
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
normalized = event_type.upper()
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
interaction_id = data.get("interaction_id")
|
||||
decision_id = data.get("decision_id")
|
||||
selection_id = data.get("selection_submission_id")
|
||||
kind = data.get("kind")
|
||||
if source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and kind == _INITIAL_INTERACTION_KIND:
|
||||
category = "initial_request"
|
||||
semantic_id = interaction_id
|
||||
elif source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and isinstance(kind, str) and kind in _ADDITIONAL_INTERACTION_KINDS:
|
||||
category = "additional_interaction"
|
||||
semantic_id = interaction_id
|
||||
elif normalized in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"} and isinstance(decision_id, str) and decision_id:
|
||||
category = "decision"
|
||||
semantic_id = decision_id
|
||||
elif normalized in {"CHOICE_OFFERED", "OPTIONS_OFFERED"} and isinstance(data.get("choice_offer_id"), str) and isinstance(data.get("choice_count"), int) and not isinstance(data["choice_count"], bool) and data["choice_count"] >= 0:
|
||||
category = "offered_choice"
|
||||
semantic_id = data["choice_offer_id"]
|
||||
elif normalized in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"} and isinstance(selection_id, str) and selection_id:
|
||||
category = "selection_submission"
|
||||
semantic_id = selection_id
|
||||
else:
|
||||
category = "other"
|
||||
semantic_id = None
|
||||
return {
|
||||
"event_id": event["event_id"],
|
||||
"sequence": event.get("seq"),
|
||||
"revision": event.get("revision", event.get("seq")),
|
||||
"type": event_type,
|
||||
"category": category,
|
||||
"semantic_id": semantic_id,
|
||||
"subject": event.get("subject"),
|
||||
"created_at": event.get("created_at"),
|
||||
"decision": data.get("decision") if category == "decision" else None,
|
||||
"choice_id": data.get("choice_id") if category in {"offered_choice", "selection_submission"} else None,
|
||||
"choice_count": data.get("choice_count", 0) if category == "offered_choice" else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _authorization(task: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raw = task.get("authorization")
|
||||
raw = raw if isinstance(raw, Mapping) else {}
|
||||
capabilities = raw.get("capabilities", task.get("capabilities", []))
|
||||
return {
|
||||
"owner_gate_required": bool(task.get("owner_gate_required", raw.get("owner_gate_required", False))),
|
||||
"owner_gate_status": task.get("owner_gate_status", raw.get("owner_gate_status")),
|
||||
"decision": raw.get("decision"),
|
||||
"capabilities": list(capabilities) if isinstance(capabilities, (list, tuple, set, frozenset)) else [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _actions(task: Mapping[str, Any], authorization: Mapping[str, Any]) -> list[str]:
|
||||
explicit = task.get("authorized_actions")
|
||||
if not isinstance(explicit, (list, tuple, set, frozenset)):
|
||||
return []
|
||||
allowed = [action for action in explicit if isinstance(action, str)]
|
||||
capabilities = set(authorization.get("capabilities", []))
|
||||
decision = authorization.get("decision")
|
||||
if "approve" in allowed and not (
|
||||
{"approve", "decision.approve", "task.approve"} & capabilities
|
||||
and isinstance(decision, Mapping)
|
||||
and decision.get("status") in {"pending", "eligible"}
|
||||
):
|
||||
allowed.remove("approve")
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def _kanban(tasks: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
|
||||
for task in tasks:
|
||||
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
|
||||
return columns
|
||||
|
||||
def _timeline(self, tasks):
|
||||
return [
|
||||
{
|
||||
"task_id": task.get("task_id"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"status": task.get("status"),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
@staticmethod
|
||||
def _timeline(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [{"task_id": task.get("task_id"), "status": task.get("status"), "revision": task.get("revision")} for task in tasks]
|
||||
|
||||
def _flow(self, tasks):
|
||||
return {
|
||||
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
|
||||
"task_count": len(tasks),
|
||||
}
|
||||
@staticmethod
|
||||
def _flow(tasks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"], "task_count": len(tasks)}
|
||||
|
||||
def _statistics(self, tasks):
|
||||
@staticmethod
|
||||
def _statistics(tasks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"running": len([t for t in tasks if t.get("status") == "running"]),
|
||||
"blocked": len([t for t in tasks if t.get("status") == "blocked"]),
|
||||
"waiting_approval": len([t for t in tasks if t.get("status") == "waiting_approval"]),
|
||||
"completed": len([t for t in tasks if t.get("status") == "completed"]),
|
||||
"running": sum(task.get("status") == "running" for task in tasks),
|
||||
"blocked": sum(task.get("status") == "blocked" for task in tasks),
|
||||
"waiting_approval": sum(task.get("status") == "waiting_approval" for task in tasks),
|
||||
"completed": sum(task.get("status") == "completed" for task in tasks),
|
||||
}
|
||||
|
||||
def _event_rows(self, tasks, events_by_task) -> list[dict[str, Any]]:
|
||||
visible_ids = {task.get("task_id") for task in tasks or [] if isinstance(task, Mapping)}
|
||||
result: list[dict[str, Any]] = []
|
||||
for task_id, events in (events_by_task or {}).items():
|
||||
if task_id not in visible_ids or not isinstance(events, list):
|
||||
continue
|
||||
# The stream carries every durable append. Semantic deduplication
|
||||
# belongs only to human-history metrics: a later conflicting event
|
||||
# must reach the client so its current task card becomes conflicted.
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
continue
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
continue
|
||||
result.append({"event_id": event["event_id"], "entity_id": task_id, "sequence": sequence, "revision": event.get("revision"), "type": event.get("type", "UNKNOWN"), "created_at": event.get("created_at")})
|
||||
# A per-task append sequence is the cursor authority. Timestamp order
|
||||
# can be displayed by clients, but cannot drive paging: a backdated
|
||||
# seq=6 must never be emitted before seq=5 for the same task.
|
||||
result.sort(key=lambda event: (event["sequence"], event["entity_id"], event["event_id"]))
|
||||
return result
|
||||
|
||||
__all__ = ["TaskDashboardView"]
|
||||
@staticmethod
|
||||
def _encode_cursor(watermarks: Mapping[str, int]) -> str:
|
||||
raw = json.dumps({"v": 2, "after": dict(watermarks)}, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
token = base64.urlsafe_b64encode(zlib.compress(raw, level=9)).decode("ascii").rstrip("=")
|
||||
if len(token) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _decode_cursor(cursor: str) -> dict[str, int]:
|
||||
if not isinstance(cursor, str) or not cursor or len(cursor) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
encoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
decompressor = zlib.decompressobj()
|
||||
raw = decompressor.decompress(encoded, _MAX_CURSOR_PAYLOAD_BYTES + 1)
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ValueError("cursor payload invalid")
|
||||
raw += decompressor.flush(_MAX_CURSOR_PAYLOAD_BYTES + 1 - len(raw))
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES or decompressor.unconsumed_tail or not decompressor.eof:
|
||||
raise ValueError("cursor payload invalid")
|
||||
value = json.loads(raw)
|
||||
except (UnicodeEncodeError, ValueError, zlib.error, json.JSONDecodeError) as exc:
|
||||
raise ProjectionCursorExpired("event cursor expired") from exc
|
||||
after = value.get("after") if isinstance(value, Mapping) and value.get("v") == 2 else None
|
||||
if not isinstance(after, Mapping) or not after or any(
|
||||
not isinstance(task_id, str) or not task_id or not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool) or sequence < 0
|
||||
for task_id, sequence in after.items()
|
||||
):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
return dict(after)
|
||||
|
||||
@staticmethod
|
||||
def _validate_watermarks(rows: list[dict[str, Any]], watermarks: Mapping[str, int]) -> None:
|
||||
positions_by_task: dict[str, set[int]] = defaultdict(set)
|
||||
for row in rows:
|
||||
positions_by_task[row["entity_id"]].add(row["sequence"])
|
||||
# A retained sequence floor is not otherwise exposed by TaskPlane.
|
||||
# Requiring the acknowledged position itself prevents silently skipping
|
||||
# purged seqs 4..9 when a stale cursor still says seq=3.
|
||||
if any(sequence not in positions_by_task.get(task_id, set()) for task_id, sequence in watermarks.items()):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
|
||||
|
||||
__all__ = ["ProjectionCursorExpired", "ProjectionCursorTransportLimit", "TaskDashboardView"]
|
||||
|
||||
@@ -35,7 +35,7 @@ from .models import (
|
||||
contains_secret_text,
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .policy import IdentityVerifier, PolicyEngine
|
||||
from .root_action_consumer import (
|
||||
RootActionConsumer,
|
||||
RootActionExecutor,
|
||||
@@ -71,7 +71,9 @@ class TaskPlaneService:
|
||||
root_action_executor: RootActionExecutor | None = None,
|
||||
):
|
||||
self.store = store
|
||||
self.c6_runtime = None
|
||||
self.policy = policy or PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({"anon"}),
|
||||
execution_enabled=False,
|
||||
identity_capabilities={
|
||||
"anon": {"task.creator", "task.read", "task.audit"}
|
||||
@@ -117,6 +119,9 @@ class TaskPlaneService:
|
||||
else None
|
||||
)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
self.store.configure_evidence_attestation_verifier(
|
||||
self.policy.evidence_attestation_verifier
|
||||
)
|
||||
|
||||
def consume_one_root_action(
|
||||
self,
|
||||
@@ -412,6 +417,11 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.claim_task(
|
||||
task_id=task_id, worker_id=worker_id, actor_identity=actor,
|
||||
lease_seconds=lease_seconds,
|
||||
).to_dict()
|
||||
claim = self.lease.claim(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
@@ -436,6 +446,10 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.renew_claim(task_id=task_id, claim_id=claim_id,
|
||||
actor_identity=actor, receipt=body.get("claim_receipt"),
|
||||
lease_seconds=lease_seconds)
|
||||
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
||||
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
||||
|
||||
@@ -538,16 +552,32 @@ class TaskPlaneService:
|
||||
previous = self.store._last_evidence_hash(task_id)
|
||||
evidence_entries = []
|
||||
for evidence in evidences:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
||||
worker_identity=actor,
|
||||
payload=evidence,
|
||||
previous_evidence_hash=previous,
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
)
|
||||
if "schema_version" in evidence:
|
||||
try:
|
||||
envelope = EvidenceEnvelope.from_dict(evidence)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid evidence envelope") from exc
|
||||
else:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(
|
||||
evidence.get("kind") or evidence.get("type") or "generic"
|
||||
),
|
||||
worker_identity=actor,
|
||||
payload=evidence.get("payload", evidence),
|
||||
previous_evidence_hash=previous,
|
||||
evidence_id=evidence.get("evidence_id"),
|
||||
created_at=evidence.get("created_at"),
|
||||
expires_at=evidence.get("expires_at"),
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
signature_algorithm=evidence.get("signature_algorithm"),
|
||||
signing_key_id=evidence.get("signing_key_id"),
|
||||
verifier_identity=evidence.get("verifier_identity"),
|
||||
authority_domain=evidence.get("authority_domain"),
|
||||
sequence=evidence.get("sequence"),
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
final_status = (
|
||||
@@ -910,8 +940,29 @@ def run_server(
|
||||
*,
|
||||
authenticator: Callable[[str], str | None] | None = None,
|
||||
):
|
||||
handler = TaskPlaneRequestHandler
|
||||
handler.service = service
|
||||
handler.authenticator = authenticator
|
||||
# A separate handler class prevents one server/tenant from overwriting
|
||||
# another concurrently running server's service or authenticator.
|
||||
handler = type("BoundTaskPlaneRequestHandler", (TaskPlaneRequestHandler,), {
|
||||
"service": service, "authenticator": authenticator,
|
||||
})
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
server.serve_forever()
|
||||
runtime = service.c6_runtime
|
||||
stop = __import__("threading").Event()
|
||||
runner = None
|
||||
if runtime is not None and runtime.bindings.enabled:
|
||||
import threading
|
||||
def run_ticks():
|
||||
while not stop.is_set():
|
||||
runtime.tick()
|
||||
stop.wait(1.0)
|
||||
runner = threading.Thread(target=run_ticks, name="c6-host-tick", daemon=True)
|
||||
runner.start()
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
if runtime is not None:
|
||||
runtime.close()
|
||||
if runner is not None:
|
||||
runner.join(timeout=2.0)
|
||||
server.server_close()
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""C6 ARO Phase A: bounded read-only audit evaluation and immutable reports."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .operation_view import DomainStamp, validate_digest
|
||||
|
||||
|
||||
class Dimension(str, Enum):
|
||||
FUNCTION = "function"
|
||||
PERFORMANCE = "performance"
|
||||
EXCEPTION = "exception"
|
||||
USAGE = "usage"
|
||||
CODE_DEFECT = "code_defect"
|
||||
HISTORY = "history"
|
||||
DEADLOCK = "deadlock"
|
||||
LONG_RUNNING = "long_running"
|
||||
|
||||
|
||||
_DIMENSIONS = tuple(Dimension)
|
||||
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,255}$")
|
||||
_METRICS: dict[Dimension, frozenset[str]] = {
|
||||
Dimension.FUNCTION: frozenset({"required_checks", "passed_checks"}),
|
||||
Dimension.PERFORMANCE: frozenset({"duration_p95_ms"}),
|
||||
Dimension.EXCEPTION: frozenset({"unhandled_exception_count"}),
|
||||
Dimension.USAGE: frozenset({"operational_prompt_count", "manual_continue_count"}),
|
||||
Dimension.CODE_DEFECT: frozenset({"validated_open_defect_count"}),
|
||||
Dimension.HISTORY: frozenset({"same_fingerprint_failure_count"}),
|
||||
Dimension.DEADLOCK: frozenset({"wait_cycle_count", "oldest_no_progress_seconds"}),
|
||||
Dimension.LONG_RUNNING: frozenset({"oldest_nonterminal_age_seconds"}),
|
||||
}
|
||||
_COUNT_METRICS = frozenset(
|
||||
{
|
||||
"required_checks",
|
||||
"passed_checks",
|
||||
"unhandled_exception_count",
|
||||
"operational_prompt_count",
|
||||
"manual_continue_count",
|
||||
"validated_open_defect_count",
|
||||
"same_fingerprint_failure_count",
|
||||
"wait_cycle_count",
|
||||
}
|
||||
)
|
||||
_THRESHOLDS = frozenset(
|
||||
{
|
||||
"max_duration_p95_ms",
|
||||
"repeated_failure_threshold",
|
||||
"deadlock_seconds",
|
||||
"max_nonterminal_age_seconds",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_ref(value: object) -> str:
|
||||
ref = _text(value, "evidence_ref")
|
||||
lowered = ref.casefold()
|
||||
if (
|
||||
_SAFE_REF_RE.fullmatch(ref) is None
|
||||
or ".." in ref
|
||||
or ref.startswith(("/", "\\"))
|
||||
or "://" in ref
|
||||
or any(
|
||||
marker in lowered
|
||||
for marker in (
|
||||
"token=",
|
||||
"secret=",
|
||||
"password=",
|
||||
"cookie=",
|
||||
"authorization=",
|
||||
"private_key=",
|
||||
"bearer ",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("evidence_ref is not an allowed opaque reference")
|
||||
return ref
|
||||
|
||||
|
||||
def _aware(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, name: str, *, non_negative: bool = True) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be numeric")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or (non_negative and result < 0):
|
||||
raise ValueError(f"{name} must be finite and non-negative")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
metric: str
|
||||
value: float
|
||||
observed_at: datetime
|
||||
evidence_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.metric, "metric")
|
||||
_number(self.value, "sample value")
|
||||
_aware(self.observed_at, "observed_at")
|
||||
_safe_ref(self.evidence_ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DimensionInput:
|
||||
dimension: Dimension
|
||||
samples: tuple[Sample, ...]
|
||||
stamp: DomainStamp
|
||||
captured_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if not isinstance(self.samples, tuple):
|
||||
raise ValueError("samples must be a tuple")
|
||||
if any(not isinstance(sample, Sample) for sample in self.samples):
|
||||
raise ValueError("samples must contain Sample values")
|
||||
if not isinstance(self.stamp, DomainStamp):
|
||||
raise ValueError("stamp must be DomainStamp")
|
||||
captured = _aware(self.captured_at, "captured_at")
|
||||
expires = _aware(self.expires_at, "expires_at")
|
||||
if expires <= captured:
|
||||
raise ValueError("expires_at must be after captured_at")
|
||||
if any(sample.observed_at > captured for sample in self.samples):
|
||||
raise ValueError("sample cannot be observed after capture")
|
||||
|
||||
|
||||
def _canonical_datetime(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _snapshot_payload(
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"window_start": _canonical_datetime(window_start),
|
||||
"window_end": _canonical_datetime(window_end),
|
||||
"rules_revision": rules_revision,
|
||||
"dimensions": [
|
||||
{
|
||||
"dimension": item.dimension.value,
|
||||
"stamp": {
|
||||
"domain": item.stamp.domain,
|
||||
"object_id": item.stamp.object_id,
|
||||
"generation": item.stamp.generation,
|
||||
"revision": item.stamp.revision,
|
||||
"event_id": item.stamp.event_id,
|
||||
},
|
||||
"captured_at": _canonical_datetime(item.captured_at),
|
||||
"expires_at": _canonical_datetime(item.expires_at),
|
||||
"samples": [
|
||||
{
|
||||
"metric": sample.metric,
|
||||
"value": sample.value,
|
||||
"observed_at": _canonical_datetime(sample.observed_at),
|
||||
"evidence_ref": sample.evidence_ref,
|
||||
}
|
||||
for sample in sorted(
|
||||
item.samples,
|
||||
key=lambda value: (
|
||||
value.metric,
|
||||
value.observed_at,
|
||||
value.evidence_ref,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
for item in sorted(
|
||||
dimensions,
|
||||
key=lambda value: (
|
||||
value.dimension.value,
|
||||
value.stamp.domain,
|
||||
value.stamp.object_id,
|
||||
value.stamp.generation,
|
||||
value.stamp.revision,
|
||||
value.stamp.event_id,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: Mapping[str, object]) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditSnapshot:
|
||||
snapshot_id: str
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
dimensions: tuple[DimensionInput, ...]
|
||||
rules_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.snapshot_id)
|
||||
start = _aware(self.window_start, "window_start")
|
||||
end = _aware(self.window_end, "window_end")
|
||||
if end <= start:
|
||||
raise ValueError("audit window must be increasing")
|
||||
if not isinstance(self.dimensions, tuple):
|
||||
raise ValueError("dimensions must be a tuple")
|
||||
if any(not isinstance(item, DimensionInput) for item in self.dimensions):
|
||||
raise ValueError("dimensions must contain DimensionInput values")
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
expected = _digest(
|
||||
_snapshot_payload(
|
||||
window_start=start,
|
||||
window_end=end,
|
||||
dimensions=self.dimensions,
|
||||
rules_revision=self.rules_revision,
|
||||
)
|
||||
)
|
||||
if self.snapshot_id != expected:
|
||||
raise ValueError("snapshot_id does not match complete canonical inputs")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> "AuditSnapshot":
|
||||
payload = _snapshot_payload(
|
||||
window_start=_aware(window_start, "window_start"),
|
||||
window_end=_aware(window_end, "window_end"),
|
||||
dimensions=dimensions,
|
||||
rules_revision=_text(rules_revision, "rules_revision"),
|
||||
)
|
||||
return cls(
|
||||
snapshot_id=_digest(payload),
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=dimensions,
|
||||
rules_revision=rules_revision,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleLimits:
|
||||
rules_revision: str
|
||||
max_snapshot_skew_seconds: int
|
||||
threshold_by_metric: tuple[tuple[str, float], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if (
|
||||
isinstance(self.max_snapshot_skew_seconds, bool)
|
||||
or not isinstance(self.max_snapshot_skew_seconds, int)
|
||||
or self.max_snapshot_skew_seconds < 0
|
||||
):
|
||||
raise ValueError("max_snapshot_skew_seconds must be non-negative int")
|
||||
if not isinstance(self.threshold_by_metric, tuple):
|
||||
raise ValueError("threshold_by_metric must be a tuple")
|
||||
names: list[str] = []
|
||||
for item in self.threshold_by_metric:
|
||||
if not isinstance(item, tuple) or len(item) != 2:
|
||||
raise ValueError("threshold entries must be pairs")
|
||||
name = _text(item[0], "threshold metric")
|
||||
if name not in _THRESHOLDS:
|
||||
raise ValueError("unknown threshold metric")
|
||||
_number(item[1], "threshold")
|
||||
names.append(name)
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate threshold metric")
|
||||
|
||||
@property
|
||||
def thresholds(self) -> dict[str, float]:
|
||||
return {name: float(value) for name, value in self.threshold_by_metric}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
dimension: Dimension
|
||||
status: Literal["CLEAR", "FINDING", "UNKNOWN"]
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if self.status not in ("CLEAR", "FINDING", "UNKNOWN"):
|
||||
raise ValueError("invalid finding status")
|
||||
_text(self.reason, "reason")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_safe_ref(ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditReport:
|
||||
report_id: str
|
||||
snapshot_id: str
|
||||
rules_revision: str
|
||||
findings: tuple[Finding, ...]
|
||||
mutation_count: Literal[0]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.report_id)
|
||||
validate_digest(self.snapshot_id)
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if not isinstance(self.findings, tuple) or any(
|
||||
not isinstance(item, Finding) for item in self.findings
|
||||
):
|
||||
raise ValueError("findings must be a tuple of Finding")
|
||||
if type(self.mutation_count) is not int or self.mutation_count != 0:
|
||||
raise ValueError("ARO Phase A mutation_count must be exactly zero")
|
||||
|
||||
|
||||
def _unknown(dimension: Dimension, reason: str, refs: tuple[str, ...] = ()) -> Finding:
|
||||
return Finding(dimension, "UNKNOWN", reason, refs)
|
||||
|
||||
|
||||
def _metric_values(item: DimensionInput) -> tuple[dict[str, float] | None, tuple[str, ...]]:
|
||||
expected = _METRICS[item.dimension]
|
||||
if not item.samples:
|
||||
return None, ()
|
||||
names = [sample.metric for sample in item.samples]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if set(names) != expected or len(names) != len(set(names)):
|
||||
return None, refs
|
||||
values = {sample.metric: float(sample.value) for sample in item.samples}
|
||||
for name in _COUNT_METRICS & expected:
|
||||
if not values[name].is_integer():
|
||||
return None, refs
|
||||
return values, refs
|
||||
|
||||
|
||||
def _evaluate_dimension(
|
||||
item: DimensionInput, thresholds: Mapping[str, float]
|
||||
) -> Finding:
|
||||
values, refs = _metric_values(item)
|
||||
if values is None:
|
||||
return _unknown(item.dimension, "METRICS_MISSING_DUPLICATE_OR_UNKNOWN", refs)
|
||||
dimension = item.dimension
|
||||
if dimension == Dimension.FUNCTION:
|
||||
required = values["required_checks"]
|
||||
passed = values["passed_checks"]
|
||||
if required <= 0 or passed > required:
|
||||
return _unknown(dimension, "FUNCTION_COUNTS_INVALID", refs)
|
||||
status = "FINDING" if passed < required else "CLEAR"
|
||||
return Finding(dimension, status, "REQUIRED_CHECKS_INCOMPLETE" if status == "FINDING" else "ALL_REQUIRED_CHECKS_PASSED", refs)
|
||||
if dimension == Dimension.PERFORMANCE:
|
||||
threshold = thresholds.get("max_duration_p95_ms")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["duration_p95_ms"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "P95_DURATION_EXCEEDED" if status == "FINDING" else "P95_DURATION_WITHIN_LIMIT", refs)
|
||||
if dimension == Dimension.EXCEPTION:
|
||||
status = "FINDING" if values["unhandled_exception_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "UNHANDLED_EXCEPTIONS_PRESENT" if status == "FINDING" else "NO_UNHANDLED_EXCEPTIONS", refs)
|
||||
if dimension == Dimension.USAGE:
|
||||
status = "FINDING" if any(value > 0 for value in values.values()) else "CLEAR"
|
||||
return Finding(dimension, status, "OPERATIONAL_PROMPT_OR_CONTINUE_PRESENT" if status == "FINDING" else "NO_OPERATIONAL_PROMPT_FRICTION", refs)
|
||||
if dimension == Dimension.CODE_DEFECT:
|
||||
status = "FINDING" if values["validated_open_defect_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "VALIDATED_OPEN_DEFECTS_PRESENT" if status == "FINDING" else "NO_VALIDATED_OPEN_DEFECTS", refs)
|
||||
if dimension == Dimension.HISTORY:
|
||||
threshold = thresholds.get("repeated_failure_threshold")
|
||||
if threshold is None or threshold <= 0 or not threshold.is_integer():
|
||||
return _unknown(dimension, "THRESHOLD_MISSING_OR_INVALID", refs)
|
||||
status = "FINDING" if values["same_fingerprint_failure_count"] >= threshold else "CLEAR"
|
||||
return Finding(dimension, status, "FAILURE_FINGERPRINT_REPEATED" if status == "FINDING" else "FAILURE_FINGERPRINT_BELOW_LIMIT", refs)
|
||||
if dimension == Dimension.DEADLOCK:
|
||||
threshold = thresholds.get("deadlock_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if (
|
||||
values["wait_cycle_count"] > 0
|
||||
and values["oldest_no_progress_seconds"] > threshold
|
||||
) else "CLEAR"
|
||||
return Finding(dimension, status, "WAIT_CYCLE_WITHOUT_PROGRESS" if status == "FINDING" else "NO_STALE_WAIT_CYCLE", refs)
|
||||
if dimension == Dimension.LONG_RUNNING:
|
||||
threshold = thresholds.get("max_nonterminal_age_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["oldest_nonterminal_age_seconds"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "NONTERMINAL_AGE_EXCEEDED" if status == "FINDING" else "NONTERMINAL_AGE_WITHIN_LIMIT", refs)
|
||||
raise AssertionError("unhandled dimension")
|
||||
|
||||
|
||||
def evaluate_snapshot(
|
||||
snapshot: AuditSnapshot, *, limits: RuleLimits, now: datetime
|
||||
) -> AuditReport:
|
||||
"""Evaluate all eight dimensions; missing or inconsistent evidence is UNKNOWN."""
|
||||
if not isinstance(snapshot, AuditSnapshot) or not isinstance(limits, RuleLimits):
|
||||
raise TypeError("snapshot and limits must be typed values")
|
||||
now = _aware(now, "now")
|
||||
grouped: dict[Dimension, list[DimensionInput]] = {dimension: [] for dimension in _DIMENSIONS}
|
||||
for item in snapshot.dimensions:
|
||||
grouped[item.dimension].append(item)
|
||||
|
||||
global_reason: str | None = None
|
||||
if snapshot.rules_revision != limits.rules_revision:
|
||||
global_reason = "RULES_REVISION_UNKNOWN"
|
||||
elif snapshot.window_end > now:
|
||||
global_reason = "AUDIT_WINDOW_NOT_CLOSED"
|
||||
else:
|
||||
observed = [sample.observed_at for item in snapshot.dimensions for sample in item.samples]
|
||||
if observed and (max(observed) - min(observed)).total_seconds() > limits.max_snapshot_skew_seconds:
|
||||
global_reason = "CROSS_DOMAIN_SNAPSHOT_SKEW"
|
||||
|
||||
findings: list[Finding] = []
|
||||
for dimension in _DIMENSIONS:
|
||||
items = grouped[dimension]
|
||||
if global_reason is not None:
|
||||
refs = tuple(
|
||||
dict.fromkeys(sample.evidence_ref for item in items for sample in item.samples)
|
||||
)
|
||||
findings.append(_unknown(dimension, global_reason, refs))
|
||||
continue
|
||||
if len(items) != 1:
|
||||
findings.append(
|
||||
_unknown(
|
||||
dimension,
|
||||
"DIMENSION_MISSING" if not items else "DIMENSION_CONFLICT",
|
||||
)
|
||||
)
|
||||
continue
|
||||
item = items[0]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if item.captured_at > now or now >= item.expires_at:
|
||||
findings.append(_unknown(dimension, "DIMENSION_STALE_OR_FUTURE", refs))
|
||||
continue
|
||||
if any(
|
||||
sample.observed_at < snapshot.window_start
|
||||
or sample.observed_at > snapshot.window_end
|
||||
for sample in item.samples
|
||||
):
|
||||
findings.append(_unknown(dimension, "SAMPLE_OUTSIDE_WINDOW", refs))
|
||||
continue
|
||||
findings.append(_evaluate_dimension(item, limits.thresholds))
|
||||
|
||||
report_payload = {
|
||||
"window_start": _canonical_datetime(snapshot.window_start),
|
||||
"window_end": _canonical_datetime(snapshot.window_end),
|
||||
"snapshot_id": snapshot.snapshot_id,
|
||||
"rules_revision": limits.rules_revision,
|
||||
}
|
||||
return AuditReport(
|
||||
report_id=_digest(report_payload),
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
rules_revision=limits.rules_revision,
|
||||
findings=tuple(findings),
|
||||
mutation_count=0,
|
||||
)
|
||||
|
||||
|
||||
class ReadonlyAuditSource(Protocol):
|
||||
def collect(
|
||||
self, *, window_start: datetime, window_end: datetime
|
||||
) -> AuditSnapshot: ...
|
||||
|
||||
|
||||
class AuditReportSink(Protocol):
|
||||
def put_if_absent(self, report: AuditReport) -> str: ...
|
||||
|
||||
|
||||
def _report_bytes(report: AuditReport) -> bytes:
|
||||
payload = {
|
||||
"schema": "aios.aro-readonly-report.v1",
|
||||
"report_id": report.report_id,
|
||||
"snapshot_id": report.snapshot_id,
|
||||
"rules_revision": report.rules_revision,
|
||||
"mutation_count": report.mutation_count,
|
||||
"findings": [
|
||||
{
|
||||
"dimension": finding.dimension.value,
|
||||
"status": finding.status,
|
||||
"reason": finding.reason,
|
||||
"evidence_refs": list(finding.evidence_refs),
|
||||
}
|
||||
for finding in report.findings
|
||||
],
|
||||
}
|
||||
return (
|
||||
json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class FilesystemAuditReportSink:
|
||||
"""Write one immutable, deterministic report under a host-fixed root."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not isinstance(root, Path) or not root.is_absolute() or ".." in root.parts:
|
||||
raise ValueError("audit root must be an absolute fixed path")
|
||||
self.root = root
|
||||
self._prepare_root()
|
||||
|
||||
def _prepare_root(self) -> None:
|
||||
existing = self.root
|
||||
while not existing.exists() and existing != existing.parent:
|
||||
existing = existing.parent
|
||||
cursor = existing
|
||||
while True:
|
||||
if cursor.is_symlink():
|
||||
raise ValueError("audit root ancestry must not contain symlinks")
|
||||
if cursor == cursor.parent:
|
||||
break
|
||||
cursor = cursor.parent
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("audit root must be a real directory")
|
||||
if self.root.resolve(strict=True) != self.root.absolute():
|
||||
raise ValueError("audit root must not traverse reparse points or symlinks")
|
||||
|
||||
def put_if_absent(self, report: AuditReport) -> str:
|
||||
if not isinstance(report, AuditReport):
|
||||
raise TypeError("report must be AuditReport")
|
||||
self._prepare_root()
|
||||
validate_digest(report.report_id)
|
||||
body = _report_bytes(report)
|
||||
target = self.root / f"{report.report_id}.json"
|
||||
if target.parent != self.root or target.is_symlink():
|
||||
raise ValueError("REPORT_PATH_INVALID")
|
||||
if target.exists():
|
||||
if not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
return f"aro:{report.report_id}"
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{report.report_id}.", suffix=".tmp", dir=self.root
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(file_descriptor, "wb") as handle:
|
||||
handle.write(body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
os.link(temporary, target)
|
||||
except FileExistsError:
|
||||
if target.is_symlink() or not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
if target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"aro:{report.report_id}"
|
||||
|
||||
|
||||
class AroReadonlyTick:
|
||||
"""Default-off in-process tick over the most recently closed UTC window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ReadonlyAuditSource,
|
||||
sink: AuditReportSink,
|
||||
limits: RuleLimits,
|
||||
interval_seconds: int,
|
||||
clock: Callable[[], datetime],
|
||||
*,
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
if not hasattr(source, "collect") or not callable(source.collect):
|
||||
raise ValueError("source must implement read-only collect")
|
||||
if not hasattr(sink, "put_if_absent") or not callable(sink.put_if_absent):
|
||||
raise ValueError("sink must implement put_if_absent")
|
||||
if not isinstance(limits, RuleLimits):
|
||||
raise ValueError("limits must be RuleLimits")
|
||||
if isinstance(interval_seconds, bool) or not isinstance(interval_seconds, int) or interval_seconds <= 0:
|
||||
raise ValueError("interval_seconds must be positive int")
|
||||
if not callable(clock) or type(enabled) is not bool:
|
||||
raise ValueError("clock must be callable and enabled must be bool")
|
||||
self.source = source
|
||||
self.sink = sink
|
||||
self.limits = limits
|
||||
self.interval_seconds = interval_seconds
|
||||
self.clock = clock
|
||||
self.enabled = enabled
|
||||
self._last_window_end: datetime | None = None
|
||||
|
||||
def run_due(self) -> str | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
now = _aware(self.clock(), "clock result").astimezone(timezone.utc)
|
||||
end_timestamp = int(now.timestamp()) // self.interval_seconds * self.interval_seconds
|
||||
window_end = datetime.fromtimestamp(end_timestamp, tz=timezone.utc)
|
||||
window_start = window_end - timedelta(seconds=self.interval_seconds)
|
||||
if self._last_window_end == window_end:
|
||||
return None
|
||||
try:
|
||||
snapshot = self.source.collect(
|
||||
window_start=window_start, window_end=window_end
|
||||
)
|
||||
if (
|
||||
not isinstance(snapshot, AuditSnapshot)
|
||||
or snapshot.window_start != window_start
|
||||
or snapshot.window_end != window_end
|
||||
):
|
||||
raise ValueError("source returned a snapshot for the wrong window")
|
||||
except Exception:
|
||||
snapshot = AuditSnapshot.create(
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=(),
|
||||
rules_revision=self.limits.rules_revision,
|
||||
)
|
||||
report = evaluate_snapshot(snapshot, limits=self.limits, now=now)
|
||||
try:
|
||||
reference = self.sink.put_if_absent(report)
|
||||
except Exception:
|
||||
return None
|
||||
self._last_window_end = window_end
|
||||
return reference
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AroReadonlyTick",
|
||||
"AuditReport",
|
||||
"AuditReportSink",
|
||||
"AuditSnapshot",
|
||||
"Dimension",
|
||||
"DimensionInput",
|
||||
"FilesystemAuditReportSink",
|
||||
"Finding",
|
||||
"ReadonlyAuditSource",
|
||||
"RuleLimits",
|
||||
"Sample",
|
||||
"evaluate_snapshot",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Narrow production seam from a validated C6 plan to existing acquisition."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from .authorization_planner import (
|
||||
AuthorizationPlan,
|
||||
AuthorizationPlanInput,
|
||||
CapabilityNamespace,
|
||||
GitManifestRegistry,
|
||||
RuntimeObservation,
|
||||
StaticCapabilityManifest,
|
||||
VerifiedRootSnapshot,
|
||||
VerifiedRootSnapshotReader,
|
||||
authorization_input_digest,
|
||||
authorization_scope_digest,
|
||||
build_authorization_plan,
|
||||
verified_root_snapshot_digest,
|
||||
)
|
||||
from .governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
RecoveryAuthorization,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeObservationReader(Protocol):
|
||||
"""Host-owned fresh runtime authority used at writer acquisition."""
|
||||
|
||||
def read_runtime_observation(
|
||||
self,
|
||||
*,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
manifest: StaticCapabilityManifest,
|
||||
root: VerifiedRootSnapshot | None,
|
||||
now: float,
|
||||
) -> RuntimeObservation | None: ...
|
||||
|
||||
|
||||
def acquire_authorized_writer(
|
||||
*,
|
||||
automation: GovernedWriterAutomation,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
plan: AuthorizationPlan,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
root_reader: VerifiedRootSnapshotReader,
|
||||
runtime_reader: RuntimeObservationReader,
|
||||
registry: GitManifestRegistry,
|
||||
expected_registry_oid: str,
|
||||
expected_manifest_sha256: str,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
) -> GovernedWriterSession:
|
||||
"""Re-read existing verified Root immediately before `acquire_verified`.
|
||||
|
||||
This function deliberately has no signer, claim, lease, or direct
|
||||
`automation.acquire` path. The existing automation remains the sole
|
||||
composition root for effects.
|
||||
"""
|
||||
supplied_now = time.time() if now is None else now
|
||||
if isinstance(supplied_now, bool):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
try:
|
||||
current = float(supplied_now)
|
||||
except (TypeError, ValueError):
|
||||
raise AuthorizationDenied("TIME_INVALID") from None
|
||||
if not math.isfinite(current):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
input_digest = authorization_input_digest(plan_input)
|
||||
scope_digest = authorization_scope_digest(plan_input.scope)
|
||||
if (plan_input.root_approval_ref != authorization_ref
|
||||
or plan.input_digest != input_digest
|
||||
or plan.scope_digest != scope_digest):
|
||||
raise AuthorizationDenied("PLAN_INPUT_DRIFT")
|
||||
if (request.subject != plan_input.actor_subject or request.task_id != plan_input.task_id
|
||||
or request.repository != plan_input.scope.repository
|
||||
or request.target_branch != plan_input.scope.target_branch
|
||||
or request.base_oid != plan_input.scope.source_oid
|
||||
or request.risk != plan_input.scope.risk_level
|
||||
or request.file_allowlist != plan_input.scope.file_allowlist
|
||||
or request.plan_digest != input_digest):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_PLAN_DRIFT")
|
||||
if any(ref.namespace != CapabilityNamespace.AGENT_TASK for ref in plan_input.required_capabilities):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_NAMESPACE_INVALID")
|
||||
requested_capabilities = frozenset(ref.token for ref in plan_input.required_capabilities)
|
||||
if request.required_capabilities != requested_capabilities:
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_DRIFT")
|
||||
# This is a second, explicit frozen-runtime read. The existing
|
||||
# acquire_verified path repeats it before effects as its own invariant.
|
||||
automation.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
manifest = registry.read_manifest(
|
||||
expected_registry_oid=expected_registry_oid,
|
||||
expected_manifest_sha256=expected_manifest_sha256,
|
||||
)
|
||||
if manifest.manifest_id != request.manifest_id or manifest.declaration_digest != plan.policy_digest:
|
||||
raise AuthorizationDenied("POLICY_MANIFEST_DRIFT")
|
||||
root = root_reader.read_verified_root(
|
||||
authorization_ref=authorization_ref,
|
||||
root_request_sha256=plan_input.root_request_sha256,
|
||||
now=current,
|
||||
)
|
||||
if root is None or root.status != "VALID" or root.approval_ref != authorization_ref:
|
||||
raise AuthorizationDenied("ROOT_REVOKED_OR_UNAVAILABLE")
|
||||
if (root.root_request_sha256 != plan_input.root_request_sha256
|
||||
or root.subject != plan_input.actor_subject
|
||||
or root.audience != plan_input.audience
|
||||
or root.mission_id != plan_input.mission_id
|
||||
or root.scope != plan_input.scope):
|
||||
raise AuthorizationDenied("ROOT_SCOPE_DRIFT")
|
||||
if verified_root_snapshot_digest(root) != plan.root_snapshot_digest:
|
||||
raise AuthorizationDenied("ROOT_SNAPSHOT_DRIFT")
|
||||
if not (root.not_before <= current and root.observed_at <= current < root.snapshot_expires_at <= root.expires_at):
|
||||
raise AuthorizationDenied("ROOT_EXPIRED")
|
||||
reader = getattr(runtime_reader, "read_runtime_observation", None)
|
||||
if not callable(reader):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
runtime = reader(
|
||||
plan_input=plan_input,
|
||||
manifest=manifest,
|
||||
root=root,
|
||||
now=current,
|
||||
)
|
||||
except Exception:
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE") from None
|
||||
if not isinstance(runtime, RuntimeObservation):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
fresh_input = replace(plan_input, now=current)
|
||||
fresh_plan = build_authorization_plan(
|
||||
request=fresh_input,
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
root=root,
|
||||
)
|
||||
if fresh_plan.next_action != "EXECUTE_GOVERNED_PLAN":
|
||||
raise AuthorizationDenied(
|
||||
fresh_plan.reason_code or "authorization plan is not executable"
|
||||
)
|
||||
if (
|
||||
plan.policy_digest != fresh_plan.policy_digest
|
||||
or plan.required_authorization != fresh_plan.required_authorization
|
||||
or plan.scope_digest != fresh_plan.scope_digest
|
||||
or plan.root_snapshot_digest != fresh_plan.root_snapshot_digest
|
||||
):
|
||||
raise AuthorizationDenied("AUTHORIZATION_PLAN_DRIFT")
|
||||
# `acquire_verified` repeats the existing Owner authority verification
|
||||
# before its own effect path. Do not substitute a locally reconstructed
|
||||
# policy or call its test-only `acquire` method here.
|
||||
return automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=current,
|
||||
)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Read-only C6 authorization planning over existing authority boundaries."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Literal, Mapping, Protocol
|
||||
|
||||
from aios_capability_contract import normalize_required_capabilities
|
||||
from .governed_writer_automation import RISK_ORDER
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_REGISTRY_PATH = "governance/capabilities/registry.v1.json"
|
||||
|
||||
|
||||
class RegistryReadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CapabilityNamespace(str, Enum):
|
||||
AGENT_TASK = "agent-task"
|
||||
TASK_PLANE_WILDCARD = "task-plane-wildcard"
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _OID.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _digest(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _SHA.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _finite(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(float(value)):
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def authorization_scope_digest(scope: "ScopeSpec") -> str:
|
||||
return hashlib.sha256(_canonical({
|
||||
"repository": scope.repository, "target_branch": scope.target_branch,
|
||||
"source_oid": scope.source_oid, "file_allowlist": list(scope.file_allowlist),
|
||||
"budget_units": scope.budget_units, "risk_level": scope.risk_level,
|
||||
})).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRef:
|
||||
namespace: CapabilityNamespace
|
||||
token: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("CAPABILITY_NAMESPACE_INVALID")
|
||||
_text(self.token, "capability")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeSpec:
|
||||
repository: str
|
||||
target_branch: str
|
||||
source_oid: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
budget_units: int
|
||||
risk_level: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository"); _text(self.target_branch, "target_branch")
|
||||
_oid(self.source_oid, "source_oid"); _text(self.risk_level, "risk_level")
|
||||
if type(self.budget_units) is not int or self.budget_units < 0:
|
||||
raise ValueError("budget_units_INVALID")
|
||||
if not self.file_allowlist or len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
for path in self.file_allowlist:
|
||||
if not isinstance(path, str) or not path or path != PurePosixPath(path).as_posix() or PurePosixPath(path).is_absolute() or ".." in PurePosixPath(path).parts:
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeObservation:
|
||||
observation_id: str; observed_at: float; expires_at: float; source_revision: str
|
||||
actor_subject: str; audience: str; operation_id: str; scope_digest: str
|
||||
manifest_revision: str; manifest_digest: str; executor_installed: bool | None
|
||||
executor_callable: bool | None; authorization_live: bool | None
|
||||
preconditions_pass: bool | None; revocation_epoch: int | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("observation_id", "source_revision", "actor_subject", "audience", "operation_id", "manifest_revision"):
|
||||
_text(getattr(self, name), name)
|
||||
_finite(self.observed_at, "observed_at"); _finite(self.expires_at, "expires_at")
|
||||
if self.expires_at <= self.observed_at: raise ValueError("OBSERVATION_EXPIRY_INVALID")
|
||||
_digest(self.scope_digest, "scope_digest"); _digest(self.manifest_digest, "manifest_digest")
|
||||
for name in ("executor_installed", "executor_callable", "authorization_live", "preconditions_pass"):
|
||||
if getattr(self, name) is not None and type(getattr(self, name)) is not bool: raise ValueError(f"{name}_INVALID")
|
||||
if self.revocation_epoch is not None and (type(self.revocation_epoch) is not int or self.revocation_epoch < 0): raise ValueError("revocation_epoch_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedRootSnapshot:
|
||||
approval_ref: str; root_request_sha256: str; subject: str; audience: str; mission_id: str
|
||||
scope: ScopeSpec; capabilities: tuple[CapabilityRef, ...]; nonce: str; not_before: float
|
||||
expires_at: float; revocation_epoch: int; receipt_ref: str; observed_at: float
|
||||
snapshot_expires_at: float; status: Literal["VALID", "REVOKED"]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("approval_ref", "subject", "audience", "mission_id", "nonce", "receipt_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256")
|
||||
for name in ("not_before", "expires_at", "observed_at", "snapshot_expires_at"): _finite(getattr(self, name), name)
|
||||
if not self.not_before <= self.snapshot_expires_at <= self.expires_at: raise ValueError("ROOT_TIME_INVALID")
|
||||
if type(self.revocation_epoch) is not int or self.revocation_epoch < 0: raise ValueError("revocation_epoch_INVALID")
|
||||
if self.status not in ("VALID", "REVOKED"): raise ValueError("ROOT_STATUS_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticCapabilityManifest:
|
||||
manifest_id: str; revision: str; capabilities: tuple[CapabilityRef, ...]
|
||||
executor_refs: tuple[str, ...]; source_oid: str; declaration_digest: str
|
||||
allowed_scopes: tuple[ScopeSpec, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.manifest_id, "manifest_id"); _text(self.revision, "revision")
|
||||
_oid(self.source_oid, "source_oid"); _digest(self.declaration_digest, "declaration_digest")
|
||||
if len(set(self.capabilities)) != len(self.capabilities): raise ValueError("CAPABILITY_DUPLICATE")
|
||||
for ref in self.executor_refs: _text(ref, "executor_ref")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlanInput:
|
||||
operation_id: str; actor_subject: str; audience: str; mission_id: str; task_id: str
|
||||
scope: ScopeSpec; required_capabilities: tuple[CapabilityRef, ...]
|
||||
root_approval_ref: str; root_request_sha256: str; now: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("operation_id", "actor_subject", "audience", "mission_id", "task_id", "root_approval_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256"); _finite(self.now, "now")
|
||||
if not self.required_capabilities: raise ValueError("REQUIRED_CAPABILITIES_EMPTY")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlan:
|
||||
input_digest: str; scope_digest: str; policy_digest: str; required_authorization: str
|
||||
root_snapshot_digest: str | None
|
||||
reason_code: str | None; next_action: Literal["EXECUTE_GOVERNED_PLAN", "WAITING_ROOT_AUTHORIZATION", "AUTHORIZATION_REQUIRED", "CAPABILITY_UNAVAILABLE", "PRECONDITION_FAILED", "UNKNOWN"]
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
class GitManifestRegistry(Protocol):
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest: ...
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None: ...
|
||||
|
||||
|
||||
class VerifiedRootSnapshotReader(Protocol):
|
||||
def read_verified_root(self, *, authorization_ref: str, root_request_sha256: str, now: float) -> VerifiedRootSnapshot | None: ...
|
||||
|
||||
|
||||
def verified_root_snapshot_digest(root: VerifiedRootSnapshot | None) -> str | None:
|
||||
return None if root is None else hashlib.sha256(_canonical(asdict(root))).hexdigest()
|
||||
|
||||
|
||||
def authorization_input_digest(request: AuthorizationPlanInput) -> str:
|
||||
return hashlib.sha256(_canonical(asdict(request))).hexdigest()
|
||||
|
||||
|
||||
def _plan(request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, action: str, reason: str | None, refs: tuple[str, ...], root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
return AuthorizationPlan(
|
||||
input_digest=authorization_input_digest(request), scope_digest=authorization_scope_digest(request.scope),
|
||||
policy_digest=manifest.declaration_digest, required_authorization=request.root_approval_ref,
|
||||
root_snapshot_digest=verified_root_snapshot_digest(root),
|
||||
reason_code=reason, next_action=action, evidence_refs=refs,
|
||||
)
|
||||
|
||||
|
||||
def _capabilities_cover(granted: tuple[CapabilityRef, ...], required: tuple[CapabilityRef, ...]) -> tuple[bool, str | None]:
|
||||
for capability in required:
|
||||
if capability.namespace == CapabilityNamespace.AGENT_TASK:
|
||||
if "*" in capability.token:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
normalized = normalize_required_capabilities([capability.token])
|
||||
if any(value.startswith("exact:") for value in normalized): return False, "CAPABILITY_UNKNOWN"
|
||||
same_namespace = [item.token for item in granted if item.namespace == CapabilityNamespace.AGENT_TASK]
|
||||
if not same_namespace and granted:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
effective_grants = normalize_required_capabilities(same_namespace)
|
||||
if any(value.startswith("exact:") for value in effective_grants):
|
||||
effective_grants = frozenset(value for value in effective_grants if not value.startswith("exact:"))
|
||||
if not normalized.issubset(effective_grants): return False, "CAPABILITY_UNAVAILABLE"
|
||||
elif capability.namespace == CapabilityNamespace.TASK_PLANE_WILDCARD:
|
||||
if not any(item.namespace == capability.namespace and item.token == capability.token for item in granted): return False, "CAPABILITY_UNAVAILABLE"
|
||||
return True, None
|
||||
|
||||
|
||||
def _scope_covers(parent: ScopeSpec, child: ScopeSpec) -> bool:
|
||||
# Use the same risk vocabulary as the authority that will issue the lease.
|
||||
rank = RISK_ORDER
|
||||
return (parent.repository == child.repository and parent.target_branch == child.target_branch and parent.source_oid == child.source_oid
|
||||
and set(child.file_allowlist).issubset(parent.file_allowlist) and child.budget_units <= parent.budget_units
|
||||
and rank.get(child.risk_level.lower()) is not None
|
||||
and rank.get(parent.risk_level.lower(), 0) >= rank[child.risk_level.lower()])
|
||||
|
||||
|
||||
def build_authorization_plan(*, request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, runtime: RuntimeObservation, root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
refs = () if root is None else (root.receipt_ref,)
|
||||
if manifest.source_oid != request.scope.source_oid or not any(_scope_covers(scope, request.scope) for scope in manifest.allowed_scopes):
|
||||
return _plan(request, manifest, "UNKNOWN", "REGISTRY_DRIFT", refs, root)
|
||||
allowed, reason = _capabilities_cover(manifest.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "CAPABILITY_UNAVAILABLE", reason, refs, root)
|
||||
if root is None: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_MISSING", refs, root)
|
||||
if root.status == "REVOKED": return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_REVOKED", refs, root)
|
||||
if root.approval_ref != request.root_approval_ref or root.root_request_sha256 != request.root_request_sha256: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_DIGEST_MISMATCH", refs, root)
|
||||
if root.subject != request.actor_subject or root.audience != request.audience: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SUBJECT_AUDIENCE_MISMATCH", refs, root)
|
||||
if root.mission_id != request.mission_id or not _scope_covers(root.scope, request.scope): return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SCOPE_EXCEEDED", refs, root)
|
||||
allowed, reason = _capabilities_cover(root.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", reason, refs, root)
|
||||
if not (root.observed_at <= request.now < root.snapshot_expires_at <= root.expires_at) or request.now < root.not_before: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_EXPIRED", refs, root)
|
||||
if runtime.expires_at <= request.now or runtime.observed_at > request.now: return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if runtime.revocation_epoch is None or runtime.revocation_epoch != root.revocation_epoch: return _plan(request, manifest, "UNKNOWN", "EPOCH_REVOKED", refs, root)
|
||||
if (runtime.actor_subject != request.actor_subject or runtime.audience != request.audience or runtime.operation_id != request.operation_id
|
||||
or runtime.scope_digest != authorization_scope_digest(request.scope) or runtime.manifest_revision != manifest.revision
|
||||
or runtime.manifest_digest != manifest.declaration_digest): return _plan(request, manifest, "UNKNOWN", "PLAN_INPUT_DRIFT", refs, root)
|
||||
if any(value is None for value in (runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if not all((runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "PRECONDITION_FAILED", "PRECONDITION_FAILED", refs, root)
|
||||
return _plan(request, manifest, "EXECUTE_GOVERNED_PLAN", None, refs, root)
|
||||
|
||||
|
||||
class GitManifestRegistryReader:
|
||||
"""Fixed-path, immutable Git blob reader; intentionally has no write API."""
|
||||
def __init__(self, *, configured_repo_root: Path, manifest_id: str) -> None:
|
||||
self.root = Path(configured_repo_root); self.manifest_id = _text(manifest_id, "manifest_id")
|
||||
self._last_registry: Mapping[str, Any] | None = None
|
||||
|
||||
def _blob(self, oid: str) -> bytes:
|
||||
_oid(oid, "expected_registry_oid")
|
||||
if self.root.is_symlink() or not self.root.is_dir(): raise RegistryReadError("REGISTRY_ROOT_INVALID")
|
||||
result = subprocess.run(["git", "-C", str(self.root), "show", f"{oid}:{_REGISTRY_PATH}"], capture_output=True, timeout=15)
|
||||
if result.returncode: raise RegistryReadError("REGISTRY_UNAVAILABLE")
|
||||
return result.stdout
|
||||
|
||||
def _registry(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> Mapping[str, Any]:
|
||||
raw = self._blob(expected_registry_oid)
|
||||
if hashlib.sha256(raw).hexdigest() != _digest(expected_manifest_sha256, "expected_manifest_sha256"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try: value = json.loads(raw)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "records", "supersessions"} or value["schema"] != "aios.capability-registry.v1": raise RegistryReadError("REGISTRY_INVALID")
|
||||
return value
|
||||
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest:
|
||||
registry = self._registry(expected_registry_oid=expected_registry_oid, expected_manifest_sha256=expected_manifest_sha256)
|
||||
records = registry["records"]
|
||||
if not isinstance(records, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
supersessions = registry["supersessions"]
|
||||
if not isinstance(supersessions, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
record_index: dict[tuple[str, str], Mapping[str, Any]] = {}
|
||||
for record in records:
|
||||
if not isinstance(record, Mapping) or not isinstance(record.get("manifest_id"), str) or not isinstance(record.get("revision"), str):
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
key = (record["manifest_id"], record["revision"])
|
||||
if key in record_index: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record_index[key] = record
|
||||
replaced: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
for item in supersessions:
|
||||
if not isinstance(item, Mapping) or set(item) != {"manifest_id", "prior_revision", "new_revision", "evidence_ref"}:
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
try:
|
||||
manifest_id = _text(item["manifest_id"], "manifest_id")
|
||||
prior = _text(item["prior_revision"], "prior_revision")
|
||||
newer = _text(item["new_revision"], "new_revision")
|
||||
_text(item["evidence_ref"], "evidence_ref")
|
||||
except ValueError as exc:
|
||||
raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
prior_key, new_key = (manifest_id, prior), (manifest_id, newer)
|
||||
if prior_key not in record_index or new_key not in record_index or prior_key == new_key or prior_key in replaced:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
replaced[prior_key] = new_key
|
||||
candidates = [record for record in records if isinstance(record, Mapping) and record.get("manifest_id") == self.manifest_id]
|
||||
if not candidates: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
current = [record for record in candidates if (self.manifest_id, record.get("revision")) not in replaced]
|
||||
if len(current) != 1: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record = current[0]; required = {"manifest_id", "revision", "capabilities", "executor_refs", "source_oid", "declaration_digest", "allowed_scopes"}
|
||||
if set(record) != required: raise RegistryReadError("REGISTRY_INVALID")
|
||||
unsigned = {key: value for key, value in record.items() if key != "declaration_digest"}
|
||||
if hashlib.sha256(_canonical(unsigned)).hexdigest() != _digest(record["declaration_digest"], "declaration_digest"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try:
|
||||
manifest = StaticCapabilityManifest(str(record["manifest_id"]), str(record["revision"]), tuple(CapabilityRef(CapabilityNamespace(item["namespace"]), item["token"]) for item in record["capabilities"]), tuple(record["executor_refs"]), str(record["source_oid"]), str(record["declaration_digest"]), tuple(ScopeSpec(**item) for item in record["allowed_scopes"]))
|
||||
except (KeyError, TypeError, ValueError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
self._last_registry = registry
|
||||
return manifest
|
||||
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None:
|
||||
_text(manifest_id, "manifest_id"); _text(revision, "revision")
|
||||
if self._last_registry is None:
|
||||
raise RegistryReadError("REGISTRY_READ_REQUIRES_PINNED_DIGEST")
|
||||
matches = [
|
||||
item["new_revision"] for item in self._last_registry["supersessions"]
|
||||
if item["manifest_id"] == manifest_id and item["prior_revision"] == revision
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
return matches[0] if matches else None
|
||||
@@ -0,0 +1,972 @@
|
||||
"""AIOS-native binding layer for the unattended resume controller.
|
||||
|
||||
This module intentionally contains no HTTP client, Unix-socket client, database,
|
||||
systemd integration, scheduler loop, secret handling, or privileged identity
|
||||
mutation.
|
||||
|
||||
It converts existing AIOS authorities into the generic callbacks defined by
|
||||
``autonomous_resume_integration``. Field code supplies the current authoritative
|
||||
implementations for:
|
||||
|
||||
- durable Owner authorization/grant projection;
|
||||
- canonical governed-worker activation/status;
|
||||
- governed task admission;
|
||||
- task/claim/TaskLease/TaskPrincipal/WriterLease/CAS ownership;
|
||||
- bounded source-lane execution and integration;
|
||||
- Gate0;
|
||||
- ReleaseReady/deployability;
|
||||
- PromotionController;
|
||||
- final production acceptance;
|
||||
- mission resume lease/checkpoint/effect reconciliation.
|
||||
|
||||
The adapter is deterministic and fail-closed. It never fabricates any credential,
|
||||
principal, lease, fence, task, commit, receipt, approval, or deployment result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from .autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class WorkerState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
STARTING = "STARTING"
|
||||
INACTIVE = "INACTIVE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class WorkState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class GateState(str, Enum):
|
||||
PASS = "PASS"
|
||||
HOLD = "HOLD"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReadyState(str, Enum):
|
||||
READY = "READY"
|
||||
NOT_READY = "NOT_READY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class PromotionState(str, Enum):
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
VERIFIED = "VERIFIED"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class AcceptanceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL_ROLLED_BACK = "FAIL_ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableGrantView:
|
||||
grant_id: str
|
||||
active: bool
|
||||
repository: str
|
||||
allowed_target_prefixes: tuple[str, ...]
|
||||
max_task_risk: str
|
||||
expires_at: float | None = None
|
||||
owner_action_required: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def permits(self, *, repository: str, branch: str, risk: str) -> bool:
|
||||
if not self.active:
|
||||
return False
|
||||
if self.repository != repository:
|
||||
return False
|
||||
if not any(branch.startswith(prefix) for prefix in self.allowed_target_prefixes):
|
||||
return False
|
||||
rank = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
if risk not in rank or self.max_task_risk not in rank:
|
||||
return False
|
||||
return rank[risk] <= rank[self.max_task_risk]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLaneSpec:
|
||||
lane: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
write_set: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lane or not self.task_id or not self.repository:
|
||||
raise ValueError("lane_task_repository_required")
|
||||
if not self.target_branch.startswith("agent/"):
|
||||
raise ValueError("target_branch_must_use_agent_prefix")
|
||||
if not self.write_set:
|
||||
raise ValueError("write_set_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionRequest:
|
||||
task_id: str
|
||||
idempotency_key: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
expected_branch_oid: str
|
||||
risk: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionReceipt:
|
||||
task_id: str
|
||||
generation: int
|
||||
target_branch: str
|
||||
persisted: bool
|
||||
claimable: bool
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.generation < 1:
|
||||
raise ValueError("task_generation_must_be_positive")
|
||||
if not self.receipt_ref:
|
||||
raise ValueError("task_admission_receipt_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerActivationReceipt:
|
||||
state: WorkerState
|
||||
receipt_ref: str | None = None
|
||||
retry_after_seconds: int | None = None
|
||||
owner_action_required: bool = False
|
||||
code: str = "WORKER_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneExecutionView:
|
||||
lane: str
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "LANE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationView:
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "INTEGRATION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateView:
|
||||
state: GateState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "GATE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseReadyView:
|
||||
state: ReadyState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "READY_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionView:
|
||||
state: PromotionState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "PROMOTION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceView:
|
||||
state: AcceptanceState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
code: str = "ACCEPTANCE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionEffectView:
|
||||
effect_state: EffectState
|
||||
condition_fingerprint: str
|
||||
requires_reconcile: bool
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AiosMissionAuthorities(Protocol):
|
||||
"""Existing AIOS authority surface consumed by this adapter."""
|
||||
|
||||
# Live truth.
|
||||
def read_head_oid(self, repository: str) -> str: ...
|
||||
def read_material_digest(self, mission_id: str) -> str: ...
|
||||
def read_generation(self, mission_id: str) -> int: ...
|
||||
def read_effect_view(self, mission_id: str) -> MissionEffectView: ...
|
||||
def owner_action_resolved(self, mission_id: str) -> bool: ...
|
||||
|
||||
# Durable mission state / one-winner resume authority.
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
def reconcile_effects(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
# Admission.
|
||||
def read_durable_grant(self, mission_id: str) -> DurableGrantView: ...
|
||||
def ensure_canonical_worker(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WorkerActivationReceipt: ...
|
||||
def create_or_resume_task(
|
||||
self,
|
||||
request: TaskAdmissionRequest,
|
||||
) -> TaskAdmissionReceipt: ...
|
||||
def read_task_admission(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
) -> TaskAdmissionReceipt | None: ...
|
||||
|
||||
# Phase execution.
|
||||
def read_lane_execution(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
task: TaskAdmissionReceipt,
|
||||
) -> LaneExecutionView: ...
|
||||
def drive_integration(
|
||||
self,
|
||||
lanes: Sequence[TaskLaneSpec],
|
||||
tasks: Sequence[TaskAdmissionReceipt],
|
||||
) -> IntegrationView: ...
|
||||
def run_gate0(self, mission_id: str) -> GateView: ...
|
||||
def build_release_ready(self, mission_id: str) -> ReleaseReadyView: ...
|
||||
def run_promotion(self, mission_id: str) -> PromotionView: ...
|
||||
def run_final_acceptance(self, mission_id: str) -> AcceptanceView: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiosUnattendedMissionConfig:
|
||||
mission_id: str
|
||||
repository: str
|
||||
lanes: tuple[TaskLaneSpec, ...]
|
||||
admission_risk: str = "medium"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id or not self.repository:
|
||||
raise ValueError("mission_and_repository_required")
|
||||
if not self.lanes:
|
||||
raise ValueError("at_least_one_lane_required")
|
||||
names = [lane.lane for lane in self.lanes]
|
||||
task_ids = [lane.task_id for lane in self.lanes]
|
||||
branches = [lane.target_branch for lane in self.lanes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate_lane")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("duplicate_task_id")
|
||||
if len(branches) != len(set(branches)):
|
||||
raise ValueError("duplicate_target_branch")
|
||||
owners: dict[str, str] = {}
|
||||
for lane in self.lanes:
|
||||
if lane.repository != self.repository:
|
||||
raise ValueError("lane_repository_mismatch")
|
||||
for resource in lane.write_set:
|
||||
if resource in owners:
|
||||
raise ValueError(
|
||||
f"overlapping_lane_resource:{resource}:"
|
||||
f"{owners[resource]}:{lane.lane}"
|
||||
)
|
||||
owners[resource] = lane.lane
|
||||
|
||||
|
||||
class AiosUnattendedCallbackFactory:
|
||||
"""Construct generic controller callbacks from current AIOS authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AiosMissionAuthorities,
|
||||
config: AiosUnattendedMissionConfig,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.config = config
|
||||
self._admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
|
||||
def build_port(self) -> CallableAutonomousExecutionPort:
|
||||
return CallableAutonomousExecutionPort(
|
||||
AuthorityCallbacks(
|
||||
read_observation=self.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile_effects,
|
||||
),
|
||||
PhaseCallbacks(
|
||||
admission=self.admission,
|
||||
source_execution=self.source_execution,
|
||||
gate0=self.gate0,
|
||||
release_ready=self.release_ready,
|
||||
promotion=self.promotion,
|
||||
final_acceptance=self.final_acceptance,
|
||||
),
|
||||
)
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
if mission_id != self.config.mission_id:
|
||||
raise ValueError("mission_id_mismatch")
|
||||
generation = self.authority.read_generation(mission_id)
|
||||
head_oid = self.authority.read_head_oid(self.config.repository)
|
||||
material = self.authority.read_material_digest(mission_id)
|
||||
effect = self.authority.read_effect_view(mission_id)
|
||||
fingerprint = _digest({
|
||||
"schema": "aios.unattended-condition.v1",
|
||||
"mission_id": mission_id,
|
||||
"generation": generation,
|
||||
"head_oid": head_oid,
|
||||
"material_digest": material,
|
||||
"effect_state": effect.effect_state.value,
|
||||
"effect_condition": effect.condition_fingerprint,
|
||||
"requires_reconcile": effect.requires_reconcile,
|
||||
})
|
||||
return MissionObservation(
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
head_oid=head_oid,
|
||||
material_digest=material,
|
||||
condition_fingerprint=fingerprint,
|
||||
effect_state=effect.effect_state,
|
||||
requires_reconcile=effect.requires_reconcile,
|
||||
owner_action_resolved=self.authority.owner_action_resolved(mission_id),
|
||||
evidence_refs=effect.receipt_refs,
|
||||
)
|
||||
|
||||
def admission(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
grant = self.authority.read_durable_grant(observation.mission_id)
|
||||
grant_refs = tuple(grant.evidence_refs)
|
||||
|
||||
if not grant.active:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_NOT_ACTIVE",
|
||||
self._phase_fingerprint(
|
||||
"admission", observation, {"grant_id": grant.grant_id, "active": False}
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_NOT_ACTIVE",
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
for lane in self.config.lanes:
|
||||
if not grant.permits(
|
||||
repository=lane.repository,
|
||||
branch=lane.target_branch,
|
||||
risk=lane.risk,
|
||||
):
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
"branch": lane.target_branch,
|
||||
"risk": lane.risk,
|
||||
},
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
worker = self.authority.ensure_canonical_worker(
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
self._idempotency(
|
||||
"worker",
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
),
|
||||
)
|
||||
worker_refs = self._refs(grant_refs, (worker.receipt_ref or "",))
|
||||
|
||||
if worker.state is WorkerState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
if worker.state is WorkerState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if worker.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
owner_action_payload=(
|
||||
{"boundary": worker.code}
|
||||
if worker.owner_action_required else None
|
||||
),
|
||||
)
|
||||
if worker.state in {WorkerState.STARTING, WorkerState.INACTIVE}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
retry_after_seconds=worker.retry_after_seconds,
|
||||
)
|
||||
if worker.state is WorkerState.ACTIVE and not worker.receipt_ref:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"WORKER_ACTIVE_WITHOUT_RECEIPT",
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
|
||||
task_refs: list[str] = list(worker_refs)
|
||||
admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
for lane in self.config.lanes:
|
||||
request = TaskAdmissionRequest(
|
||||
task_id=lane.task_id,
|
||||
idempotency_key=self._idempotency(
|
||||
"task",
|
||||
observation.generation,
|
||||
lane.task_id,
|
||||
observation.head_oid,
|
||||
),
|
||||
repository=lane.repository,
|
||||
base_oid=observation.head_oid,
|
||||
target_branch=lane.target_branch,
|
||||
expected_branch_oid=observation.head_oid,
|
||||
risk=lane.risk,
|
||||
description=lane.description,
|
||||
)
|
||||
receipt = self.authority.create_or_resume_task(request)
|
||||
task_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
admissions[lane.lane] = receipt
|
||||
|
||||
self._admissions = admissions
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"GOVERNED_TASKS_ADMITTED",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{"tasks": sorted(r.task_id for r in admissions.values())},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
next_phase=MissionPhase.S2_EXECUTION,
|
||||
)
|
||||
|
||||
def source_execution(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
recovery_refs: list[str] = []
|
||||
for lane in self.config.lanes:
|
||||
if lane.lane in self._admissions:
|
||||
continue
|
||||
receipt = self.authority.read_task_admission(lane)
|
||||
if receipt is None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_ADMISSION_NOT_RESOLVED",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
recovery_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
self._admissions[lane.lane] = receipt
|
||||
|
||||
views: list[LaneExecutionView] = []
|
||||
refs: list[str] = list(recovery_refs)
|
||||
for lane in self.config.lanes:
|
||||
view = self.authority.read_lane_execution(
|
||||
lane,
|
||||
self._admissions[lane.lane],
|
||||
)
|
||||
if view.lane != lane.lane:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_IDENTITY_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"expected": lane.lane, "actual": view.lane},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if view.state is WorkState.COMPLETE and not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_COMPLETE_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
views.append(view)
|
||||
refs.extend(view.receipt_refs)
|
||||
|
||||
unknown = [v for v in views if v.state is WorkState.UNKNOWN]
|
||||
if unknown:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
unknown[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
blocked = [v for v in views if v.state is WorkState.BLOCKED]
|
||||
if blocked:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
blocked[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
pending = [
|
||||
v for v in views
|
||||
if v.state in {WorkState.PENDING, WorkState.RUNNING}
|
||||
]
|
||||
if pending:
|
||||
retry_values = [
|
||||
v.retry_after_seconds
|
||||
for v in pending
|
||||
if v.retry_after_seconds is not None
|
||||
]
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"SOURCE_LANES_IN_PROGRESS",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=min(retry_values) if retry_values else None,
|
||||
)
|
||||
|
||||
integration = self.authority.drive_integration(
|
||||
self.config.lanes,
|
||||
tuple(self._admissions[lane.lane] for lane in self.config.lanes),
|
||||
)
|
||||
refs.extend(integration.receipt_refs)
|
||||
if integration.state is WorkState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state is WorkState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state in {WorkState.PENDING, WorkState.RUNNING}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=integration.retry_after_seconds,
|
||||
)
|
||||
if integration.state is WorkState.COMPLETE and not integration.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"INTEGRATION_COMPLETE_WITHOUT_RECEIPT",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"SOURCE_INTEGRATION_COMPLETE",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
next_phase=MissionPhase.GATE0,
|
||||
)
|
||||
|
||||
def gate0(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_gate0(observation.mission_id)
|
||||
if view.state is GateState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is GateState.HOLD:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"GATE0_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.RELEASE_READY
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
def release_ready(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.build_release_ready(observation.mission_id)
|
||||
if view.state is ReadyState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is ReadyState.NOT_READY:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.material_digest:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_MATERIAL_DIGEST",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
)
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.PROMOTION
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def promotion(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_promotion(observation.mission_id)
|
||||
if view.state is PromotionState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is PromotionState.IN_PROGRESS:
|
||||
status = StepStatus.IN_PROGRESS
|
||||
elif view.state is PromotionState.VERIFIED:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"PROMOTION_VERIFIED_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
elif view.state is PromotionState.ROLLED_BACK:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.FINAL_ACCEPTANCE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def final_acceptance(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_final_acceptance(observation.mission_id)
|
||||
if view.state is AcceptanceState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is AcceptanceState.PASS:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"FINAL_ACCEPTANCE_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
next_phase=(
|
||||
MissionPhase.COMPLETE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _admission_receipt_error(
|
||||
lane: TaskLaneSpec,
|
||||
receipt: TaskAdmissionReceipt,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
) -> str | None:
|
||||
if receipt.task_id != lane.task_id:
|
||||
return "TASK_IDENTITY_MISMATCH"
|
||||
if receipt.target_branch != lane.target_branch:
|
||||
return "TASK_BRANCH_MISMATCH"
|
||||
if receipt.generation != observation.generation:
|
||||
return "TASK_GENERATION_MISMATCH"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
values: list[str] = []
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
values.append(item)
|
||||
return tuple(values)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency(*parts: object) -> str:
|
||||
return "aios:auto:" + _digest(
|
||||
{"schema": "aios.unattended-idempotency.v1", "parts": list(parts)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _phase_fingerprint(
|
||||
phase: str,
|
||||
observation: MissionObservation,
|
||||
details: Mapping[str, object],
|
||||
) -> str:
|
||||
return _digest({
|
||||
"schema": "aios.unattended-phase-condition.v1",
|
||||
"phase": phase,
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
"head_oid": observation.head_oid,
|
||||
"material_digest": observation.material_digest,
|
||||
"details": details,
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Deterministic unattended mission-resume controller for AIOS.
|
||||
|
||||
The controller is deliberately small and does not create a second scheduler,
|
||||
database, identity system, or release control plane.
|
||||
|
||||
It executes exactly one reconciliation/progress cycle per ``run_once`` call.
|
||||
An existing AIOS scheduler/timer/worker loop should invoke it periodically.
|
||||
|
||||
All consequential effects are delegated through ``AutonomousExecutionPort`` and
|
||||
must use the existing AIOS authorities: durable Owner authorization, task
|
||||
admission, claim/TaskLease/TaskPrincipal, WriterLease/CAS/fencing, Gate0,
|
||||
ReleaseReady, PromotionController and governed rollback.
|
||||
|
||||
The controller's responsibilities are limited to:
|
||||
- one-winner resume lease acquisition;
|
||||
- durable checkpoint progression;
|
||||
- bounded retry/backoff;
|
||||
- unchanged deterministic blocker suppression;
|
||||
- UNKNOWN/ambiguous-effect reconciliation before retry;
|
||||
- non-interactive owner-boundary recording;
|
||||
- crash-safe phase resumption.
|
||||
|
||||
It never manufactures credentials, group membership, principals, leases,
|
||||
signatures, branch OIDs, or release receipts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class MissionPhase(str, Enum):
|
||||
S2_ADMISSION = "S2_ADMISSION"
|
||||
S2_EXECUTION = "S2_EXECUTION"
|
||||
GATE0 = "GATE0"
|
||||
RELEASE_READY = "RELEASE_READY"
|
||||
PROMOTION = "PROMOTION"
|
||||
FINAL_ACCEPTANCE = "FINAL_ACCEPTANCE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class EffectState(str, Enum):
|
||||
ABSENT = "ABSENT"
|
||||
ACTIVE = "ACTIVE"
|
||||
COMPLETED = "COMPLETED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRYABLE = "RETRYABLE"
|
||||
RECONCILE = "RECONCILE"
|
||||
BLOCKED_DETERMINISTIC = "BLOCKED_DETERMINISTIC"
|
||||
BLOCKED_OWNER = "BLOCKED_OWNER"
|
||||
|
||||
|
||||
class OutcomeStatus(str, Enum):
|
||||
COMPLETE = "COMPLETE"
|
||||
PROGRESSED = "PROGRESSED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRY_SCHEDULED = "RETRY_SCHEDULED"
|
||||
WAIT_CONDITION_CHANGE = "WAIT_CONDITION_CHANGE"
|
||||
ACTIVE_ELSEWHERE = "ACTIVE_ELSEWHERE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
|
||||
|
||||
_PHASE_NEXT: dict[MissionPhase, MissionPhase] = {
|
||||
MissionPhase.S2_ADMISSION: MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.S2_EXECUTION: MissionPhase.GATE0,
|
||||
MissionPhase.GATE0: MissionPhase.RELEASE_READY,
|
||||
MissionPhase.RELEASE_READY: MissionPhase.PROMOTION,
|
||||
MissionPhase.PROMOTION: MissionPhase.FINAL_ACCEPTANCE,
|
||||
MissionPhase.FINAL_ACCEPTANCE: MissionPhase.COMPLETE,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumePolicy:
|
||||
resume_lease_seconds: int = 120
|
||||
default_poll_seconds: int = 30
|
||||
deterministic_block_poll_seconds: int = 300
|
||||
transient_max_attempts: int = 3
|
||||
transient_base_delay_seconds: int = 2
|
||||
transient_max_delay_seconds: int = 60
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"resume_lease_seconds",
|
||||
"default_poll_seconds",
|
||||
"deterministic_block_poll_seconds",
|
||||
"transient_max_attempts",
|
||||
"transient_base_delay_seconds",
|
||||
"transient_max_delay_seconds",
|
||||
):
|
||||
if getattr(self, name) < 1:
|
||||
raise ValueError(f"{name}_must_be_positive")
|
||||
if self.transient_max_delay_seconds < self.transient_base_delay_seconds:
|
||||
raise ValueError("transient_max_delay_seconds_too_small")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionObservation:
|
||||
mission_id: str
|
||||
generation: int
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
condition_fingerprint: str
|
||||
effect_state: EffectState = EffectState.ABSENT
|
||||
requires_reconcile: bool = False
|
||||
owner_action_resolved: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.head_oid:
|
||||
raise ValueError("head_oid_required")
|
||||
if not self.material_digest:
|
||||
raise ValueError("material_digest_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeLease:
|
||||
lease_id: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
fencing_token: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lease_id:
|
||||
raise ValueError("lease_id_required")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.fencing_token:
|
||||
raise ValueError("fencing_token_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepResult:
|
||||
status: StepStatus
|
||||
code: str
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
next_phase: MissionPhase | None = None
|
||||
material_digest: str | None = None
|
||||
owner_action_payload: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
raise ValueError("step_code_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
if self.retry_after_seconds is not None and self.retry_after_seconds < 0:
|
||||
raise ValueError("retry_after_seconds_must_be_non_negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionCheckpoint:
|
||||
schema: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
phase: MissionPhase
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
last_condition_fingerprint: str
|
||||
attempt_count: int
|
||||
blocked_condition_fingerprint: str | None
|
||||
reconcile_required: bool
|
||||
frozen_material_digest: str | None
|
||||
resume_phase_after_owner: MissionPhase | None
|
||||
receipt_refs: tuple[str, ...]
|
||||
updated_at: float
|
||||
revision: int = 1
|
||||
|
||||
@classmethod
|
||||
def initial(
|
||||
cls,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
observed_at: float,
|
||||
) -> "MissionCheckpoint":
|
||||
return cls(
|
||||
schema="aios.autonomous-resume-checkpoint.v1",
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=tuple(observation.evidence_refs),
|
||||
updated_at=observed_at,
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.schema != "aios.autonomous-resume-checkpoint.v1":
|
||||
raise ValueError("unsupported_checkpoint_schema")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if self.attempt_count < 0:
|
||||
raise ValueError("attempt_count_must_be_non_negative")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision_must_be_positive")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
payload = asdict(self)
|
||||
payload["phase"] = self.phase.value
|
||||
payload["resume_phase_after_owner"] = (
|
||||
None
|
||||
if self.resume_phase_after_owner is None
|
||||
else self.resume_phase_after_owner.value
|
||||
)
|
||||
payload["receipt_refs"] = list(self.receipt_refs)
|
||||
return _sha256(_canonical(payload))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerOutcome:
|
||||
status: OutcomeStatus
|
||||
mission_id: str
|
||||
phase: MissionPhase
|
||||
code: str
|
||||
next_wake_after_seconds: int | None
|
||||
checkpoint_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AutonomousExecutionPort(Protocol):
|
||||
"""Adapter boundary to existing AIOS authorities and executors."""
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation: ...
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class AutonomousResumeController:
|
||||
"""One-cycle deterministic unattended mission controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: AutonomousExecutionPort,
|
||||
*,
|
||||
policy: ResumePolicy | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.policy = policy or ResumePolicy()
|
||||
self.clock = clock or time.time
|
||||
|
||||
def run_once(self, mission_id: str) -> ControllerOutcome:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
|
||||
now = float(self.clock())
|
||||
observation = self.port.read_observation(mission_id)
|
||||
checkpoint = self.port.load_checkpoint(mission_id)
|
||||
if checkpoint is None:
|
||||
checkpoint = MissionCheckpoint.initial(observation, observed_at=now)
|
||||
forced_reconcile = False
|
||||
else:
|
||||
checkpoint, forced_reconcile = self._validate_and_refresh_checkpoint(
|
||||
checkpoint,
|
||||
observation,
|
||||
now=now,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.COMPLETE:
|
||||
return self._outcome(
|
||||
OutcomeStatus.COMPLETE,
|
||||
checkpoint,
|
||||
"MISSION_ALREADY_COMPLETE",
|
||||
next_wake=None,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.OWNER_ACTION_REQUIRED:
|
||||
if not observation.owner_action_resolved:
|
||||
return self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
checkpoint,
|
||||
"OWNER_ACTION_STILL_REQUIRED",
|
||||
next_wake=None,
|
||||
)
|
||||
resume_phase = (
|
||||
checkpoint.resume_phase_after_owner
|
||||
or MissionPhase.S2_ADMISSION
|
||||
)
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
phase=resume_phase,
|
||||
resume_phase_after_owner=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
== observation.condition_fingerprint
|
||||
):
|
||||
return self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
checkpoint,
|
||||
"UNCHANGED_DETERMINISTIC_BLOCKER",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
!= observation.condition_fingerprint
|
||||
):
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
blocked_condition_fingerprint=None,
|
||||
attempt_count=0,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
lease = self.port.acquire_resume_lease(
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
idempotency_key=self._resume_idempotency_key(observation),
|
||||
lease_seconds=self.policy.resume_lease_seconds,
|
||||
)
|
||||
if lease is None:
|
||||
return self._outcome(
|
||||
OutcomeStatus.ACTIVE_ELSEWHERE,
|
||||
checkpoint,
|
||||
"RESUME_LEASE_HELD_ELSEWHERE",
|
||||
next_wake=self.policy.default_poll_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_resume_lease(
|
||||
lease,
|
||||
observation=observation,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code="INVALID_RESUME_LEASE",
|
||||
)
|
||||
raise
|
||||
|
||||
outcome_code = "UNSET"
|
||||
try:
|
||||
reconciling = (
|
||||
forced_reconcile
|
||||
or checkpoint.reconcile_required
|
||||
or observation.requires_reconcile
|
||||
or observation.effect_state is EffectState.UNKNOWN
|
||||
)
|
||||
if reconciling:
|
||||
result = self.port.reconcile(
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
else:
|
||||
result = self.port.step(
|
||||
phase=checkpoint.phase,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
outcome, new_checkpoint = self._apply_result(
|
||||
result=result,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
now=now,
|
||||
lease=lease,
|
||||
reconciling=reconciling,
|
||||
)
|
||||
outcome_code = outcome.code
|
||||
self.port.persist_checkpoint(new_checkpoint, lease=lease)
|
||||
return replace(
|
||||
outcome,
|
||||
checkpoint_digest=new_checkpoint.digest,
|
||||
)
|
||||
finally:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code=outcome_code,
|
||||
)
|
||||
|
||||
def _validate_and_refresh_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
now: float,
|
||||
) -> tuple[MissionCheckpoint, bool]:
|
||||
if checkpoint.mission_id != observation.mission_id:
|
||||
raise ValueError("checkpoint_mission_mismatch")
|
||||
if checkpoint.generation > observation.generation:
|
||||
raise ValueError("checkpoint_generation_ahead_of_authority")
|
||||
|
||||
refreshed = checkpoint
|
||||
forced_reconcile = False
|
||||
if checkpoint.generation < observation.generation:
|
||||
refreshed = replace(
|
||||
checkpoint,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
refreshed.phase in {
|
||||
MissionPhase.RELEASE_READY,
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and refreshed.frozen_material_digest is not None
|
||||
and refreshed.frozen_material_digest
|
||||
!= observation.material_digest
|
||||
):
|
||||
# Material changed after READY. Promotion/final may already have
|
||||
# observable side effects, so reconcile before rebuilding PREPARE.
|
||||
previous_phase = refreshed.phase
|
||||
force_for_effect = (
|
||||
previous_phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and observation.effect_state in {
|
||||
EffectState.ACTIVE,
|
||||
EffectState.UNKNOWN,
|
||||
}
|
||||
)
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=observation.material_digest,
|
||||
head_oid=observation.head_oid,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=force_for_effect,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
forced_reconcile = forced_reconcile or force_for_effect
|
||||
elif (
|
||||
refreshed.phase in {
|
||||
MissionPhase.S2_ADMISSION,
|
||||
MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.GATE0,
|
||||
}
|
||||
and refreshed.head_oid != observation.head_oid
|
||||
):
|
||||
# Source moved before READY. Re-enter S2 admission/replan.
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
|
||||
return refreshed, forced_reconcile
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
*,
|
||||
result: StepResult,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
now: float,
|
||||
lease: ResumeLease,
|
||||
reconciling: bool,
|
||||
) -> tuple[ControllerOutcome, MissionCheckpoint]:
|
||||
refs = self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
result.receipt_refs,
|
||||
)
|
||||
base = replace(
|
||||
checkpoint,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=(
|
||||
result.material_digest
|
||||
or observation.material_digest
|
||||
),
|
||||
last_condition_fingerprint=result.condition_fingerprint,
|
||||
receipt_refs=refs,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
result.status is StepStatus.SUCCEEDED
|
||||
and checkpoint.phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and checkpoint.frozen_material_digest is not None
|
||||
and result.material_digest is not None
|
||||
and result.material_digest != checkpoint.frozen_material_digest
|
||||
):
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=result.material_digest,
|
||||
frozen_material_digest=None,
|
||||
reconcile_required=True,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:MATERIAL_CHANGED_DURING_PROMOTION",
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.SUCCEEDED:
|
||||
next_phase = (
|
||||
result.next_phase
|
||||
or _PHASE_NEXT.get(checkpoint.phase, checkpoint.phase)
|
||||
)
|
||||
frozen = base.frozen_material_digest
|
||||
if checkpoint.phase is MissionPhase.RELEASE_READY:
|
||||
frozen = result.material_digest or observation.material_digest
|
||||
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=next_phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=frozen,
|
||||
)
|
||||
status = (
|
||||
OutcomeStatus.COMPLETE
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else OutcomeStatus.PROGRESSED
|
||||
)
|
||||
code = (
|
||||
"MISSION_COMPLETE"
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else result.code
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
status,
|
||||
new_checkpoint,
|
||||
code,
|
||||
next_wake=(
|
||||
None
|
||||
if status is OutcomeStatus.COMPLETE
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.IN_PROGRESS:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=(
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RETRYABLE:
|
||||
attempts = checkpoint.attempt_count + 1
|
||||
if attempts > self.policy.transient_max_attempts:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
"RETRY_BUDGET_EXHAUSTED",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
delay = (
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else min(
|
||||
self.policy.transient_base_delay_seconds
|
||||
* (2 ** (attempts - 1)),
|
||||
self.policy.transient_max_delay_seconds,
|
||||
)
|
||||
)
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.RETRY_SCHEDULED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=delay,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_DETERMINISTIC:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=checkpoint.attempt_count,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_OWNER:
|
||||
payload = dict(result.owner_action_payload or {})
|
||||
payload.setdefault("mission_id", observation.mission_id)
|
||||
payload.setdefault("generation", observation.generation)
|
||||
payload.setdefault("phase", checkpoint.phase.value)
|
||||
payload.setdefault("code", result.code)
|
||||
record_ref = self.port.persist_owner_action_required(
|
||||
payload,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
if not record_ref:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=checkpoint.phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
receipt_refs=self._merge_refs(refs, (record_ref,)),
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=None,
|
||||
refs=(record_ref,),
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RECONCILE:
|
||||
# The next scheduler cycle must reconcile before any retry.
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=True,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:" + result.code,
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
raise AssertionError(f"unsupported_step_status:{result.status}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_resume_lease(
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease.mission_id != observation.mission_id:
|
||||
raise ValueError("resume_lease_mission_mismatch")
|
||||
if lease.generation != observation.generation:
|
||||
raise ValueError("resume_lease_generation_mismatch")
|
||||
if lease.expires_at <= now:
|
||||
raise ValueError("resume_lease_expired")
|
||||
|
||||
def _resume_idempotency_key(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema": "aios.resume-effect.v1",
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
}
|
||||
return "resume:sha256:" + _sha256(_canonical(payload))
|
||||
|
||||
@staticmethod
|
||||
def _merge_refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
value = str(item)
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
merged.append(value)
|
||||
return tuple(merged)
|
||||
|
||||
@staticmethod
|
||||
def _outcome(
|
||||
status: OutcomeStatus,
|
||||
checkpoint: MissionCheckpoint,
|
||||
code: str,
|
||||
*,
|
||||
next_wake: int | None,
|
||||
refs: Sequence[str] = (),
|
||||
) -> ControllerOutcome:
|
||||
return ControllerOutcome(
|
||||
status=status,
|
||||
mission_id=checkpoint.mission_id,
|
||||
phase=checkpoint.phase,
|
||||
code=code,
|
||||
next_wake_after_seconds=next_wake,
|
||||
checkpoint_digest=None,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Thin callable adapter for :mod:`autonomous_resume_controller`.
|
||||
|
||||
This module contains no transport, daemon, database or scheduler loop. It only
|
||||
binds current AIOS runtime functions to the stable ``AutonomousExecutionPort``
|
||||
contract and exposes one scheduler tick.
|
||||
|
||||
A field integrator should:
|
||||
- resolve the live current APIs;
|
||||
- supply callables that use existing governed authorities;
|
||||
- register ``AutonomousSchedulerTick.run`` with the existing scheduler/timer.
|
||||
|
||||
No additional control plane is created here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
AutonomousExecutionPort,
|
||||
AutonomousResumeController,
|
||||
ControllerOutcome,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
)
|
||||
|
||||
|
||||
PhaseHandler = Callable[
|
||||
[MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
StepResult,
|
||||
]
|
||||
ReconcileHandler = PhaseHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseCallbacks:
|
||||
admission: PhaseHandler
|
||||
source_execution: PhaseHandler
|
||||
gate0: PhaseHandler
|
||||
release_ready: PhaseHandler
|
||||
promotion: PhaseHandler
|
||||
final_acceptance: PhaseHandler
|
||||
|
||||
def for_phase(self, phase: MissionPhase) -> PhaseHandler:
|
||||
mapping = {
|
||||
MissionPhase.S2_ADMISSION: self.admission,
|
||||
MissionPhase.S2_EXECUTION: self.source_execution,
|
||||
MissionPhase.GATE0: self.gate0,
|
||||
MissionPhase.RELEASE_READY: self.release_ready,
|
||||
MissionPhase.PROMOTION: self.promotion,
|
||||
MissionPhase.FINAL_ACCEPTANCE: self.final_acceptance,
|
||||
}
|
||||
try:
|
||||
return mapping[phase]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"phase_has_no_executor:{phase.value}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityCallbacks:
|
||||
read_observation: Callable[[str], MissionObservation]
|
||||
load_checkpoint: Callable[[str], MissionCheckpoint | None]
|
||||
acquire_resume_lease: Callable[[str, int, str, int], ResumeLease | None]
|
||||
persist_checkpoint: Callable[[MissionCheckpoint, ResumeLease], None]
|
||||
persist_owner_action_required: Callable[
|
||||
[dict, MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
str,
|
||||
]
|
||||
release_resume_lease: Callable[[ResumeLease, str], None]
|
||||
reconcile: ReconcileHandler
|
||||
|
||||
|
||||
class CallableAutonomousExecutionPort(AutonomousExecutionPort):
|
||||
"""Runtime adapter composed from already-governed AIOS callables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AuthorityCallbacks,
|
||||
phases: PhaseCallbacks,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.phases = phases
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
value = self.authority.read_observation(mission_id)
|
||||
if not isinstance(value, MissionObservation):
|
||||
raise TypeError("read_observation_must_return_MissionObservation")
|
||||
return value
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None:
|
||||
value = self.authority.load_checkpoint(mission_id)
|
||||
if value is not None and not isinstance(value, MissionCheckpoint):
|
||||
raise TypeError("load_checkpoint_must_return_checkpoint_or_none")
|
||||
return value
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None:
|
||||
value = self.authority.acquire_resume_lease(
|
||||
mission_id,
|
||||
generation,
|
||||
idempotency_key,
|
||||
lease_seconds,
|
||||
)
|
||||
if value is not None and not isinstance(value, ResumeLease):
|
||||
raise TypeError("acquire_resume_lease_must_return_lease_or_none")
|
||||
return value
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
handler = self.phases.for_phase(phase)
|
||||
value = handler(observation, checkpoint, lease)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("phase_handler_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
value = self.authority.reconcile(
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("reconcile_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None:
|
||||
self.authority.persist_checkpoint(checkpoint, lease)
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str:
|
||||
value = self.authority.persist_owner_action_required(
|
||||
dict(payload),
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
return value
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None:
|
||||
self.authority.release_resume_lease(lease, outcome_code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTickResult:
|
||||
outcome: ControllerOutcome
|
||||
next_wake_after_seconds: int | None
|
||||
|
||||
|
||||
class AutonomousSchedulerTick:
|
||||
"""One invocation for an existing scheduler/timer.
|
||||
|
||||
This object never sleeps or loops. The host scheduler owns cadence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AutonomousResumeController,
|
||||
mission_id: str,
|
||||
) -> None:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
self.controller = controller
|
||||
self.mission_id = mission_id
|
||||
|
||||
def run(self) -> SchedulerTickResult:
|
||||
outcome = self.controller.run_once(self.mission_id)
|
||||
return SchedulerTickResult(
|
||||
outcome=outcome,
|
||||
next_wake_after_seconds=outcome.next_wake_after_seconds,
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Explicit C6 composition in the existing TaskPlane host, default disabled.
|
||||
|
||||
All authorities are host-owned dependencies. No request can register an
|
||||
executor, construct a worker identity, or turn an observation into authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from typing import Callable, Literal, Protocol
|
||||
from aios_capability_contract import has_capabilities as formal_has_capabilities, normalize_required_capabilities
|
||||
|
||||
from .aro_readonly import AroReadonlyTick
|
||||
from .authorization_planner import CapabilityNamespace, CapabilityRef, GitManifestRegistryReader, authorization_scope_digest
|
||||
from .auto_claim import AutoClaimCoordinator, DiscoveryResult
|
||||
from .errors import ForbiddenError
|
||||
from .operation_readback import OperationReaders, collect_closure, collect_observation
|
||||
from .operation_view import (ClosureObservation, OperationBinding, PreflightResult, ClosureResult,
|
||||
binding_digest, derive_closure, derive_preflight, reconcile_effect, validate_digest, validate_oid)
|
||||
from .schedulability import MemoryProofRepository, SchedulabilityService
|
||||
from .schedulability_store_adapter import (TaskPlaneSchedulabilityAuthority,
|
||||
TaskPlaneClaimReceiptV3, ScopedLeaseController, validate_task_plane_receipt)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedWorker:
|
||||
worker_id: str
|
||||
actor_identity: str
|
||||
authority_receipt_ref: str
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.worker_id, self.actor_identity, self.authority_receipt_ref)):
|
||||
raise ValueError("authenticated worker receipt required")
|
||||
if not isinstance(self.expires_at, datetime) or self.expires_at.tzinfo is None:
|
||||
raise ValueError("worker expiry must be timezone aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationCapabilityBinding:
|
||||
operation_id: str
|
||||
namespace: CapabilityNamespace
|
||||
capability_id: str
|
||||
registry_oid: str
|
||||
manifest_id: str
|
||||
manifest_revision: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_oid(self.registry_oid)
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("explicit capability namespace required")
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.operation_id, self.capability_id, self.manifest_id, self.manifest_revision)):
|
||||
raise ValueError("operation capability identity required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableExecutionIntent:
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
claim_id: str
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_digest(self.binding_digest)
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.effect_key, self.claim_id, self.receipt_ref)):
|
||||
raise ValueError("exact durable execution intent required")
|
||||
|
||||
|
||||
class GovernedSourcePatchPilot(Protocol):
|
||||
"""Existing governed writer/executor adapter, never a general shell port.
|
||||
|
||||
Implementation must revalidate Root, revocation, scope, generation and
|
||||
WriterLease/CAS at write linearization, and persist intent before effect.
|
||||
Readback uses the same immutable effect key through OperationReaders.
|
||||
"""
|
||||
def prepare_intent(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3) -> DurableExecutionIntent: ...
|
||||
def execute(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
def reconcile(self, *, binding: OperationBinding,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6Bindings:
|
||||
claim_authority: TaskPlaneSchedulabilityAuthority
|
||||
registry_reader: GitManifestRegistryReader
|
||||
registry_oid: str
|
||||
registry_digest: str
|
||||
operation_capabilities: tuple[OperationCapabilityBinding, ...]
|
||||
operation_readers: OperationReaders
|
||||
worker_provider: Callable[[], AuthenticatedWorker | None]
|
||||
operation_provider: Callable[[str, AuthenticatedWorker], OperationBinding]
|
||||
pilot: GovernedSourcePatchPilot | None = None
|
||||
aro_tick: AroReadonlyTick | None = None
|
||||
enabled: bool = False
|
||||
request_worker_provider: Callable[[str, str], AuthenticatedWorker | None] | None = None
|
||||
# Readers are injected from the host's existing durable intent and worker
|
||||
# authority. A provider error is UNKNOWN, never an absent record.
|
||||
intent_reader: Callable[[OperationBinding], DurableExecutionIntent | None] | None = None
|
||||
pending_intent_provider: Callable[
|
||||
[], tuple[tuple[OperationBinding, DurableExecutionIntent], ...]
|
||||
] | None = None
|
||||
pending_intent_completion: Callable[
|
||||
[OperationBinding, DurableExecutionIntent, ClosureObservation], None
|
||||
] | None = None
|
||||
worker_authority_verifier: Callable[[AuthenticatedWorker, OperationBinding], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6TickResult:
|
||||
status: Literal["DISABLED", "NO_CANDIDATES", "CLAIMED", "OBSERVED", "BLOCKED", "UNKNOWN"]
|
||||
reason_code: str
|
||||
discovery: DiscoveryResult | None = None
|
||||
claim: TaskPlaneClaimReceiptV3 | None = None
|
||||
preflight: PreflightResult | None = None
|
||||
closure: ClosureResult | None = None
|
||||
audit_report_ref: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class C6Runtime:
|
||||
def __init__(self, service, bindings: C6Bindings, *, clock=None):
|
||||
if not isinstance(bindings, C6Bindings) or type(bindings.enabled) is not bool:
|
||||
raise ValueError("explicit C6 bindings required")
|
||||
if not isinstance(bindings.claim_authority, TaskPlaneSchedulabilityAuthority) or bindings.claim_authority.store is not service.store:
|
||||
raise ValueError("C6 must share the host's existing TaskPlane authority")
|
||||
if not callable(bindings.worker_provider) or not callable(bindings.operation_provider):
|
||||
raise ValueError("host authenticated worker and operation providers required")
|
||||
if bindings.enabled and not service.policy.execution_enabled:
|
||||
raise ValueError("execution policy and explicit trust must be enabled first")
|
||||
if bindings.pilot is not None and not all(callable(getattr(bindings.pilot, method, None)) for method in ("prepare_intent", "execute", "reconcile")):
|
||||
raise ValueError("governed source patch pilot required")
|
||||
if bindings.enabled and bindings.pilot is not None and not all(
|
||||
callable(value)
|
||||
for value in (
|
||||
bindings.intent_reader,
|
||||
bindings.pending_intent_provider,
|
||||
bindings.pending_intent_completion,
|
||||
bindings.worker_authority_verifier,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"enabled C6 pilot requires intent, recovery, and worker authority providers"
|
||||
)
|
||||
validate_oid(bindings.registry_oid); validate_digest(bindings.registry_digest)
|
||||
if not isinstance(bindings.operation_capabilities, tuple) or not bindings.operation_capabilities:
|
||||
raise ValueError("immutable operation capability map required")
|
||||
self.service, self.bindings = service, bindings
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
self._pending: OperationBinding | None = None
|
||||
self._validate_map()
|
||||
self.schedulability = SchedulabilityService(bindings.claim_authority,
|
||||
MemoryProofRepository(), clock=lambda: self.clock().timestamp(),
|
||||
capability_matcher=bindings.claim_authority.capability_matcher)
|
||||
self.coordinator = AutoClaimCoordinator(self.schedulability)
|
||||
self.lease_controller = ScopedLeaseController(service.store, bindings.claim_authority.binding)
|
||||
|
||||
def _validate_map(self):
|
||||
manifest = self.bindings.registry_reader.read_manifest(
|
||||
expected_registry_oid=self.bindings.registry_oid,
|
||||
expected_manifest_sha256=self.bindings.registry_digest)
|
||||
seen = set()
|
||||
for item in self.bindings.operation_capabilities:
|
||||
key = (item.operation_id, item.namespace)
|
||||
if key in seen or item.registry_oid != self.bindings.registry_oid or item.manifest_id != manifest.manifest_id or item.manifest_revision != manifest.revision:
|
||||
raise ValueError("operation capability map revision conflict")
|
||||
if CapabilityRef(item.namespace, item.capability_id) not in manifest.capabilities:
|
||||
raise ValueError("operation capability not declared in authoritative manifest")
|
||||
if item.namespace == CapabilityNamespace.AGENT_TASK and any(
|
||||
value.startswith("exact:") for value in normalize_required_capabilities([item.capability_id])):
|
||||
raise ValueError("unknown formal operation capability")
|
||||
seen.add(key)
|
||||
return manifest
|
||||
|
||||
def _worker(self, worker=None):
|
||||
worker = self.bindings.worker_provider() if worker is None else worker
|
||||
if not isinstance(worker, AuthenticatedWorker) or worker.expires_at <= self.clock():
|
||||
raise ForbiddenError("fresh authenticated worker required")
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if registered.identity != worker.actor_identity or registered.disabled:
|
||||
raise ForbiddenError("registered worker identity mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity)
|
||||
return worker
|
||||
|
||||
def _prepare(self, task_id, worker):
|
||||
manifest = self._validate_map()
|
||||
binding = self.bindings.operation_provider(task_id, worker)
|
||||
if not isinstance(binding, OperationBinding) or binding.task_id != task_id or binding.actor_id != worker.actor_identity or binding.source_oid != manifest.source_oid:
|
||||
raise ForbiddenError("operation identity mismatch")
|
||||
if binding.operation_id != "governed_source_patch_apply":
|
||||
raise ForbiddenError("operation outside C6 pilot")
|
||||
task = self.service.store.get_task(task_id)
|
||||
metadata = task.task_plan.metadata
|
||||
if (metadata.get("c6_operation_binding_digest") != binding_digest(binding)
|
||||
or metadata.get("generation") != binding.generation
|
||||
or binding.policy_digest != manifest.declaration_digest
|
||||
or binding.scope_digest not in {authorization_scope_digest(scope) for scope in manifest.allowed_scopes}):
|
||||
raise ForbiddenError("operation scope or immutable TaskPlan binding mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity, task=task)
|
||||
required = task.task_plan.required_capabilities
|
||||
if binding.capability_namespace == CapabilityNamespace.AGENT_TASK.value:
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if (not formal_has_capabilities(registered.capabilities, [binding.capability_id])
|
||||
or not normalize_required_capabilities([binding.capability_id]).issubset(
|
||||
normalize_required_capabilities(required))):
|
||||
raise ForbiddenError("formal capability cannot use wildcard worker authority")
|
||||
elif binding.capability_id not in required:
|
||||
raise ForbiddenError("operation capability absent from TaskPlan")
|
||||
verifier = self.bindings.worker_authority_verifier
|
||||
if verifier is None or verifier(worker, binding) is not True:
|
||||
raise ForbiddenError("worker receipt not bound to operation authority")
|
||||
if not any(item.operation_id == binding.operation_id and item.namespace.value == binding.capability_namespace and item.capability_id == binding.capability_id for item in self.bindings.operation_capabilities):
|
||||
raise ForbiddenError("operation capability namespace mismatch")
|
||||
preflight = derive_preflight(collect_observation(binding, self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return binding, preflight
|
||||
|
||||
def _intent(self, binding):
|
||||
reader = self.bindings.intent_reader
|
||||
if reader is None:
|
||||
raise ForbiddenError("durable execution intent reader required")
|
||||
intent = reader(binding)
|
||||
if intent is not None and (not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key):
|
||||
raise ForbiddenError("durable execution intent binding mismatch")
|
||||
return intent
|
||||
|
||||
def _durable_pending(self):
|
||||
provider = self.bindings.pending_intent_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("durable pending intent provider required")
|
||||
values = provider()
|
||||
if not isinstance(values, tuple) or len(values) > 16:
|
||||
raise ForbiddenError("bounded durable pending intent enumeration required")
|
||||
result = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if not isinstance(value, tuple) or len(value) != 2:
|
||||
raise ForbiddenError("durable pending intent entry is invalid")
|
||||
binding, intent = value
|
||||
if (
|
||||
not isinstance(binding, OperationBinding)
|
||||
or not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or binding.effect_key in seen
|
||||
):
|
||||
raise ForbiddenError("durable pending intent binding mismatch")
|
||||
seen.add(binding.effect_key)
|
||||
result.append((binding, intent))
|
||||
return tuple(result)
|
||||
|
||||
def _reconcile_pending(self, binding, intent, worker):
|
||||
current, _preflight = self._prepare(binding.task_id, worker)
|
||||
if current != binding or self._intent(binding) != intent:
|
||||
raise ForbiddenError("durable pending authority changed")
|
||||
assert self.bindings.pilot is not None
|
||||
try:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
observation = collect_closure(
|
||||
binding, self.bindings.operation_readers, now=self.clock()
|
||||
)
|
||||
return observation, derive_closure(binding, observation, now=self.clock())
|
||||
|
||||
def renew_claim(self, *, task_id, claim_id, actor_identity, receipt, lease_seconds):
|
||||
if self._closed or not self.bindings.enabled or not isinstance(receipt, dict):
|
||||
raise ForbiddenError("C6 v3 claim receipt required")
|
||||
if receipt.get("task_id") != task_id or receipt.get("claim_id") != claim_id:
|
||||
raise ForbiddenError("C6 claim receipt identity mismatch")
|
||||
provider = self.bindings.request_worker_provider
|
||||
worker_id = receipt.get("worker_id")
|
||||
candidate = provider(worker_id, actor_identity) if provider is not None else None
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker required")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("request worker identity mismatch")
|
||||
self._prepare(task_id, worker)
|
||||
return self.lease_controller.renew(receipt, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
|
||||
def claim_task(self, *, task_id: str, worker_id: str, actor_identity: str, lease_seconds: int = 30):
|
||||
"""Manual and automatic host claims share this exact authority seam."""
|
||||
if self._closed or not self.bindings.enabled or self.bindings.pilot is None:
|
||||
raise ForbiddenError("C6 claim binding disabled or unavailable")
|
||||
provider = self.bindings.request_worker_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("authenticated request worker provider required")
|
||||
candidate = provider(worker_id, actor_identity)
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker unavailable")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("worker provider binding mismatch")
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
return self._claim_prepared(binding, preflight, worker, lease_seconds=lease_seconds)
|
||||
|
||||
def _claim_prepared(self, binding, preflight, worker, *, lease_seconds=30):
|
||||
task_id, worker_id, actor_identity = binding.task_id, worker.worker_id, worker.actor_identity
|
||||
if preflight.state != "READY":
|
||||
raise ForbiddenError("C6 preflight: " + preflight.reason_code)
|
||||
if self._intent(binding) is not None:
|
||||
raise ForbiddenError("existing execution intent requires readback")
|
||||
closure = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
if reconcile_effect(closure.effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY":
|
||||
raise ForbiddenError("C6 effect must be authoritatively absent before claim")
|
||||
proof = self.schedulability.preflight(task_id=task_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, persist=True)
|
||||
if proof.observation.scope_digest != binding.scope_digest:
|
||||
raise ForbiddenError("claim authority scope differs from operation scope")
|
||||
result = self.coordinator.claim(proof_id=proof.proof_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
parsed = validate_task_plane_receipt(result, proof=proof)
|
||||
return parsed
|
||||
|
||||
def tick(self) -> C6TickResult:
|
||||
if self._closed or not self.bindings.enabled:
|
||||
return C6TickResult("DISABLED", "C6_DISABLED")
|
||||
if not self._lock.acquire(blocking=False):
|
||||
return C6TickResult("BLOCKED", "LOCAL_TICK_IN_PROGRESS")
|
||||
try:
|
||||
audit = self.bindings.aro_tick.run_due() if self.bindings.aro_tick else None
|
||||
if self.bindings.pilot is None:
|
||||
return C6TickResult("BLOCKED", "EXECUTION_ENTRYPOINT_ABSENT", audit_report_ref=audit)
|
||||
if self.bindings.pending_intent_provider is None:
|
||||
return C6TickResult(
|
||||
"BLOCKED", "DURABLE_PENDING_INTENT_PROVIDER_ABSENT",
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
# Restart recovery is ledger-first. It does not depend on the Task
|
||||
# remaining discoverable and it never calls execute/resubmits.
|
||||
pending = self._durable_pending()
|
||||
if pending:
|
||||
worker = self._worker()
|
||||
closures = []
|
||||
for binding, intent in pending:
|
||||
observation, closed = self._reconcile_pending(binding, intent, worker)
|
||||
closures.append(closed)
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
binding, intent, observation
|
||||
)
|
||||
all_succeeded = all(item.outcome == "SUCCEEDED" for item in closures)
|
||||
return C6TickResult(
|
||||
"OBSERVED" if all_succeeded else "UNKNOWN",
|
||||
"DURABLE_PENDING_INTENTS_RECONCILED"
|
||||
if all_succeeded
|
||||
else "DURABLE_PENDING_INTENT_READBACK",
|
||||
closure=closures[-1],
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
if self._pending is not None:
|
||||
# ACK loss is sticky. Reconciliation is a distinct, pure
|
||||
# owner seam: it may observe/reconcile the exact durable action
|
||||
# but may never acquire a new WriterLease or resend the commit.
|
||||
intent = self._intent(self._pending)
|
||||
if intent is None:
|
||||
return C6TickResult("UNKNOWN", "PENDING_INTENT_UNAVAILABLE",
|
||||
audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
observation, closure = self._reconcile_pending(
|
||||
self._pending, intent, worker
|
||||
)
|
||||
if closure.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
self._pending, intent, observation
|
||||
)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closure.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"PENDING_EFFECT_READBACK", closure=closure, audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
discovered = self.coordinator.discover(worker_id=worker.worker_id, actor_identity=worker.actor_identity)
|
||||
if discovered.proof is None:
|
||||
return C6TickResult("NO_CANDIDATES" if discovered.candidate_count == 0 else "BLOCKED",
|
||||
discovered.status, discovery=discovered, audit_report_ref=audit)
|
||||
task_id = discovered.proof.task_id
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
if self._intent(binding) is not None:
|
||||
self._pending = binding
|
||||
intent = self._intent(binding)
|
||||
try:
|
||||
if intent is not None:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
closed = derive_closure(binding, collect_closure(binding,
|
||||
self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"DURABLE_INTENT_READBACK", discovered, closure=closed, audit_report_ref=audit)
|
||||
if preflight.state != "READY":
|
||||
return C6TickResult("BLOCKED", preflight.reason_code, discovered, preflight=preflight, audit_report_ref=audit)
|
||||
claim = self._claim_prepared(binding, preflight, worker)
|
||||
# Re-read every precondition immediately before the effect. The
|
||||
# executor independently enforces its linearization-point fences.
|
||||
binding_after, refreshed = self._prepare(task_id, worker)
|
||||
self._worker(worker)
|
||||
if binding_after != binding or refreshed.state != "READY" or self._closed:
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_BINDING_CHANGED", discovered, claim, refreshed)
|
||||
try:
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
binding_after, refreshed = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (binding_after != binding or refreshed.state != "READY" or self._closed
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"
|
||||
or self._intent(binding) is not None):
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_AUTHORITY_CHANGED", discovered, claim, refreshed)
|
||||
self._pending = binding
|
||||
intent = self.bindings.pilot.prepare_intent(binding=binding, claim=claim)
|
||||
observed_intent = self._intent(binding)
|
||||
if (not isinstance(intent, DurableExecutionIntent) or intent != observed_intent
|
||||
or intent.claim_id != claim.claim_id):
|
||||
raise ForbiddenError("execution intent not durably observed")
|
||||
# Intent persistence may take time: acquire a new guard and
|
||||
# read authority/effect again before calling the governed writer.
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
after, ready = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (after != binding or ready.state != "READY" or self._closed
|
||||
or self._intent(binding) != intent
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"):
|
||||
raise ForbiddenError("pre-effect authority changed during intent persistence")
|
||||
self.bindings.pilot.execute(binding=binding, claim=claim, intent=intent)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "EXECUTION_OUTCOME_UNKNOWN", discovered, claim, refreshed)
|
||||
observed = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
closed = derive_closure(binding, observed, now=self.clock())
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(binding, intent, observed)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"THREE_LAYER_CLOSURE" if closed.outcome == "SUCCEEDED" else "CLOSURE_INCOMPLETE",
|
||||
discovered, claim, refreshed, closed, audit)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "AUTHORITY_OR_OBSERVATION_UNAVAILABLE")
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def close(self):
|
||||
# Local tick lifecycle only. Authority leases are never implicitly
|
||||
# released or renewed by closing an observer/coordinator.
|
||||
self._closed = True
|
||||
|
||||
|
||||
def bind_c6(service, bindings: C6Bindings, *, clock=None) -> C6Runtime:
|
||||
if getattr(service, "c6_runtime", None) is not None:
|
||||
raise ValueError("C6 runtime already bound")
|
||||
runtime = C6Runtime(service, bindings, clock=clock)
|
||||
service.c6_runtime = runtime
|
||||
return runtime
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Pure six-value C6 decision explanation policy.
|
||||
|
||||
The result is advice over already-read facts. It is not an authorization,
|
||||
does not map implicitly to legacy NextAction, and owns no write capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
|
||||
RootState = Literal["VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"]
|
||||
|
||||
|
||||
class DecisionKind(str, Enum):
|
||||
AUTO_FIX = "AUTO_FIX"
|
||||
PLAN_ONLY = "PLAN_ONLY"
|
||||
OWNER_APPROVAL_REQUIRED = "OWNER_APPROVAL_REQUIRED"
|
||||
HARD_BLOCK = "HARD_BLOCK"
|
||||
WAITING_ROOT_AUTHORIZATION = "WAITING_ROOT_AUTHORIZATION"
|
||||
BLOCKED_WITH_EVIDENCE = "BLOCKED_WITH_EVIDENCE"
|
||||
|
||||
|
||||
def _evidence_refs(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in value:
|
||||
if not isinstance(ref, str) or not ref or ref != ref.strip() or "\x00" in ref:
|
||||
raise ValueError("invalid evidence reference")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionInput:
|
||||
root_state: RootState
|
||||
facts_complete: bool
|
||||
scope_covered: bool
|
||||
policy_denied: bool
|
||||
proposal_only: bool
|
||||
executor_ready: bool
|
||||
aro_phase_a: bool
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.root_state not in ("VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"):
|
||||
raise ValueError("invalid root_state")
|
||||
for name in (
|
||||
"facts_complete",
|
||||
"scope_covered",
|
||||
"policy_denied",
|
||||
"proposal_only",
|
||||
"executor_ready",
|
||||
"aro_phase_a",
|
||||
):
|
||||
if type(getattr(self, name)) is not bool:
|
||||
raise ValueError(f"{name} must be bool")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
kind: DecisionKind
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, DecisionKind):
|
||||
raise ValueError("kind must be DecisionKind")
|
||||
if not isinstance(self.reason, str) or not self.reason:
|
||||
raise ValueError("reason must be non-empty")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
def decide(value: DecisionInput) -> Decision:
|
||||
if not isinstance(value, DecisionInput):
|
||||
raise TypeError("value must be DecisionInput")
|
||||
if value.policy_denied or value.root_state == "REVOKED":
|
||||
return Decision(
|
||||
DecisionKind.HARD_BLOCK,
|
||||
"POLICY_DENIED_OR_ROOT_REVOKED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.facts_complete or value.root_state == "UNKNOWN":
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"CRITICAL_FACTS_INCOMPLETE",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.root_state in ("MISSING", "EXPIRED"):
|
||||
return Decision(
|
||||
DecisionKind.WAITING_ROOT_AUTHORIZATION,
|
||||
"ROOT_AUTHORIZATION_MISSING_OR_EXPIRED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.scope_covered:
|
||||
return Decision(
|
||||
DecisionKind.OWNER_APPROVAL_REQUIRED,
|
||||
"SCOPE_OR_BUDGET_REQUIRES_OWNER_DECISION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.proposal_only or value.aro_phase_a:
|
||||
return Decision(
|
||||
DecisionKind.PLAN_ONLY,
|
||||
"NON_EXECUTABLE_PLAN_OR_READONLY_ARO",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.executor_ready:
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"GOVERNED_EXECUTOR_NOT_READY",
|
||||
value.evidence_refs,
|
||||
)
|
||||
return Decision(
|
||||
DecisionKind.AUTO_FIX,
|
||||
"EXISTING_AUTHORIZED_SCOPE_ELIGIBLE_FOR_GOVERNED_EXECUTION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Decision", "DecisionInput", "DecisionKind", "decide"]
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from .models import EvidenceEnvelope
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
@@ -17,8 +18,44 @@ class EvidenceService:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
|
||||
return self.store.add_evidence(task_id, kind, payload, worker_identity)
|
||||
def add(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict,
|
||||
worker_identity: str,
|
||||
*,
|
||||
envelope: EvidenceEnvelope | None = None,
|
||||
claim_id: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> str:
|
||||
return self.store.add_evidence(
|
||||
task_id,
|
||||
kind,
|
||||
payload,
|
||||
worker_identity,
|
||||
envelope=envelope,
|
||||
claim_id=claim_id,
|
||||
evidence_id=evidence_id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
def list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_evidence(task_id)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Authenticated Task Plane evidence envelope adapter.
|
||||
|
||||
The adapter deliberately receives key material through an external resolver.
|
||||
It does not create, persist, or select a production trust root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Callable
|
||||
|
||||
from .models import EvidenceEnvelope, canonical_json, compute_sha256_hex
|
||||
|
||||
|
||||
HMAC_SHA256_V1 = "hmac-sha256-v1"
|
||||
_SIGNATURE_DOMAIN = b"aios.task-plane.evidence-envelope.v1"
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _signature_payload(envelope: EvidenceEnvelope) -> bytes:
|
||||
return (
|
||||
_SIGNATURE_DOMAIN
|
||||
+ b"\0"
|
||||
+ canonical_json(envelope.digest_payload()).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
class HmacEvidenceVerifier:
|
||||
"""Verify full evidence envelopes using an externally managed HMAC key."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_identity: str,
|
||||
key_id: str,
|
||||
key_resolver: Callable[[str], bytes],
|
||||
max_age_seconds: int = 300,
|
||||
now_fn: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not verifier_identity or not key_id:
|
||||
raise ValueError("verifier_identity and key_id are required")
|
||||
if not callable(key_resolver):
|
||||
raise ValueError("key_resolver is required")
|
||||
if (
|
||||
isinstance(max_age_seconds, bool)
|
||||
or not isinstance(max_age_seconds, int)
|
||||
or max_age_seconds <= 0
|
||||
):
|
||||
raise ValueError("max_age_seconds must be a positive integer")
|
||||
self.verifier_identity = verifier_identity
|
||||
self.key_id = key_id
|
||||
self._key_resolver = key_resolver
|
||||
self.max_age_seconds = max_age_seconds
|
||||
self._now_fn = now_fn or time.time
|
||||
|
||||
def __call__(self, envelope: EvidenceEnvelope) -> bool:
|
||||
try:
|
||||
now = float(self._now_fn())
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (now, envelope.created_at, envelope.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(envelope.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
not finite_timestamps
|
||||
or envelope.expires_at <= envelope.created_at
|
||||
or envelope.signature_algorithm != HMAC_SHA256_V1
|
||||
or envelope.signing_key_id != self.key_id
|
||||
or envelope.verifier_identity != self.verifier_identity
|
||||
or envelope.authority_domain != self.verifier_identity
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
or not isinstance(envelope.signature, str)
|
||||
or not _HEX64.fullmatch(envelope.signature)
|
||||
or envelope.created_at > now
|
||||
or envelope.expires_at <= now
|
||||
or envelope.expires_at - envelope.created_at > self.max_age_seconds
|
||||
or envelope.evidence_digest != expected_digest
|
||||
):
|
||||
return False
|
||||
try:
|
||||
key = self._key_resolver(self.key_id)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
return False
|
||||
expected = hmac.new(
|
||||
bytes(key), _signature_payload(envelope), hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(envelope.signature, expected)
|
||||
|
||||
|
||||
def sign_evidence_envelope(
|
||||
envelope: EvidenceEnvelope,
|
||||
*,
|
||||
key_id: str,
|
||||
key: bytes,
|
||||
verifier_identity: str,
|
||||
) -> EvidenceEnvelope:
|
||||
"""Return a signed envelope for an already authorized producer.
|
||||
|
||||
Production code should obtain ``key`` from its existing credential provider;
|
||||
this helper intentionally performs no key generation or storage.
|
||||
"""
|
||||
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
raise ValueError("key is required")
|
||||
if not key_id or not verifier_identity:
|
||||
raise ValueError("key_id and verifier_identity are required")
|
||||
if envelope.authority_domain != verifier_identity:
|
||||
raise ValueError("envelope authority_domain must match verifier_identity")
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
if envelope.expires_at <= envelope.created_at:
|
||||
raise ValueError("evidence expiry must follow creation")
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ValueError("evidence sequence must be a positive integer")
|
||||
unsigned = replace(
|
||||
envelope,
|
||||
signature=None,
|
||||
signature_algorithm=HMAC_SHA256_V1,
|
||||
signing_key_id=key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
evidence_digest="",
|
||||
)
|
||||
unsigned = replace(
|
||||
unsigned,
|
||||
evidence_digest=compute_sha256_hex(unsigned.digest_payload()),
|
||||
)
|
||||
signature = hmac.new(
|
||||
bytes(key), _signature_payload(unsigned), hashlib.sha256
|
||||
).hexdigest()
|
||||
return replace(unsigned, signature=signature)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""现场(field)组合根:让"Owner 基于授权"真正可接线、可执行。
|
||||
|
||||
本模块是 V2 governed-writer 机制的**现场装配层**,职责:
|
||||
- 实现正式的 `OwnerAuthorizationAuthority`:持有 Owner 登记的授权记录
|
||||
(authorization_ref -> OwnerAuthorizationPolicy),`verify()` 解析引用、
|
||||
核验策略、签发 verification receipt;未知引用 / 策略失效一律 fail-closed。
|
||||
- 提供 `FieldGovernedWriterFactory`(组合根):把 7 个协议适配器(signer /
|
||||
execution / principal_issuer / jobs / git / writer_lease / owner_authority)
|
||||
装配到 `GovernedWriterAutomation.acquire_verified()` 生产入口上。
|
||||
- 提供默认的生产适配器参考实现(`_DefaultOwnerAuthority`),供现场在真实
|
||||
端点未就绪时先闭环;真实端点就绪后替换对应 adapter 即可,组合根接口不变。
|
||||
|
||||
设计原则(与 OWNER_AUTHORIZATION_MODEL.md 的 `aios.owner-authorization.v1` 一致):
|
||||
1. 生产入口唯一授权来源是 Owner 登记的 `authorization_ref`;Agent 本地自造的
|
||||
`OwnerAuthorizationPolicy` 不构成 proof(fail-closed)。
|
||||
2. 长期业务授权(Policy)与短时执行凭证(epoch/lease/principal)分离。
|
||||
3. `legacy_global_writes_enabled=false` 是安全不变式,不是 governed-writer blocker。
|
||||
|
||||
本模块不修改 S6 frozen 9 文件,不触碰 legacy/global 写;只做 governed 写路径的
|
||||
现场装配。适配器接口全部复用 `governed_writer_automation.py` 的 Protocol。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
JobBindingReceipt,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
VerifiedOwnerAuthorization,
|
||||
WriterLeaseReceipt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 正式 OwnerAuthorizationAuthority 现场实现
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldOwnerAuthorizationAuthority:
|
||||
"""现场 authority:登记 Owner 授权记录并按引用验证,签发核验回执。
|
||||
|
||||
生产语义:
|
||||
- `register(ref, policy)` 由 Owner/管理员在受信侧登记;登记即"一次授权"。
|
||||
- `verify(authorization_ref, request)` 解析引用 -> 核验 -> 返回
|
||||
`VerifiedOwnerAuthorization`(含非空 receipt)。
|
||||
- 任何未知引用、未登记、策略与请求越界,都抛 `AuthorizationDenied`
|
||||
(fail-closed:不猜测、不降级)。
|
||||
"""
|
||||
|
||||
_registry: dict[str, OwnerAuthorizationPolicy] = field(default_factory=dict)
|
||||
_receipt_counter: int = 0
|
||||
|
||||
def register(self, authorization_ref: str, policy: OwnerAuthorizationPolicy) -> None:
|
||||
if authorization_ref != policy.authorization_id:
|
||||
raise AuthorizationDenied(
|
||||
"authorization_ref must equal policy.authorization_id"
|
||||
)
|
||||
self._registry[authorization_ref] = policy
|
||||
|
||||
def contains(self, authorization_ref: str) -> bool:
|
||||
return authorization_ref in self._registry
|
||||
|
||||
def policy(self, authorization_ref: str) -> OwnerAuthorizationPolicy:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(f"unknown authorization reference: {authorization_ref}")
|
||||
return self._registry[authorization_ref]
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(
|
||||
f"owner authorization reference not registered: {authorization_ref}"
|
||||
)
|
||||
policy = self._registry[authorization_ref]
|
||||
policy.validate(request, now=time.time())
|
||||
self._receipt_counter += 1
|
||||
receipt = f"receipt:owner-{authorization_ref}:{self._receipt_counter}"
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt=receipt,
|
||||
verified_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 默认生产适配器参考实现(真实端点未就绪时先闭环,接口与协议一一对应)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _DefaultTrustedSigner:
|
||||
"""TrustedSignerAdapter:JIT 重签同 scope 的 epoch(幂等由 ledger 守护)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus:
|
||||
self._counter += 1
|
||||
self.calls.append(
|
||||
{
|
||||
"key": idempotency_key,
|
||||
"scope": request.scope_fingerprint,
|
||||
"policy": owner_policy.policy_fingerprint,
|
||||
"prior": None if prior_epoch is None else prior_epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-gw-{self._counter}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() + 7200.0,
|
||||
policy_digest=owner_policy.policy_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultExecutionPlane:
|
||||
"""ExecutionPlane:QUEUED 自动 claim + lease;heartbeat 记录。"""
|
||||
|
||||
def __init__(self, initial: TaskState) -> None:
|
||||
self.task = initial
|
||||
self._lease_counter = 0
|
||||
self.claims: list = []
|
||||
self.heartbeats: list = []
|
||||
|
||||
def read_task(self, task_id: str) -> TaskState:
|
||||
if task_id != self.task.task_id:
|
||||
raise AuthorizationDenied(f"unknown task: {task_id}")
|
||||
return self.task
|
||||
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState:
|
||||
raise AuthorizationDenied("terminal recovery requires a real recovery endpoint")
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
):
|
||||
from app.task_plane.governed_writer_automation import ClaimReceipt
|
||||
|
||||
self.claims.append({"task": task.task_id, "actor": actor, "key": idempotency_key})
|
||||
return ClaimReceipt(
|
||||
claim_id=f"claim-gw-{len(self.claims)}",
|
||||
task_id=task.task_id,
|
||||
actor=actor,
|
||||
fencing=len(self.claims),
|
||||
)
|
||||
|
||||
def heartbeat(self, *, claim) -> None:
|
||||
self.heartbeats.append(claim.claim_id)
|
||||
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt:
|
||||
self._lease_counter += 1
|
||||
return TaskLeaseReceipt(
|
||||
task_lease_id=f"tasklease-gw-{self._lease_counter}",
|
||||
task_id=claim.task_id,
|
||||
token_ref=f"token-gw-{self._lease_counter}",
|
||||
expires_at=time.time() + 3600.0,
|
||||
fencing=claim.fencing,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultPrincipalIssuer:
|
||||
"""TaskPrincipalIssuer:按 lease 派发 scoped TaskPrincipal。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt:
|
||||
self._counter += 1
|
||||
return TaskPrincipalReceipt(
|
||||
principal_id=f"principal-gw-{self._counter}",
|
||||
task_id=lease.task_id,
|
||||
capabilities=requested_capabilities,
|
||||
expires_at=time.time() + 1800.0,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultJobs:
|
||||
"""JobAuthority:reconcile 到与请求一致的 job 绑定。"""
|
||||
|
||||
def __init__(self, *, repository: str, target_branch: str, generation: int) -> None:
|
||||
self._repo = repository
|
||||
self._branch = target_branch
|
||||
self._gen = generation
|
||||
self._counter = 0
|
||||
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt:
|
||||
self._counter += 1
|
||||
return JobBindingReceipt(
|
||||
job_id=f"job-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
repository=self._repo,
|
||||
target_branch=self._branch,
|
||||
generation=self._gen,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultGit:
|
||||
"""GitAuthority:只读 branch 读回,决定 CAS 模式。"""
|
||||
|
||||
def __init__(self, *, exists: bool, head_oid: str | None = None) -> None:
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self._exists = exists
|
||||
self._head = head_oid
|
||||
self.calls: list = []
|
||||
|
||||
def read_branch(self, *, repository: str, target_branch: str):
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self.calls.append({"repo": repository, "branch": target_branch})
|
||||
return BranchReadback(exists=self._exists, head_oid=self._head)
|
||||
|
||||
|
||||
class _DefaultWriterLease:
|
||||
"""WriterLeaseAuthority:签发短时 writer lease(CAS 与 base 一致)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt:
|
||||
self._counter += 1
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id=f"writerlease-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
job_id=job.job_id,
|
||||
repository=repository,
|
||||
target_branch=target_branch,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=generation,
|
||||
expires_at=time.time() + 900.0,
|
||||
fencing=self._counter,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 组合根:装配 7 适配器 -> 生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldRuntimeStatus:
|
||||
"""现场运行时状态:由 W-RUNTIME 只读线提供,用于派生 governed 写就绪。"""
|
||||
|
||||
legacy_global_writes_enabled: bool = False
|
||||
task_principal_writes_enabled: bool = True
|
||||
task_principal_verifier_ready: bool = True
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldGovernedWriterFactory:
|
||||
"""组合根:把现场配置装配成 `acquire_verified()` 生产入口可直接执行的对象。
|
||||
|
||||
用法(现场接线):
|
||||
factory = FieldGovernedWriterFactory.build(
|
||||
authority=owner_authority,
|
||||
manifest_store=...,
|
||||
action_ledger=...,
|
||||
task_initial=TaskState(...),
|
||||
branch_exists=...,
|
||||
head_oid=...,
|
||||
repo=...,
|
||||
branch=...,
|
||||
generation=...,
|
||||
runtime_status=...,
|
||||
)
|
||||
session = factory.acquire(request, authorization_ref)
|
||||
"""
|
||||
|
||||
automation: GovernedWriterAutomation
|
||||
adapters: GovernedWriterAdapters
|
||||
readiness: GovernedWriteReadiness
|
||||
owner_authority: FieldOwnerAuthorizationAuthority
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
authority: FieldOwnerAuthorizationAuthority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task_initial: TaskState,
|
||||
branch_exists: bool,
|
||||
head_oid: str | None,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
runtime_status: FieldRuntimeStatus | None = None,
|
||||
decision_engine=None,
|
||||
) -> "FieldGovernedWriterFactory":
|
||||
runtime_status = runtime_status or FieldRuntimeStatus()
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task_initial),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(
|
||||
repository=repository, target_branch=target_branch, generation=generation
|
||||
),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
automation = GovernedWriterAutomation(
|
||||
manifest_store=manifest_store,
|
||||
action_ledger=action_ledger,
|
||||
decision_engine=decision_engine,
|
||||
)
|
||||
readiness = GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=runtime_status.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=runtime_status.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=runtime_status.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(runtime_status.governed_write_blockers),
|
||||
)
|
||||
return cls(
|
||||
automation=automation,
|
||||
adapters=adapters,
|
||||
readiness=readiness,
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
*,
|
||||
epoch: EpochStatus | None = None,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""生产入口:先验 Owner 授权,再自动完成受治理写链。"""
|
||||
return self.automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=self.readiness,
|
||||
epoch=epoch,
|
||||
adapters=self.adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
"""Fail-closed candidate-specific governed admission preflight.
|
||||
|
||||
Ordinary/global writes are deliberately NOT a prerequisite. This evaluates the
|
||||
governed Bridge/TaskPrincipal path only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Sequence
|
||||
|
||||
from .governance_resolver import ResolutionResult, ResolutionState
|
||||
|
||||
|
||||
class AdmissionOperation(str, Enum):
|
||||
GOVERNED_COMMIT = "GOVERNED_COMMIT"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceFacts:
|
||||
governed_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
recovery_execution_enabled: bool
|
||||
recovery_shadow_mode: bool
|
||||
authenticated_principal_id: str | None
|
||||
target_branch: str | None
|
||||
branch_protection_known: bool
|
||||
expected_branch_oid: str | None
|
||||
generation: int | None
|
||||
task_lease_id: str | None
|
||||
task_principal_id: str | None
|
||||
writer_lease_id: str | None
|
||||
accepted_file_allowlist: tuple[str, ...] = ()
|
||||
acceptance_revision: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionBlocker:
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionResult:
|
||||
ready: bool
|
||||
operation: AdmissionOperation
|
||||
blockers: tuple[AdmissionBlocker, ...]
|
||||
|
||||
|
||||
class AdmissionPreflight:
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
resolution: ResolutionResult,
|
||||
facts: GovernanceFacts,
|
||||
operation: AdmissionOperation,
|
||||
) -> AdmissionResult:
|
||||
blockers: list[AdmissionBlocker] = []
|
||||
|
||||
if resolution.state is ResolutionState.NOT_FOUND:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_NOT_RESOLVED", "exact candidate task not found"
|
||||
))
|
||||
elif resolution.state is ResolutionState.AMBIGUOUS:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_AMBIGUOUS", "multiple exact candidate tasks"
|
||||
))
|
||||
elif resolution.state is not ResolutionState.RESOLVED:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_INVALID", resolution.detail or "resolver invalid"
|
||||
))
|
||||
|
||||
if not facts.governed_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GOVERNED_WRITES_DISABLED",
|
||||
"governed Bridge write path is disabled",
|
||||
))
|
||||
if not facts.task_principal_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED",
|
||||
"TaskPrincipal governed writes are disabled",
|
||||
))
|
||||
if not facts.authenticated_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"authenticated governed caller principal is missing",
|
||||
))
|
||||
if not facts.target_branch:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TARGET_BRANCH_UNRESOLVED", "target branch is missing"
|
||||
))
|
||||
if not facts.branch_protection_known:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"BRANCH_PROTECTION_UNKNOWN",
|
||||
"branch protection has not been read through authority",
|
||||
))
|
||||
if not facts.expected_branch_oid:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED",
|
||||
"expected_branch_oid/CAS preimage is missing",
|
||||
))
|
||||
if facts.generation is None:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GENERATION_UNRESOLVED", "governed generation is missing"
|
||||
))
|
||||
if not facts.task_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_LEASE_MISSING", "TaskLease not established"
|
||||
))
|
||||
if not facts.task_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_MISSING", "TaskPrincipal not established"
|
||||
))
|
||||
if not facts.writer_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"WRITER_LEASE_MISSING", "WriterLease not established"
|
||||
))
|
||||
if not facts.accepted_file_allowlist:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"FILE_ALLOWLIST_MISSING", "accepted file allowlist is empty"
|
||||
))
|
||||
if not facts.acceptance_revision:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
"candidate acceptance revision is missing",
|
||||
))
|
||||
|
||||
if operation is AdmissionOperation.RECOVER_EXISTING_CHILD:
|
||||
if not facts.recovery_execution_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_EXECUTION_DISABLED",
|
||||
"recovery execution path is disabled",
|
||||
))
|
||||
if facts.recovery_shadow_mode:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_SHADOW_MODE",
|
||||
"recovery is still shadow-only",
|
||||
))
|
||||
|
||||
# Ordinary/global write state is intentionally absent from the model.
|
||||
return AdmissionResult(
|
||||
ready=not blockers,
|
||||
operation=operation,
|
||||
blockers=tuple(blockers),
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
"""Canonical recovery observation checkpoint and unchanged-blocker budget.
|
||||
|
||||
The checkpoint is NOT an authority. It records the last reconciled observation
|
||||
so agents do not repeatedly prove the same unchanged blocker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCheckpoint:
|
||||
schema: str
|
||||
goal_id: str
|
||||
repository: str
|
||||
source_oid: str
|
||||
candidate_digest: str
|
||||
task_id: str | None
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
principal_id: str | None
|
||||
blocker_code: str | None
|
||||
blocker_fingerprint: str | None
|
||||
next_legal_action: str
|
||||
last_verified_at: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
unchanged_cycles: int
|
||||
mechanism_repair_required: bool
|
||||
recovery_idempotency_key: str | None = None
|
||||
recovery_receipt_ref: str | None = None
|
||||
recovery_context_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
value = asdict(self)
|
||||
value["evidence_refs"] = list(self.evidence_refs)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return _digest(self.to_dict())
|
||||
|
||||
|
||||
class CheckpointStore(Protocol):
|
||||
def load(self) -> RecoveryCheckpoint | None: ...
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None: ...
|
||||
|
||||
|
||||
class JsonCheckpointStore:
|
||||
"""Atomic local observation cache; never used as authorization evidence."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RecoveryCheckpoint | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
raw["evidence_refs"] = tuple(raw.get("evidence_refs") or ())
|
||||
return RecoveryCheckpoint(**raw)
|
||||
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_text(
|
||||
json.dumps(
|
||||
checkpoint.to_dict(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp, self.path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryBudget:
|
||||
unchanged_threshold: int = 3
|
||||
escalation_eligible_codes: frozenset[str] = frozenset({
|
||||
"TASK_NOT_RESOLVED",
|
||||
"TASK_RESOLUTION_AMBIGUOUS",
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"CANONICAL_CHANNEL_MISSING",
|
||||
"RESOLVER_UNAVAILABLE",
|
||||
})
|
||||
|
||||
def __post_init__(self):
|
||||
if self.unchanged_threshold < 2 or self.unchanged_threshold > 100:
|
||||
raise ValueError("unchanged_threshold must be in [2,100]")
|
||||
|
||||
@staticmethod
|
||||
def blocker_fingerprint(code: str, details: Sequence[str]) -> str:
|
||||
return _digest({
|
||||
"code": code,
|
||||
"details": sorted(str(item) for item in details),
|
||||
})
|
||||
|
||||
def observe(
|
||||
self,
|
||||
*,
|
||||
previous: RecoveryCheckpoint | None,
|
||||
blocker_code: str,
|
||||
blocker_fingerprint: str,
|
||||
) -> tuple[int, bool]:
|
||||
same = (
|
||||
previous is not None
|
||||
and previous.blocker_code == blocker_code
|
||||
and previous.blocker_fingerprint == blocker_fingerprint
|
||||
)
|
||||
cycles = (previous.unchanged_cycles + 1) if same else 1
|
||||
repair = (
|
||||
blocker_code in self.escalation_eligible_codes
|
||||
and cycles >= self.unchanged_threshold
|
||||
)
|
||||
return cycles, repair
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
"""Live driver that converts external surface failures into one checkpoint.
|
||||
|
||||
The controller remains authority-neutral. This driver owns the site boundary:
|
||||
surface availability, binding-contract failures, and poll fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .governance_checkpoint import RecoveryCheckpoint, RetryBudget
|
||||
from .governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
ReconcileResult,
|
||||
)
|
||||
from .site_adapters import LiveBindingViolation, LiveSurfaceUnavailable
|
||||
|
||||
|
||||
class GovernanceLiveDriver:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GovernanceRecoveryController,
|
||||
*,
|
||||
retry_budget: RetryBudget | None = None,
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.retry_budget = retry_budget or controller.retry_budget
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
query = self.controller.query
|
||||
return (
|
||||
query.patch_digest
|
||||
or query.artifact_sha256
|
||||
or query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _persist_external_blocker(
|
||||
self,
|
||||
event: GovernanceEvent,
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
mechanism_repair_required: bool,
|
||||
) -> ReconcileResult:
|
||||
previous = self.controller.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(code, (detail,))
|
||||
if mechanism_repair_required:
|
||||
cycles = (
|
||||
previous.unchanged_cycles + 1
|
||||
if previous
|
||||
and previous.blocker_code == code
|
||||
and previous.blocker_fingerprint == fingerprint
|
||||
else 1
|
||||
)
|
||||
else:
|
||||
cycles, _ = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = (
|
||||
"REPAIR_MECHANISM"
|
||||
if mechanism_repair_required
|
||||
else "WAIT_DEPENDENCY"
|
||||
)
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.controller.goal_id,
|
||||
repository=self.controller.query.repository,
|
||||
source_oid=self.controller.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self.controller._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
self.controller.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=None,
|
||||
blocker_code=code,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
try:
|
||||
return self.controller.on_event(event)
|
||||
except LiveSurfaceUnavailable as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_SURFACE_UNAVAILABLE",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
except LiveBindingViolation as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_BINDING_VIOLATION",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=True,
|
||||
)
|
||||
|
||||
def poll_once(
|
||||
self, *, revision: str, occurred_at: str
|
||||
) -> ReconcileResult:
|
||||
return self.on_event(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,474 @@
|
||||
|
||||
"""Event-driven governed recovery reconciler with polling fallback.
|
||||
|
||||
The controller cannot create a task and cannot bypass admission. It resumes
|
||||
only an exact, already-existing child through an injected governed executor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol
|
||||
|
||||
from .governance_admission import (
|
||||
AdmissionOperation,
|
||||
AdmissionPreflight,
|
||||
GovernanceFacts,
|
||||
)
|
||||
from .governance_checkpoint import (
|
||||
CheckpointStore,
|
||||
RecoveryCheckpoint,
|
||||
RetryBudget,
|
||||
)
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionState
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryReceipt:
|
||||
receipt_ref: str
|
||||
task_id: str
|
||||
accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileResult:
|
||||
status: str
|
||||
next_legal_action: str
|
||||
task_id: str | None
|
||||
blocker_code: str | None
|
||||
mechanism_repair_required: bool
|
||||
receipt_ref: str | None = None
|
||||
|
||||
|
||||
class GovernanceFactsProvider(Protocol):
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts: ...
|
||||
|
||||
|
||||
class ExistingChildRecoveryExecutor(Protocol):
|
||||
def recover_existing_child(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
facts: GovernanceFacts,
|
||||
) -> RecoveryReceipt: ...
|
||||
|
||||
# Optional at runtime. If implemented, this is preferred after an
|
||||
# unknown-result write so the controller reconciles before re-executing.
|
||||
def reconcile_recovery_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
) -> RecoveryReceipt | None: ...
|
||||
|
||||
|
||||
class GovernanceRecoveryController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
goal_id: str,
|
||||
query: ResolutionQuery,
|
||||
actor_identity: str,
|
||||
resolver: ExactTaskResolver,
|
||||
facts_provider: GovernanceFactsProvider,
|
||||
executor: ExistingChildRecoveryExecutor,
|
||||
checkpoint_store: CheckpointStore,
|
||||
retry_budget: RetryBudget = RetryBudget(),
|
||||
) -> None:
|
||||
self.goal_id = goal_id
|
||||
self.query = query
|
||||
self.actor_identity = actor_identity
|
||||
self.resolver = resolver
|
||||
self.facts_provider = facts_provider
|
||||
self.executor = executor
|
||||
self.checkpoints = checkpoint_store
|
||||
self.retry_budget = retry_budget
|
||||
self.preflight = AdmissionPreflight()
|
||||
|
||||
def _timestamp(self, event: GovernanceEvent) -> str:
|
||||
return event.occurred_at or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _save_blocked(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
task_id: str | None,
|
||||
mission_id: str | None,
|
||||
root_task_id: str | None,
|
||||
parent_task_id: str | None,
|
||||
principal_id: str | None,
|
||||
blocker_code: str,
|
||||
blocker_details: tuple[str, ...],
|
||||
) -> ReconcileResult:
|
||||
previous = self.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(
|
||||
blocker_code, blocker_details
|
||||
)
|
||||
cycles, repair = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = "REPAIR_MECHANISM" if repair else "WAIT_DEPENDENCY"
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=task_id,
|
||||
mission_id=mission_id,
|
||||
root_task_id=root_task_id,
|
||||
parent_task_id=parent_task_id,
|
||||
principal_id=principal_id,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=task_id,
|
||||
blocker_code=blocker_code,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
return (
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _recovery_context_digest(
|
||||
self, *, task_id: str, facts: GovernanceFacts
|
||||
) -> str:
|
||||
return _digest({
|
||||
"task_id": task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": self._candidate_digest(),
|
||||
"principal": facts.authenticated_principal_id,
|
||||
"branch": facts.target_branch,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"task_lease_id": facts.task_lease_id,
|
||||
"task_principal_id": facts.task_principal_id,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
"acceptance_revision": facts.acceptance_revision,
|
||||
"file_allowlist": sorted(facts.accepted_file_allowlist),
|
||||
})
|
||||
|
||||
def _finish_recovered(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
resolved,
|
||||
facts: GovernanceFacts,
|
||||
idempotency_key: str,
|
||||
context_digest: str,
|
||||
receipt: RecoveryReceipt,
|
||||
) -> ReconcileResult:
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=None,
|
||||
blocker_fingerprint=None,
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(
|
||||
f"event:{event.kind}:{event.revision}",
|
||||
receipt.receipt_ref,
|
||||
),
|
||||
unchanged_cycles=0,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_receipt_ref=receipt.receipt_ref,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RECOVERED",
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code=None,
|
||||
mechanism_repair_required=False,
|
||||
receipt_ref=receipt.receipt_ref,
|
||||
)
|
||||
|
||||
def reconcile(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
resolution = self.resolver.resolve(
|
||||
self.query, actor_identity=self.actor_identity
|
||||
)
|
||||
if resolution.state is not ResolutionState.RESOLVED:
|
||||
code = {
|
||||
ResolutionState.NOT_FOUND: "TASK_NOT_RESOLVED",
|
||||
ResolutionState.AMBIGUOUS: "TASK_RESOLUTION_AMBIGUOUS",
|
||||
ResolutionState.INVALID_QUERY: "TASK_RESOLUTION_INVALID",
|
||||
}[resolution.state]
|
||||
details = (
|
||||
resolution.detail,
|
||||
*resolution.candidate_task_ids,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_details=tuple(str(v) for v in details if v),
|
||||
)
|
||||
|
||||
resolved = resolution.resolved
|
||||
assert resolved is not None
|
||||
facts = self.facts_provider.snapshot(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
admission = self.preflight.evaluate(
|
||||
resolution=resolution,
|
||||
facts=facts,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
if not admission.ready:
|
||||
primary = admission.blockers[0]
|
||||
details = tuple(
|
||||
f"{blocker.code}:{blocker.detail}"
|
||||
for blocker in admission.blockers
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=primary.code,
|
||||
blocker_details=details,
|
||||
)
|
||||
|
||||
context_digest = self._recovery_context_digest(
|
||||
task_id=resolved.task_id, facts=facts
|
||||
)
|
||||
previous = self.checkpoints.load()
|
||||
candidate_digest = self._candidate_digest()
|
||||
unknown_previous = (
|
||||
previous is not None
|
||||
and previous.blocker_code == "RECOVERY_RESULT_UNKNOWN"
|
||||
and previous.task_id == resolved.task_id
|
||||
and previous.candidate_digest == candidate_digest
|
||||
and bool(previous.recovery_idempotency_key)
|
||||
)
|
||||
|
||||
if unknown_previous:
|
||||
idempotency_key = previous.recovery_idempotency_key
|
||||
reconcile = getattr(
|
||||
self.executor, "reconcile_recovery_result", None
|
||||
)
|
||||
if callable(reconcile):
|
||||
receipt = reconcile(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if receipt is not None:
|
||||
if receipt.accepted:
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=(
|
||||
previous.recovery_context_digest
|
||||
or context_digest
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
if (
|
||||
previous.recovery_context_digest is not None
|
||||
and previous.recovery_context_digest != context_digest
|
||||
):
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=candidate_digest,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"context_changed": True,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=(
|
||||
previous.recovery_context_digest
|
||||
),
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
else:
|
||||
idempotency_key = "recover-" + _digest({
|
||||
"task_id": resolved.task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": candidate_digest,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
})[:40]
|
||||
|
||||
try:
|
||||
receipt = self.executor.recover_existing_child(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception:
|
||||
# Unknown-result write: retain the SAME idempotency key for the next
|
||||
# reconcile. Never assume the side effect did not happen.
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
|
||||
if not receipt.accepted:
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=context_digest,
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
# Event-driven primary path: reconcile immediately in the same call.
|
||||
return self.reconcile(event)
|
||||
|
||||
def poll_once(self, *, revision: str, occurred_at: str) -> ReconcileResult:
|
||||
# Polling is only a fallback and uses exactly the same reconcile path.
|
||||
return self.reconcile(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,229 @@
|
||||
|
||||
"""Read-only exact Task/lineage resolver.
|
||||
|
||||
This is an index/projection over existing TaskPlane authority, not a second task
|
||||
authority. It never creates, claims, mutates, or guesses a task.
|
||||
|
||||
The source supplied by the host MUST enforce the caller's existing TaskPlane
|
||||
read authorization before returning records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class ResolutionState(str, Enum):
|
||||
RESOLVED = "RESOLVED"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
AMBIGUOUS = "AMBIGUOUS"
|
||||
INVALID_QUERY = "INVALID_QUERY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionQuery:
|
||||
repository: str
|
||||
base_oid: str
|
||||
task_id: str | None = None
|
||||
candidate_oid: str | None = None
|
||||
patch_digest: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
pr_id: str | None = None
|
||||
release_intent: str | None = None
|
||||
mission_id: str | None = None
|
||||
root_task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
owner_principal_id: str | None = None
|
||||
|
||||
def selectors(self) -> Mapping[str, str]:
|
||||
values = {
|
||||
"repository": self.repository,
|
||||
"base_oid": self.base_oid,
|
||||
"task_id": self.task_id,
|
||||
"candidate_oid": self.candidate_oid,
|
||||
"patch_digest": self.patch_digest,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"pr_id": self.pr_id,
|
||||
"release_intent": self.release_intent,
|
||||
"mission_id": self.mission_id,
|
||||
"root_task_id": self.root_task_id,
|
||||
"parent_task_id": self.parent_task_id,
|
||||
"owner_principal_id": self.owner_principal_id,
|
||||
}
|
||||
return {key: str(value) for key, value in values.items() if value}
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.repository.strip() or not self.base_oid.strip():
|
||||
raise ValueError("repository and base_oid required")
|
||||
strong = (
|
||||
self.task_id,
|
||||
self.candidate_oid,
|
||||
self.patch_digest,
|
||||
self.artifact_sha256,
|
||||
self.pr_id,
|
||||
self.release_intent,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
)
|
||||
if not any(value for value in strong):
|
||||
raise ValueError(
|
||||
"at least one strong selector required "
|
||||
"(task/candidate/patch/artifact/pr/intent/mission/root)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedTask:
|
||||
task_id: str
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
owner_principal_id: str | None
|
||||
record: Mapping[str, Any]
|
||||
matched_selectors: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
state: ResolutionState
|
||||
resolved: ResolvedTask | None = None
|
||||
candidate_task_ids: tuple[str, ...] = ()
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class TaskResolutionSource(Protocol):
|
||||
"""Caller-authorized read projection over the existing TaskPlane."""
|
||||
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _metadata(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
plan = _mapping(record.get("plan") or record.get("task_plan"))
|
||||
merged: dict[str, Any] = {}
|
||||
for source in (
|
||||
_mapping(record.get("metadata")),
|
||||
_mapping(plan.get("metadata")),
|
||||
):
|
||||
merged.update(source)
|
||||
# Some existing lineage metadata is nested.
|
||||
for key in ("aios_task_lineage", "lineage", "task_lineage"):
|
||||
nested = _mapping(merged.get(key))
|
||||
for nkey, value in nested.items():
|
||||
merged.setdefault(nkey, value)
|
||||
return merged
|
||||
|
||||
|
||||
def _first(record: Mapping[str, Any], metadata: Mapping[str, Any], *keys: str):
|
||||
for key in keys:
|
||||
if key in record and record.get(key) not in (None, ""):
|
||||
return record.get(key)
|
||||
if key in metadata and metadata.get(key) not in (None, ""):
|
||||
return metadata.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _projection(record: Mapping[str, Any]) -> Mapping[str, str]:
|
||||
metadata = _metadata(record)
|
||||
values = {
|
||||
"repository": _first(record, metadata, "repository", "repo"),
|
||||
"base_oid": _first(record, metadata, "base_oid", "source_oid"),
|
||||
"task_id": _first(record, metadata, "task_id"),
|
||||
"candidate_oid": _first(record, metadata, "candidate_oid", "head_oid"),
|
||||
"patch_digest": _first(record, metadata, "patch_digest", "candidate_digest"),
|
||||
"artifact_sha256": _first(record, metadata, "artifact_sha256", "artifact_digest"),
|
||||
"pr_id": _first(record, metadata, "pr_id", "pull_request_id"),
|
||||
"release_intent": _first(record, metadata, "release_intent", "release_intent_id"),
|
||||
"mission_id": _first(record, metadata, "mission_id"),
|
||||
"root_task_id": _first(record, metadata, "root_task_id"),
|
||||
"parent_task_id": _first(record, metadata, "parent_task_id"),
|
||||
"owner_principal_id": _first(record, metadata, "owner_principal_id"),
|
||||
}
|
||||
return {
|
||||
key: str(value)
|
||||
for key, value in values.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
|
||||
|
||||
def _matches(record: Mapping[str, Any], selectors: Mapping[str, str]) -> bool:
|
||||
projection = _projection(record)
|
||||
# Exact-only: a missing selector never counts as a partial match.
|
||||
return all(projection.get(key) == value for key, value in selectors.items())
|
||||
|
||||
|
||||
class ExactTaskResolver:
|
||||
def __init__(self, source: TaskResolutionSource) -> None:
|
||||
self.source = source
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
try:
|
||||
query.validate()
|
||||
except ValueError as exc:
|
||||
return ResolutionResult(
|
||||
ResolutionState.INVALID_QUERY, detail=str(exc)
|
||||
)
|
||||
|
||||
selectors = query.selectors()
|
||||
|
||||
if query.task_id:
|
||||
record = self.source.get_task(
|
||||
query.task_id, actor_identity=actor_identity
|
||||
)
|
||||
records = [record] if record is not None else []
|
||||
else:
|
||||
records = list(
|
||||
self.source.list_tasks(actor_identity=actor_identity)
|
||||
)
|
||||
|
||||
matches = [
|
||||
record
|
||||
for record in records
|
||||
if record is not None and _matches(record, selectors)
|
||||
]
|
||||
if not matches:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="no caller-authorized task exactly matches all selectors",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
ids = sorted(
|
||||
str(_projection(record).get("task_id") or "")
|
||||
for record in matches
|
||||
)
|
||||
return ResolutionResult(
|
||||
ResolutionState.AMBIGUOUS,
|
||||
candidate_task_ids=tuple(ids),
|
||||
detail="multiple exact matches; resolver refuses to guess",
|
||||
)
|
||||
|
||||
record = matches[0]
|
||||
projected = _projection(record)
|
||||
task_id = projected.get("task_id")
|
||||
if not task_id:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="matching record lacks authoritative task_id",
|
||||
)
|
||||
resolved = ResolvedTask(
|
||||
task_id=task_id,
|
||||
mission_id=projected.get("mission_id"),
|
||||
root_task_id=projected.get("root_task_id"),
|
||||
parent_task_id=projected.get("parent_task_id"),
|
||||
owner_principal_id=projected.get("owner_principal_id"),
|
||||
record=record,
|
||||
matched_selectors=tuple(sorted(selectors)),
|
||||
)
|
||||
return ResolutionResult(ResolutionState.RESOLVED, resolved=resolved)
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .governance_control_kernel import (
|
||||
ActionKind,
|
||||
ActionLedger,
|
||||
AutonomousDecisionEngine,
|
||||
BlockerClassification,
|
||||
GovernanceControlError,
|
||||
ManifestStore,
|
||||
NextAction,
|
||||
ProducerKind,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterUnavailable(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnerActionRequired(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterState(str, Enum):
|
||||
PREFLIGHT_FROZEN = "PREFLIGHT_FROZEN"
|
||||
OWNER_AUTHORIZED = "OWNER_AUTHORIZED"
|
||||
EPOCH_VALID = "EPOCH_VALID"
|
||||
TASK_CLAIMED = "TASK_CLAIMED"
|
||||
TASK_LEASED = "TASK_LEASED"
|
||||
PRINCIPAL_ISSUED = "PRINCIPAL_ISSUED"
|
||||
JOB_BOUND = "JOB_BOUND"
|
||||
CAS_VERIFIED = "CAS_VERIFIED"
|
||||
WRITER_LEASED = "WRITER_LEASED"
|
||||
|
||||
|
||||
RISK_ORDER = {
|
||||
"low": 10,
|
||||
"medium": 20,
|
||||
"engineering": 25,
|
||||
"high": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerAuthorizationPolicy:
|
||||
"""Long-lived business authorization, separate from short-lived credentials."""
|
||||
|
||||
authorization_id: str
|
||||
subject: str
|
||||
repository: str
|
||||
allowed_branch_prefixes: tuple[str, ...]
|
||||
allowed_capabilities: frozenset[str]
|
||||
risk_ceiling: str
|
||||
trust_domain: str
|
||||
revocation_epoch: int
|
||||
not_before: float
|
||||
expires_at: float | None = None
|
||||
revoked: bool = False
|
||||
allowed_operations: frozenset[str] = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"heartbeat",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"job_reconcile",
|
||||
"writer_lease",
|
||||
"governed_commit",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"epoch_reissue",
|
||||
"successor_recovery",
|
||||
}
|
||||
)
|
||||
|
||||
def validate(self, request: "GovernedWriterRequest", *, now: float | None = None) -> None:
|
||||
now = time.time() if now is None else float(now)
|
||||
if self.revoked:
|
||||
raise AuthorizationDenied("owner authorization revoked")
|
||||
if now < self.not_before:
|
||||
raise AuthorizationDenied("owner authorization not yet valid")
|
||||
if self.expires_at is not None and now >= self.expires_at:
|
||||
raise AuthorizationDenied("owner authorization expired")
|
||||
if request.subject != self.subject:
|
||||
raise AuthorizationDenied("subject mismatch")
|
||||
if request.repository != self.repository:
|
||||
raise AuthorizationDenied("repository outside authorization")
|
||||
if not any(request.target_branch.startswith(p) for p in self.allowed_branch_prefixes):
|
||||
raise AuthorizationDenied("branch outside authorization")
|
||||
if not request.required_capabilities.issubset(self.allowed_capabilities):
|
||||
missing = sorted(request.required_capabilities - self.allowed_capabilities)
|
||||
raise AuthorizationDenied("capability outside authorization: " + ",".join(missing))
|
||||
ceiling = RISK_ORDER.get(self.risk_ceiling.lower())
|
||||
requested = RISK_ORDER.get(request.risk.lower())
|
||||
if ceiling is None or requested is None:
|
||||
raise AuthorizationDenied("unknown risk level")
|
||||
if requested > ceiling:
|
||||
raise AuthorizationDenied("risk exceeds authorization ceiling")
|
||||
|
||||
@property
|
||||
def policy_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"authorization_id": self.authorization_id,
|
||||
"subject": self.subject,
|
||||
"repository": self.repository,
|
||||
"allowed_branch_prefixes": list(self.allowed_branch_prefixes),
|
||||
"allowed_capabilities": sorted(self.allowed_capabilities),
|
||||
"risk_ceiling": self.risk_ceiling,
|
||||
"trust_domain": self.trust_domain,
|
||||
"revocation_epoch": self.revocation_epoch,
|
||||
"not_before": self.not_before,
|
||||
"expires_at": self.expires_at,
|
||||
"allowed_operations": sorted(self.allowed_operations),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterRequest:
|
||||
subject: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
required_capabilities: frozenset[str]
|
||||
file_allowlist: tuple[str, ...]
|
||||
plan_digest: str
|
||||
manifest_id: str
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
|
||||
@property
|
||||
def scope_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"subject": self.subject,
|
||||
"task_id": self.task_id,
|
||||
"repository": self.repository,
|
||||
"target_branch": self.target_branch,
|
||||
"risk": self.risk,
|
||||
"generation": self.generation,
|
||||
"base_oid": self.base_oid,
|
||||
"required_capabilities": sorted(self.required_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist),
|
||||
"plan_digest": self.plan_digest,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriteReadiness:
|
||||
"""Unambiguous write-path semantics.
|
||||
|
||||
`legacy_global_writes_enabled=False` is a required safety invariant, not a
|
||||
blocker. `governed_write_path_open` is derived from the scoped writer
|
||||
prerequisites below and is intentionally independent from any ambiguous
|
||||
legacy `writes_enabled` field.
|
||||
"""
|
||||
|
||||
legacy_global_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
task_principal_verifier_ready: bool
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def blockers(self) -> tuple[str, ...]:
|
||||
blockers = list(self.governed_write_blockers)
|
||||
if self.legacy_global_writes_enabled:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self.task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
if not self.task_principal_verifier_ready:
|
||||
blockers.append("TASK_PRINCIPAL_VERIFIER_NOT_READY")
|
||||
return tuple(dict.fromkeys(blockers))
|
||||
|
||||
@property
|
||||
def governed_write_path_open(self) -> bool:
|
||||
return not self.blockers
|
||||
|
||||
@classmethod
|
||||
def from_runtime_status(cls, status: Mapping[str, Any]) -> "GovernedWriteReadiness":
|
||||
# Deliberately ignore ambiguous `writes_enabled` /
|
||||
# `AIOS_GITOPS_WRITES_ENABLED`; those describe legacy/global surfaces
|
||||
# in older control planes and must never be used as the sole governed
|
||||
# writer readiness signal.
|
||||
readiness = status.get("task_principal_readiness") or {}
|
||||
blockers = status.get("governed_write_blockers")
|
||||
if blockers is None:
|
||||
blockers = status.get("blockers") or ()
|
||||
return cls(
|
||||
legacy_global_writes_enabled=bool(
|
||||
status.get(
|
||||
"legacy_global_writes_enabled",
|
||||
status.get("legacy_writes_enabled", False),
|
||||
)
|
||||
),
|
||||
task_principal_writes_enabled=bool(
|
||||
status.get("task_principal_writes_enabled", False)
|
||||
),
|
||||
task_principal_verifier_ready=bool(
|
||||
readiness.get("ready", status.get("task_principal_verifier_ready", False))
|
||||
),
|
||||
governed_write_blockers=tuple(str(x) for x in blockers),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpochStatus:
|
||||
epoch_id: str
|
||||
scope_fingerprint: str
|
||||
expires_at: float
|
||||
policy_digest: str
|
||||
|
||||
def is_valid(self, *, now: float, minimum_remaining_ttl: float) -> bool:
|
||||
return (self.expires_at - now) >= minimum_remaining_ttl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskState:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
claimed_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimReceipt:
|
||||
claim_id: str
|
||||
task_id: str
|
||||
actor: str
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLeaseReceipt:
|
||||
task_lease_id: str
|
||||
task_id: str
|
||||
token_ref: str
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPrincipalReceipt:
|
||||
principal_id: str
|
||||
task_id: str
|
||||
capabilities: frozenset[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobBindingReceipt:
|
||||
job_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchReadback:
|
||||
exists: bool
|
||||
head_oid: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriterLeaseReceipt:
|
||||
writer_lease_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterSession:
|
||||
state: GovernedWriterState
|
||||
authorization_id: str
|
||||
policy_fingerprint: str
|
||||
scope_fingerprint: str
|
||||
epoch_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
task_lease_id: str
|
||||
principal_id: str
|
||||
job_id: str
|
||||
writer_lease_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["state"] = self.state.value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryAuthorization:
|
||||
"""Pre-approved signed recovery/successor authorization reference."""
|
||||
|
||||
authorization_ref: str
|
||||
task_id: str
|
||||
action: str # TERMINAL_RECOVERY | SUCCESSOR
|
||||
expires_at: float
|
||||
|
||||
|
||||
class TrustedSignerAdapter(Protocol):
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus: ...
|
||||
|
||||
|
||||
class ExecutionPlane(Protocol):
|
||||
def read_task(self, task_id: str) -> TaskState: ...
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState: ...
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
) -> ClaimReceipt: ...
|
||||
def heartbeat(self, *, claim: ClaimReceipt) -> None: ...
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim: ClaimReceipt,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt: ...
|
||||
|
||||
|
||||
class TaskPrincipalIssuer(Protocol):
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt: ...
|
||||
|
||||
|
||||
class JobAuthority(Protocol):
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt: ...
|
||||
|
||||
|
||||
class GitAuthority(Protocol):
|
||||
def read_branch(self, *, repository: str, target_branch: str) -> BranchReadback: ...
|
||||
|
||||
|
||||
class WriterLeaseAuthority(Protocol):
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedOwnerAuthorization:
|
||||
policy: OwnerAuthorizationPolicy
|
||||
authorization_ref: str
|
||||
verification_receipt: str
|
||||
verified_at: float
|
||||
|
||||
|
||||
class OwnerAuthorizationAuthority(Protocol):
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovernedWriterAdapters:
|
||||
signer: TrustedSignerAdapter
|
||||
execution: ExecutionPlane
|
||||
principal_issuer: TaskPrincipalIssuer
|
||||
jobs: JobAuthority
|
||||
git: GitAuthority
|
||||
writer_lease: WriterLeaseAuthority
|
||||
owner_authority: OwnerAuthorizationAuthority | None = None
|
||||
|
||||
|
||||
class GovernedWriterAutomation:
|
||||
"""Owner-policy-driven acquisition of a scoped governed writer capability.
|
||||
|
||||
This is the mechanism that removes repeated manual intervention. Long-lived
|
||||
OwnerAuthorizationPolicy establishes allowed scope. Every concrete effect is
|
||||
still protected by fresh runtime manifest, short-lived TaskLease,
|
||||
TaskPrincipal, WriterLease, CAS and idempotency.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_store: ManifestStore,
|
||||
action_ledger: ActionLedger,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
|
||||
def _begin_action(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
suffix: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition: Mapping[str, Any],
|
||||
):
|
||||
return self.action_ledger.begin(
|
||||
idempotency_key=f"writer:{request.scope_fingerprint}:{suffix}",
|
||||
manifest_id=request.manifest_id,
|
||||
phase="GOVERNED_WRITER",
|
||||
kind=kind,
|
||||
target=target,
|
||||
precondition_hash=_digest(precondition),
|
||||
)
|
||||
|
||||
def _ensure_epoch(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
minimum_remaining_ttl: float,
|
||||
) -> EpochStatus:
|
||||
if (
|
||||
epoch is not None
|
||||
and epoch.scope_fingerprint == request.scope_fingerprint
|
||||
and epoch.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl)
|
||||
):
|
||||
return epoch
|
||||
|
||||
# Changed scope may not silently inherit an old Owner grant.
|
||||
if epoch is not None and epoch.scope_fingerprint != request.scope_fingerprint:
|
||||
raise OwnerActionRequired("epoch scope changed; new Owner authorization required")
|
||||
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="epoch",
|
||||
kind=ActionKind.EPOCH_ISSUANCE,
|
||||
target="trusted-grant-signer-adapter",
|
||||
precondition={
|
||||
"owner_policy": owner_policy.policy_fingerprint,
|
||||
"scope": request.scope_fingerprint,
|
||||
"prior_epoch": None if epoch is None else epoch.epoch_id,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("epoch issuance effect unknown; reconcile required")
|
||||
if ticket.reused and ticket.state.value == "SUCCEEDED":
|
||||
# The ledger proves the action succeeded, but the concrete receipt
|
||||
# must be obtained from the signer/readback, not fabricated locally.
|
||||
raise GovernedWriterUnavailable("epoch already issued; signer readback required")
|
||||
|
||||
try:
|
||||
fresh = adapters.signer.reissue_epoch(
|
||||
owner_policy=owner_policy,
|
||||
request=request,
|
||||
prior_epoch=epoch,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if fresh.scope_fingerprint != request.scope_fingerprint:
|
||||
raise GovernedWriterUnavailable("signer returned mismatched scope")
|
||||
if not fresh.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl):
|
||||
raise GovernedWriterUnavailable("signer returned insufficient TTL")
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"epoch_id": fresh.epoch_id,
|
||||
"expires_at": fresh.expires_at,
|
||||
"scope_fingerprint": fresh.scope_fingerprint,
|
||||
},
|
||||
)
|
||||
return fresh
|
||||
except OwnerActionRequired:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result={"owner_action": True})
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
def _ensure_claimable_task(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
recovery_authorization: RecoveryAuthorization | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
) -> TaskState:
|
||||
task = adapters.execution.read_task(request.task_id)
|
||||
status = task.status.upper()
|
||||
if status == "QUEUED":
|
||||
return task
|
||||
if status in {"CLAIMED", "RUNNING"}:
|
||||
return task
|
||||
if status == "EXPIRED":
|
||||
if recovery_authorization is None:
|
||||
raise OwnerActionRequired(
|
||||
"terminal task expired; owner-signed recovery/successor authorization required"
|
||||
)
|
||||
if recovery_authorization.task_id != task.task_id:
|
||||
raise AuthorizationDenied("recovery authorization task mismatch")
|
||||
if recovery_authorization.expires_at <= now:
|
||||
raise AuthorizationDenied("recovery authorization expired")
|
||||
if recovery_authorization.action not in {"TERMINAL_RECOVERY", "SUCCESSOR"}:
|
||||
raise AuthorizationDenied("unsupported recovery action")
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="task-recovery",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="agent-task-terminal-recovery",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"status": task.status,
|
||||
"authorization_ref": recovery_authorization.authorization_ref,
|
||||
"action": recovery_authorization.action,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("task recovery effect unknown; reconcile required")
|
||||
try:
|
||||
recovered = adapters.execution.apply_recovery(
|
||||
task=task,
|
||||
authorization=recovery_authorization,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if recovered.status.upper() != "QUEUED":
|
||||
raise GovernedWriterUnavailable(
|
||||
"recovery did not return claimable QUEUED task"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"task_id": recovered.task_id,
|
||||
"status": recovered.status,
|
||||
"revision": recovered.revision,
|
||||
},
|
||||
)
|
||||
return recovered
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
raise GovernedWriterUnavailable(f"task status not claimable: {task.status}")
|
||||
|
||||
def acquire_verified(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""Production entry: verify Owner authorization before any effect.
|
||||
|
||||
The field composition root MUST use this method. `acquire()` is an
|
||||
internal/test hook for already-verified policy objects.
|
||||
"""
|
||||
if adapters.owner_authority is None:
|
||||
raise AuthorizationDenied("OwnerAuthorizationAuthority is not configured")
|
||||
verified = adapters.owner_authority.verify(
|
||||
authorization_ref=authorization_ref,
|
||||
request=request,
|
||||
)
|
||||
if verified.authorization_ref != authorization_ref:
|
||||
raise AuthorizationDenied("owner authorization reference mismatch")
|
||||
if not verified.verification_receipt:
|
||||
raise AuthorizationDenied("owner authorization verification receipt missing")
|
||||
return self.acquire(
|
||||
request=request,
|
||||
owner_policy=verified.policy,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
now = time.time() if now is None else float(now)
|
||||
|
||||
# Gate 1: authoritative frozen runtime.
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
|
||||
# Gate 2: long-lived Owner policy.
|
||||
owner_policy.validate(request, now=now)
|
||||
|
||||
# Gate 3: scoped writer readiness, deliberately independent from legacy
|
||||
# global-write switches.
|
||||
if not readiness.governed_write_path_open:
|
||||
raise GovernedWriterUnavailable(
|
||||
"governed writer not ready: " + ",".join(readiness.blockers)
|
||||
)
|
||||
|
||||
# Gate 4: JIT delegation epoch. If unchanged scope is authorized, the
|
||||
# Agent may use the trusted signer adapter autonomously.
|
||||
epoch = self._ensure_epoch(
|
||||
request=request,
|
||||
owner_policy=owner_policy,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
# Gate 5: task must be claimable, with Owner recovery only when the
|
||||
# lifecycle contract explicitly requires a signed terminal recovery.
|
||||
task = self._ensure_claimable_task(
|
||||
request=request,
|
||||
recovery_authorization=recovery_authorization,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# Claim is idempotent per scope/task revision.
|
||||
claim_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"claim-r{task.revision}",
|
||||
kind=ActionKind.CLAIM,
|
||||
target=f"task:{task.task_id}",
|
||||
precondition={"task_id": task.task_id, "revision": task.revision, "status": task.status},
|
||||
)
|
||||
if claim_ticket.reused and claim_ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("claim effect unknown; reconcile required")
|
||||
if task.status.upper() in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedWriterUnavailable(
|
||||
"existing claim must be reconciled into a concrete claim receipt"
|
||||
)
|
||||
try:
|
||||
claim = adapters.execution.claim(
|
||||
task=task,
|
||||
actor=request.subject,
|
||||
idempotency_key=claim_ticket.idempotency_key,
|
||||
)
|
||||
adapters.execution.heartbeat(claim=claim)
|
||||
self.action_ledger.mark_succeeded(
|
||||
claim_ticket.action_id,
|
||||
result={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
claim_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
lease_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"tasklease-{claim.claim_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-lease",
|
||||
precondition={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
try:
|
||||
task_lease = adapters.execution.issue_task_lease(
|
||||
claim=claim,
|
||||
idempotency_key=lease_ticket.idempotency_key,
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
lease_ticket.action_id,
|
||||
result={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"expires_at": task_lease.expires_at,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
lease_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
principal_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"principal-{task_lease.task_lease_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-principal-issuer",
|
||||
precondition={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"capabilities": sorted(request.required_capabilities),
|
||||
},
|
||||
)
|
||||
try:
|
||||
principal = adapters.principal_issuer.issue(
|
||||
lease=task_lease,
|
||||
requested_capabilities=request.required_capabilities,
|
||||
idempotency_key=principal_ticket.idempotency_key,
|
||||
)
|
||||
if not request.required_capabilities.issubset(principal.capabilities):
|
||||
raise GovernedWriterUnavailable(
|
||||
"issued TaskPrincipal lacks required capabilities"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
principal_ticket.action_id,
|
||||
result={"principal_id": principal.principal_id, "expires_at": principal.expires_at},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
principal_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
job_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="job",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="gitops-job",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"plan_digest": request.plan_digest,
|
||||
},
|
||||
)
|
||||
try:
|
||||
job = adapters.jobs.read_or_reconcile(
|
||||
task_id=task.task_id,
|
||||
principal=principal,
|
||||
idempotency_key=job_ticket.idempotency_key,
|
||||
)
|
||||
if (
|
||||
job.repository != request.repository
|
||||
or job.target_branch != request.target_branch
|
||||
or job.generation != request.generation
|
||||
):
|
||||
raise GovernedWriterUnavailable("job binding mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
job_ticket.action_id,
|
||||
result={"job_id": job.job_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
job_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
branch = adapters.git.read_branch(
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
)
|
||||
if branch.exists:
|
||||
if not branch.head_oid:
|
||||
raise GovernedWriterUnavailable("branch exists without readable HEAD")
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = branch.head_oid
|
||||
else:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
|
||||
writer_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"writer-{job.job_id}-{expected_branch_oid}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="writer-lease",
|
||||
precondition={
|
||||
"job_id": job.job_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"generation": request.generation,
|
||||
},
|
||||
)
|
||||
try:
|
||||
writer = adapters.writer_lease.acquire(
|
||||
task_id=task.task_id,
|
||||
job=job,
|
||||
principal=principal,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
branch_mode=branch_mode,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=request.generation,
|
||||
idempotency_key=writer_ticket.idempotency_key,
|
||||
)
|
||||
if writer.expected_branch_oid != expected_branch_oid:
|
||||
raise GovernedWriterUnavailable("WriterLease CAS mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
writer_ticket.action_id,
|
||||
result={
|
||||
"writer_lease_id": writer.writer_lease_id,
|
||||
"expires_at": writer.expires_at,
|
||||
"expected_branch_oid": writer.expected_branch_oid,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
writer_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
expires_at = min(
|
||||
epoch.expires_at,
|
||||
task_lease.expires_at,
|
||||
principal.expires_at,
|
||||
writer.expires_at,
|
||||
)
|
||||
return GovernedWriterSession(
|
||||
state=GovernedWriterState.WRITER_LEASED,
|
||||
authorization_id=owner_policy.authorization_id,
|
||||
policy_fingerprint=owner_policy.policy_fingerprint,
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
epoch_id=epoch.epoch_id,
|
||||
task_id=task.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
task_lease_id=task_lease.task_lease_id,
|
||||
principal_id=principal.principal_id,
|
||||
job_id=job.job_id,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
@@ -0,0 +1,789 @@
|
||||
"""Fail-closed host adapter for the C6 governed source-patch pilot.
|
||||
|
||||
The adapter owns no worker registration, authority database, effect ledger, or
|
||||
Git mutation implementation. Those are host-owned. It only joins an exact C6
|
||||
operation binding and v3 TaskPlane claim to an existing durable external
|
||||
transaction owner.
|
||||
|
||||
The transaction owner is a security boundary. Its ``execute_prepared`` method
|
||||
must durably record the send phase before an external call, call
|
||||
``before_external_call`` while holding its own lease/fence, acquire and persist
|
||||
the real WriterLease, enforce expected-OID CAS at the write linearization point,
|
||||
and leave an ambiguous call in EFFECT_UNKNOWN. A callback by itself is not an
|
||||
atomic revocation or CAS fence.
|
||||
|
||||
``RootActionConsumer`` exposes an exact-key facade only after the host installs
|
||||
a trusted operation-to-review binding provider. C6 callers cannot synthesize
|
||||
review_id, mission_id, root_task_id, root_revision, plan digest, or execution
|
||||
key. The facade reuses the consumer's existing review, fence, and effect rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
|
||||
from .c6_composition import DurableExecutionIntent
|
||||
from .operation_view import ClosureObservation, OperationBinding, binding_digest, validate_oid
|
||||
from .patch_contract import ExecutablePatchV1, parse_executable_patch
|
||||
from .schedulability_store_adapter import TaskPlaneClaimReceiptV3
|
||||
|
||||
|
||||
_ACTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,191}$")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
HOST_PATCH_AUTHORITY_PURPOSE = "aios.c6.host-patch-authority.current.v1"
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE = (
|
||||
"aios.c6.host-patch-reconcile-authority.current.v1"
|
||||
)
|
||||
HOST_PATCH_AUTHORITY_SCHEMA = "aios.c6.host-patch-authority.v1"
|
||||
_AUTHORITY_RECORD_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authorization_ref",
|
||||
"authority_generation",
|
||||
"task_generation",
|
||||
"claim_id",
|
||||
"claim_fence",
|
||||
"expected_branch_oid",
|
||||
"binding_digest",
|
||||
"lifecycle_state",
|
||||
"scope_state",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_json_bytes(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
ActionState = Literal[
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
]
|
||||
|
||||
|
||||
class HostPatchIntegrationError(RuntimeError):
|
||||
"""The host binding is incomplete, stale, or contradictory."""
|
||||
|
||||
|
||||
class HostPatchEffectUnknown(HostPatchIntegrationError):
|
||||
"""The same immutable action must be reconciled, never resubmitted."""
|
||||
|
||||
|
||||
class HostPatchConfigurationError(HostPatchIntegrationError):
|
||||
"""No production-safe exact-key transaction owner is configured."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchArtifact:
|
||||
document: Mapping[str, object]
|
||||
patch_bytes: bytes
|
||||
allowed_files: frozenset[str]
|
||||
commit_message: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.document, Mapping):
|
||||
raise ValueError("patch document provider must return a mapping")
|
||||
if not isinstance(self.patch_bytes, bytes) or not self.patch_bytes:
|
||||
raise ValueError("patch provider must return non-empty bytes")
|
||||
if not isinstance(self.allowed_files, frozenset) or not self.allowed_files:
|
||||
raise ValueError("patch provider must return a frozen non-empty allowlist")
|
||||
if (
|
||||
not isinstance(self.commit_message, str)
|
||||
or not self.commit_message.strip()
|
||||
or self.commit_message != self.commit_message.strip()
|
||||
):
|
||||
raise ValueError("canonical commit message required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAuthority:
|
||||
"""Fresh host readback before a transaction-owner call.
|
||||
|
||||
``claim_fence`` is the TaskPlane receipt revision. It is deliberately
|
||||
separate from the WriterLease fence returned by the external transaction.
|
||||
"""
|
||||
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
authority_generation: int
|
||||
task_generation: int
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref.strip()
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id.strip()
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id.strip()
|
||||
):
|
||||
raise ValueError("host authority identity required")
|
||||
if (
|
||||
not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
):
|
||||
raise ValueError("verified host authority key fingerprint required")
|
||||
if (
|
||||
not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
):
|
||||
raise ValueError("verified host authority record digest required")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid host authority {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if (
|
||||
isinstance(self.expires_at, bool)
|
||||
or not isinstance(self.expires_at, (int, float))
|
||||
or not math.isfinite(float(self.expires_at))
|
||||
):
|
||||
raise ValueError("invalid host authority expiry")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAction:
|
||||
"""Readback owned by the existing external transaction boundary."""
|
||||
|
||||
action_id: str
|
||||
state: ActionState
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
task_generation: int
|
||||
authority_generation: int
|
||||
expected_branch_oid: str
|
||||
claim_receipt: Mapping[str, object]
|
||||
writer_lease_id: str | None = None
|
||||
writer_lease_fence: int | None = None
|
||||
result_oid: str | None = None
|
||||
receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.action_id, str) or _ACTION_ID.fullmatch(self.action_id) is None:
|
||||
raise ValueError("canonical immutable action_id required")
|
||||
if self.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
}:
|
||||
raise ValueError("invalid host patch action state")
|
||||
if not isinstance(self.binding_digest, str) or _DIGEST.fullmatch(self.binding_digest) is None:
|
||||
raise ValueError("action binding digest required")
|
||||
if not isinstance(self.effect_key, str) or not self.effect_key:
|
||||
raise ValueError("action effect key required")
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id
|
||||
or not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
or not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id
|
||||
or not isinstance(self.claim_receipt, Mapping)
|
||||
):
|
||||
raise ValueError("action authority/claim identity required")
|
||||
for name in ("claim_fence", "task_generation", "authority_generation"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid action {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if self.writer_lease_fence is not None and (
|
||||
isinstance(self.writer_lease_fence, bool)
|
||||
or not isinstance(self.writer_lease_fence, int)
|
||||
or self.writer_lease_fence < 0
|
||||
):
|
||||
raise ValueError("invalid WriterLease fence")
|
||||
if self.result_oid is not None:
|
||||
validate_oid(self.result_oid)
|
||||
if self.state == "COMMITTED":
|
||||
if (
|
||||
not self.writer_lease_id
|
||||
or self.writer_lease_fence is None
|
||||
or self.result_oid is None
|
||||
or not self.receipt_ref
|
||||
):
|
||||
raise ValueError("committed action lacks WriterLease/OID/receipt binding")
|
||||
|
||||
|
||||
class SignedHostPatchAuthorityReader:
|
||||
"""Build host authority only from a verified, purpose-bound current record.
|
||||
|
||||
The underlying reader enforces a registered key, signature, short lifetime,
|
||||
exact query hash, and a fresh nonce. This remains a freshness challenge;
|
||||
the root-action transaction owner still supplies the atomic fence and CAS.
|
||||
|
||||
Host configuration must register both effect and reconcile purposes to the
|
||||
same authority key id/raw-key fingerprint. Reconcile has a distinct signed
|
||||
query and grants observation only, while prepared identity remains the same
|
||||
stable authorization semantics across the two purposes.
|
||||
"""
|
||||
|
||||
def __init__(self, current_records: VerifiedCurrentRecordReader) -> None:
|
||||
if not isinstance(current_records, VerifiedCurrentRecordReader):
|
||||
raise TypeError("current_records must be VerifiedCurrentRecordReader")
|
||||
purposes = (
|
||||
HOST_PATCH_AUTHORITY_PURPOSE,
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE,
|
||||
)
|
||||
try:
|
||||
key_ids = [current_records.purpose_key_ids[purpose] for purpose in purposes]
|
||||
fingerprints = [
|
||||
current_records.purpose_key_fingerprints[purpose]
|
||||
for purpose in purposes
|
||||
]
|
||||
except KeyError as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile authority purposes must both be registered"
|
||||
) from exc
|
||||
if (
|
||||
any(len(ids) != 1 for ids in key_ids)
|
||||
or key_ids[0] != key_ids[1]
|
||||
or set(fingerprints[0]) != set(key_ids[0])
|
||||
or fingerprints[0] != fingerprints[1]
|
||||
):
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile purposes require one identical key/fingerprint"
|
||||
)
|
||||
self._records = current_records
|
||||
|
||||
@staticmethod
|
||||
def _query(
|
||||
binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema": (
|
||||
"aios.c6.host-patch-reconcile-authority-query.v1"
|
||||
if reconcile
|
||||
else "aios.c6.host-patch-authority-query.v1"
|
||||
),
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"claim": dict(claim),
|
||||
}
|
||||
|
||||
def _read(
|
||||
self, binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> HostPatchAuthority:
|
||||
purpose = (
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE
|
||||
if reconcile
|
||||
else HOST_PATCH_AUTHORITY_PURPOSE
|
||||
)
|
||||
verified = self._records.read(
|
||||
purpose, self._query(binding, claim, reconcile=reconcile)
|
||||
)
|
||||
record = verified.record
|
||||
if frozenset(record) != _AUTHORITY_RECORD_FIELDS:
|
||||
missing = sorted(_AUTHORITY_RECORD_FIELDS - frozenset(record))
|
||||
extra = sorted(frozenset(record) - _AUTHORITY_RECORD_FIELDS)
|
||||
raise HostPatchIntegrationError(
|
||||
f"host authority record fields are not exact; missing={missing!r} extra={extra!r}"
|
||||
)
|
||||
if (
|
||||
record["schema"] != HOST_PATCH_AUTHORITY_SCHEMA
|
||||
or record["lifecycle_state"] != "ACTIVE"
|
||||
or record["scope_state"] != "IN_SCOPE"
|
||||
or record["binding_digest"] != binding_digest(binding)
|
||||
or record["task_generation"] != binding.generation
|
||||
or record["claim_id"] != claim.get("claim_id")
|
||||
or record["claim_fence"] != claim.get("receipt_revision")
|
||||
or record["expected_branch_oid"] != binding.source_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority binding mismatch")
|
||||
if any(
|
||||
not isinstance(record[name], str) or not record[name].strip()
|
||||
for name in (
|
||||
"authorization_ref", "claim_id", "expected_branch_oid",
|
||||
"binding_digest", "lifecycle_state", "scope_state",
|
||||
)
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority text fields are invalid")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
if (
|
||||
isinstance(record[name], bool)
|
||||
or not isinstance(record[name], int)
|
||||
or record[name] < 0
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
f"signed host authority {name} is invalid"
|
||||
)
|
||||
if (
|
||||
isinstance(record["expires_at"], bool)
|
||||
or not isinstance(record["expires_at"], (int, float))
|
||||
or not math.isfinite(float(record["expires_at"]))
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority expiry is invalid")
|
||||
try:
|
||||
envelope_expiry = datetime.fromisoformat(
|
||||
verified.expires_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc).timestamp()
|
||||
authority = HostPatchAuthority(
|
||||
authorization_ref=record["authorization_ref"],
|
||||
authority_key_id=verified.key_id,
|
||||
authority_key_fingerprint=verified.key_fingerprint,
|
||||
# The signed envelope and its short observation expiry change
|
||||
# across fresh reads. Prepared identity binds only the stable
|
||||
# authorization semantics; freshness is independently checked
|
||||
# on every read.
|
||||
authority_record_sha256=hashlib.sha256(
|
||||
_canonical_json_bytes(
|
||||
{key: value for key, value in record.items() if key != "expires_at"}
|
||||
)
|
||||
).hexdigest(),
|
||||
authority_generation=record["authority_generation"], # type: ignore[arg-type]
|
||||
task_generation=record["task_generation"], # type: ignore[arg-type]
|
||||
claim_id=record["claim_id"],
|
||||
claim_fence=record["claim_fence"], # type: ignore[arg-type]
|
||||
expected_branch_oid=record["expected_branch_oid"],
|
||||
expires_at=min(float(record["expires_at"]), envelope_expiry),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HostPatchIntegrationError("signed host authority record is invalid") from exc
|
||||
if (
|
||||
not reconcile
|
||||
and authority.expires_at
|
||||
> float(claim.get("lease_until", authority.expires_at))
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority outlives the TaskPlane claim")
|
||||
return authority
|
||||
|
||||
def __call__(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(claim, TaskPlaneClaimReceiptV3):
|
||||
raise TypeError("v3 TaskPlane claim receipt required")
|
||||
return self._read(binding, claim.to_dict(), reconcile=False)
|
||||
|
||||
def read_for_action(
|
||||
self, binding: OperationBinding, action: HostPatchAction
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise TypeError("host patch action required")
|
||||
return self._read(binding, action.claim_receipt, reconcile=True)
|
||||
|
||||
|
||||
def signed_host_patch_authority_provider(
|
||||
current_records: VerifiedCurrentRecordReader,
|
||||
) -> SignedHostPatchAuthorityReader:
|
||||
"""Factory used by host composition without weakening reader type checks."""
|
||||
|
||||
return SignedHostPatchAuthorityReader(current_records)
|
||||
|
||||
|
||||
class HostPatchTransactionOwner(Protocol):
|
||||
"""Exact-key facade implemented by an existing durable transaction owner."""
|
||||
|
||||
def read_exact(self, effect_key: str) -> HostPatchAction | None: ...
|
||||
|
||||
def iter_pending_exact(
|
||||
self, *, limit: int
|
||||
) -> tuple[tuple[OperationBinding, HostPatchAction], ...]: ...
|
||||
|
||||
def mark_exact_closed(
|
||||
self, *, binding: OperationBinding, action_id: str,
|
||||
observation: ClosureObservation
|
||||
) -> None: ...
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
authority: HostPatchAuthority,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
|
||||
_EXACT_OWNER_METHODS = (
|
||||
"read_exact", "iter_pending_exact", "mark_exact_closed", "prepare_exact", "reconcile_exact",
|
||||
"execute_prepared"
|
||||
)
|
||||
|
||||
|
||||
def require_exact_root_action_owner(service: object) -> HostPatchTransactionOwner:
|
||||
"""Return an exact-key root-action facade or fail before enabling C6.
|
||||
|
||||
``TaskPlaneService.root_actions`` currently exposes ``run_once`` only, so a
|
||||
stock service is rejected. A production composition may install a facade
|
||||
backed by the same RootActionConsumer/PublicBridgeRootActionExecutor store;
|
||||
it must not create another effect ledger.
|
||||
"""
|
||||
|
||||
owner = getattr(service, "root_actions", None)
|
||||
missing = [name for name in _EXACT_OWNER_METHODS if not callable(getattr(owner, name, None))]
|
||||
if missing:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner unavailable: " + ",".join(missing)
|
||||
)
|
||||
if getattr(owner, "store", None) is not getattr(service, "store", None):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner must reuse the host TaskPlane store"
|
||||
)
|
||||
configured = getattr(owner, "assert_exact_owner_configured", None)
|
||||
if not callable(configured):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner configuration assertion unavailable"
|
||||
)
|
||||
try:
|
||||
configured()
|
||||
except Exception as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner is not configured"
|
||||
) from exc
|
||||
return owner # type: ignore[return-value]
|
||||
|
||||
|
||||
class HostPatchExecutor:
|
||||
"""Concrete ``GovernedSourcePatchPilot`` over host-owned formal providers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
artifact_provider: Callable[[OperationBinding], HostPatchArtifact],
|
||||
authority_provider: Callable[
|
||||
[OperationBinding, TaskPlaneClaimReceiptV3], HostPatchAuthority
|
||||
],
|
||||
transaction_owner: HostPatchTransactionOwner,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not callable(artifact_provider) or not callable(authority_provider):
|
||||
raise HostPatchConfigurationError("artifact and authority providers required")
|
||||
if not callable(getattr(authority_provider, "read_for_action", None)):
|
||||
raise HostPatchConfigurationError(
|
||||
"authority provider must support signed restart reconciliation"
|
||||
)
|
||||
if any(not callable(getattr(transaction_owner, name, None)) for name in _EXACT_OWNER_METHODS):
|
||||
raise HostPatchConfigurationError("exact external transaction owner required")
|
||||
self._artifacts = artifact_provider
|
||||
self._authority_provider = authority_provider
|
||||
self._transactions = transaction_owner
|
||||
self._clock = clock or time.time
|
||||
|
||||
def _artifact(self, binding: OperationBinding) -> tuple[HostPatchArtifact, ExecutablePatchV1]:
|
||||
artifact = self._artifacts(binding)
|
||||
if not isinstance(artifact, HostPatchArtifact):
|
||||
raise HostPatchIntegrationError("invalid host patch artifact")
|
||||
contract = parse_executable_patch(
|
||||
artifact.document,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
allowed_files=artifact.allowed_files,
|
||||
expected_binding=binding,
|
||||
)
|
||||
return artifact, contract
|
||||
|
||||
def _authority(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
value = self._authority_provider(binding, claim)
|
||||
if not isinstance(value, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid host authority readback")
|
||||
if (
|
||||
value.claim_id != claim.claim_id
|
||||
or value.claim_fence != claim.receipt_revision
|
||||
or value.task_generation != binding.generation
|
||||
or value.expected_branch_oid != binding.source_oid
|
||||
or value.expires_at <= float(self._clock())
|
||||
):
|
||||
raise HostPatchIntegrationError("stale or mismatched host authority")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _validate_action(
|
||||
action: HostPatchAction,
|
||||
binding: OperationBinding,
|
||||
*,
|
||||
claim_id: str | None = None,
|
||||
claim_fence: int | None = None,
|
||||
authority: HostPatchAuthority | None = None,
|
||||
) -> HostPatchAction:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise HostPatchIntegrationError("transaction owner returned invalid action")
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or action.task_generation != binding.generation
|
||||
or action.expected_branch_oid != binding.source_oid
|
||||
or (claim_id is not None and action.claim_id != claim_id)
|
||||
or (claim_fence is not None and action.claim_fence != claim_fence)
|
||||
):
|
||||
raise HostPatchIntegrationError("external action binding mismatch")
|
||||
if authority is not None and (
|
||||
action.authorization_ref != authority.authorization_ref
|
||||
or action.authority_key_id != authority.authority_key_id
|
||||
or action.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or action.authority_record_sha256 != authority.authority_record_sha256
|
||||
or action.authority_generation != authority.authority_generation
|
||||
or action.claim_id != authority.claim_id
|
||||
or action.claim_fence != authority.claim_fence
|
||||
or action.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("external action authority changed")
|
||||
if action.state == "FAILED_FINAL":
|
||||
raise HostPatchIntegrationError("external action failed final")
|
||||
return action
|
||||
|
||||
def read_intent(self, binding: OperationBinding) -> DurableExecutionIntent | None:
|
||||
"""C6 ``intent_reader`` wiring backed only by the transaction owner."""
|
||||
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
return None
|
||||
action = self._validate_action(action, binding)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, action.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def pending_intents(
|
||||
self, *, limit: int = 16
|
||||
) -> tuple[tuple[OperationBinding, DurableExecutionIntent], ...]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise HostPatchConfigurationError("bounded pending intent limit required")
|
||||
result: list[tuple[OperationBinding, DurableExecutionIntent]] = []
|
||||
for pending_binding, action in self._transactions.iter_pending_exact(limit=limit):
|
||||
checked = self._validate_action(action, pending_binding)
|
||||
if checked.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
raise HostPatchIntegrationError("transaction owner enumerated failed action")
|
||||
result.append(
|
||||
(
|
||||
pending_binding,
|
||||
DurableExecutionIntent(
|
||||
binding_digest(pending_binding),
|
||||
pending_binding.effect_key,
|
||||
checked.claim_id,
|
||||
checked.action_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
def mark_intent_closed(
|
||||
self, binding: OperationBinding, intent: DurableExecutionIntent,
|
||||
observation: ClosureObservation,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or not isinstance(observation, ClosureObservation)
|
||||
):
|
||||
raise HostPatchIntegrationError("invalid durable closure acknowledgement")
|
||||
self._transactions.mark_exact_closed(
|
||||
binding=binding, action_id=intent.receipt_ref, observation=observation
|
||||
)
|
||||
|
||||
def prepare_intent(
|
||||
self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> DurableExecutionIntent:
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.prepare_exact(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
)
|
||||
action = self._validate_action(
|
||||
action,
|
||||
binding,
|
||||
claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision,
|
||||
authority=authority,
|
||||
)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, claim.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or intent.claim_id != claim.claim_id
|
||||
):
|
||||
raise HostPatchIntegrationError("execution intent binding mismatch")
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(
|
||||
action, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = self._authority(binding, claim)
|
||||
if (
|
||||
fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority changed before external call")
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(
|
||||
observed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state == "COMMITTED":
|
||||
return
|
||||
if observed.state in {"IN_PROGRESS", "EFFECT_UNKNOWN"}:
|
||||
raise HostPatchEffectUnknown("external effect requires exact-key reconciliation")
|
||||
if observed.state != "PREPARED":
|
||||
raise HostPatchIntegrationError("external action is not executable")
|
||||
|
||||
completed = self._transactions.execute_prepared(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
completed = self._validate_action(
|
||||
completed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if completed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if completed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external transaction did not return committed readback")
|
||||
|
||||
def reconcile(
|
||||
self, *, binding: OperationBinding, intent: DurableExecutionIntent
|
||||
) -> None:
|
||||
"""Reconcile a durable intent without acquiring a lease or resending a commit."""
|
||||
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
):
|
||||
raise HostPatchIntegrationError("reconciliation intent binding mismatch")
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(action, binding, claim_id=intent.claim_id)
|
||||
reader = self._authority_provider
|
||||
authority = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if not isinstance(authority, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid reconciliation authority readback")
|
||||
action = self._validate_action(action, binding, authority=authority)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if (
|
||||
not isinstance(fresh, HostPatchAuthority)
|
||||
or fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
"host authority changed before reconciliation call"
|
||||
)
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(observed, binding, authority=authority)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external effect remains unresolved")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HostPatchAction",
|
||||
"HostPatchArtifact",
|
||||
"HostPatchAuthority",
|
||||
"HostPatchConfigurationError",
|
||||
"HostPatchEffectUnknown",
|
||||
"HostPatchExecutor",
|
||||
"HostPatchIntegrationError",
|
||||
"HostPatchTransactionOwner",
|
||||
"HOST_PATCH_AUTHORITY_PURPOSE",
|
||||
"HOST_PATCH_AUTHORITY_SCHEMA",
|
||||
"SignedHostPatchAuthorityReader",
|
||||
"require_exact_root_action_owner",
|
||||
"signed_host_patch_authority_provider",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Authenticated host readback for independent verification and acceptance.
|
||||
|
||||
The host supplies separate, registered signing identities and current-record
|
||||
transports. This adapter never creates receipts or treats an ``authenticated``
|
||||
payload flag as evidence. Missing providers remain UNKNOWN through OperationReaders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
from .operation_view import OperationBinding, binding_digest, validate_digest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReceiptAuthorityPolicy:
|
||||
verifier_id: str
|
||||
verifier_key_id: str
|
||||
acceptor_id: str
|
||||
acceptor_key_id: str
|
||||
acceptance_revision: str
|
||||
verifier_key_fingerprint: str
|
||||
acceptor_key_fingerprint: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in asdict(self).items():
|
||||
if not isinstance(value, str) or not value or value.strip() != value or "\x00" in value:
|
||||
raise ValueError(f"invalid {name}")
|
||||
if self.verifier_id == self.acceptor_id or self.verifier_key_id == self.acceptor_key_id:
|
||||
raise ValueError("verification and acceptance must use independent identities and keys")
|
||||
validate_digest(self.verifier_key_fingerprint)
|
||||
validate_digest(self.acceptor_key_fingerprint)
|
||||
if self.verifier_key_fingerprint == self.acceptor_key_fingerprint:
|
||||
raise ValueError("verification and acceptance require independent public keys")
|
||||
|
||||
|
||||
class HostOperationReceiptReaders:
|
||||
"""Bind verified transport signatures to host-pinned role identities.
|
||||
|
||||
Policies are trusted host configuration, never request or receipt fields.
|
||||
Receipt/result linkage and freshness are also checked by collect_closure /
|
||||
derive_closure against the separately observed Git effect.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_reader: VerifiedCurrentRecordReader,
|
||||
acceptance_reader: VerifiedCurrentRecordReader,
|
||||
policy: ReceiptAuthorityPolicy,
|
||||
) -> None:
|
||||
self._verifier = verifier_reader
|
||||
self._acceptance = acceptance_reader
|
||||
self._policy = policy
|
||||
|
||||
def _read(self, binding: OperationBinding, *, acceptance: bool) -> dict[str, object]:
|
||||
policy = self._policy
|
||||
identity = policy.acceptor_id if acceptance else policy.verifier_id
|
||||
key_id = policy.acceptor_key_id if acceptance else policy.verifier_key_id
|
||||
if binding.actor_id in (policy.verifier_id, policy.acceptor_id):
|
||||
raise ValueError("executor cannot verify or accept its own operation")
|
||||
kind = "acceptance" if acceptance else "verification"
|
||||
query = {
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"expected_identity": identity,
|
||||
"expected_key_id": key_id,
|
||||
"expected_key_fingerprint": policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint,
|
||||
}
|
||||
if acceptance:
|
||||
query["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
reader = self._acceptance if acceptance else self._verifier
|
||||
verified = reader.read(f"operation-{kind}", query)
|
||||
if verified.key_id != key_id:
|
||||
raise ValueError("receipt signer does not match the pinned role key")
|
||||
fingerprint = policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint
|
||||
if verified.key_fingerprint != fingerprint:
|
||||
raise ValueError("receipt signer public key fingerprint mismatch")
|
||||
document = verified.record
|
||||
if not isinstance(document, Mapping) or set(document) != {"schema", kind}:
|
||||
raise ValueError("invalid signed receipt document")
|
||||
schema = f"aios.operation-{kind}-observation.v1"
|
||||
if document["schema"] != schema:
|
||||
raise ValueError("invalid receipt schema")
|
||||
observation = document[kind]
|
||||
common = {"binding_digest", "receipt_ref", "result_artifact_digest", "outcome", "observed_at", "expires_at"}
|
||||
fields = common | (
|
||||
{"acceptor_id", "acceptance_revision", "verifier_receipt_ref"}
|
||||
if acceptance else {"verifier_id", "verification_receipt_ref"}
|
||||
)
|
||||
if not isinstance(observation, Mapping) or set(observation) != fields:
|
||||
raise ValueError("invalid signed receipt observation")
|
||||
if observation["binding_digest"] != binding_digest(binding):
|
||||
raise ValueError("receipt binding mismatch")
|
||||
if observation["acceptor_id" if acceptance else "verifier_id"] != identity:
|
||||
raise ValueError("receipt identity mismatch")
|
||||
if acceptance and observation["acceptance_revision"] != policy.acceptance_revision:
|
||||
raise ValueError("acceptance revision mismatch")
|
||||
result: dict[str, object] = {
|
||||
"schema": schema,
|
||||
kind: {**observation, "authenticated": True},
|
||||
}
|
||||
if acceptance:
|
||||
result["expected_acceptor_id"] = policy.acceptor_id
|
||||
result["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
return result
|
||||
|
||||
def verifier(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=False)
|
||||
|
||||
def acceptance(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=True)
|
||||
@@ -0,0 +1,990 @@
|
||||
"""Live AIOS protocol adapters for governed-writer execution.
|
||||
|
||||
This module closes the gap between the generic governed-writer model and the
|
||||
live field contracts observed on the V2R2C3 control plane:
|
||||
|
||||
- agent-task-api :18940
|
||||
* POST /v1/agents/register
|
||||
* GET /v1/tasks/{task_id}
|
||||
* POST /v1/tasks/{task_id}/claim -> lease_token
|
||||
* POST /v1/tasks/{task_id}/heartbeat
|
||||
- task-principal-issuer :18946
|
||||
* POST /v1/task-principal/assertions
|
||||
* Authorization: TaskLease <lease-token>
|
||||
* one assertion per concrete operation + bridge_request
|
||||
- bridge :18892
|
||||
* GET /v2/runtime
|
||||
* POST /v2/writer-lease
|
||||
* POST /v2/commit-files
|
||||
* Authorization: TaskPrincipal <operation-assertion>
|
||||
|
||||
WriterLease and commit-files share one continuation idempotency key.
|
||||
|
||||
The module never prints or returns the agent token, lease token or TaskPrincipal
|
||||
assertion. Secret values are kept only in an in-memory handle store.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriteReadiness,
|
||||
GovernedWriterRequest,
|
||||
OwnerAuthorizationPolicy,
|
||||
)
|
||||
|
||||
|
||||
class LiveProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolAuthError(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolConflict(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolUnavailable(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class IssuerResponseError(LiveProtocolError):
|
||||
"""Safe, structured issuer failure metadata for isolated diagnostics."""
|
||||
|
||||
def __init__(self, message: str, *, status: int, error_code: str | None,
|
||||
operation: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.operation = operation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResult:
|
||||
status: int
|
||||
body: Mapping[str, Any]
|
||||
|
||||
|
||||
class JsonHttpTransport:
|
||||
"""Small stdlib JSON transport. Authorization values are never logged."""
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: float = 20.0,
|
||||
) -> HttpResult:
|
||||
payload = None
|
||||
req_headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
payload = json.dumps(
|
||||
dict(body),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
req_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers=req_headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
status = int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = int(exc.code)
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise LiveProtocolUnavailable(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: transport unavailable"
|
||||
) from exc
|
||||
if not raw:
|
||||
obj: Any = {}
|
||||
else:
|
||||
try:
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: non-JSON response"
|
||||
) from exc
|
||||
if not isinstance(obj, Mapping):
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: response must be object"
|
||||
)
|
||||
return HttpResult(status=status, body=dict(obj))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveProtocolConfig:
|
||||
bridge_url: str = "http://127.0.0.1:18892"
|
||||
agent_task_url: str = "http://127.0.0.1:18940"
|
||||
principal_issuer_url: str = "http://127.0.0.1:18946"
|
||||
control_token_file: str = (
|
||||
"/run/credentials/aios-agent-task-api.service/control_token"
|
||||
)
|
||||
authority_git_dir: str = "/data/aios-gitops/repo.git"
|
||||
issue_schema: str = "aios.task-principal-issue.v1"
|
||||
agent_type: str = "aios.p0-task-admission-worker"
|
||||
agent_version: str = "governed-writer-live-v1"
|
||||
lease_seconds: int = 900
|
||||
assertion_ttl_seconds: int = 60
|
||||
minimum_remaining_window_seconds: int = 240
|
||||
|
||||
def normalized(self) -> "LiveProtocolConfig":
|
||||
return LiveProtocolConfig(
|
||||
bridge_url=self.bridge_url.rstrip("/"),
|
||||
agent_task_url=self.agent_task_url.rstrip("/"),
|
||||
principal_issuer_url=self.principal_issuer_url.rstrip("/"),
|
||||
control_token_file=self.control_token_file,
|
||||
authority_git_dir=self.authority_git_dir,
|
||||
issue_schema=self.issue_schema,
|
||||
agent_type=self.agent_type,
|
||||
agent_version=self.agent_version,
|
||||
lease_seconds=int(self.lease_seconds),
|
||||
assertion_ttl_seconds=int(self.assertion_ttl_seconds),
|
||||
minimum_remaining_window_seconds=int(
|
||||
self.minimum_remaining_window_seconds
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SecretHandleStore:
|
||||
"""Process-local secret container.
|
||||
|
||||
Public receipts contain handles only; secret values are not serialized.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
|
||||
def put(self, kind: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise LiveProtocolAuthError(f"{kind} secret missing")
|
||||
handle = f"{kind}:{secrets.token_hex(12)}"
|
||||
self._values[handle] = value
|
||||
return handle
|
||||
|
||||
def get(self, handle: str) -> str:
|
||||
try:
|
||||
return self._values[handle]
|
||||
except KeyError as exc:
|
||||
raise LiveProtocolAuthError("secret handle not available") from exc
|
||||
|
||||
def drop(self, handle: str) -> None:
|
||||
self._values.pop(handle, None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveTask:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
deadline_epoch: float | None
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveClaim:
|
||||
task_id: str
|
||||
claim_ref: str
|
||||
server_claim_id: str | None
|
||||
lease_handle: str
|
||||
fencing_generation: int | None
|
||||
lease_expires_at: float | None
|
||||
task_revision: int | None
|
||||
public_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterLease:
|
||||
# /v2/writer-lease is a validated binding projection. The durable branch
|
||||
# lease is created only by the continuation executor during commit.
|
||||
writer_lease_id: str | None
|
||||
task_id: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float | None
|
||||
continuation_key: str
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterSession:
|
||||
authorization_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
claim_ref: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
writer_lease_id: str | None
|
||||
continuation_key: str
|
||||
principal_issue_schema: str
|
||||
agent_token_handle: str = field(repr=False)
|
||||
lease_handle: str = field(repr=False)
|
||||
task_lease_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
writer_lease_binding: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
public = {
|
||||
"authorization_id": self.authorization_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"claim_ref": self.claim_ref,
|
||||
"job_id": self.job_id,
|
||||
"branch_mode": self.branch_mode,
|
||||
"expected_branch_oid": self.expected_branch_oid,
|
||||
"continuation_key": self.continuation_key,
|
||||
"principal_issue_schema": self.principal_issue_schema,
|
||||
"writer_lease_binding": dict(self.writer_lease_binding),
|
||||
}
|
||||
return public
|
||||
|
||||
|
||||
def _parse_epoch(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
pass
|
||||
# ISO-8601 minimal support.
|
||||
import datetime as dt
|
||||
|
||||
try:
|
||||
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _safe_ref_from_token(token: str, prefix: str) -> str:
|
||||
return f"{prefix}:sha256:{hashlib.sha256(token.encode('utf-8')).hexdigest()[:24]}"
|
||||
|
||||
|
||||
class LiveAgentTaskClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store or SecretHandleStore()
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = Path(self.config.control_token_file)
|
||||
try:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise LiveProtocolAuthError(
|
||||
"agent-task control token file unavailable"
|
||||
) from exc
|
||||
if len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent-task control token invalid")
|
||||
return token
|
||||
|
||||
def _control_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer " + self._control_token()}
|
||||
|
||||
def read_task(self, task_id: str) -> LiveTask:
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
result = self.transport.request(
|
||||
"GET",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}",
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 404:
|
||||
raise LiveProtocolConflict("task not found")
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task read failed HTTP {result.status}")
|
||||
raw = result.body
|
||||
task_obj = raw.get("task") if isinstance(raw.get("task"), Mapping) else raw
|
||||
status = str(task_obj.get("status") or "")
|
||||
if not status:
|
||||
raise LiveProtocolError("task readback missing status")
|
||||
revision = int(task_obj.get("revision") or 0)
|
||||
return LiveTask(
|
||||
task_id=str(task_obj.get("task_id") or task_id),
|
||||
status=status,
|
||||
revision=revision,
|
||||
deadline_epoch=_parse_epoch(task_obj.get("deadline")),
|
||||
raw=dict(task_obj),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
capabilities: Sequence[str],
|
||||
route: str,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
if agent_id is None:
|
||||
agent_id = (
|
||||
"governed-writer-"
|
||||
+ hashlib.sha256(
|
||||
(task_id + "\0" + str(time.time_ns())).encode("utf-8")
|
||||
).hexdigest()[:20]
|
||||
)
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": self.config.agent_type,
|
||||
"agent_version": self.config.agent_version,
|
||||
"capabilities": list(capabilities),
|
||||
"metadata": {"task_id": task_id, "route": route},
|
||||
}
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/agents/register",
|
||||
body=body,
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"agent register failed HTTP {result.status}")
|
||||
token = result.body.get("agent_token")
|
||||
if not isinstance(token, str) or len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent register returned no usable token")
|
||||
return agent_id, self.secrets.put("agent-token", token)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveClaim:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
token = self.secrets.get(agent_token_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/claim",
|
||||
body={"lease_seconds": lease_seconds},
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task not claimable")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"claim failed HTTP {result.status}")
|
||||
if result.body.get("status") != "CLAIMED":
|
||||
raise LiveProtocolError("claim response not CLAIMED")
|
||||
lease_token = result.body.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 8:
|
||||
raise LiveProtocolAuthError("claim returned no lease token")
|
||||
server_claim_id = result.body.get("claim_id")
|
||||
lease_id = result.body.get("lease_id")
|
||||
public_ref = None
|
||||
for candidate in (server_claim_id, lease_id):
|
||||
if isinstance(candidate, str) and candidate:
|
||||
public_ref = candidate
|
||||
break
|
||||
if public_ref is None:
|
||||
public_ref = _safe_ref_from_token(lease_token, "claim")
|
||||
lease_handle = self.secrets.put("task-lease", lease_token)
|
||||
fencing = result.body.get("fencing_generation")
|
||||
return LiveClaim(
|
||||
task_id=task_id,
|
||||
claim_ref=public_ref,
|
||||
server_claim_id=(
|
||||
str(server_claim_id)
|
||||
if isinstance(server_claim_id, str) and server_claim_id
|
||||
else None
|
||||
),
|
||||
lease_handle=lease_handle,
|
||||
fencing_generation=int(fencing) if fencing is not None else None,
|
||||
lease_expires_at=_parse_epoch(result.body.get("lease_expires_at")),
|
||||
task_revision=(
|
||||
int(result.body["receipt_revision"])
|
||||
if result.body.get("receipt_revision") is not None
|
||||
else None
|
||||
),
|
||||
public_receipt={k: result.body[k] for k in (
|
||||
"task_id", "status", "claim_id", "lease_id", "fencing_generation",
|
||||
"receipt_revision", "lease_expires_at", "attempt", "revision",
|
||||
) if k in result.body},
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
claim: LiveClaim,
|
||||
agent_token_handle: str,
|
||||
phase: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveTask:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(claim.task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(claim.lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/heartbeat",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"lease_seconds": lease_seconds,
|
||||
"progress": {"phase": phase, "status": "running"},
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"heartbeat failed HTTP {result.status}")
|
||||
if result.body.get("status") not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolError("heartbeat did not yield RUNNING/CLAIMED")
|
||||
task = self.read_task(claim.task_id)
|
||||
if task.status not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolConflict(
|
||||
f"authoritative task state after heartbeat is {task.status}"
|
||||
)
|
||||
return task
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_handle: str,
|
||||
idempotency_key: str,
|
||||
result_value: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 160:
|
||||
raise LiveProtocolError("task result idempotency key invalid")
|
||||
if not isinstance(result_value, Mapping):
|
||||
raise LiveProtocolError("task result mapping required")
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/result",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": idempotency_key,
|
||||
"result": dict(result_value),
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task result conflict")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task result HTTP {result.status}")
|
||||
if (
|
||||
result.body.get("task_id") != task_id
|
||||
or result.body.get("status") != "VERIFYING"
|
||||
or result.body.get("claimed_by") != agent_id
|
||||
or result.body.get("result") != dict(result_value)
|
||||
):
|
||||
raise LiveProtocolConflict("task result readback mismatch")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveTaskPrincipalClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store
|
||||
self.public_receipts: list[dict[str, Any]] = []
|
||||
|
||||
def issue_assertion(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_handle: str,
|
||||
operation: str,
|
||||
bridge_request: Mapping[str, Any],
|
||||
request_id: str,
|
||||
) -> str:
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
body = {
|
||||
"schema": self.config.issue_schema,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"operation": operation,
|
||||
"bridge_request": dict(bridge_request),
|
||||
"requested_ttl_seconds": self.config.assertion_ttl_seconds,
|
||||
}
|
||||
last: HttpResult | None = None
|
||||
for attempt in range(2):
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.principal_issuer_url}/v1/task-principal/assertions",
|
||||
body=body,
|
||||
headers={"Authorization": "TaskLease " + lease_token},
|
||||
timeout=20,
|
||||
)
|
||||
last = result
|
||||
assertion = result.body.get("assertion")
|
||||
if result.status in (200, 201) and isinstance(assertion, str) and assertion:
|
||||
self.public_receipts.append({
|
||||
"task_id": task_id, "operation": operation, "request_id": request_id,
|
||||
"assertion_sha256": hashlib.sha256(assertion.encode()).hexdigest(),
|
||||
"issuer_response": {k: result.body[k] for k in ("jti", "expires_at", "replayed") if k in result.body},
|
||||
})
|
||||
return assertion
|
||||
if not (
|
||||
attempt == 0
|
||||
and result.status == 503
|
||||
and result.body.get("error") == "TASK_PRINCIPAL_ISSUER_UNAVAILABLE"
|
||||
):
|
||||
break
|
||||
time.sleep(1)
|
||||
status = 0 if last is None else last.status
|
||||
raw_code = None if last is None else last.body.get("error")
|
||||
error_code = (
|
||||
raw_code if isinstance(raw_code, str)
|
||||
and re.fullmatch(r"(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}", raw_code) else None
|
||||
)
|
||||
raise IssuerResponseError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}",
|
||||
status=status, error_code=error_code, operation=operation,
|
||||
)
|
||||
|
||||
|
||||
class LiveGitReader:
|
||||
def __init__(self, git_dir: str) -> None:
|
||||
self.git_dir = str(git_dir)
|
||||
|
||||
def branch_head(self, branch: str) -> str | None:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "rev-parse", f"refs/heads/{branch}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
head = proc.stdout.strip()
|
||||
if len(head) != 40 or any(c not in "0123456789abcdef" for c in head):
|
||||
raise LiveProtocolError("git branch HEAD is not a full OID")
|
||||
return head
|
||||
|
||||
def file_sha256(self, oid: str, path: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "show", f"{oid}:{path}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise LiveProtocolError(f"git readback failed for {path}")
|
||||
return hashlib.sha256(proc.stdout).hexdigest()
|
||||
|
||||
|
||||
class LiveBridgeClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
principal_client: LiveTaskPrincipalClient,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.principal_client = principal_client
|
||||
|
||||
def runtime_status(self) -> Mapping[str, Any]:
|
||||
result = self.transport.request(
|
||||
"GET", f"{self.config.bridge_url}/v2/runtime", timeout=10
|
||||
)
|
||||
if result.status != 200:
|
||||
raise LiveProtocolUnavailable(f"bridge runtime HTTP {result.status}")
|
||||
return result.body
|
||||
|
||||
def acquire_writer_lease(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job_id: str,
|
||||
lease_handle: str,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
branch_mode: str,
|
||||
continuation_key: str,
|
||||
) -> LiveWriterLease:
|
||||
req = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"idempotency_key": continuation_key,
|
||||
"repository": repository,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=task_id,
|
||||
lease_handle=lease_handle,
|
||||
operation="acquireWriterLease",
|
||||
bridge_request=req,
|
||||
request_id="writer-lease-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/writer-lease",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=30,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"writer lease HTTP {result.status}")
|
||||
body = result.body
|
||||
expected_binding = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
for field, expected in expected_binding.items():
|
||||
if body.get(field) != expected:
|
||||
raise LiveProtocolConflict(f"writer lease {field} mismatch")
|
||||
projected_branch_mode = body.get("branch_mode")
|
||||
allowed_branch_modes = {branch_mode}
|
||||
# CREATE_NEW is materialized by the Bridge before it returns the
|
||||
# descriptor, so the returned binding is CONTINUE_EXISTING.
|
||||
if branch_mode == "CREATE_NEW":
|
||||
allowed_branch_modes.add("CONTINUE_EXISTING")
|
||||
if projected_branch_mode not in allowed_branch_modes:
|
||||
raise LiveProtocolConflict("writer lease branch_mode mismatch")
|
||||
return LiveWriterLease(
|
||||
writer_lease_id=None,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
branch_mode=str(projected_branch_mode),
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=int(generation),
|
||||
expires_at=None,
|
||||
continuation_key=continuation_key,
|
||||
raw=dict(body),
|
||||
)
|
||||
|
||||
def commit_files(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
entries = [
|
||||
{
|
||||
"path": path,
|
||||
"content_base64": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
for path, data in files.items()
|
||||
]
|
||||
req = {
|
||||
"task_id": session.task_id,
|
||||
"job_id": session.job_id,
|
||||
"repository": repository,
|
||||
"expected_branch_oid": session.expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"idempotency_key": session.continuation_key,
|
||||
"files": entries,
|
||||
"message": message,
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=session.task_id,
|
||||
lease_handle=session.lease_handle,
|
||||
operation="commitFilesGoverned",
|
||||
bridge_request=req,
|
||||
request_id="commit-files-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/commit-files",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=45,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"commit-files HTTP {result.status}")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveGovernedWriterExecutor:
|
||||
"""Execute the real post-admission writer chain.
|
||||
|
||||
Prerequisites:
|
||||
- Owner authorization was already verified by the caller/authority.
|
||||
- A fresh admission task exists and is QUEUED.
|
||||
- The task's execution plan already binds the supplied job_id.
|
||||
- A valid delegation epoch/admission window is already active.
|
||||
|
||||
This executor deliberately does not create/rewrite admission plans or sign
|
||||
Owner epochs. Those remain in the existing admission/signer authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: LiveProtocolConfig,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
authorization_ref: str,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
agent_id: str | None = None,
|
||||
scope_enrollment: Callable[..., Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.owner_policy = owner_policy
|
||||
self.authorization_ref = authorization_ref
|
||||
if scope_enrollment is not None and (not callable(scope_enrollment) or not agent_id):
|
||||
raise ValueError("scope enrollment requires a configured agent identity")
|
||||
self.agent_id = agent_id
|
||||
self.scope_enrollment = scope_enrollment
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secret_store = SecretHandleStore()
|
||||
self.agent = LiveAgentTaskClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.principals = LiveTaskPrincipalClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.bridge = LiveBridgeClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
principal_client=self.principals,
|
||||
)
|
||||
self.git = LiveGitReader(self.config.authority_git_dir)
|
||||
|
||||
def _assert_owner_scope(self, request: GovernedWriterRequest) -> None:
|
||||
if self.authorization_ref != self.owner_policy.authorization_id:
|
||||
raise AuthorizationDenied("authorization_ref does not match verified policy")
|
||||
self.owner_policy.validate(request, now=time.time())
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
job_id: str,
|
||||
route: str = "owner-authorized-governed-writer",
|
||||
) -> LiveWriterSession:
|
||||
self._assert_owner_scope(request)
|
||||
|
||||
runtime = self.bridge.runtime_status()
|
||||
readiness = GovernedWriteReadiness.from_runtime_status(runtime)
|
||||
if not readiness.governed_write_path_open:
|
||||
raise LiveProtocolConflict(
|
||||
"governed write path blocked: " + ",".join(readiness.blockers)
|
||||
)
|
||||
if readiness.legacy_global_writes_enabled:
|
||||
raise LiveProtocolConflict("legacy/global writes must remain disabled")
|
||||
|
||||
task = self.agent.read_task(request.task_id)
|
||||
if task.status != "QUEUED":
|
||||
raise LiveProtocolConflict(
|
||||
f"task must be QUEUED before claim; live={task.status}"
|
||||
)
|
||||
now = time.time()
|
||||
if (
|
||||
task.deadline_epoch is not None
|
||||
and task.deadline_epoch - now
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict("task deadline too close to safely claim")
|
||||
|
||||
agent_id, agent_token_handle = self.agent.register_agent(
|
||||
task_id=request.task_id,
|
||||
capabilities=sorted(request.required_capabilities),
|
||||
route=route,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
claim = self.agent.claim(
|
||||
task_id=request.task_id,
|
||||
agent_token_handle=agent_token_handle,
|
||||
)
|
||||
running = self.agent.heartbeat(
|
||||
claim=claim,
|
||||
agent_token_handle=agent_token_handle,
|
||||
phase=route,
|
||||
)
|
||||
if (
|
||||
running.deadline_epoch is not None
|
||||
and running.deadline_epoch - time.time()
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict(
|
||||
"remaining governed task window too short after heartbeat"
|
||||
)
|
||||
|
||||
if self.scope_enrollment is not None:
|
||||
# The existing issuer and Bridge still verify every assertion.
|
||||
# Enrollment only happens after a real claim has established the
|
||||
# agent/attempt/lease binding; failures stop before any assertion.
|
||||
enrollment = self.scope_enrollment(
|
||||
request=request, job_id=job_id, agent_id=agent_id,
|
||||
claim=claim, task=running,
|
||||
)
|
||||
if (not isinstance(enrollment, Mapping)
|
||||
or enrollment.get("status") != "ENROLLED"
|
||||
or enrollment.get("task_id") != request.task_id
|
||||
or enrollment.get("agent_id") != agent_id
|
||||
or enrollment.get("job_id") != job_id):
|
||||
raise LiveProtocolConflict("verified principal scope enrollment required")
|
||||
|
||||
live_head = self.git.branch_head(request.target_branch)
|
||||
if live_head is None:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
else:
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = live_head
|
||||
|
||||
continuation_key = (
|
||||
"gw-cont-"
|
||||
+ hashlib.sha256(
|
||||
(request.task_id + "\0" + job_id).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
)
|
||||
writer = self.bridge.acquire_writer_lease(
|
||||
task_id=request.task_id,
|
||||
job_id=job_id,
|
||||
lease_handle=claim.lease_handle,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
branch_mode=branch_mode,
|
||||
continuation_key=continuation_key,
|
||||
)
|
||||
return LiveWriterSession(
|
||||
authorization_id=self.authorization_ref,
|
||||
task_id=request.task_id,
|
||||
agent_id=agent_id,
|
||||
claim_ref=claim.claim_ref,
|
||||
job_id=job_id,
|
||||
branch_mode=writer.branch_mode,
|
||||
expected_branch_oid=writer.expected_branch_oid,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
continuation_key=writer.continuation_key,
|
||||
principal_issue_schema=self.config.issue_schema,
|
||||
agent_token_handle=agent_token_handle,
|
||||
lease_handle=claim.lease_handle,
|
||||
task_lease_receipt=dict(claim.public_receipt),
|
||||
writer_lease_binding=dict(writer.raw),
|
||||
)
|
||||
|
||||
def commit_and_readback(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes],
|
||||
expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
self._assert_owner_scope(request)
|
||||
if set(files) != set(expected_sha256):
|
||||
raise LiveProtocolConflict("commit file set differs from expected SHA set")
|
||||
for path, data in files.items():
|
||||
got = hashlib.sha256(data).hexdigest()
|
||||
if got != expected_sha256[path]:
|
||||
raise LiveProtocolConflict(f"pre-commit SHA mismatch: {path}")
|
||||
|
||||
response = self.bridge.commit_files(
|
||||
session=session,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
files=files,
|
||||
message=message,
|
||||
)
|
||||
|
||||
new_head = self.git.branch_head(request.target_branch)
|
||||
if new_head is None or new_head == session.expected_branch_oid:
|
||||
raise LiveProtocolConflict("branch did not advance after governed commit")
|
||||
readback = {
|
||||
path: self.git.file_sha256(new_head, path) for path in expected_sha256
|
||||
}
|
||||
if readback != dict(expected_sha256):
|
||||
raise LiveProtocolConflict("governed commit readback SHA mismatch")
|
||||
return {
|
||||
"commit_response": response,
|
||||
"branch_head": new_head,
|
||||
"readback_sha256": readback,
|
||||
}
|
||||
|
||||
def submit_task_result(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
result: Mapping[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Mapping[str, Any]:
|
||||
return self.agent.submit_result(
|
||||
task_id=session.task_id,
|
||||
agent_id=session.agent_id,
|
||||
agent_token_handle=session.agent_token_handle,
|
||||
lease_handle=session.lease_handle,
|
||||
idempotency_key=idempotency_key,
|
||||
result_value=result,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -74,7 +75,13 @@ def _now_ts() -> float:
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
def compute_sha256_hex(value: Any) -> str:
|
||||
@@ -532,10 +539,16 @@ class EvidenceEnvelope:
|
||||
worker_identity: str
|
||||
payload: dict[str, Any]
|
||||
created_at: float
|
||||
expires_at: float
|
||||
previous_evidence_hash: str
|
||||
evidence_digest: str
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signing_key_id: str | None = None
|
||||
verifier_identity: str | None = None
|
||||
authority_domain: str | None = None
|
||||
sequence: int | None = None
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -551,7 +564,17 @@ class EvidenceEnvelope:
|
||||
attestation_ref: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> "EvidenceEnvelope":
|
||||
issued_at = float(_now_ts() if created_at is None else created_at)
|
||||
valid_until = float(issued_at + 300.0 if expires_at is None else expires_at)
|
||||
if not math.isfinite(issued_at) or not math.isfinite(valid_until):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
envelope = cls(
|
||||
schema_version=1,
|
||||
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
|
||||
@@ -560,11 +583,17 @@ class EvidenceEnvelope:
|
||||
kind=validate_type(kind, "evidence kind"),
|
||||
worker_identity=str(worker_identity),
|
||||
payload=redact_value(payload),
|
||||
created_at=float(created_at or _now_ts()),
|
||||
created_at=issued_at,
|
||||
expires_at=valid_until,
|
||||
previous_evidence_hash=str(previous_evidence_hash or ""),
|
||||
evidence_digest="",
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
return dataclass_replace(
|
||||
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
|
||||
@@ -580,8 +609,14 @@ class EvidenceEnvelope:
|
||||
"worker_identity": self.worker_identity,
|
||||
"payload": self.payload,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"previous_evidence_hash": self.previous_evidence_hash,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"signing_key_id": self.signing_key_id,
|
||||
"verifier_identity": self.verifier_identity,
|
||||
"authority_domain": self.authority_domain,
|
||||
"sequence": self.sequence,
|
||||
}
|
||||
|
||||
def verify(
|
||||
@@ -591,18 +626,40 @@ class EvidenceEnvelope:
|
||||
expected_claim_id: str,
|
||||
expected_previous_hash: str,
|
||||
attestation_verifier: Any = None,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
observed_at = float(_now_ts() if now is None else now)
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (observed_at, self.created_at, self.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(self.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
self.schema_version != 1
|
||||
not finite_timestamps
|
||||
or self.schema_version != 1
|
||||
or self.task_id != expected_task_id
|
||||
or self.claim_id != expected_claim_id
|
||||
or self.previous_evidence_hash != expected_previous_hash
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
or self.created_at > observed_at
|
||||
or self.expires_at <= observed_at
|
||||
or self.expires_at <= self.created_at
|
||||
or self.evidence_digest != expected_digest
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
# An unsigned envelope has no proof of origin and must never verify.
|
||||
if not (self.signature or self.attestation_ref):
|
||||
return False
|
||||
# Every proof form requires an injected verifier. A reference or
|
||||
# signature must never become trusted merely because it is present.
|
||||
if attestation_verifier is None:
|
||||
return False
|
||||
try:
|
||||
return bool(attestation_verifier(self))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -611,6 +668,41 @@ class EvidenceEnvelope:
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "EvidenceEnvelope":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("evidence envelope must be an object")
|
||||
try:
|
||||
envelope = cls(
|
||||
schema_version=int(value["schema_version"]),
|
||||
evidence_id=validate_identifier(value["evidence_id"], "evidence_id"),
|
||||
task_id=validate_identifier(value["task_id"], "task_id"),
|
||||
claim_id=validate_identifier(value["claim_id"], "claim_id"),
|
||||
kind=validate_type(value["kind"], "evidence kind"),
|
||||
worker_identity=str(value["worker_identity"]),
|
||||
payload=redact_value(value["payload"]),
|
||||
created_at=float(value["created_at"]),
|
||||
expires_at=float(value["expires_at"]),
|
||||
previous_evidence_hash=str(value["previous_evidence_hash"]),
|
||||
evidence_digest=str(value["evidence_digest"]),
|
||||
signature=value.get("signature"),
|
||||
attestation_ref=value.get("attestation_ref"),
|
||||
signature_algorithm=value.get("signature_algorithm"),
|
||||
signing_key_id=value.get("signing_key_id"),
|
||||
verifier_identity=value.get("verifier_identity"),
|
||||
authority_domain=value.get("authority_domain"),
|
||||
sequence=(
|
||||
None if value.get("sequence") is None else int(value["sequence"])
|
||||
),
|
||||
)
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
return envelope
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid evidence envelope") from exc
|
||||
|
||||
|
||||
class ProbeOutcome(StrEnum):
|
||||
NOT_APPLIED = "NOT_APPLIED"
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Strict adapters from existing authority readers into C6 observations.
|
||||
|
||||
Readers are injected by the trusted composition root. They are query-only:
|
||||
this module has no controller/reconcile, signer, store, or network factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .operation_view import (
|
||||
AcceptanceObservation,
|
||||
ClosureObservation,
|
||||
DomainStamp,
|
||||
EffectObservation,
|
||||
Fact,
|
||||
OperationBinding,
|
||||
OperationObservation,
|
||||
VerificationObservation,
|
||||
binding_digest,
|
||||
)
|
||||
|
||||
|
||||
Reader = Callable[[OperationBinding], Mapping[str, object]]
|
||||
|
||||
_STAMP_FIELDS = frozenset({"domain", "object_id", "generation", "revision", "event_id"})
|
||||
_FACT_FIELDS = frozenset(
|
||||
{"value", "stamp", "binding_digest", "observed_at", "expires_at", "evidence_refs"}
|
||||
)
|
||||
_DECLARATION_FIELDS = frozenset({"schema", "declared", "installed", "callable"})
|
||||
_TASK_FIELDS = frozenset({"schema", "preconditions"})
|
||||
_AUTHORIZATION_FIELDS = frozenset({"schema", "authorized"})
|
||||
_EFFECT_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"outcome",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"actor_id",
|
||||
"stamp",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"trust_status",
|
||||
}
|
||||
)
|
||||
_GITOPS_FIELDS = frozenset({"schema", "effect"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"verification_receipt_ref",
|
||||
}
|
||||
)
|
||||
_VERIFIER_FIELDS = frozenset({"schema", "verification"})
|
||||
_ACCEPTANCE_OBSERVATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_receipt_ref",
|
||||
"acceptance_revision",
|
||||
"acceptor_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
_ACCEPTANCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"acceptance",
|
||||
"expected_acceptor_id",
|
||||
"expected_acceptance_revision",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationReaders:
|
||||
declaration: Reader | None
|
||||
task: Reader | None
|
||||
authorization: Reader | None
|
||||
gitops: Reader | None
|
||||
verifier: Reader | None
|
||||
acceptance: Reader | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"declaration",
|
||||
"task",
|
||||
"authorization",
|
||||
"gitops",
|
||||
"verifier",
|
||||
"acceptance",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not callable(value):
|
||||
raise ValueError(f"{name} reader must be callable or None")
|
||||
|
||||
|
||||
def _exact_mapping(value: object, fields: frozenset[str], name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValueError(f"{name} has unknown or missing fields")
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} keys must be strings")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(value: object, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _text(value, name)
|
||||
|
||||
|
||||
def _datetime(value: object, name: str) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
value = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} is not an RFC3339 datetime") from exc
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(value: object, name: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an array")
|
||||
return tuple(_text(item, name) for item in value)
|
||||
|
||||
|
||||
def _stamp(value: object) -> DomainStamp | None:
|
||||
if value is None:
|
||||
return None
|
||||
raw = _exact_mapping(value, _STAMP_FIELDS, "stamp")
|
||||
return DomainStamp(
|
||||
domain=_text(raw["domain"], "domain"),
|
||||
object_id=_text(raw["object_id"], "object_id"),
|
||||
generation=raw["generation"], # type: ignore[arg-type]
|
||||
revision=raw["revision"], # type: ignore[arg-type]
|
||||
event_id=_text(raw["event_id"], "event_id"),
|
||||
)
|
||||
|
||||
|
||||
def _fact(value: object) -> Fact:
|
||||
raw = _exact_mapping(value, _FACT_FIELDS, "fact")
|
||||
return Fact(
|
||||
value=raw["value"], # type: ignore[arg-type]
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
evidence_refs=_tuple_of_text(raw["evidence_refs"], "evidence_refs"),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_fact(binding: OperationBinding, code: str) -> Fact:
|
||||
return Fact(
|
||||
value="UNKNOWN",
|
||||
stamp=None,
|
||||
binding_digest=binding_digest(binding),
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
evidence_refs=(f"operation-readback:{code}",),
|
||||
)
|
||||
|
||||
|
||||
def _fact_candidates(value: object) -> tuple[Fact, ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return (_fact(value),)
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
if not value:
|
||||
raise ValueError("fact candidate list is empty")
|
||||
return tuple(_fact(item) for item in value)
|
||||
raise ValueError("fact must be an object or non-empty candidate list")
|
||||
|
||||
|
||||
def _select_fact(
|
||||
value: object, binding: OperationBinding, *, expected_domain: str
|
||||
) -> Fact:
|
||||
candidates = _fact_candidates(value)
|
||||
digest = binding_digest(binding)
|
||||
if any(item.binding_digest != digest for item in candidates):
|
||||
return _unknown_fact(binding, "BINDING_MISMATCH")
|
||||
if any(item.stamp is None for item in candidates):
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return _unknown_fact(binding, "INVALID_CANDIDATE_SET")
|
||||
stamps = [item.stamp for item in candidates]
|
||||
assert all(stamp is not None for stamp in stamps)
|
||||
domains = {(stamp.domain, stamp.object_id) for stamp in stamps if stamp is not None}
|
||||
if len(domains) != 1 or next(iter(domains))[0] != expected_domain:
|
||||
return _unknown_fact(binding, "CROSS_DOMAIN_CANDIDATES")
|
||||
highest = max((stamp.generation, stamp.revision) for stamp in stamps if stamp is not None)
|
||||
current = [
|
||||
item
|
||||
for item in candidates
|
||||
if item.stamp is not None
|
||||
and (item.stamp.generation, item.stamp.revision) == highest
|
||||
]
|
||||
if len(current) > 1:
|
||||
first = current[0]
|
||||
# Event identity may differ for an idempotent duplicate. Every semantic
|
||||
# field must still agree at the same authority revision.
|
||||
semantic = (
|
||||
first.value,
|
||||
first.binding_digest,
|
||||
first.observed_at,
|
||||
first.expires_at,
|
||||
first.evidence_refs,
|
||||
)
|
||||
if any(
|
||||
(
|
||||
item.value,
|
||||
item.binding_digest,
|
||||
item.observed_at,
|
||||
item.expires_at,
|
||||
item.evidence_refs,
|
||||
)
|
||||
!= semantic
|
||||
for item in current[1:]
|
||||
):
|
||||
return _unknown_fact(binding, "SAME_REVISION_CONFLICT")
|
||||
return current[0]
|
||||
|
||||
|
||||
def _read(reader: Reader | None, binding: OperationBinding) -> Mapping[str, object] | None:
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
value = reader(binding)
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def collect_observation(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> OperationObservation:
|
||||
"""Query three authority seams and build a typed preflight observation."""
|
||||
_datetime(now, "now")
|
||||
declared = installed = callable_fact = _unknown_fact(binding, "DECLARATION_UNAVAILABLE")
|
||||
raw = _read(readers.declaration, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _DECLARATION_FIELDS, "declaration reader result")
|
||||
if doc["schema"] != "aios.operation-declaration-observation.v1":
|
||||
raise ValueError("unsupported declaration schema")
|
||||
declared = _select_fact(
|
||||
doc["declared"], binding, expected_domain="declaration"
|
||||
)
|
||||
installed = _select_fact(
|
||||
doc["installed"], binding, expected_domain="runtime"
|
||||
)
|
||||
callable_fact = _select_fact(
|
||||
doc["callable"], binding, expected_domain="runtime"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
declared = installed = callable_fact = _unknown_fact(
|
||||
binding, "INVALID_DECLARATION_OBSERVATION"
|
||||
)
|
||||
|
||||
preconditions = _unknown_fact(binding, "TASK_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.task, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _TASK_FIELDS, "task reader result")
|
||||
if doc["schema"] != "aios.operation-task-observation.v1":
|
||||
raise ValueError("unsupported task schema")
|
||||
preconditions = _select_fact(
|
||||
doc["preconditions"], binding, expected_domain="task"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
preconditions = _unknown_fact(binding, "INVALID_TASK_OBSERVATION")
|
||||
|
||||
authorized = _unknown_fact(binding, "AUTHORIZATION_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.authorization, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _AUTHORIZATION_FIELDS, "authorization reader result")
|
||||
if doc["schema"] != "aios.operation-authorization-observation.v1":
|
||||
raise ValueError("unsupported authorization schema")
|
||||
authorized = _select_fact(
|
||||
doc["authorized"], binding, expected_domain="authorization"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
authorized = _unknown_fact(binding, "INVALID_AUTHORIZATION_OBSERVATION")
|
||||
|
||||
return OperationObservation(
|
||||
binding=binding,
|
||||
declared=declared,
|
||||
installed=installed,
|
||||
callable=callable_fact,
|
||||
authorized=authorized,
|
||||
preconditions=preconditions,
|
||||
)
|
||||
|
||||
|
||||
def _unknown_effect(binding: OperationBinding) -> EffectObservation:
|
||||
return EffectObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
outcome="UNKNOWN",
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
actor_id=None,
|
||||
stamp=None,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
trust_status="UNTRUSTED",
|
||||
)
|
||||
|
||||
|
||||
def _effect(value: object) -> EffectObservation:
|
||||
raw = _exact_mapping(value, _EFFECT_FIELDS, "effect")
|
||||
return EffectObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
actor_id=_optional_text(raw["actor_id"], "actor_id"),
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
trust_status=raw["trust_status"], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _unknown_verification(binding: OperationBinding) -> VerificationObservation:
|
||||
return VerificationObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_id=None,
|
||||
outcome="UNAVAILABLE",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
verification_receipt_ref=None,
|
||||
)
|
||||
|
||||
|
||||
def _verification(value: object) -> VerificationObservation:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
return VerificationObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_id=_optional_text(raw["verifier_id"], "verifier_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
verification_receipt_ref=_optional_text(
|
||||
raw["verification_receipt_ref"], "verification_receipt_ref"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_acceptance(binding: OperationBinding) -> AcceptanceObservation:
|
||||
return AcceptanceObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_receipt_ref=None,
|
||||
acceptance_revision=None,
|
||||
acceptor_id=None,
|
||||
outcome="UNKNOWN",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _acceptance(value: object) -> AcceptanceObservation:
|
||||
raw = _exact_mapping(value, _ACCEPTANCE_OBSERVATION_FIELDS, "acceptance")
|
||||
return AcceptanceObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_receipt_ref=_optional_text(
|
||||
raw["verifier_receipt_ref"], "verifier_receipt_ref"
|
||||
),
|
||||
acceptance_revision=_optional_text(
|
||||
raw["acceptance_revision"], "acceptance_revision"
|
||||
),
|
||||
acceptor_id=_optional_text(raw["acceptor_id"], "acceptor_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
)
|
||||
|
||||
|
||||
def collect_closure(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureObservation:
|
||||
"""Read closure claims independently; absent adapters remain UNKNOWN."""
|
||||
_datetime(now, "now")
|
||||
effect = _unknown_effect(binding)
|
||||
raw = _read(readers.gitops, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _GITOPS_FIELDS, "gitops reader result")
|
||||
if doc["schema"] != "aios.operation-effect-observation.v1":
|
||||
raise ValueError("unsupported effect schema")
|
||||
effect = _effect(doc["effect"])
|
||||
except (TypeError, ValueError):
|
||||
effect = _unknown_effect(binding)
|
||||
|
||||
verification = _unknown_verification(binding)
|
||||
raw = _read(readers.verifier, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _VERIFIER_FIELDS, "verifier reader result")
|
||||
if doc["schema"] != "aios.operation-verification-observation.v1":
|
||||
raise ValueError("unsupported verifier schema")
|
||||
verification = _verification(doc["verification"])
|
||||
except (TypeError, ValueError):
|
||||
verification = _unknown_verification(binding)
|
||||
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
expected_acceptor_id = "unresolved-acceptor"
|
||||
expected_acceptance_revision = "unresolved-revision"
|
||||
raw = _read(readers.acceptance, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _ACCEPTANCE_FIELDS, "acceptance reader result")
|
||||
if doc["schema"] != "aios.operation-acceptance-observation.v1":
|
||||
raise ValueError("unsupported acceptance schema")
|
||||
acceptance = _acceptance(doc["acceptance"])
|
||||
expected_acceptor_id = _text(
|
||||
doc["expected_acceptor_id"], "expected_acceptor_id"
|
||||
)
|
||||
expected_acceptance_revision = _text(
|
||||
doc["expected_acceptance_revision"],
|
||||
"expected_acceptance_revision",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
|
||||
return ClosureObservation(
|
||||
effect=effect,
|
||||
verification=verification,
|
||||
acceptance=acceptance,
|
||||
expected_acceptor_id=expected_acceptor_id,
|
||||
expected_acceptance_revision=expected_acceptance_revision,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OperationReaders", "collect_closure", "collect_observation"]
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Read-only C6 operation observations and fail-closed reductions.
|
||||
|
||||
This module deliberately owns no persistence, signer, lease, or mutation port.
|
||||
Its digests are local to the C6 operation-binding schema and do not replace any
|
||||
existing domain encoder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
FactValue = Literal["TRUE", "FALSE", "UNKNOWN"]
|
||||
PreflightState = Literal[
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PRECONDITION_FAILED",
|
||||
"UNKNOWN",
|
||||
"READY",
|
||||
]
|
||||
EffectOutcome = Literal["COMMITTED", "NOT_COMMITTED", "UNKNOWN"]
|
||||
VerificationOutcome = Literal["PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"]
|
||||
AcceptanceOutcome = Literal["ACCEPTED", "REJECTED", "UNKNOWN"]
|
||||
ClosureOutcome = Literal["SUCCEEDED", "PARTIAL", "OUTCOME_UNKNOWN"]
|
||||
EffectReconciliation = Literal[
|
||||
"REBUILD_RECEIPT_REFERENCE",
|
||||
"REVALIDATE_BEFORE_RETRY",
|
||||
"OUTCOME_UNKNOWN",
|
||||
]
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_EFFECT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$")
|
||||
_CAPABILITY_NAMESPACES = frozenset({"agent-task", "task-plane-wildcard"})
|
||||
|
||||
|
||||
def _string(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{name} contains NUL")
|
||||
return value
|
||||
|
||||
|
||||
def _non_negative_int(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _aware_utc(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
if value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must have a UTC offset")
|
||||
return value
|
||||
|
||||
|
||||
def validate_oid(value: str) -> str:
|
||||
if not isinstance(value, str) or _OID_RE.fullmatch(value) is None:
|
||||
raise ValueError("OID must be 40 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_digest(value: str) -> str:
|
||||
if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None:
|
||||
raise ValueError("digest must be 64 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_effect_key(value: str) -> str:
|
||||
if not isinstance(value, str) or _EFFECT_KEY_RE.fullmatch(value) is None:
|
||||
raise ValueError("effect key has invalid length or characters")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationBinding:
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
repository: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
input_kind: str
|
||||
source_oid: str
|
||||
patch_digest: str
|
||||
target_branch: str
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"repository",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"input_kind",
|
||||
"target_branch",
|
||||
):
|
||||
_string(getattr(self, name), name)
|
||||
if self.capability_namespace not in _CAPABILITY_NAMESPACES:
|
||||
raise ValueError("unsupported capability namespace")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
validate_oid(self.source_oid)
|
||||
for name in ("patch_digest", "scope_digest", "policy_digest", "plan_digest"):
|
||||
validate_digest(getattr(self, name))
|
||||
validate_effect_key(self.effect_key)
|
||||
|
||||
|
||||
def binding_digest(binding: OperationBinding) -> str:
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("binding must be OperationBinding")
|
||||
payload = json.dumps(
|
||||
asdict(binding),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainStamp:
|
||||
domain: str
|
||||
object_id: str
|
||||
generation: int
|
||||
revision: int
|
||||
event_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.domain, "domain")
|
||||
_string(self.object_id, "object_id")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
_non_negative_int(self.revision, "revision")
|
||||
_string(self.event_id, "event_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fact:
|
||||
value: FactValue
|
||||
stamp: DomainStamp | None
|
||||
binding_digest: str
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.value not in ("TRUE", "FALSE", "UNKNOWN"):
|
||||
raise ValueError("invalid fact value")
|
||||
validate_digest(self.binding_digest)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_string(ref, "evidence_ref")
|
||||
if self.value != "UNKNOWN" and self.stamp is None:
|
||||
raise ValueError("known fact requires an authority stamp")
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("fact timestamps must both be present or absent")
|
||||
if self.observed_at is None:
|
||||
if self.value != "UNKNOWN":
|
||||
raise ValueError("known fact requires timestamps")
|
||||
return
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationObservation:
|
||||
binding: OperationBinding
|
||||
declared: Fact
|
||||
installed: Fact
|
||||
callable: Fact
|
||||
authorized: Fact
|
||||
preconditions: Fact
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightResult:
|
||||
state: PreflightState
|
||||
next_action: str
|
||||
reason_code: str
|
||||
retryable: bool
|
||||
binding_digest: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
def _now(value: datetime) -> datetime:
|
||||
return _aware_utc(value, "now")
|
||||
|
||||
|
||||
def _fact_invalid_reason(fact: Fact, expected_digest: str, now: datetime) -> str | None:
|
||||
if fact.binding_digest != expected_digest:
|
||||
return "BINDING_MISMATCH"
|
||||
if fact.value == "UNKNOWN":
|
||||
return "OBSERVATION_UNKNOWN"
|
||||
if fact.stamp is None or fact.observed_at is None or fact.expires_at is None:
|
||||
return "INVALID_OBSERVATION"
|
||||
if fact.observed_at > now:
|
||||
return "FUTURE_OBSERVATION"
|
||||
if now >= fact.expires_at:
|
||||
return "STALE_OBSERVATION"
|
||||
return None
|
||||
|
||||
|
||||
def derive_preflight(
|
||||
observation: OperationObservation, *, now: datetime
|
||||
) -> PreflightResult:
|
||||
"""Reduce fresh authority observations without invoking any external port."""
|
||||
now = _now(now)
|
||||
if not isinstance(observation, OperationObservation):
|
||||
raise TypeError("observation must be OperationObservation")
|
||||
digest = binding_digest(observation.binding)
|
||||
evidence: list[str] = []
|
||||
frontier = (
|
||||
(
|
||||
"declared",
|
||||
observation.declared,
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"ENGINEERING_CHANGE_REQUIRED",
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
),
|
||||
(
|
||||
"installed",
|
||||
observation.installed,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_INSTALLED",
|
||||
),
|
||||
(
|
||||
"callable",
|
||||
observation.callable,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_CALLABLE",
|
||||
),
|
||||
(
|
||||
"authorized",
|
||||
observation.authorized,
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PREPARE_EXACT_AUTH_REQUEST",
|
||||
"EXACT_AUTHORIZATION_MISSING",
|
||||
),
|
||||
(
|
||||
"preconditions",
|
||||
observation.preconditions,
|
||||
"PRECONDITION_FAILED",
|
||||
"REPAIR_PRECONDITION",
|
||||
"PRECONDITION_FAILED",
|
||||
),
|
||||
)
|
||||
for name, fact, false_state, false_action, false_reason in frontier:
|
||||
evidence.extend(fact.evidence_refs)
|
||||
invalid_reason = _fact_invalid_reason(fact, digest, now)
|
||||
if invalid_reason is not None:
|
||||
return PreflightResult(
|
||||
state="UNKNOWN",
|
||||
next_action="OBSERVE_OR_RECONCILE",
|
||||
reason_code=f"{name.upper()}_{invalid_reason}",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
if fact.value == "FALSE":
|
||||
return PreflightResult(
|
||||
state=false_state, # type: ignore[arg-type]
|
||||
next_action=false_action,
|
||||
reason_code=false_reason,
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
return PreflightResult(
|
||||
state="READY",
|
||||
next_action="EXECUTE_GOVERNED_PLAN",
|
||||
reason_code="ALL_REQUIRED_FACTS_FRESH_AND_TRUE",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectObservation:
|
||||
binding_digest: str
|
||||
outcome: EffectOutcome
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
actor_id: str | None
|
||||
stamp: DomainStamp | None
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
trust_status: Literal["TRUSTED", "UNTRUSTED", "SHADOW"] = "TRUSTED"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("COMMITTED", "NOT_COMMITTED", "UNKNOWN"):
|
||||
raise ValueError("invalid effect outcome")
|
||||
if self.trust_status not in ("TRUSTED", "UNTRUSTED", "SHADOW"):
|
||||
raise ValueError("invalid effect trust status")
|
||||
for value, name in ((self.receipt_ref, "receipt_ref"), (self.actor_id, "actor_id")):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("effect timestamps must both be present or absent")
|
||||
if self.observed_at is not None:
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_id: str | None
|
||||
outcome: VerificationOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
verification_receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"):
|
||||
raise ValueError("invalid verification outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_id, "verifier_id"),
|
||||
(self.verification_receipt_ref, "verification_receipt_ref"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "verification")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_receipt_ref: str | None
|
||||
acceptance_revision: str | None
|
||||
acceptor_id: str | None
|
||||
outcome: AcceptanceOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("ACCEPTED", "REJECTED", "UNKNOWN"):
|
||||
raise ValueError("invalid acceptance outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_receipt_ref, "verifier_receipt_ref"),
|
||||
(self.acceptance_revision, "acceptance_revision"),
|
||||
(self.acceptor_id, "acceptor_id"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "acceptance")
|
||||
|
||||
|
||||
def _validate_optional_times(
|
||||
observed_at: datetime | None, expires_at: datetime | None, prefix: str
|
||||
) -> None:
|
||||
if (observed_at is None) != (expires_at is None):
|
||||
raise ValueError(f"{prefix} timestamps must both be present or absent")
|
||||
if observed_at is None:
|
||||
return
|
||||
observed = _aware_utc(observed_at, "observed_at")
|
||||
expires = _aware_utc(expires_at, "expires_at")
|
||||
if expires <= observed:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureObservation:
|
||||
effect: EffectObservation
|
||||
verification: VerificationObservation
|
||||
acceptance: AcceptanceObservation
|
||||
expected_acceptor_id: str
|
||||
expected_acceptance_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.expected_acceptor_id, "expected_acceptor_id")
|
||||
_string(self.expected_acceptance_revision, "expected_acceptance_revision")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureResult:
|
||||
execution_recorded: bool
|
||||
independently_verified: bool
|
||||
business_accepted: bool
|
||||
outcome: ClosureOutcome
|
||||
reason_code: str
|
||||
|
||||
|
||||
def _fresh(
|
||||
observed_at: datetime | None, expires_at: datetime | None, now: datetime
|
||||
) -> bool:
|
||||
return (
|
||||
observed_at is not None
|
||||
and expires_at is not None
|
||||
and observed_at <= now < expires_at
|
||||
)
|
||||
|
||||
|
||||
def reconcile_effect(
|
||||
effect: EffectObservation, *, binding: OperationBinding, now: datetime
|
||||
) -> EffectReconciliation:
|
||||
"""Choose a safe recovery route; this function never retries the effect."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
if (
|
||||
effect.binding_digest != digest
|
||||
or effect.stamp is None
|
||||
or effect.trust_status != "TRUSTED"
|
||||
or not _fresh(effect.observed_at, effect.expires_at, now)
|
||||
):
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "COMMITTED":
|
||||
if (
|
||||
effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
):
|
||||
return "REBUILD_RECEIPT_REFERENCE"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "NOT_COMMITTED":
|
||||
# stamp + fresh trusted authority receipt is the deterministic negative
|
||||
# observation. Revalidation is still required before any governed retry.
|
||||
if effect.receipt_ref is not None and effect.result_artifact_digest is None:
|
||||
return "REVALIDATE_BEFORE_RETRY"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
|
||||
|
||||
def derive_closure(
|
||||
binding: OperationBinding,
|
||||
observation: ClosureObservation,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureResult:
|
||||
"""Reduce execution, verification, and acceptance as three distinct claims."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
execution_recorded = (
|
||||
effect.binding_digest == digest
|
||||
and effect.outcome == "COMMITTED"
|
||||
and effect.trust_status == "TRUSTED"
|
||||
and effect.stamp is not None
|
||||
and effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
and _fresh(effect.observed_at, effect.expires_at, now)
|
||||
)
|
||||
if not execution_recorded:
|
||||
outcome: ClosureOutcome = (
|
||||
"OUTCOME_UNKNOWN" if effect.outcome == "UNKNOWN" else "PARTIAL"
|
||||
)
|
||||
return ClosureResult(False, False, False, outcome, "EXECUTION_NOT_RECORDED")
|
||||
|
||||
verification = observation.verification
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref or verification.receipt_ref
|
||||
)
|
||||
independently_verified = (
|
||||
verification.binding_digest == digest
|
||||
and verification.receipt_ref == effect.receipt_ref
|
||||
and verification.result_artifact_digest == effect.result_artifact_digest
|
||||
and verification.verifier_id is not None
|
||||
and verification.verifier_id != effect.actor_id
|
||||
and verification.outcome == "PASS"
|
||||
and verification.authenticated is True
|
||||
and verifier_receipt_ref is not None
|
||||
and _fresh(verification.observed_at, verification.expires_at, now)
|
||||
)
|
||||
if not independently_verified:
|
||||
return ClosureResult(True, False, False, "PARTIAL", "INDEPENDENT_VERIFICATION_MISSING")
|
||||
|
||||
acceptance = observation.acceptance
|
||||
business_accepted = (
|
||||
acceptance.binding_digest == digest
|
||||
and acceptance.receipt_ref == effect.receipt_ref
|
||||
and acceptance.result_artifact_digest == effect.result_artifact_digest
|
||||
and acceptance.verifier_receipt_ref == verifier_receipt_ref
|
||||
and acceptance.acceptance_revision
|
||||
== observation.expected_acceptance_revision
|
||||
and acceptance.acceptor_id == observation.expected_acceptor_id
|
||||
and acceptance.outcome == "ACCEPTED"
|
||||
and acceptance.authenticated is True
|
||||
and _fresh(acceptance.observed_at, acceptance.expires_at, now)
|
||||
)
|
||||
if not business_accepted:
|
||||
return ClosureResult(True, True, False, "PARTIAL", "AUTHORIZED_ACCEPTANCE_MISSING")
|
||||
return ClosureResult(True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AcceptanceObservation",
|
||||
"ClosureObservation",
|
||||
"ClosureResult",
|
||||
"DomainStamp",
|
||||
"EffectObservation",
|
||||
"Fact",
|
||||
"OperationBinding",
|
||||
"OperationObservation",
|
||||
"PreflightResult",
|
||||
"VerificationObservation",
|
||||
"binding_digest",
|
||||
"derive_closure",
|
||||
"derive_preflight",
|
||||
"reconcile_effect",
|
||||
"validate_digest",
|
||||
"validate_effect_key",
|
||||
"validate_oid",
|
||||
]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Strict, pure validation for the C6 executable source-patch contract.
|
||||
|
||||
Validation here proves contract and unified-diff shape only. The governed
|
||||
executor remains responsible for secure checkout containment, isolated apply,
|
||||
result-file hashing, live authorization, leases/fences, and CAS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from .operation_view import (
|
||||
OperationBinding,
|
||||
validate_digest,
|
||||
validate_effect_key,
|
||||
validate_oid,
|
||||
)
|
||||
|
||||
|
||||
_TOP_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"input_kind",
|
||||
"type",
|
||||
"target",
|
||||
"before_hash",
|
||||
"after_hash",
|
||||
"operations",
|
||||
"rollback",
|
||||
"verification",
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"capability_namespace",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"generation",
|
||||
"executor_ref",
|
||||
"repository",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"patch_digest",
|
||||
"file_digests",
|
||||
"scope_digest",
|
||||
"policy_digest",
|
||||
"plan_digest",
|
||||
"effect_key",
|
||||
"rollback_artifact_digest",
|
||||
"verification_contract_revision",
|
||||
"acceptance_contract_revision",
|
||||
}
|
||||
)
|
||||
_TARGET_FIELDS = frozenset({"repository", "branch"})
|
||||
_OPERATION_FIELDS = frozenset({"path", "kind", "before_hash", "after_hash"})
|
||||
_ROLLBACK_FIELDS = frozenset({"artifact_digest", "expected_after_hash"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{"revision", "acceptance_revision", "required_checks"}
|
||||
)
|
||||
_PROTECTED_PATHS = frozenset({".git", ".gitmodules"})
|
||||
_DIFF_HEADER_RE = re.compile(r"^diff --git a/([^\s]+) b/([^\s]+)$")
|
||||
|
||||
|
||||
def _exact_mapping(value: object, fields: frozenset[str], name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValueError(f"{name} has unknown or missing fields")
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} keys must be strings")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _path(value: object) -> str:
|
||||
path = _text(value, "path")
|
||||
if "\\" in path or ":" in path or path.startswith(("/", "//")):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
parsed = PurePosixPath(path)
|
||||
if path != parsed.as_posix() or any(part in ("", ".", "..") for part in parsed.parts):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
if any(part.casefold() == ".git" for part in parsed.parts) or path.casefold() in _PROTECTED_PATHS:
|
||||
raise ValueError("protected Git metadata is outside patch scope")
|
||||
return path
|
||||
|
||||
|
||||
def _content_hash(value: object, name: str, *, literal: str | None = None) -> str:
|
||||
text = _text(value, name)
|
||||
if literal is not None and text == literal:
|
||||
return text
|
||||
return validate_digest(text)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchTarget:
|
||||
repository: str
|
||||
branch: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository")
|
||||
_text(self.branch, "branch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchFileOperation:
|
||||
path: str
|
||||
kind: Literal["ADD", "MODIFY", "DELETE"]
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_path(self.path)
|
||||
if self.kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
if self.kind == "ADD":
|
||||
if self.before_hash != "ABSENT":
|
||||
raise ValueError("ADD before_hash must be ABSENT")
|
||||
validate_digest(self.after_hash)
|
||||
elif self.kind == "DELETE":
|
||||
validate_digest(self.before_hash)
|
||||
if self.after_hash != "DELETED":
|
||||
raise ValueError("DELETE after_hash must be DELETED")
|
||||
else:
|
||||
validate_digest(self.before_hash)
|
||||
validate_digest(self.after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollbackContract:
|
||||
artifact_digest: str
|
||||
expected_after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.artifact_digest)
|
||||
validate_digest(self.expected_after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationContract:
|
||||
revision: str
|
||||
acceptance_revision: str
|
||||
required_checks: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.revision, "verification revision")
|
||||
_text(self.acceptance_revision, "acceptance revision")
|
||||
if not isinstance(self.required_checks, tuple) or not self.required_checks:
|
||||
raise ValueError("required_checks must be a non-empty tuple")
|
||||
if len(set(self.required_checks)) != len(self.required_checks):
|
||||
raise ValueError("required_checks contains duplicates")
|
||||
for check in self.required_checks:
|
||||
_text(check, "required check")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutablePatchV1:
|
||||
schema: Literal["aios.executable-patch.v1"]
|
||||
input_kind: Literal["EXECUTABLE_PATCH"]
|
||||
type: Literal["git_patch"]
|
||||
target: PatchTarget
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
operations: tuple[PatchFileOperation, ...]
|
||||
rollback: RollbackContract
|
||||
verification: VerificationContract
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
executor_ref: Literal["governed_source_patch_apply"]
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
patch_digest: str
|
||||
file_digests: tuple[tuple[str, str], ...]
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
rollback_artifact_digest: str
|
||||
verification_contract_revision: str
|
||||
acceptance_contract_revision: str
|
||||
|
||||
|
||||
def _aggregate_hash(
|
||||
operations: tuple[PatchFileOperation, ...], attribute: Literal["before_hash", "after_hash"]
|
||||
) -> str:
|
||||
values = [
|
||||
{"path": item.path, "hash": getattr(item, attribute)}
|
||||
for item in sorted(operations, key=lambda operation: operation.path)
|
||||
]
|
||||
encoded = json.dumps(
|
||||
values,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _parse_target(value: object) -> PatchTarget:
|
||||
raw = _exact_mapping(value, _TARGET_FIELDS, "target")
|
||||
return PatchTarget(
|
||||
repository=_text(raw["repository"], "target.repository"),
|
||||
branch=_text(raw["branch"], "target.branch"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_operation(value: object) -> PatchFileOperation:
|
||||
raw = _exact_mapping(value, _OPERATION_FIELDS, "operation")
|
||||
kind = raw["kind"]
|
||||
if kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
before = _content_hash(
|
||||
raw["before_hash"],
|
||||
"before_hash",
|
||||
literal="ABSENT" if kind == "ADD" else None,
|
||||
)
|
||||
after = _content_hash(
|
||||
raw["after_hash"],
|
||||
"after_hash",
|
||||
literal="DELETED" if kind == "DELETE" else None,
|
||||
)
|
||||
return PatchFileOperation(
|
||||
path=_path(raw["path"]),
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
before_hash=before,
|
||||
after_hash=after,
|
||||
)
|
||||
|
||||
|
||||
def _parse_operations(value: object) -> tuple[PatchFileOperation, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)) or not value:
|
||||
raise ValueError("operations must be a non-empty array")
|
||||
operations = tuple(_parse_operation(item) for item in value)
|
||||
paths = [item.path for item in operations]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("operation paths must be unique, including case-folding")
|
||||
return operations
|
||||
|
||||
|
||||
def _parse_rollback(value: object) -> RollbackContract:
|
||||
raw = _exact_mapping(value, _ROLLBACK_FIELDS, "rollback")
|
||||
return RollbackContract(
|
||||
artifact_digest=validate_digest(_text(raw["artifact_digest"], "artifact_digest")),
|
||||
expected_after_hash=validate_digest(
|
||||
_text(raw["expected_after_hash"], "expected_after_hash")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_verification(value: object) -> VerificationContract:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
checks = raw["required_checks"]
|
||||
if not isinstance(checks, (list, tuple)) or isinstance(checks, (str, bytes)):
|
||||
raise ValueError("required_checks must be an array")
|
||||
return VerificationContract(
|
||||
revision=_text(raw["revision"], "verification revision"),
|
||||
acceptance_revision=_text(
|
||||
raw["acceptance_revision"], "acceptance revision"
|
||||
),
|
||||
required_checks=tuple(_text(item, "required check") for item in checks),
|
||||
)
|
||||
|
||||
|
||||
def _parse_file_digests(value: object) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError("file_digests must be an array of pairs")
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
||||
raise ValueError("file_digests entries must be path/digest pairs")
|
||||
path = _path(item[0])
|
||||
digest = _content_hash(item[1], "file digest", literal="DELETED")
|
||||
pairs.append((path, digest))
|
||||
paths = [path for path, _ in pairs]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("file_digests paths must be unique")
|
||||
return tuple(sorted(pairs))
|
||||
|
||||
|
||||
def _strip_patch_path(line: str, prefix: str) -> str:
|
||||
value = line[len(prefix) :]
|
||||
if "\t" in value:
|
||||
raise ValueError("timestamped unified-diff paths are unsupported")
|
||||
if value == "/dev/null":
|
||||
return value
|
||||
expected = "a/" if prefix == "--- " else "b/"
|
||||
if not value.startswith(expected):
|
||||
raise ValueError("diff path has unexpected prefix")
|
||||
return _path(value[2:])
|
||||
|
||||
|
||||
def _actual_diff_operations(patch_bytes: bytes) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(patch_bytes, bytes) or not patch_bytes:
|
||||
raise ValueError("patch_bytes must be non-empty bytes")
|
||||
if b"\x00" in patch_bytes:
|
||||
raise ValueError("binary patches are unsupported")
|
||||
try:
|
||||
text = patch_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("patch must be UTF-8 text") from exc
|
||||
lines = text.splitlines()
|
||||
starts = [index for index, line in enumerate(lines) if line.startswith("diff --git ")]
|
||||
if not starts or any(line for line in lines[: starts[0]]):
|
||||
raise ValueError("patch must consist only of git unified-diff blocks")
|
||||
starts.append(len(lines))
|
||||
actual: list[tuple[str, str]] = []
|
||||
for position in range(len(starts) - 1):
|
||||
block = lines[starts[position] : starts[position + 1]]
|
||||
match = _DIFF_HEADER_RE.fullmatch(block[0])
|
||||
if match is None:
|
||||
raise ValueError("quoted, whitespace, or malformed diff paths are unsupported")
|
||||
old_header_path = _path(match.group(1))
|
||||
new_header_path = _path(match.group(2))
|
||||
forbidden = (
|
||||
"GIT binary patch",
|
||||
"Binary files ",
|
||||
"rename from ",
|
||||
"rename to ",
|
||||
"copy from ",
|
||||
"copy to ",
|
||||
"similarity index ",
|
||||
"dissimilarity index ",
|
||||
"old mode ",
|
||||
"new mode ",
|
||||
)
|
||||
if any(line.startswith(forbidden) for line in block[1:]):
|
||||
raise ValueError("rename, copy, binary, or mode-only patches are unsupported")
|
||||
mode_lines = [
|
||||
line
|
||||
for line in block[1:]
|
||||
if line.startswith(("new file mode ", "deleted file mode "))
|
||||
]
|
||||
if any(not line.endswith("100644") for line in mode_lines):
|
||||
raise ValueError("symlink, executable, and submodule modes are unsupported")
|
||||
old_lines = [line for line in block[1:] if line.startswith("--- ")]
|
||||
new_lines = [line for line in block[1:] if line.startswith("+++ ")]
|
||||
if len(old_lines) != 1 or len(new_lines) != 1:
|
||||
raise ValueError("each diff block requires one old and new path")
|
||||
old_path = _strip_patch_path(old_lines[0], "--- ")
|
||||
new_path = _strip_patch_path(new_lines[0], "+++ ")
|
||||
if not any(line.startswith("@@ ") for line in block[1:]):
|
||||
raise ValueError("mode-only and empty diff blocks are unsupported")
|
||||
if old_path == "/dev/null":
|
||||
if new_path != new_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("ADD paths do not agree")
|
||||
if mode_lines != ["new file mode 100644"]:
|
||||
raise ValueError("ADD requires regular-text new file mode")
|
||||
actual.append((new_path, "ADD"))
|
||||
elif new_path == "/dev/null":
|
||||
if old_path != old_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("DELETE paths do not agree")
|
||||
if mode_lines != ["deleted file mode 100644"]:
|
||||
raise ValueError("DELETE requires regular-text deleted file mode")
|
||||
actual.append((old_path, "DELETE"))
|
||||
else:
|
||||
if mode_lines or len({old_path, new_path, old_header_path, new_header_path}) != 1:
|
||||
raise ValueError("rename or mode-changing MODIFY is unsupported")
|
||||
actual.append((old_path, "MODIFY"))
|
||||
paths = [path for path, _ in actual]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("diff paths must be unique, including case-folding")
|
||||
return tuple(sorted(actual))
|
||||
|
||||
|
||||
def validate_patch_shape(patch_bytes: bytes, contract: ExecutablePatchV1) -> None:
|
||||
actual = _actual_diff_operations(patch_bytes)
|
||||
declared = tuple(sorted((item.path, item.kind) for item in contract.operations))
|
||||
if actual != declared:
|
||||
raise ValueError("actual diff file set or operation kinds do not match contract")
|
||||
|
||||
|
||||
def parse_executable_patch(
|
||||
raw: Mapping[str, object],
|
||||
*,
|
||||
patch_bytes: bytes,
|
||||
allowed_files: frozenset[str],
|
||||
expected_binding: OperationBinding,
|
||||
) -> ExecutablePatchV1:
|
||||
"""Parse a complete FD-005 contract without applying or executing it."""
|
||||
document = _exact_mapping(raw, _TOP_FIELDS, "executable patch")
|
||||
if document["schema"] != "aios.executable-patch.v1":
|
||||
raise ValueError("unsupported executable patch schema")
|
||||
if document["input_kind"] != "EXECUTABLE_PATCH":
|
||||
raise ValueError("PATCH_PROPOSAL is not executable")
|
||||
if document["type"] != "git_patch":
|
||||
raise ValueError("unsupported executable patch type")
|
||||
if document["executor_ref"] != "governed_source_patch_apply":
|
||||
raise ValueError("executor_ref is not the governed pilot")
|
||||
if not isinstance(allowed_files, frozenset):
|
||||
raise ValueError("allowed_files must be a frozen manifest set")
|
||||
canonical_allowed = frozenset(_path(item) for item in allowed_files)
|
||||
if len(canonical_allowed) != len(allowed_files) or len(canonical_allowed) != len(
|
||||
{item.casefold() for item in canonical_allowed}
|
||||
):
|
||||
raise ValueError("allowed_files contains aliases or case-fold collisions")
|
||||
|
||||
target = _parse_target(document["target"])
|
||||
operations = _parse_operations(document["operations"])
|
||||
rollback = _parse_rollback(document["rollback"])
|
||||
verification = _parse_verification(document["verification"])
|
||||
file_digests = _parse_file_digests(document["file_digests"])
|
||||
before_hash = validate_digest(_text(document["before_hash"], "before_hash"))
|
||||
after_hash = validate_digest(_text(document["after_hash"], "after_hash"))
|
||||
patch_digest = validate_digest(_text(document["patch_digest"], "patch_digest"))
|
||||
|
||||
binding_fields = {
|
||||
"operation_id": expected_binding.operation_id,
|
||||
"actor_id": expected_binding.actor_id,
|
||||
"tenant_id": expected_binding.tenant_id,
|
||||
"capability_namespace": expected_binding.capability_namespace,
|
||||
"capability_id": expected_binding.capability_id,
|
||||
"task_id": expected_binding.task_id,
|
||||
"job_id": expected_binding.job_id,
|
||||
"generation": expected_binding.generation,
|
||||
"input_kind": expected_binding.input_kind,
|
||||
"repository": expected_binding.repository,
|
||||
"target_branch": expected_binding.target_branch,
|
||||
"patch_digest": expected_binding.patch_digest,
|
||||
"scope_digest": expected_binding.scope_digest,
|
||||
"policy_digest": expected_binding.policy_digest,
|
||||
"plan_digest": expected_binding.plan_digest,
|
||||
"effect_key": expected_binding.effect_key,
|
||||
}
|
||||
for name, expected in binding_fields.items():
|
||||
if document[name] != expected:
|
||||
raise ValueError(f"binding mismatch: {name}")
|
||||
generation = _integer(document["generation"], "generation")
|
||||
if generation != expected_binding.generation:
|
||||
raise ValueError("binding mismatch: generation")
|
||||
base_oid = validate_oid(_text(document["base_oid"], "base_oid"))
|
||||
if base_oid != expected_binding.source_oid:
|
||||
raise ValueError("binding mismatch: base_oid")
|
||||
if target.repository != expected_binding.repository or target.branch != expected_binding.target_branch:
|
||||
raise ValueError("target does not match top-level binding")
|
||||
actual_patch_digest = hashlib.sha256(patch_bytes).hexdigest()
|
||||
if patch_digest != actual_patch_digest:
|
||||
raise ValueError("patch_digest does not match raw patch bytes")
|
||||
if any(item.path not in canonical_allowed for item in operations):
|
||||
raise ValueError("operation path is outside allowed_files")
|
||||
expected_file_digests = tuple(sorted((item.path, item.after_hash) for item in operations))
|
||||
if file_digests != expected_file_digests:
|
||||
raise ValueError("file_digests do not match operation result hashes")
|
||||
if before_hash != _aggregate_hash(operations, "before_hash"):
|
||||
raise ValueError("before_hash does not match the operation manifest")
|
||||
if after_hash != _aggregate_hash(operations, "after_hash"):
|
||||
raise ValueError("after_hash does not match the operation manifest")
|
||||
if rollback.expected_after_hash != after_hash:
|
||||
raise ValueError("rollback expected_after_hash mismatch")
|
||||
rollback_artifact_digest = validate_digest(
|
||||
_text(document["rollback_artifact_digest"], "rollback_artifact_digest")
|
||||
)
|
||||
if rollback_artifact_digest != rollback.artifact_digest:
|
||||
raise ValueError("rollback_artifact_digest mismatch")
|
||||
verification_revision = _text(
|
||||
document["verification_contract_revision"],
|
||||
"verification_contract_revision",
|
||||
)
|
||||
acceptance_revision = _text(
|
||||
document["acceptance_contract_revision"],
|
||||
"acceptance_contract_revision",
|
||||
)
|
||||
if verification_revision != verification.revision:
|
||||
raise ValueError("verification_contract_revision mismatch")
|
||||
if acceptance_revision != verification.acceptance_revision:
|
||||
raise ValueError("acceptance_contract_revision mismatch")
|
||||
validate_effect_key(_text(document["effect_key"], "effect_key"))
|
||||
|
||||
contract = ExecutablePatchV1(
|
||||
schema="aios.executable-patch.v1",
|
||||
input_kind="EXECUTABLE_PATCH",
|
||||
type="git_patch",
|
||||
target=target,
|
||||
before_hash=before_hash,
|
||||
after_hash=after_hash,
|
||||
operations=operations,
|
||||
rollback=rollback,
|
||||
verification=verification,
|
||||
operation_id=expected_binding.operation_id,
|
||||
actor_id=expected_binding.actor_id,
|
||||
tenant_id=expected_binding.tenant_id,
|
||||
capability_namespace=expected_binding.capability_namespace,
|
||||
capability_id=expected_binding.capability_id,
|
||||
task_id=expected_binding.task_id,
|
||||
job_id=expected_binding.job_id,
|
||||
generation=generation,
|
||||
executor_ref="governed_source_patch_apply",
|
||||
repository=expected_binding.repository,
|
||||
base_oid=base_oid,
|
||||
target_branch=expected_binding.target_branch,
|
||||
patch_digest=patch_digest,
|
||||
file_digests=file_digests,
|
||||
scope_digest=expected_binding.scope_digest,
|
||||
policy_digest=expected_binding.policy_digest,
|
||||
plan_digest=expected_binding.plan_digest,
|
||||
effect_key=expected_binding.effect_key,
|
||||
rollback_artifact_digest=rollback_artifact_digest,
|
||||
verification_contract_revision=verification_revision,
|
||||
acceptance_contract_revision=acceptance_revision,
|
||||
)
|
||||
validate_patch_shape(patch_bytes, contract)
|
||||
return contract
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExecutablePatchV1",
|
||||
"PatchFileOperation",
|
||||
"PatchTarget",
|
||||
"RollbackContract",
|
||||
"VerificationContract",
|
||||
"parse_executable_patch",
|
||||
"validate_patch_shape",
|
||||
]
|
||||
@@ -54,13 +54,27 @@ class ReadonlyWorkerProfile:
|
||||
|
||||
|
||||
class IdentityVerifier:
|
||||
def __init__(self, trusted_identities: set[str] | None = None):
|
||||
self.trusted_identities = trusted_identities
|
||||
def __init__(
|
||||
self,
|
||||
trusted_identities: set[str] | None = None,
|
||||
*,
|
||||
verifier_identity: str | None = None,
|
||||
):
|
||||
self.trusted_identities = frozenset(trusted_identities or ())
|
||||
self.verifier_identity = verifier_identity
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return any(identity != "*" for identity in self.trusted_identities)
|
||||
|
||||
def verify(self, identity: str) -> bool:
|
||||
if self.trusted_identities is None:
|
||||
return True
|
||||
return identity in self.trusted_identities
|
||||
return bool(
|
||||
identity
|
||||
and (
|
||||
identity in self.trusted_identities
|
||||
or "*" in self.trusted_identities
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EvidenceVerifier:
|
||||
@@ -92,6 +106,7 @@ class PolicyEngine:
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
evidence_attestation_verifier: Callable[[object], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
@@ -99,12 +114,28 @@ class PolicyEngine:
|
||||
) -> None:
|
||||
if max_risk not in RISK_RANK:
|
||||
raise ValueError("invalid max_risk")
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier()
|
||||
if execution_enabled and (
|
||||
identity_verifier is None or not identity_verifier.is_configured
|
||||
):
|
||||
raise ValueError(
|
||||
"execution_enabled requires an explicit trusted identity verifier"
|
||||
)
|
||||
if execution_enabled and evidence_attestation_verifier is None:
|
||||
raise ValueError(
|
||||
"execution_enabled requires an evidence attestation verifier"
|
||||
)
|
||||
configured_identities = {
|
||||
identity for identity in (identity_capabilities or {}) if identity
|
||||
}
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier(
|
||||
configured_identities if not execution_enabled else None
|
||||
)
|
||||
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
|
||||
self.execution_enabled = execution_enabled
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.evidence_attestation_verifier = evidence_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
@@ -195,6 +226,8 @@ class PolicyEngine:
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
if action not in action_capabilities:
|
||||
return PolicyDecision(False, f"unknown action {action}")
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
@@ -39,6 +41,22 @@ def _digest(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json(dict(value)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical_observation_value(value: object) -> object:
|
||||
"""Convert a typed readback observation to stable JSON data."""
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ConflictError("exact host closure time is not timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): _canonical_observation_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_canonical_observation_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthoritativeRootActionPlan:
|
||||
review_id: str
|
||||
@@ -265,6 +283,37 @@ class RootActionRunResult:
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExactRootActionBinding:
|
||||
"""Trusted host resolution from a C6 operation to an existing root review."""
|
||||
|
||||
review_id: str
|
||||
mission_id: str
|
||||
root_task_id: str
|
||||
root_revision: int
|
||||
plan_digest: str
|
||||
execution_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if any(
|
||||
not isinstance(value, str) or not value
|
||||
for value in (
|
||||
self.review_id,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
self.plan_digest,
|
||||
self.execution_key,
|
||||
)
|
||||
):
|
||||
raise ValueError("exact root action binding identity required")
|
||||
if (
|
||||
isinstance(self.root_revision, bool)
|
||||
or not isinstance(self.root_revision, int)
|
||||
or self.root_revision < 0
|
||||
):
|
||||
raise ValueError("exact root action revision is invalid")
|
||||
|
||||
|
||||
class RootActionExecutor(Protocol):
|
||||
def reconcile(
|
||||
self,
|
||||
@@ -278,7 +327,7 @@ class RootActionExecutor(Protocol):
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
*,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> None: ...
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
@@ -292,6 +341,7 @@ PlanBuilder = Callable[[RootReviewRecord], AuthoritativeRootActionPlan]
|
||||
Rereviewer = Callable[
|
||||
[AuthoritativeRootActionPlan, Mapping[str, object] | None], RootActionRereview
|
||||
]
|
||||
ExactReviewBindingProvider = Callable[[object], ExactRootActionBinding]
|
||||
|
||||
|
||||
class RootActionConsumer:
|
||||
@@ -304,12 +354,14 @@ class RootActionConsumer:
|
||||
plan_builder: PlanBuilder,
|
||||
rereviewer: Rereviewer,
|
||||
executor: RootActionExecutor | None,
|
||||
exact_review_binding_provider: ExactReviewBindingProvider | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
) -> Mapping[str, object]:
|
||||
self.store = store
|
||||
self._plan_builder = plan_builder
|
||||
self._rereviewer = rereviewer
|
||||
self._executor = executor
|
||||
self._exact_review_binding_provider = exact_review_binding_provider
|
||||
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
@@ -416,9 +468,20 @@ class RootActionConsumer:
|
||||
raise ConflictError("root action claim renewal CAS lost")
|
||||
|
||||
def _claim_next(
|
||||
self, *, consumer_id: str, now: float, lease_seconds: int
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: float,
|
||||
lease_seconds: int,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
) -> RootActionClaim | RootActionRunResult | None:
|
||||
with self.store._transaction():
|
||||
exact_sql = " AND d.review_id=?" if exact_binding is not None else ""
|
||||
args: tuple[object, ...] = (
|
||||
(now, exact_binding.review_id)
|
||||
if exact_binding is not None
|
||||
else (now,)
|
||||
)
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json,r.root_revision_after "
|
||||
"FROM task_root_action_dispatches d JOIN task_root_reviews r "
|
||||
@@ -426,11 +489,13 @@ class RootActionConsumer:
|
||||
"WHERE d.status IN ('EFFECT_UNKNOWN','DISPATCHED',"
|
||||
"'EXECUTED_PENDING_REREVIEW','CLAIMED','PENDING') "
|
||||
"AND (d.retry_at IS NULL OR d.retry_at<=?) "
|
||||
+ exact_sql
|
||||
+ " "
|
||||
"ORDER BY r.root_revision_after DESC,CASE d.status "
|
||||
"WHEN 'EFFECT_UNKNOWN' THEN 0 WHEN 'DISPATCHED' THEN 0 "
|
||||
"WHEN 'EXECUTED_PENDING_REREVIEW' THEN 1 "
|
||||
"WHEN 'CLAIMED' THEN 2 ELSE 3 END,d.created_at ASC",
|
||||
(now,),
|
||||
args,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if (
|
||||
@@ -443,7 +508,11 @@ class RootActionConsumer:
|
||||
}
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
continue
|
||||
if not (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
):
|
||||
continue
|
||||
root_task_id = str(row["root_task_id"])
|
||||
active = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_fences WHERE root_task_id=?",
|
||||
@@ -488,6 +557,43 @@ class RootActionConsumer:
|
||||
or plan.action != review.next_legal_action
|
||||
):
|
||||
raise ConflictError("root action plan/review authority mismatch")
|
||||
host_effect = self.store.conn.execute(
|
||||
"SELECT host_effect_key FROM task_root_action_effects "
|
||||
"WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
exact_binding is None
|
||||
and host_effect is not None
|
||||
and host_effect["host_effect_key"] is not None
|
||||
):
|
||||
# A C6-prepared action may only be resumed through the
|
||||
# exact facade, which supplies fresh signed authority.
|
||||
continue
|
||||
if exact_binding is not None and (
|
||||
plan.review_id != exact_binding.review_id
|
||||
or plan.mission_id != exact_binding.mission_id
|
||||
or plan.root_task_id != exact_binding.root_task_id
|
||||
or plan.root_revision != exact_binding.root_revision
|
||||
or plan.plan_digest != exact_binding.plan_digest
|
||||
or plan.execution_key != exact_binding.execution_key
|
||||
):
|
||||
raise ConflictError("trusted exact root action binding mismatch")
|
||||
if (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
return RootActionClaim(
|
||||
plan=plan,
|
||||
consumer_id=consumer_id,
|
||||
claim_token=str(row["claim_token"]),
|
||||
fence=int(row["claim_fence"]),
|
||||
lease_expires_at=float(row["claim_expires_at"]),
|
||||
lease_seconds=lease_seconds,
|
||||
reconcile_only=row["status"]
|
||||
in {"EFFECT_UNKNOWN", "DISPATCHED"},
|
||||
)
|
||||
if active is not None and active["active_review_id"] not in {
|
||||
None,
|
||||
row["review_id"],
|
||||
@@ -582,7 +688,13 @@ class RootActionConsumer:
|
||||
return claim
|
||||
return None
|
||||
|
||||
def _prepare_effect(self, claim: RootActionClaim, now: float):
|
||||
def _prepare_effect(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
now: float,
|
||||
*,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
):
|
||||
plan = claim.plan
|
||||
request_digest = _digest(
|
||||
{
|
||||
@@ -635,6 +747,41 @@ class RootActionConsumer:
|
||||
if effect["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if host_binding is not None:
|
||||
host_json = canonical_json(dict(host_binding))
|
||||
host_digest = hashlib.sha256(host_json.encode("utf-8")).hexdigest()
|
||||
host_effect_key = host_binding.get("effect_key")
|
||||
if not isinstance(host_effect_key, str) or not host_effect_key:
|
||||
raise ConflictError("exact host effect key is invalid")
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_effect_key,host_binding_json,host_binding_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if current["host_effect_key"] is None:
|
||||
try:
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_effect_key=?,"
|
||||
"host_binding_json=?,host_binding_digest=?,updated_at=? "
|
||||
"WHERE execution_key=? AND host_effect_key IS NULL",
|
||||
(
|
||||
host_effect_key,
|
||||
host_json,
|
||||
host_digest,
|
||||
now,
|
||||
plan.execution_key,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ConflictError("exact host effect key already bound") from exc
|
||||
if changed.rowcount != 1:
|
||||
raise ConflictError("exact host binding CAS lost")
|
||||
elif (
|
||||
current["host_effect_key"] != host_effect_key
|
||||
or current["host_binding_json"] != host_json
|
||||
or current["host_binding_digest"] != host_digest
|
||||
):
|
||||
raise ConflictError("exact host action binding conflict")
|
||||
status = (
|
||||
"EXECUTED_PENDING_REREVIEW"
|
||||
if effect_state == "SUCCEEDED"
|
||||
@@ -654,6 +801,52 @@ class RootActionConsumer:
|
||||
)
|
||||
return effect_state, receipt, effect_phase
|
||||
|
||||
def _record_writer_lease(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
lease: Mapping[str, object] | None,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease is None:
|
||||
return
|
||||
lease_json = canonical_json(dict(lease))
|
||||
lease_digest = hashlib.sha256(lease_json.encode("utf-8")).hexdigest()
|
||||
with self.store._transaction():
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET writer_lease_json=?,"
|
||||
"writer_lease_digest=?,updated_at=? WHERE execution_key=? "
|
||||
"AND writer_lease_json IS NULL",
|
||||
(lease_json, lease_digest, now, claim.plan.execution_key),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT writer_lease_json,writer_lease_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(claim.plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
row is None
|
||||
or row["writer_lease_json"] != lease_json
|
||||
or row["writer_lease_digest"] != lease_digest
|
||||
):
|
||||
raise ConflictError("WriterLease durable binding conflict")
|
||||
|
||||
@staticmethod
|
||||
def _validate_exact_writer_lease(lease: object) -> Mapping[str, object]:
|
||||
if not isinstance(lease, Mapping):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
fence = lease.get("writer_lease_fence")
|
||||
if (
|
||||
not isinstance(lease_id, str)
|
||||
or not lease_id.strip()
|
||||
or isinstance(fence, bool)
|
||||
or not isinstance(fence, int)
|
||||
or fence < 0
|
||||
):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
return lease
|
||||
|
||||
def _transition_effect_phase(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
@@ -910,18 +1103,25 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
)
|
||||
|
||||
def run_once(
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
prepare_only: bool = False,
|
||||
external_revalidator: Callable[[], None] | None = None,
|
||||
) -> RootActionRunResult:
|
||||
if not consumer_id or lease_seconds < 1:
|
||||
raise ValueError("invalid root action consumer claim")
|
||||
now_value = self._now(now)
|
||||
claimed = self._claim_next(
|
||||
consumer_id=consumer_id, now=now_value, lease_seconds=lease_seconds
|
||||
consumer_id=consumer_id,
|
||||
now=now_value,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=exact_binding,
|
||||
)
|
||||
if claimed is None:
|
||||
return RootActionRunResult(RootActionRunState.IDLE)
|
||||
@@ -949,7 +1149,11 @@ class RootActionConsumer:
|
||||
claim, status="EXECUTED_PENDING_REREVIEW", now=now_value
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
effect_state, receipt, effect_phase = self._prepare_effect(claim, now_value)
|
||||
effect_state, receipt, effect_phase = (
|
||||
self._prepare_effect(claim, now_value)
|
||||
if host_binding is None
|
||||
else self._prepare_effect(claim, now_value, host_binding=host_binding)
|
||||
)
|
||||
if effect_state == "SUPERSEDED":
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.CLOSED,
|
||||
@@ -970,6 +1174,13 @@ class RootActionConsumer:
|
||||
extra_args=(receipt_json, _digest(receipt)),
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
if prepare_only:
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.EXECUTED,
|
||||
plan.review_id,
|
||||
plan.execution_key,
|
||||
"PREPARED",
|
||||
)
|
||||
if self._executor is None:
|
||||
with self.store._transaction():
|
||||
self._set_dispatch(
|
||||
@@ -986,9 +1197,10 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
"ROOT_ACTION_EXECUTOR_UNAVAILABLE",
|
||||
)
|
||||
before_external_call = lambda: self._assert_live_claim(
|
||||
claim, now_value, renew=True
|
||||
)
|
||||
def before_external_call() -> None:
|
||||
self._assert_live_claim(claim, now_value, renew=True)
|
||||
if external_revalidator is not None:
|
||||
external_revalidator()
|
||||
if effect_phase == "BEFORE_LEASE":
|
||||
# Admission and the immutable plan are the authority for this one
|
||||
# first send. A pre-lease GET cannot prove absence: the live
|
||||
@@ -997,10 +1209,13 @@ class RootActionConsumer:
|
||||
claim, expected="BEFORE_LEASE", target="LEASE_INTENT", now=now_value
|
||||
)
|
||||
before_external_call()
|
||||
self._executor.acquire_lease(
|
||||
lease = self._executor.acquire_lease(
|
||||
plan, before_external_call=before_external_call
|
||||
)
|
||||
before_external_call()
|
||||
if host_binding is not None:
|
||||
lease = self._validate_exact_writer_lease(lease)
|
||||
self._record_writer_lease(claim, lease, now_value)
|
||||
self._transition_effect_phase(
|
||||
claim, expected="LEASE_INTENT", target="LEASE_ACQUIRED", now=now_value
|
||||
)
|
||||
@@ -1033,6 +1248,594 @@ class RootActionConsumer:
|
||||
return result
|
||||
return self._rereview(claim, now_value)
|
||||
|
||||
def run_once(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
) -> RootActionRunResult:
|
||||
return self._run(
|
||||
consumer_id=consumer_id,
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
|
||||
def configure_exact_review_binding_provider(
|
||||
self, provider: ExactReviewBindingProvider
|
||||
) -> None:
|
||||
if not callable(provider):
|
||||
raise TypeError("trusted exact review binding provider required")
|
||||
if self._exact_review_binding_provider is not None:
|
||||
raise ConflictError("exact review binding provider already configured")
|
||||
self._exact_review_binding_provider = provider
|
||||
|
||||
def assert_exact_owner_configured(self) -> None:
|
||||
if self._exact_review_binding_provider is None or self._executor is None:
|
||||
raise ConflictError("exact root action owner is not configured")
|
||||
|
||||
def _exact_resolution(self, binding: object) -> ExactRootActionBinding:
|
||||
self.assert_exact_owner_configured()
|
||||
assert self._exact_review_binding_provider is not None
|
||||
resolved = self._exact_review_binding_provider(binding)
|
||||
if not isinstance(resolved, ExactRootActionBinding):
|
||||
raise ConflictError("trusted exact review binding is invalid")
|
||||
return resolved
|
||||
|
||||
def _exact_plan(
|
||||
self, binding: object, resolved: ExactRootActionBinding
|
||||
) -> AuthoritativeRootActionPlan:
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("operation binding required")
|
||||
row = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json FROM task_root_action_dispatches d "
|
||||
"JOIN task_root_reviews r ON r.review_id=d.review_id "
|
||||
"WHERE d.review_id=?",
|
||||
(resolved.review_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("trusted exact root review is unavailable")
|
||||
if row["plan_json"]:
|
||||
raw = json.loads(str(row["plan_json"]))
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("persisted root action plan is invalid")
|
||||
plan = AuthoritativeRootActionPlan.from_dict(raw)
|
||||
else:
|
||||
plan = self._plan_builder(self._review(row))
|
||||
if (
|
||||
plan.review_id != resolved.review_id
|
||||
or plan.mission_id != resolved.mission_id
|
||||
or plan.root_task_id != resolved.root_task_id
|
||||
or plan.root_revision != resolved.root_revision
|
||||
or plan.plan_digest != resolved.plan_digest
|
||||
or plan.execution_key != resolved.execution_key
|
||||
or plan.target_task_id != binding.task_id
|
||||
or plan.target_job_id != binding.job_id
|
||||
or plan.external_idempotency_key != binding.effect_key
|
||||
):
|
||||
raise ConflictError("C6 operation/root action authority mismatch")
|
||||
for name in ("writer_lease_request", "commit_request"):
|
||||
request = plan.effect_payload.get(name)
|
||||
if not isinstance(request, Mapping):
|
||||
raise ConflictError(f"exact root action {name} unavailable")
|
||||
expected = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"expected_branch_oid": binding.source_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"generation": binding.generation,
|
||||
}
|
||||
allowed = set(expected)
|
||||
if name == "writer_lease_request":
|
||||
expected.update(
|
||||
{"branch_mode": "CREATE_NEW", "base_oid": binding.source_oid}
|
||||
)
|
||||
allowed.update({"branch_mode", "base_oid"})
|
||||
else:
|
||||
allowed.update({"files", "message"})
|
||||
if set(request) != allowed:
|
||||
raise ConflictError(f"exact root action {name} fields mismatch")
|
||||
if any(request.get(key) != value for key, value in expected.items()):
|
||||
raise ConflictError(f"exact root action {name} binding mismatch")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def _c6_commit_mutation(
|
||||
*, binding, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
"""Bind the real Bridge mutation to the signed C6 operation identity."""
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if hashlib.sha256(patch_bytes).hexdigest() != contract.patch_digest:
|
||||
raise ConflictError("C6 patch bytes digest mismatch")
|
||||
commit = plan.effect_payload.get("commit_request")
|
||||
if not isinstance(commit, Mapping):
|
||||
raise ConflictError("exact root action commit request unavailable")
|
||||
if commit.get("message") != commit_message:
|
||||
raise ConflictError("C6 commit message/root mutation mismatch")
|
||||
raw_files = commit.get("files")
|
||||
if not isinstance(raw_files, (list, tuple)) or isinstance(
|
||||
raw_files, (str, bytes)
|
||||
):
|
||||
raise ConflictError("C6 exact commit files require Bridge entry array")
|
||||
actual: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
seen_folded: set[str] = set()
|
||||
for raw in raw_files:
|
||||
if not isinstance(raw, Mapping) or set(raw) != {"path", "content_base64"}:
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
path = raw.get("path")
|
||||
encoded = raw.get("content_base64")
|
||||
if not isinstance(path, str) or not path or not isinstance(encoded, str):
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
if path in seen or path.casefold() in seen_folded:
|
||||
raise ConflictError("C6 exact commit files contain duplicate paths")
|
||||
try:
|
||||
content = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
|
||||
raise ConflictError("C6 exact commit file content is invalid") from exc
|
||||
seen.add(path)
|
||||
seen_folded.add(path.casefold())
|
||||
actual.append({"path": path, "sha256": hashlib.sha256(content).hexdigest()})
|
||||
actual.sort(key=lambda item: item["path"])
|
||||
expected = [
|
||||
{"path": path, "sha256": digest}
|
||||
for path, digest in contract.file_digests
|
||||
]
|
||||
if any(item["sha256"] == "DELETED" for item in expected) or actual != expected:
|
||||
raise ConflictError("C6 patch contract/root files mismatch")
|
||||
mutation = {
|
||||
"schema": "aios.c6.commit-mutation.v1",
|
||||
"files": actual,
|
||||
"message": commit_message,
|
||||
}
|
||||
mutation_digest = hashlib.sha256(
|
||||
canonical_json(mutation).encode("utf-8")
|
||||
).hexdigest()
|
||||
expected_projection = {
|
||||
"schema": "aios.c6.root-plan-binding.v1",
|
||||
"operation_binding_digest": binding_digest(binding),
|
||||
"plan_digest": binding.plan_digest,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"commit_mutation_digest": mutation_digest,
|
||||
}
|
||||
projection = plan.effect_payload.get("c6_operation_binding")
|
||||
if not isinstance(projection, Mapping) or dict(projection) != expected_projection:
|
||||
raise ConflictError("C6 operation/root plan mutation identity mismatch")
|
||||
return {**mutation, "digest": mutation_digest}
|
||||
|
||||
@staticmethod
|
||||
def _host_binding_document(
|
||||
*, binding, claim, authority, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
mutation = RootActionConsumer._c6_commit_mutation(
|
||||
binding=binding,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "aios.c6.root-action-host-binding.v2",
|
||||
"action_id": plan.execution_key,
|
||||
"operation_binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"effect_key": binding.effect_key,
|
||||
"claim_receipt": claim.to_dict(),
|
||||
"authorization_ref": authority.authorization_ref,
|
||||
"authority_key_id": authority.authority_key_id,
|
||||
"authority_key_fingerprint": authority.authority_key_fingerprint,
|
||||
"authority_record_sha256": authority.authority_record_sha256,
|
||||
"authority_generation": authority.authority_generation,
|
||||
"task_generation": authority.task_generation,
|
||||
"expected_branch_oid": authority.expected_branch_oid,
|
||||
"review_id": plan.review_id,
|
||||
"mission_id": plan.mission_id,
|
||||
"root_task_id": plan.root_task_id,
|
||||
"root_revision": plan.root_revision,
|
||||
"plan_digest": plan.plan_digest,
|
||||
"root_execution_key": plan.execution_key,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"c6_plan_digest": binding.plan_digest,
|
||||
"commit_mutation_digest": mutation["digest"],
|
||||
"commit_message_sha256": hashlib.sha256(
|
||||
commit_message.encode("utf-8")
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_host_document(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConflictError("exact host binding is unavailable")
|
||||
result = dict(value)
|
||||
if result.get("schema") != "aios.c6.root-action-host-binding.v2":
|
||||
raise ConflictError("exact host binding schema is invalid")
|
||||
return result
|
||||
|
||||
def _action_from_row(self, row):
|
||||
from .host_patch_executor import HostPatchAction
|
||||
|
||||
raw = str(row["host_binding_json"] or "")
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
if not raw or row["host_binding_digest"] != digest:
|
||||
raise ConflictError("exact host binding digest mismatch")
|
||||
host = self._validate_host_document(json.loads(raw))
|
||||
if host.get("effect_key") != row["host_effect_key"]:
|
||||
raise ConflictError("exact host effect key mismatch")
|
||||
lease = (
|
||||
json.loads(str(row["writer_lease_json"]))
|
||||
if row["writer_lease_json"] is not None
|
||||
else None
|
||||
)
|
||||
receipt = (
|
||||
json.loads(str(row["receipt_json"]))
|
||||
if row["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if row["state"] == "SUCCEEDED":
|
||||
state = "COMMITTED"
|
||||
elif row["state"] == "BLOCKED":
|
||||
state = "FAILED_FINAL"
|
||||
elif row["effect_phase"] == "BEFORE_LEASE":
|
||||
state = "PREPARED"
|
||||
else:
|
||||
state = "EFFECT_UNKNOWN"
|
||||
lease_id = None
|
||||
lease_fence = None
|
||||
if isinstance(lease, Mapping):
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
lease_fence = lease.get("writer_lease_fence")
|
||||
return HostPatchAction(
|
||||
action_id=str(host["action_id"]),
|
||||
state=state,
|
||||
binding_digest=str(host["binding_digest"]),
|
||||
effect_key=str(host["effect_key"]),
|
||||
authorization_ref=str(host["authorization_ref"]),
|
||||
authority_key_id=str(host["authority_key_id"]),
|
||||
authority_key_fingerprint=str(host["authority_key_fingerprint"]),
|
||||
authority_record_sha256=str(host["authority_record_sha256"]),
|
||||
claim_id=str(host["claim_receipt"]["claim_id"]),
|
||||
claim_fence=int(host["claim_receipt"]["receipt_revision"]),
|
||||
task_generation=int(host["task_generation"]),
|
||||
authority_generation=int(host["authority_generation"]),
|
||||
expected_branch_oid=str(host["expected_branch_oid"]),
|
||||
claim_receipt=dict(host["claim_receipt"]),
|
||||
writer_lease_id=(str(lease_id) if lease_id is not None else None),
|
||||
writer_lease_fence=(int(lease_fence) if lease_fence is not None else None),
|
||||
result_oid=(
|
||||
str(receipt["commit_oid"])
|
||||
if isinstance(receipt, Mapping) and receipt.get("commit_oid") is not None
|
||||
else None
|
||||
),
|
||||
receipt_ref=(
|
||||
str(row["receipt_digest"])
|
||||
if row["receipt_digest"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def read_exact(self, effect_key: str):
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(effect_key,),
|
||||
).fetchone()
|
||||
return None if row is None else self._action_from_row(row)
|
||||
|
||||
def iter_pending_exact(self, *, limit: int = 16):
|
||||
"""Enumerate durable host intents from the existing effect ledger."""
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise ConflictError("bounded exact pending limit required")
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects "
|
||||
"WHERE host_effect_key IS NOT NULL AND host_binding_json IS NOT NULL "
|
||||
"AND host_closure_digest IS NULL "
|
||||
"ORDER BY created_at,execution_key LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
action = self._action_from_row(row)
|
||||
if action.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
continue
|
||||
raw = self._validate_host_document(
|
||||
json.loads(str(row["host_binding_json"]))
|
||||
).get("operation_binding")
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("pending exact operation binding unavailable")
|
||||
try:
|
||||
operation = OperationBinding(**dict(raw))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConflictError("pending exact operation binding invalid") from exc
|
||||
result.append((operation, action))
|
||||
return tuple(result)
|
||||
|
||||
def mark_exact_closed(self, *, binding, action_id: str, observation) -> None:
|
||||
from .operation_view import (
|
||||
ClosureObservation,
|
||||
binding_digest,
|
||||
derive_closure,
|
||||
)
|
||||
|
||||
if not isinstance(observation, ClosureObservation):
|
||||
raise ConflictError("typed exact host closure observation required")
|
||||
observed_at = self._clock()
|
||||
now = self._now(observed_at)
|
||||
closure = derive_closure(binding, observation, now=observed_at)
|
||||
if (
|
||||
closure.execution_recorded,
|
||||
closure.independently_verified,
|
||||
closure.business_accepted,
|
||||
closure.outcome,
|
||||
closure.reason_code,
|
||||
) != (True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID"):
|
||||
raise ConflictError("exact host closure is incomplete")
|
||||
|
||||
with self.store._transaction():
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("exact host action unavailable")
|
||||
action = self._action_from_row(row)
|
||||
expected_binding_digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
verification = observation.verification
|
||||
acceptance = observation.acceptance
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref
|
||||
or verification.receipt_ref
|
||||
)
|
||||
if (
|
||||
action.action_id != action_id
|
||||
or action.state != "COMMITTED"
|
||||
or action.binding_digest != expected_binding_digest
|
||||
or not action.receipt_ref
|
||||
or effect.binding_digest != expected_binding_digest
|
||||
or effect.receipt_ref != action.receipt_ref
|
||||
or effect.result_artifact_digest is None
|
||||
or verification.binding_digest != expected_binding_digest
|
||||
or verification.receipt_ref != action.receipt_ref
|
||||
or verification.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or not verifier_receipt_ref
|
||||
or acceptance.binding_digest != expected_binding_digest
|
||||
or acceptance.receipt_ref != action.receipt_ref
|
||||
or acceptance.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or acceptance.verifier_receipt_ref != verifier_receipt_ref
|
||||
):
|
||||
raise ConflictError("exact host closure/action binding mismatch")
|
||||
|
||||
observation_value = _canonical_observation_value(asdict(observation))
|
||||
observation_raw = canonical_json(observation_value)
|
||||
observation_digest = hashlib.sha256(
|
||||
observation_raw.encode("utf-8")
|
||||
).hexdigest()
|
||||
document = {
|
||||
"schema": "aios.c6.three-layer-closure.v2",
|
||||
"binding_digest": action.binding_digest,
|
||||
"effect_key": action.effect_key,
|
||||
"action_id": action.action_id,
|
||||
"root_receipt_ref": action.receipt_ref,
|
||||
"effect_receipt_ref": effect.receipt_ref,
|
||||
"result_artifact_digest": effect.result_artifact_digest,
|
||||
"verification_receipt_ref": verifier_receipt_ref,
|
||||
"verifier_id": verification.verifier_id,
|
||||
"acceptance_revision": acceptance.acceptance_revision,
|
||||
"acceptor_id": acceptance.acceptor_id,
|
||||
"expected_acceptance_revision": (
|
||||
observation.expected_acceptance_revision
|
||||
),
|
||||
"expected_acceptor_id": observation.expected_acceptor_id,
|
||||
"closure_observation_digest": observation_digest,
|
||||
"execution_recorded": closure.execution_recorded,
|
||||
"independently_verified": closure.independently_verified,
|
||||
"business_accepted": closure.business_accepted,
|
||||
"outcome": closure.outcome,
|
||||
"reason_code": closure.reason_code,
|
||||
}
|
||||
raw = canonical_json(document)
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_closure_json=?,"
|
||||
"host_closure_digest=?,host_closure_observed_at=?,updated_at=? "
|
||||
"WHERE host_effect_key=? AND state='SUCCEEDED' "
|
||||
"AND host_binding_json=? AND host_binding_digest=? "
|
||||
"AND receipt_digest=? AND host_closure_digest IS NULL",
|
||||
(
|
||||
raw, digest, now, now, binding.effect_key,
|
||||
row["host_binding_json"], row["host_binding_digest"],
|
||||
action.receipt_ref,
|
||||
),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_closure_json,host_closure_digest "
|
||||
"FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
current is None
|
||||
or current["host_closure_json"] != raw
|
||||
or current["host_closure_digest"] != digest
|
||||
):
|
||||
raise ConflictError("exact host closure CAS conflict")
|
||||
|
||||
@staticmethod
|
||||
def _assert_action_authority(action, binding, authority) -> None:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or action.authorization_ref != authority.authorization_ref
|
||||
or action.authority_key_id != authority.authority_key_id
|
||||
or action.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or action.authority_record_sha256 != authority.authority_record_sha256
|
||||
or action.authority_generation != authority.authority_generation
|
||||
or action.task_generation != authority.task_generation
|
||||
or action.claim_id != authority.claim_id
|
||||
or action.claim_fence != authority.claim_fence
|
||||
or action.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise ConflictError("exact host action authority mismatch")
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
lease_seconds = max(1, min(60, int(authority.expires_at - now.timestamp())))
|
||||
result = self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
prepare_only=True,
|
||||
)
|
||||
if result.detail != "PREPARED":
|
||||
raise ConflictError("exact root action was not prepared")
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
raise ConflictError("prepared exact root action is unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return action
|
||||
|
||||
def _claim_exact_existing(self, binding, action, authority, now: float):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
if plan.execution_key != action.action_id:
|
||||
raise ConflictError("exact root action identity changed")
|
||||
claimed = self._claim_next(
|
||||
consumer_id=f"c6:{action.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
)
|
||||
if not isinstance(claimed, RootActionClaim):
|
||||
raise ConflictError("exact root action claim unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return claimed
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
authority,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state in {"COMMITTED", "FAILED_FINAL", "PREPARED"}:
|
||||
return action
|
||||
now = self._now(self._clock())
|
||||
claim = self._claim_exact_existing(binding, action, authority, now)
|
||||
_state, _receipt, phase = self._prepare_effect(claim, now)
|
||||
if phase in {"BEFORE_LEASE", "LEASE_ACQUIRED"}:
|
||||
return self.read_exact(binding.effect_key)
|
||||
if self._executor is None:
|
||||
raise ConflictError("root action executor unavailable")
|
||||
|
||||
def revalidate() -> None:
|
||||
self._assert_live_claim(claim, now, renew=True)
|
||||
before_external_call()
|
||||
|
||||
revalidate()
|
||||
observation = self._executor.reconcile(
|
||||
claim.plan, before_external_call=revalidate
|
||||
)
|
||||
revalidate()
|
||||
result = self._record_observation(claim, observation, now)
|
||||
if result is None:
|
||||
self._rereview(claim, now)
|
||||
observed = self.read_exact(binding.effect_key)
|
||||
if observed is None:
|
||||
raise ConflictError("reconciled exact root action is unavailable")
|
||||
return observed
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state != "PREPARED":
|
||||
return action
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
persisted = self.store.conn.execute(
|
||||
"SELECT host_binding_json FROM task_root_action_effects "
|
||||
"WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if persisted is None or persisted["host_binding_json"] != canonical_json(host):
|
||||
raise ConflictError("prepared exact host binding changed")
|
||||
self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=self._clock(),
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
external_revalidator=before_external_call,
|
||||
)
|
||||
completed = self.read_exact(binding.effect_key)
|
||||
if completed is None:
|
||||
raise ConflictError("exact root action completion unavailable")
|
||||
return completed
|
||||
|
||||
|
||||
class PublicBridgeRootActionExecutor:
|
||||
"""Contract adapter for the authenticated public Bridge operation surface."""
|
||||
@@ -1329,6 +2132,7 @@ class PublicBridgeRootActionExecutor:
|
||||
raise ConflictError("Bridge writer lease response binding mismatch")
|
||||
if "job_id" in result and result["job_id"] != plan.target_job_id:
|
||||
raise ConflictError("Bridge writer lease response Job mismatch")
|
||||
return dict(result)
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
|
||||
@@ -15,7 +15,7 @@ claim transaction / repository operation.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
from typing import Any, Callable, Iterable, Mapping, Protocol, Sequence
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
@@ -94,6 +94,7 @@ class ClaimConditions:
|
||||
authorization_revision: str
|
||||
scope_digest: str
|
||||
capacity_limit: int
|
||||
operation_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -121,6 +122,7 @@ class SchedulabilityObservation:
|
||||
dependencies_satisfied: bool
|
||||
freeze_blocked: bool
|
||||
conflicting_lease: bool
|
||||
operation_digest: str | None = None
|
||||
|
||||
@property
|
||||
def capacity_remaining(self) -> int:
|
||||
@@ -128,7 +130,7 @@ class SchedulabilityObservation:
|
||||
|
||||
def stable_binding(self) -> dict[str, object]:
|
||||
"""Fields whose change invalidates an earlier proof."""
|
||||
return {
|
||||
binding = {
|
||||
"task_id": self.task_id,
|
||||
"task_revision": self.task_revision,
|
||||
"task_generation": self.task_generation,
|
||||
@@ -149,6 +151,9 @@ class SchedulabilityObservation:
|
||||
"conflicting_lease": self.conflicting_lease,
|
||||
"heartbeat_fresh": self.heartbeat_fresh,
|
||||
}
|
||||
if self.operation_digest is not None:
|
||||
binding["operation_digest"] = self.operation_digest
|
||||
return binding
|
||||
|
||||
def claim_conditions(self) -> ClaimConditions:
|
||||
return ClaimConditions(
|
||||
@@ -161,6 +166,7 @@ class SchedulabilityObservation:
|
||||
authorization_revision=self.authorization_revision,
|
||||
scope_digest=self.scope_digest,
|
||||
capacity_limit=self.capacity_limit,
|
||||
operation_digest=self.operation_digest,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +200,11 @@ class SchedulabilityProof:
|
||||
if ttl_seconds <= 0 or ttl_seconds > 300:
|
||||
raise ValueError("schedulability proof ttl must be in (0,300]")
|
||||
payload = {
|
||||
"schema": "aios.schedulability-proof.v2",
|
||||
"schema": (
|
||||
"aios.schedulability-proof.v3"
|
||||
if observation.operation_digest is not None
|
||||
else "aios.schedulability-proof.v2"
|
||||
),
|
||||
"proof_id": proof_id or f"sched-{uuid.uuid4()}",
|
||||
"task_id": _text(task_id, "task_id"),
|
||||
"worker_id": _text(worker_id, "worker_id"),
|
||||
@@ -301,12 +311,18 @@ class SchedulabilityService:
|
||||
clock=time.time,
|
||||
proof_ttl_seconds: float = 30.0,
|
||||
heartbeat_grace_factor: float = 3.0,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] | None = None,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.proofs = proofs
|
||||
self.clock = clock
|
||||
self.proof_ttl_seconds = proof_ttl_seconds
|
||||
self.heartbeat_grace_factor = heartbeat_grace_factor
|
||||
if capability_matcher is None:
|
||||
from .capabilities import has_capabilities
|
||||
|
||||
capability_matcher = has_capabilities
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _caps(value: object) -> tuple[str, ...]:
|
||||
@@ -366,9 +382,8 @@ class SchedulabilityService:
|
||||
blockers.append(capacity_error)
|
||||
if not fresh:
|
||||
blockers.append(Blocker("STALE_OR_INACTIVE_WORKER", "worker", "worker heartbeat is not fresh"))
|
||||
missing = sorted(set(required) - set(offered))
|
||||
if missing:
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(missing)))
|
||||
if not self.capability_matcher(offered, required):
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(required)))
|
||||
if not scope_ok:
|
||||
blockers.append(Blocker("EXACT_SCOPE_MISMATCH", "authorization", "worker/task signed scope mismatch"))
|
||||
if not auth_allowed:
|
||||
@@ -403,6 +418,11 @@ class SchedulabilityService:
|
||||
dependencies_satisfied=deps,
|
||||
freeze_blocked=freeze,
|
||||
conflicting_lease=lease,
|
||||
operation_digest=(
|
||||
str(task["operation_digest"])
|
||||
if task.get("operation_digest") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return obs, tuple(blockers)
|
||||
|
||||
@@ -468,7 +488,14 @@ class SchedulabilityService:
|
||||
return proof
|
||||
|
||||
@staticmethod
|
||||
def _validate_claim_receipt(result: Mapping[str, Any]) -> None:
|
||||
def _validate_claim_receipt(
|
||||
result: Mapping[str, Any], proof: SchedulabilityProof
|
||||
) -> None:
|
||||
if proof.schema == "aios.schedulability-proof.v3":
|
||||
from .schedulability_store_adapter import validate_task_plane_receipt
|
||||
|
||||
validate_task_plane_receipt(result, proof=proof)
|
||||
return
|
||||
required = ("claim_id", "lease_id", "fencing_generation", "receipt_revision")
|
||||
missing = [name for name in required if result.get(name) in (None, "")]
|
||||
if missing:
|
||||
@@ -493,6 +520,11 @@ class SchedulabilityService:
|
||||
raise SchedulabilityChanged("proof expired")
|
||||
if proof.worker_id != worker_id or proof.actor_identity != actor_identity:
|
||||
raise SchedulabilityChanged("proof identity mismatch")
|
||||
if (
|
||||
getattr(self.authority, "requires_v3_proof", False)
|
||||
and proof.schema != "aios.schedulability-proof.v3"
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim authority requires v3 proof")
|
||||
current, blockers = self.observe(proof.task_id, worker_id, actor_identity)
|
||||
if blockers:
|
||||
raise SchedulabilityChanged(
|
||||
@@ -509,5 +541,5 @@ class SchedulabilityService:
|
||||
lease_seconds=lease_seconds,
|
||||
schedulability_proof_digest=proof.proof_digest,
|
||||
)
|
||||
self._validate_claim_receipt(result)
|
||||
self._validate_claim_receipt(result, proof)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Task Plane-backed atomic schedulability authority for the C6 pilot.
|
||||
|
||||
This adapter introduces no lease or authority database. It is enabled only
|
||||
when a host supplies a binding that holds the TaskPlane transaction together
|
||||
with its real authorization and freeze boundary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Callable, Iterable, Literal, Mapping, Protocol
|
||||
import math
|
||||
import time
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import ConflictError, LockedError, ServiceUnavailableError
|
||||
from .schedulability import ClaimConditions, SchedulabilityChanged, SchedulabilityProof
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
_RECEIPT_SCHEMA = "aios.task-plane-claim-receipt.v3"
|
||||
_RECEIPT_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authority_namespace",
|
||||
"task_id",
|
||||
"claim_id",
|
||||
"worker_id",
|
||||
"worker_identity",
|
||||
"claimed_revision",
|
||||
"receipt_revision",
|
||||
"attempt",
|
||||
"operation_digest",
|
||||
"lease_until",
|
||||
"proof_digest",
|
||||
}
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthorityAtomicBindingUnavailable",
|
||||
"ClaimAuthorityBinding",
|
||||
"ClaimTransaction",
|
||||
"ScopedLeaseController",
|
||||
"TaskPlaneClaimReceiptV3",
|
||||
"TaskPlaneSchedulabilityAuthority",
|
||||
"validate_task_plane_receipt",
|
||||
]
|
||||
|
||||
|
||||
class AuthorityAtomicBindingUnavailable(ServiceUnavailableError):
|
||||
code = "AUTHORITY_ATOMIC_BINDING_UNAVAILABLE"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class ClaimTransaction(Protocol):
|
||||
"""A host-owned context that holds the store and authority lock together."""
|
||||
|
||||
store: TaskPlaneStore
|
||||
authority_id: str
|
||||
|
||||
def assert_conditions(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_holder(
|
||||
self, task_id: str, claim_id: str, worker_id: str, actor_identity: str
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_authorized(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
receipt: TaskPlaneClaimReceiptV3,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ClaimAuthorityBinding(Protocol):
|
||||
"""Host integration point; request-provided callbacks are never accepted."""
|
||||
|
||||
def claim_context(self, store: TaskPlaneStore) -> AbstractContextManager[ClaimTransaction]: ...
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]: ...
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
_BINDING_METHODS = (
|
||||
"claim_context",
|
||||
"read_authorization",
|
||||
"exact_scope_digest",
|
||||
"freeze_blocked",
|
||||
)
|
||||
|
||||
|
||||
def _require_binding(store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
"""Reject a partial host wiring before it can produce a lease effect."""
|
||||
if store is None or binding is None or any(
|
||||
not callable(getattr(binding, name, None)) for name in _BINDING_METHODS
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPlaneClaimReceiptV3:
|
||||
schema: Literal["aios.task-plane-claim-receipt.v3"]
|
||||
authority_namespace: Literal["task-plane"]
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_id: str
|
||||
worker_identity: str
|
||||
claimed_revision: int
|
||||
receipt_revision: int
|
||||
attempt: int
|
||||
operation_digest: str
|
||||
lease_until: float
|
||||
proof_digest: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _int(value: object, name: str, *, minimum: int = 0) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _float(value: object, name: str) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))
|
||||
or float(value) <= 0
|
||||
):
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _parse_receipt(result: Mapping[str, object]) -> TaskPlaneClaimReceiptV3:
|
||||
if not isinstance(result, Mapping) or set(result) != _RECEIPT_FIELDS:
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt fields")
|
||||
if result["schema"] != _RECEIPT_SCHEMA or result["authority_namespace"] != "task-plane":
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt namespace")
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=_text(result["task_id"], "task_id"),
|
||||
claim_id=_text(result["claim_id"], "claim_id"),
|
||||
worker_id=_text(result["worker_id"], "worker_id"),
|
||||
worker_identity=_text(result["worker_identity"], "worker_identity"),
|
||||
claimed_revision=_int(result["claimed_revision"], "claimed_revision"),
|
||||
receipt_revision=_int(result["receipt_revision"], "receipt_revision"),
|
||||
attempt=_int(result["attempt"], "attempt", minimum=1),
|
||||
operation_digest=_text(result["operation_digest"], "operation_digest"),
|
||||
lease_until=_float(result["lease_until"], "lease_until"),
|
||||
proof_digest=_text(result["proof_digest"], "proof_digest"),
|
||||
)
|
||||
|
||||
|
||||
def validate_task_plane_receipt(
|
||||
result: Mapping[str, object], *, proof: SchedulabilityProof
|
||||
) -> TaskPlaneClaimReceiptV3:
|
||||
"""Validate the C6 receipt without treating it as a WriterLease fence."""
|
||||
if proof.schema != "aios.schedulability-proof.v3":
|
||||
raise SchedulabilityChanged("v3 receipt requires v3 proof")
|
||||
receipt = _parse_receipt(result)
|
||||
if (
|
||||
receipt.task_id != proof.task_id
|
||||
or receipt.worker_id != proof.worker_id
|
||||
or receipt.worker_identity != proof.observation.worker_identity
|
||||
or receipt.proof_digest != proof.proof_digest
|
||||
or receipt.operation_digest != proof.observation.operation_digest
|
||||
or receipt.receipt_revision <= receipt.claimed_revision
|
||||
or receipt.lease_until <= proof.created_at
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim receipt does not bind proof")
|
||||
return receipt
|
||||
|
||||
|
||||
class TaskPlaneSchedulabilityAuthority:
|
||||
"""Use the existing TaskPlane store as the only claim/lease authority."""
|
||||
|
||||
requires_v3_proof = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskPlaneStore,
|
||||
binding: ClaimAuthorityBinding,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] = has_capabilities,
|
||||
) -> None:
|
||||
_require_binding(store, binding)
|
||||
if not callable(capability_matcher):
|
||||
raise TypeError("capability_matcher must be callable")
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _task_view(task: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"generation": task.task_plan.metadata.get("generation", 0),
|
||||
"status": task.status,
|
||||
"required_capabilities": list(task.task_plan.required_capabilities),
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
"priority": task.task_plan.metadata.get("priority", 0),
|
||||
"created_at": task.created_at,
|
||||
"metadata": dict(task.task_plan.metadata),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _worker_view(worker: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"worker_id": worker.worker_id,
|
||||
"identity": worker.identity,
|
||||
"capabilities": list(worker.capabilities),
|
||||
"status": worker.status,
|
||||
"disabled": worker.disabled,
|
||||
"registration_revision": worker.registration_revision,
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
"heartbeat_interval_seconds": worker.heartbeat_interval_seconds,
|
||||
"last_heartbeat": worker.last_heartbeat,
|
||||
}
|
||||
|
||||
def read_task(self, task_id: str) -> Mapping[str, Any]:
|
||||
return self._task_view(self.store.get_task(task_id))
|
||||
|
||||
def list_candidate_tasks(self) -> list[Mapping[str, Any]]:
|
||||
return [self._task_view(task) for task in self.store.list_tasks()]
|
||||
|
||||
def read_worker(self, worker_id: str) -> Mapping[str, Any]:
|
||||
return self._worker_view(self.store.get_worker(worker_id))
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]:
|
||||
value = self.binding.read_authorization(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(value, Mapping)
|
||||
or not isinstance(value.get("allowed"), bool)
|
||||
or not isinstance(value.get("authorization_ref"), str)
|
||||
or not value["authorization_ref"]
|
||||
or not isinstance(value.get("revision"), str)
|
||||
or not value["revision"]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return value
|
||||
|
||||
def active_claim_count(self, worker_id: str) -> int:
|
||||
with self.store._lock:
|
||||
return int(
|
||||
self.store.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, time.time()),
|
||||
).fetchone()[0]
|
||||
)
|
||||
|
||||
def dependencies_satisfied(self, task: Mapping[str, Any]) -> bool:
|
||||
record = self.store.get_task(str(task["task_id"]))
|
||||
return all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in record.task_plan.dependencies
|
||||
)
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool:
|
||||
# Only the exact boolean False proves the absence of a freeze. An
|
||||
# unavailable or malformed authority value must deny an automatic claim.
|
||||
return self.binding.freeze_blocked(task) is not False
|
||||
|
||||
def conflicting_lease(self, task_id: str) -> bool:
|
||||
return self.store.active_claim(task_id) is not None
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]:
|
||||
result = self.binding.exact_scope_digest(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(result, tuple)
|
||||
or len(result) != 2
|
||||
or not isinstance(result[0], bool)
|
||||
or not isinstance(result[1], str)
|
||||
or not result[1]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return result
|
||||
|
||||
def claim_atomically(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
lease_seconds: int,
|
||||
schedulability_proof_digest: str,
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or lease_seconds <= 0:
|
||||
raise SchedulabilityChanged("invalid claim lease duration")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
transaction.assert_conditions(task_id, worker_id, actor_identity, expected)
|
||||
task = self.store.get_task(task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
operation_digest = task.task_plan.operation_digest()
|
||||
generation = task.task_plan.metadata.get("generation", 0)
|
||||
task_view = self._task_view(task)
|
||||
worker_view = self._worker_view(worker)
|
||||
authorization = self.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.revision != expected.task_revision
|
||||
or task.status != expected.task_status
|
||||
or generation != expected.task_generation
|
||||
or worker.registration_revision != expected.worker_generation
|
||||
or worker.identity != expected.worker_identity
|
||||
or worker.max_concurrency != expected.capacity_limit
|
||||
or (expected.operation_digest is not None and operation_digest != expected.operation_digest)
|
||||
or not self.capability_matcher(worker.capabilities, task.task_plan.required_capabilities)
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or authorization["allowed"] is not True
|
||||
or authorization["authorization_ref"] != expected.authorization_ref
|
||||
or authorization["revision"] != expected.authorization_revision
|
||||
or scope != (True, expected.scope_digest)
|
||||
or self.freeze_blocked(task_view)
|
||||
or not all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in task.task_plan.dependencies
|
||||
)
|
||||
or self.store.active_claim(task_id) is not None
|
||||
or self.active_claim_count(worker_id) >= worker.max_concurrency
|
||||
):
|
||||
raise ConflictError("atomic claim condition changed")
|
||||
claim_internal = getattr(self.store, "_claim_task_in_transaction", None)
|
||||
if not callable(claim_internal):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
claim = claim_internal(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
worker_identity=actor_identity,
|
||||
lease_seconds=lease_seconds,
|
||||
expected_revision=expected.task_revision,
|
||||
operation_digest=operation_digest,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
expected_binding=expected.to_dict(),
|
||||
)
|
||||
post_claim = self.store.get_task(task_id)
|
||||
receipt = TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=claim.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,
|
||||
claimed_revision=claim.task_revision,
|
||||
receipt_revision=post_claim.revision,
|
||||
attempt=claim.attempt,
|
||||
operation_digest=claim.operation_digest,
|
||||
lease_until=claim.lease_until,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
)
|
||||
return receipt.to_dict()
|
||||
|
||||
|
||||
class ScopedLeaseController:
|
||||
"""Renew an existing TaskPlane claim; it never creates a second lease."""
|
||||
|
||||
def __init__(self, store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
_require_binding(store, binding)
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
|
||||
def _validate_persisted_claim_evidence(
|
||||
self,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
active: Any,
|
||||
task: Any,
|
||||
worker: Any,
|
||||
) -> Mapping[str, object]:
|
||||
"""Bind a C6 receipt to the durable claim decision, not its caller input."""
|
||||
# The claim row is created before the task's CLAIMED transition. The
|
||||
# transition is the only source for a v3 receipt revision; accepting a
|
||||
# larger caller-provided value would let a legacy claim masquerade as
|
||||
# a C6 claim.
|
||||
if current.receipt_revision != active.task_revision + 1:
|
||||
raise ConflictError("receipt revision does not bind active claim")
|
||||
expected_task_revision = (
|
||||
current.receipt_revision
|
||||
if task.status == "CLAIMED"
|
||||
else current.receipt_revision + 1
|
||||
)
|
||||
if task.revision != expected_task_revision:
|
||||
raise ConflictError("receipt revision does not bind task state")
|
||||
|
||||
# A C6 proof digest is durable only when it appears in the exact
|
||||
# TASK_CLAIMED event for this claim. The legacy claim path records no
|
||||
# proof digest, so it cannot be upgraded by assembling v3-shaped JSON.
|
||||
if not self.store.verify_event_chain(current.task_id):
|
||||
raise ConflictError("claim event chain verification failed")
|
||||
claim_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if (
|
||||
event.get("type") == "TASK_CLAIMED"
|
||||
and isinstance(event.get("data"), Mapping)
|
||||
and event["data"].get("claim_id") == current.claim_id
|
||||
and event["data"].get("worker_id") == current.worker_id
|
||||
and event["data"].get("proof_digest") == current.proof_digest
|
||||
)
|
||||
]
|
||||
if len(claim_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to claimed event")
|
||||
expected_binding = claim_events[0]["data"].get("expected_binding")
|
||||
required_binding_fields = {
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
}
|
||||
if not isinstance(expected_binding, Mapping) or set(expected_binding) != required_binding_fields:
|
||||
raise ConflictError("claim lacks durable C6 authority binding")
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
isinstance(expected_binding["task_revision"], bool)
|
||||
or not isinstance(expected_binding["task_revision"], int)
|
||||
or expected_binding["task_revision"] < 0
|
||||
or expected_binding["task_revision"] != current.claimed_revision
|
||||
or expected_binding["task_revision"] != active.task_revision
|
||||
or isinstance(expected_binding["task_generation"], bool)
|
||||
or not isinstance(expected_binding["task_generation"], int)
|
||||
or expected_binding["task_generation"] != task_generation
|
||||
or expected_binding["task_status"] not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}
|
||||
or isinstance(expected_binding["worker_generation"], bool)
|
||||
or not isinstance(expected_binding["worker_generation"], int)
|
||||
or expected_binding["worker_generation"] < 1
|
||||
or expected_binding["worker_identity"] != current.worker_identity
|
||||
or isinstance(expected_binding["capacity_limit"], bool)
|
||||
or not isinstance(expected_binding["capacity_limit"], int)
|
||||
or expected_binding["capacity_limit"] < 1
|
||||
or expected_binding["capacity_limit"] != worker.max_concurrency
|
||||
or not isinstance(expected_binding["operation_digest"], str)
|
||||
or expected_binding["operation_digest"] != current.operation_digest
|
||||
or not isinstance(expected_binding["authorization_ref"], str)
|
||||
or not expected_binding["authorization_ref"]
|
||||
or not isinstance(expected_binding["authorization_revision"], str)
|
||||
or not expected_binding["authorization_revision"]
|
||||
or not isinstance(expected_binding["scope_digest"], str)
|
||||
or not expected_binding["scope_digest"]
|
||||
):
|
||||
raise ConflictError("durable C6 claim binding changed")
|
||||
|
||||
# The scheduler decision retains the remaining immutable claim facts.
|
||||
# Requiring exactly one matching decision also rejects a proof digest
|
||||
# copied between attempts for the same task.
|
||||
decision_payload = {
|
||||
"worker_id": current.worker_id,
|
||||
"worker_identity": current.worker_identity,
|
||||
"operation_digest": current.operation_digest,
|
||||
"attempt": current.attempt,
|
||||
"proof_digest": current.proof_digest,
|
||||
}
|
||||
decision_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if event.get("type") == "SCHEDULE_DECISION" and event.get("data") == decision_payload
|
||||
]
|
||||
if len(decision_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to schedule decision")
|
||||
self._validate_worker_heartbeat_chain(
|
||||
bound_revision=expected_binding["worker_generation"],
|
||||
worker=worker,
|
||||
claim_actor_identity=current.worker_identity,
|
||||
)
|
||||
return expected_binding
|
||||
|
||||
def _validate_worker_heartbeat_chain(
|
||||
self,
|
||||
*,
|
||||
bound_revision: object,
|
||||
worker: Any,
|
||||
claim_actor_identity: str,
|
||||
) -> None:
|
||||
"""Allow only a complete run of ordinary worker heartbeats after claim.
|
||||
|
||||
Worker registration revisions advance on every worker heartbeat. The
|
||||
worker audit stream is therefore the authoritative distinction between
|
||||
liveness refresh and re-registration or privilege change.
|
||||
"""
|
||||
current_revision = worker.registration_revision
|
||||
if (
|
||||
isinstance(bound_revision, bool)
|
||||
or not isinstance(bound_revision, int)
|
||||
or isinstance(current_revision, bool)
|
||||
or not isinstance(current_revision, int)
|
||||
or current_revision < bound_revision
|
||||
):
|
||||
raise ConflictError("worker registration revision changed")
|
||||
if current_revision == bound_revision:
|
||||
return
|
||||
|
||||
audit_task_id = "task-worker-registry"
|
||||
if not self.store.verify_event_chain(audit_task_id):
|
||||
raise ConflictError("worker registry event chain verification failed")
|
||||
expected_revisions = list(range(bound_revision + 1, current_revision + 1))
|
||||
events = []
|
||||
for event in self.store.list_events(audit_task_id):
|
||||
data = event.get("data")
|
||||
if not isinstance(data, Mapping) or data.get("worker_id") != worker.worker_id:
|
||||
continue
|
||||
revision = data.get("registration_revision")
|
||||
if isinstance(revision, bool) or not isinstance(revision, int) or revision <= bound_revision:
|
||||
continue
|
||||
events.append((event, data, revision))
|
||||
if len(events) != len(expected_revisions):
|
||||
raise ConflictError("worker registration audit chain incomplete")
|
||||
for expected_revision, (event, data, revision) in zip(expected_revisions, events):
|
||||
if (
|
||||
event.get("type") != "WORKER_HEARTBEAT"
|
||||
or set(data) != {"worker_id", "actor_identity", "registration_revision"}
|
||||
or data.get("actor_identity") != claim_actor_identity
|
||||
or revision != expected_revision
|
||||
):
|
||||
raise ConflictError("worker registration changed after C6 claim")
|
||||
|
||||
def _validate_active_receipt(
|
||||
self,
|
||||
transaction: ClaimTransaction,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
) -> None:
|
||||
"""Validate a receipt without extending its lease or granting a new one."""
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker = getattr(transaction, "assert_lease_holder", None)
|
||||
authorization_checker = getattr(transaction, "assert_lease_authorized", None)
|
||||
if not callable(holder_checker) or not callable(authorization_checker):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker(current.task_id, current.claim_id, worker_id, actor_identity)
|
||||
|
||||
# ``active_claim`` uses the store's authoritative clock and status
|
||||
# filter. Do not trust a receipt's former lease expiry by itself.
|
||||
active = self.store.active_claim(current.task_id)
|
||||
if active is None:
|
||||
raise LockedError("lease expired or inactive")
|
||||
if (
|
||||
active.claim_id != current.claim_id
|
||||
or active.task_id != current.task_id
|
||||
or active.worker_id != worker_id
|
||||
or active.worker_identity != actor_identity
|
||||
or active.task_revision != current.claimed_revision
|
||||
or active.operation_digest != current.operation_digest
|
||||
or active.attempt != current.attempt
|
||||
or active.lease_until <= time.time()
|
||||
):
|
||||
raise ConflictError("active claim does not match receipt")
|
||||
|
||||
task = self.store.get_task(current.task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
expected_binding = self._validate_persisted_claim_evidence(
|
||||
current, active=active, task=task, worker=worker
|
||||
)
|
||||
task_view = TaskPlaneSchedulabilityAuthority._task_view(task)
|
||||
worker_view = TaskPlaneSchedulabilityAuthority._worker_view(worker)
|
||||
authorization = self.binding.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.binding.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.status not in {"CLAIMED", "RUNNING"}
|
||||
or task.task_plan.operation_digest() != current.operation_digest
|
||||
or not isinstance(authorization, Mapping)
|
||||
or authorization.get("allowed") is not True
|
||||
or authorization.get("authorization_ref") != expected_binding["authorization_ref"]
|
||||
or authorization.get("revision") != expected_binding["authorization_revision"]
|
||||
or not isinstance(scope, tuple)
|
||||
or len(scope) != 2
|
||||
or scope[0] is not True
|
||||
or not isinstance(scope[1], str)
|
||||
or not scope[1]
|
||||
or scope[1] != expected_binding["scope_digest"]
|
||||
or self.binding.freeze_blocked(task_view) is not False
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or worker.identity != current.worker_identity
|
||||
or not has_capabilities(worker.capabilities, task.task_plan.required_capabilities)
|
||||
):
|
||||
raise ConflictError("lease authority changed")
|
||||
authorization_checker(
|
||||
current.task_id, current.claim_id, worker_id, actor_identity, current
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def execution_guard(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
):
|
||||
"""Hold the authoritative claim binding through one pilot critical section.
|
||||
|
||||
This guard never calls ``heartbeat_claim`` and never makes a new claim.
|
||||
Its context remains open for the caller's critical section, so another
|
||||
same-store state change cannot interleave between validation and use.
|
||||
"""
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
yield current
|
||||
|
||||
def renew(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
lease_seconds: int,
|
||||
) -> Mapping[str, object]:
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
renewed = self.store.heartbeat_claim(
|
||||
current.task_id,
|
||||
current.claim_id,
|
||||
lease_seconds,
|
||||
expected_task_revision=current.claimed_revision,
|
||||
operation_digest=current.operation_digest,
|
||||
)
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=renewed.task_id,
|
||||
claim_id=renewed.claim_id,
|
||||
worker_id=renewed.worker_id,
|
||||
worker_identity=renewed.worker_identity,
|
||||
claimed_revision=renewed.task_revision,
|
||||
receipt_revision=current.receipt_revision,
|
||||
attempt=renewed.attempt,
|
||||
operation_digest=renewed.operation_digest,
|
||||
lease_until=renewed.lease_until,
|
||||
proof_digest=current.proof_digest,
|
||||
).to_dict()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""S5.1 live authority adapters with exact caller/candidate binding.
|
||||
|
||||
These adapters are projections over existing authorities. They do not create
|
||||
tasks, grants, leases, branches, or recovery authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .governance_admission import AdmissionOperation, GovernanceFacts
|
||||
from .governance_recovery_controller import GovernanceEvent
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionResult
|
||||
|
||||
|
||||
class LiveSurfaceUnavailable(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_SURFACE_UNAVAILABLE[{role}]: {reason}")
|
||||
|
||||
|
||||
class LiveBindingViolation(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_BINDING_VIOLATION[{role}]: {reason}")
|
||||
|
||||
|
||||
class TaskPlaneReadClient(Protocol):
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> list[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
class LiveTaskResolverAdapter:
|
||||
"""Bind ExactTaskResolver to one caller-authorized TaskPlane projection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: TaskPlaneReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
bound_repository: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
self._bound_repository = bound_repository
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A1",
|
||||
"no caller-authorized TaskPlaneReadClient and bound identity",
|
||||
)
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"controller actor_identity does not match bound read principal",
|
||||
)
|
||||
if (
|
||||
self._bound_repository
|
||||
and query.repository != self._bound_repository
|
||||
):
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"query repository is outside the bound authority projection",
|
||||
)
|
||||
return ExactTaskResolver(self._client).resolve(
|
||||
query, actor_identity=self._actor_identity
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedAuthoritySnapshot:
|
||||
"""One revision-consistent, candidate-specific governed read."""
|
||||
|
||||
task_id: str
|
||||
actor_identity: str
|
||||
operation: AdmissionOperation
|
||||
snapshot_revision: str
|
||||
facts: GovernanceFacts
|
||||
|
||||
|
||||
class GovernedAuthorityReadClient(Protocol):
|
||||
def read_snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernedAuthoritySnapshot: ...
|
||||
|
||||
|
||||
class LiveGovernanceFactsAdapter:
|
||||
"""Materialize GovernanceFacts from one atomic/revision-consistent read."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: GovernedAuthorityReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A2",
|
||||
"no candidate-specific governed authority reader and identity",
|
||||
)
|
||||
if task_id is None:
|
||||
raise LiveBindingViolation("A2", "exact task_id required")
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A2",
|
||||
"controller actor_identity does not match bound governed principal",
|
||||
)
|
||||
snapshot = self._client.read_snapshot(
|
||||
task_id=task_id,
|
||||
actor_identity=actor_identity,
|
||||
operation=operation,
|
||||
)
|
||||
if not snapshot.snapshot_revision:
|
||||
raise LiveBindingViolation(
|
||||
"A2", "authority snapshot revision is missing"
|
||||
)
|
||||
if snapshot.task_id != task_id:
|
||||
raise LiveBindingViolation("A2", "authority snapshot task mismatch")
|
||||
if snapshot.actor_identity != actor_identity:
|
||||
raise LiveBindingViolation("A2", "authority snapshot actor mismatch")
|
||||
if snapshot.operation is not operation:
|
||||
raise LiveBindingViolation("A2", "authority snapshot operation mismatch")
|
||||
return snapshot.facts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveGovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GovernanceEventSubscriber(Protocol):
|
||||
def subscribe(
|
||||
self, on_event: Callable[[LiveGovernanceEvent], object]
|
||||
) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class LiveEventSubscriptionAdapter:
|
||||
"""Translate site events into the core GovernanceEvent contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sink: Callable[[GovernanceEvent], object],
|
||||
subscriber: GovernanceEventSubscriber | None = None,
|
||||
) -> None:
|
||||
self._sink = sink
|
||||
self._subscriber = subscriber
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._subscriber is not None
|
||||
|
||||
def _on_live_event(self, event: LiveGovernanceEvent):
|
||||
if not isinstance(event, LiveGovernanceEvent):
|
||||
raise LiveBindingViolation(
|
||||
"A3", "subscriber emitted an unsupported event type"
|
||||
)
|
||||
return self._sink(GovernanceEvent(
|
||||
kind=event.kind,
|
||||
revision=event.revision,
|
||||
occurred_at=event.occurred_at,
|
||||
))
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A3",
|
||||
"no governance event subscriber injected; polling remains fallback",
|
||||
)
|
||||
self._subscriber.subscribe(self._on_live_event)
|
||||
self._active = True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._subscriber is not None:
|
||||
self._subscriber.close()
|
||||
self._active = False
|
||||
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import (
|
||||
@@ -26,6 +26,7 @@ from .models import (
|
||||
ProbeOutcome,
|
||||
TaskPlan,
|
||||
TaskRecord,
|
||||
TaskStateTransitionError,
|
||||
WorkerClaim,
|
||||
WorkerRegistration,
|
||||
canonical_json,
|
||||
@@ -65,11 +66,38 @@ class TaskPlaneStore:
|
||||
self.high_assurance = high_assurance
|
||||
self.fault_injector = None
|
||||
self.probe_attestation_verifier = None
|
||||
self.evidence_attestation_verifier = None
|
||||
self._lock = threading.RLock()
|
||||
self._tx_state = threading.local()
|
||||
self._closed = False
|
||||
self._init_db(db_path)
|
||||
|
||||
@staticmethod
|
||||
def _verifier_identity(verifier: Any) -> str | None:
|
||||
identity = getattr(verifier, "verifier_identity", None)
|
||||
return str(identity) if identity else None
|
||||
|
||||
def configure_evidence_attestation_verifier(self, verifier: Any) -> Any:
|
||||
"""Install one evidence verifier without weakening an existing binding."""
|
||||
|
||||
current = self.evidence_attestation_verifier
|
||||
if verifier is None:
|
||||
return current
|
||||
if current is None:
|
||||
self.evidence_attestation_verifier = verifier
|
||||
return verifier
|
||||
if current is verifier:
|
||||
return current
|
||||
current_identity = self._verifier_identity(current)
|
||||
candidate_identity = self._verifier_identity(verifier)
|
||||
if (
|
||||
current_identity is not None
|
||||
and candidate_identity is not None
|
||||
and current_identity == candidate_identity
|
||||
):
|
||||
return current
|
||||
raise ValueError("conflicting evidence attestation verifier identity")
|
||||
|
||||
def _init_db(self, path: str) -> None:
|
||||
if path != ":memory:":
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
@@ -410,6 +438,18 @@ class TaskPlaneStore:
|
||||
# an external request. They must never migrate to a phase
|
||||
# that permits a new lease or commit.
|
||||
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
||||
# C6 exact-owner integration extends this existing effect
|
||||
# ledger. The versioned binding is immutable and its
|
||||
# separately indexed effect key permits exact readback;
|
||||
# it is deliberately not a second action ledger.
|
||||
"host_effect_key": "TEXT",
|
||||
"host_binding_json": "TEXT",
|
||||
"host_binding_digest": "TEXT",
|
||||
"writer_lease_json": "TEXT",
|
||||
"writer_lease_digest": "TEXT",
|
||||
"host_closure_json": "TEXT",
|
||||
"host_closure_digest": "TEXT",
|
||||
"host_closure_observed_at": "REAL",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
@@ -436,6 +476,11 @@ class TaskPlaneStore:
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
||||
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_action_host_effect "
|
||||
"ON task_root_action_effects(host_effect_key) "
|
||||
"WHERE host_effect_key IS NOT NULL"
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_missions (
|
||||
@@ -754,22 +799,60 @@ class TaskPlaneStore:
|
||||
status: str,
|
||||
failure_reason: str | None = None,
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
outer_depth = getattr(self._tx_state, "depth", 0)
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
observed = current
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
if outer_depth:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status=status, error=exc
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
raise
|
||||
|
||||
def _append_transition_denial(
|
||||
self,
|
||||
task_id: str,
|
||||
observed: TaskRecord,
|
||||
*,
|
||||
attempted_status: str,
|
||||
error: TaskStateTransitionError,
|
||||
) -> None:
|
||||
try:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_TRANSITION_DENIED",
|
||||
{
|
||||
"from_status": observed.status,
|
||||
"attempted_status": attempted_status,
|
||||
"observed_revision": observed.revision,
|
||||
"reason": str(error),
|
||||
},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
except Exception as audit_error:
|
||||
raise RuntimeError(
|
||||
"task transition denied but denial audit persistence failed"
|
||||
) from audit_error
|
||||
|
||||
def register_worker(
|
||||
self,
|
||||
@@ -1048,112 +1131,203 @@ class TaskPlaneStore:
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
with self._transaction():
|
||||
return self._claim_task_in_transaction(
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
lease_seconds,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
proof_digest=proof_digest,
|
||||
expected_binding=expected_binding,
|
||||
)
|
||||
|
||||
def _claim_task_in_transaction(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
worker_identity: str,
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
"""Claim after revalidating the supplied binding in the active transaction."""
|
||||
|
||||
now = _now()
|
||||
lease_until = now + max(1, int(lease_seconds))
|
||||
with self._transaction():
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if expected_binding is not None:
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
not isinstance(task_generation, int)
|
||||
or isinstance(task_generation, bool)
|
||||
or task_generation < 0
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
raise ConflictError("task generation invalid")
|
||||
checks = {
|
||||
"task_revision": task.revision,
|
||||
"task_generation": task_generation,
|
||||
"task_status": task.status,
|
||||
"worker_generation": worker.registration_revision,
|
||||
"worker_identity": worker.identity,
|
||||
"capacity_limit": worker.max_concurrency,
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
}
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
for field, actual in checks.items():
|
||||
if expected_binding.get(field) != actual:
|
||||
raise ConflictError(f"claim binding {field} mismatch")
|
||||
if expected_binding.get("operation_digest") != operation_digest:
|
||||
raise ConflictError("claim binding operation_digest mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
}
|
||||
if proof_digest is not None:
|
||||
schedule_payload["proof_digest"] = proof_digest
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
self.append_event(task_id, "TASK_CLAIMED", {"claim_id": claim_id, "worker_id": worker_id}, subject=f"task/{task_id}")
|
||||
return self._row_to_claim(self.conn.execute(
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
)
|
||||
claimed_payload = {"claim_id": claim_id, "worker_id": worker_id}
|
||||
if proof_digest is not None:
|
||||
claimed_payload["proof_digest"] = proof_digest
|
||||
if expected_binding is not None:
|
||||
# Preserve only the C6 conditions rechecked above plus the host
|
||||
# authorization/scope version fields. A later renew/execute must
|
||||
# bind to this durable decision, rather than caller-shaped receipt
|
||||
# JSON. Do not serialize arbitrary values accepted by this
|
||||
# internal compatibility seam.
|
||||
claimed_payload["expected_binding"] = {
|
||||
field: expected_binding.get(field)
|
||||
for field in (
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
)
|
||||
}
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CLAIMED",
|
||||
claimed_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return self._row_to_claim(
|
||||
self.conn.execute(
|
||||
"SELECT * FROM claims WHERE claim_id=?",
|
||||
(claim_id,),
|
||||
).fetchone())
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def heartbeat_claim(self, task_id: str, claim_id: str, lease_seconds: int) -> WorkerClaim:
|
||||
def heartbeat_claim(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
lease_seconds: int,
|
||||
*,
|
||||
expected_task_revision: int | None = None,
|
||||
operation_digest: str | None = None,
|
||||
) -> WorkerClaim:
|
||||
now = _now()
|
||||
lease_until = now + max(1, lease_seconds)
|
||||
with self._transaction():
|
||||
@@ -1168,14 +1342,32 @@ class TaskPlaneStore:
|
||||
raise ConflictError("claim not active")
|
||||
if claim.lease_until <= now:
|
||||
raise LockedError("lease expired")
|
||||
if (
|
||||
expected_task_revision is not None
|
||||
and claim.task_revision != expected_task_revision
|
||||
):
|
||||
raise ConflictError("claim task revision mismatch")
|
||||
if (
|
||||
operation_digest is not None
|
||||
and claim.operation_digest != operation_digest
|
||||
):
|
||||
raise ConflictError("claim operation digest mismatch")
|
||||
if self.get_task(task_id).status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
||||
raise ConflictError("task is terminal")
|
||||
updated = self.conn.execute(
|
||||
"""
|
||||
UPDATE claims SET lease_until=?
|
||||
WHERE claim_id=? AND task_id=? AND status='ACTIVE' AND lease_until>?
|
||||
AND task_revision=? AND operation_digest=?
|
||||
""",
|
||||
(lease_until, claim_id, task_id, now),
|
||||
(
|
||||
lease_until,
|
||||
claim_id,
|
||||
task_id,
|
||||
now,
|
||||
claim.task_revision,
|
||||
claim.operation_digest,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise LockedError("claim changed during heartbeat")
|
||||
@@ -1408,6 +1600,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.add_evidence_envelope(envelope)
|
||||
@@ -1455,16 +1648,26 @@ class TaskPlaneStore:
|
||||
def cancel_task_with_event(
|
||||
self, task_id: str, expected_revision: int, reason: str
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
observed = self.get_task(task_id)
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return updated
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status="CANCELLED", error=exc
|
||||
)
|
||||
return updated
|
||||
raise
|
||||
|
||||
def delegate_task_with_budget_and_events(
|
||||
self,
|
||||
@@ -1699,38 +1902,120 @@ class TaskPlaneStore:
|
||||
prev = event["hash"]
|
||||
return True
|
||||
|
||||
def add_evidence(self, task_id: str, kind: str, payload: dict[str, Any], worker_identity: str) -> str:
|
||||
def add_evidence(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
worker_identity: str,
|
||||
*,
|
||||
envelope: EvidenceEnvelope | None = None,
|
||||
claim_id: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> str:
|
||||
"""Add authenticated evidence while retaining the legacy call shape.
|
||||
|
||||
Old unsigned calls now fail with a migration error before any write.
|
||||
Producers may submit a complete envelope or all explicit proof fields.
|
||||
"""
|
||||
|
||||
if envelope is not None:
|
||||
if (
|
||||
envelope.task_id != task_id
|
||||
or envelope.kind != kind
|
||||
or envelope.worker_identity != worker_identity
|
||||
or envelope.payload != redact_value(payload)
|
||||
):
|
||||
raise ValidationError("evidence envelope arguments mismatch")
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
if not (signature or attestation_ref):
|
||||
raise ValidationError(
|
||||
"evidence authentication required; submit a complete verified "
|
||||
"envelope or explicit signature/provider proof"
|
||||
)
|
||||
claim = self.active_claim(task_id)
|
||||
claim_id = claim.claim_id if claim else "claim-unbound-shadow"
|
||||
envelope = EvidenceEnvelope.create(
|
||||
effective_claim_id = claim_id or (
|
||||
claim.claim_id if claim else "claim-unbound-shadow"
|
||||
)
|
||||
if claim is not None and effective_claim_id != claim.claim_id:
|
||||
raise ValidationError("evidence claim does not match active claim")
|
||||
built = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
claim_id=effective_claim_id,
|
||||
kind=kind,
|
||||
worker_identity=worker_identity,
|
||||
payload=payload,
|
||||
previous_evidence_hash=self._last_evidence_hash(task_id),
|
||||
evidence_id=evidence_id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
self.add_evidence_envelope(built)
|
||||
return built.evidence_id
|
||||
|
||||
def _last_evidence_hash(self, task_id: str) -> str:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT digest FROM evidence WHERE task_id=?
|
||||
ORDER BY created_at DESC,evidence_id DESC LIMIT 1
|
||||
ORDER BY rowid DESC LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return str(row["digest"]) if row else ""
|
||||
|
||||
def _last_evidence_sequence(self, authority_domain: str) -> int | None:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT MAX(CAST(json_extract(envelope_json, '$.sequence') AS INTEGER))
|
||||
FROM evidence
|
||||
WHERE json_extract(envelope_json, '$.authority_domain')=?
|
||||
AND json_type(envelope_json, '$.sequence')='integer'
|
||||
""",
|
||||
(authority_domain,),
|
||||
).fetchone()
|
||||
return None if row is None or row[0] is None else int(row[0])
|
||||
|
||||
def add_evidence_envelope(self, envelope: EvidenceEnvelope) -> str:
|
||||
with self._transaction():
|
||||
self.get_task(envelope.task_id)
|
||||
if (envelope.authority_domain is None) != (envelope.sequence is None):
|
||||
raise ConflictError("evidence authority domain and sequence must be paired")
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not envelope.authority_domain
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ConflictError("evidence authority sequence invalid")
|
||||
last_sequence = self._last_evidence_sequence(
|
||||
envelope.authority_domain
|
||||
)
|
||||
if last_sequence is not None and envelope.sequence <= last_sequence:
|
||||
raise ConflictError("evidence authority sequence not monotonic")
|
||||
expected_previous = self._last_evidence_hash(envelope.task_id)
|
||||
if not envelope.verify(
|
||||
expected_task_id=envelope.task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=expected_previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.conn.execute(
|
||||
@@ -1759,13 +2044,14 @@ class TaskPlaneStore:
|
||||
task_id = _validated_id(task_id, "task_id")
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY created_at ASC",
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY rowid ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def verify_evidence_integrity(self, task_id: str) -> bool:
|
||||
previous = ""
|
||||
domain_sequences: dict[str, int] = {}
|
||||
for evidence in self.list_evidence(task_id):
|
||||
try:
|
||||
raw = evidence.get("envelope_json")
|
||||
@@ -1781,10 +2067,24 @@ class TaskPlaneStore:
|
||||
worker_identity=payload["worker_identity"],
|
||||
payload=payload["payload"],
|
||||
created_at=float(payload["created_at"]),
|
||||
expires_at=float(
|
||||
payload.get(
|
||||
"expires_at", float(payload["created_at"]) + 300.0
|
||||
)
|
||||
),
|
||||
previous_evidence_hash=payload["previous_evidence_hash"],
|
||||
evidence_digest=payload["evidence_digest"],
|
||||
signature=payload.get("signature"),
|
||||
attestation_ref=payload.get("attestation_ref"),
|
||||
signature_algorithm=payload.get("signature_algorithm"),
|
||||
signing_key_id=payload.get("signing_key_id"),
|
||||
verifier_identity=payload.get("verifier_identity"),
|
||||
authority_domain=payload.get("authority_domain"),
|
||||
sequence=(
|
||||
None
|
||||
if payload.get("sequence") is None
|
||||
else int(payload["sequence"])
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
@@ -1798,9 +2098,20 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
)
|
||||
):
|
||||
return False
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence <= domain_sequences.get(
|
||||
envelope.authority_domain, 0
|
||||
)
|
||||
):
|
||||
return False
|
||||
domain_sequences[envelope.authority_domain] = envelope.sequence
|
||||
previous = envelope.evidence_digest
|
||||
return True
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -405,51 +407,85 @@ def run_secret_scan_provider(
|
||||
tree_oid: str,
|
||||
store_id: str = "secret_scan",
|
||||
provider_identity: str = "aios.secret-scanner",
|
||||
artifact_path: Path | None = None,
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute the real secret scanner (gitleaks) and produce a root receipt."""
|
||||
if not Path(gitleaks_bin).is_file():
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_UNAVAILABLE")
|
||||
artifact_sha = None
|
||||
if artifact_path is not None:
|
||||
artifact_sha = _validate_artifact_scan_target(artifact_path, target)
|
||||
# --no-git scans the working tree as a filesystem (does not require a .git
|
||||
# dir), so an exported release checkout can be scanned faithfully.
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "0",
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
warnings = 0
|
||||
# gitleaks exits 1 when findings are present; with --exit-code 0 it returns 0
|
||||
# but prints findings. Count lines that indicate a finding.
|
||||
finding_lines = [
|
||||
ln for ln in scanned.stdout.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
] + [
|
||||
ln for ln in scanned.stderr.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
]
|
||||
artifact_sha = None
|
||||
if target.is_file():
|
||||
artifact_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-secret-report-") as report_root:
|
||||
report = Path(report_root) / "findings.json"
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "1",
|
||||
"--report-format", "json", "--report-path", str(report),
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
try:
|
||||
findings = json.loads(report.read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID") from exc
|
||||
if not isinstance(findings, list) or any(not isinstance(v, dict) for v in findings):
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID")
|
||||
report_sha = hashlib.sha256(report.read_bytes()).hexdigest()
|
||||
if artifact_path is not None and _validate_artifact_scan_target(artifact_path, target) != artifact_sha:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_CHANGED")
|
||||
# Passed only when the scanner actually ran cleanly (exit 0) AND found zero
|
||||
# leaks. A scanner crash (non-zero exit) is never a pass.
|
||||
clean_run = scanned.returncode == 0
|
||||
result = {
|
||||
"passed": clean_run and len(finding_lines) == 0,
|
||||
"passed": clean_run and len(findings) == 0,
|
||||
"scanner": "gitleaks",
|
||||
"exit_code": scanned.returncode,
|
||||
"target": str(target),
|
||||
"findings": len(finding_lines),
|
||||
"findings": len(findings),
|
||||
"report_sha256": report_sha,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"clean_run": clean_run,
|
||||
}
|
||||
return result, _receipt_ref_for(result, "secret_scan")
|
||||
|
||||
|
||||
def _validate_artifact_scan_target(artifact: Path, target: Path) -> str:
|
||||
"""Bind a real filesystem scan to every byte of its exact archive payload."""
|
||||
if artifact.is_symlink() or not artifact.is_file() or target.is_symlink() or not target.is_dir():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_TARGET_INVALID")
|
||||
raw = artifact.read_bytes()
|
||||
import io
|
||||
expected: set[str] = set()
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as archive:
|
||||
members = archive.getmembers()
|
||||
if not members or len(members) > 20_000 or sum(m.size for m in members) > 2 * 1024**3:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_LIMIT")
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if not member.isfile() or member.name.startswith("/") or "\\" in member.name or not parts or any(p in {".", ".."} for p in parts) or member.name in expected:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID")
|
||||
path = target.joinpath(*parts)
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(target.resolve()):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
if archive.extractfile(member).read() != path.read_bytes():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
expected.add(member.name)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID") from exc
|
||||
actual = {p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file()}
|
||||
if actual != expected or any(p.is_symlink() for p in target.rglob("*")):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _receipt_ref_for(result: Mapping[str, Any], kind: str) -> str:
|
||||
run_id = hashlib.sha256(canonical_json_bytes(result)).hexdigest()[:16]
|
||||
return f"run-{run_id}/receipt.json"
|
||||
@@ -531,6 +567,7 @@ def _run_provider_executor(
|
||||
target=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
artifact_path=(Path(os.environ["AIOS_RELEASE_ARTIFACT"]) if os.environ.get("AIOS_RELEASE_ARTIFACT") else None),
|
||||
)
|
||||
if kind == "gitea":
|
||||
gitea_api_url = os.environ.get("AIOS_GITEA_API_URL", "").strip()
|
||||
@@ -601,9 +638,14 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--store-root", required=True)
|
||||
parser.add_argument("--receipt-ref", required=True)
|
||||
parser.add_argument("--repo-root", default=None, help="Real source checkout to execute against")
|
||||
parser.add_argument("--artifact-path", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.repo_root:
|
||||
os.environ["AIOS_REPO_ROOT"] = args.repo_root
|
||||
if args.artifact_path:
|
||||
if args.kind != "secret_scan":
|
||||
raise SystemExit("ARTIFACT_BINDING_REQUIRES_SECRET_SCAN")
|
||||
os.environ["AIOS_RELEASE_ARTIFACT"] = str(args.artifact_path)
|
||||
try:
|
||||
digest = produce(
|
||||
args.kind,
|
||||
|
||||
@@ -46,12 +46,23 @@ def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
||||
slug=version.lower()
|
||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
||||
|
||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def _health(url: str, binding: Mapping[str, Any], *, runtime_verifier=None) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# Health bodies are untrusted declarations; even a body containing PASS
|
||||
# cannot certify itself. A host-owned independent verifier must bind
|
||||
# the response to the exact candidate and fresh observed runtime.
|
||||
try:
|
||||
independently_verified = callable(runtime_verifier) and runtime_verifier(binding, body) is True
|
||||
except Exception:
|
||||
independently_verified = False
|
||||
result_status = "ACTIVE_VERIFIED" if exact and independently_verified else "UNKNOWN"
|
||||
else:
|
||||
result_status = "ACTIVE_VERIFIED" if exact else "CONFLICT"
|
||||
return {**body, "status": result_status, "http_status": status}
|
||||
|
||||
def _extract(artifact: Path, destination: Path) -> None:
|
||||
import tarfile
|
||||
@@ -70,7 +81,13 @@ def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
||||
|
||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
binding=_binding(value)
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# This adapter installs exactly one legacy health service. A C6
|
||||
# multi-component payload requires the governed component host binding;
|
||||
# do not report a health-only installation as a C6 deployment.
|
||||
raise AdapterBlocked("component_host_binding_required")
|
||||
p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
||||
|
||||
@@ -78,7 +78,12 @@ def serve(host: str, port: int) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("--root",type=Path,default=Path(".")); parser.add_argument("--manifest",type=Path); parser.add_argument("--serve",action="store_true"); parser.add_argument("--host",default="127.0.0.1"); parser.add_argument("--port",type=int,default=18927); args=parser.parse_args()
|
||||
if args.serve: serve(args.host,args.port); return 0
|
||||
if args.serve:
|
||||
# A health listener may report liveness only after its real artifact
|
||||
# manifest has been verified. This is intentionally not runtime
|
||||
# identity proof; that needs a separate trusted observation.
|
||||
verify_manifest(args.root,args.manifest)
|
||||
serve(args.host,args.port); return 0
|
||||
print(json.dumps(verify_manifest(args.root,args.manifest),sort_keys=True)); return 0
|
||||
|
||||
if __name__ == "__main__": raise SystemExit(main())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,6 +14,25 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
||||
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
||||
|
||||
def _loopback(value):
|
||||
try: return ipaddress.ip_address(value).is_loopback
|
||||
except (ValueError, TypeError): return False
|
||||
|
||||
def validate_dev_profile():
|
||||
if DEV_ALLOW and not (os.getenv('AIOS_RUNTIME_PROFILE') == 'isolated-staging' and _loopback(HOST)):
|
||||
raise RuntimeError('DEV_ALLOW requires isolated-staging and loopback')
|
||||
|
||||
def runtime_selection(principal, data):
|
||||
if not principal.get('token_ok'): raise PermissionError('authenticated principal required')
|
||||
if any(key in data for key in ('username', 'user_id', 'role', 'tier')):
|
||||
raise ValueError('runtime identity must come from authenticated principal')
|
||||
username=principal['username']; role=principal['role']
|
||||
if role not in _ROLE_WHITELIST or re.fullmatch(r'[A-Za-z0-9_@.-]{1,120}', username) is None or username in ('.','..'):
|
||||
raise ValueError('invalid authenticated principal')
|
||||
tier='ultra' if role=='owner' else 'regular'
|
||||
return username, role, tier
|
||||
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
||||
@@ -66,6 +85,7 @@ def token_identity(token: str):
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
@@ -83,7 +103,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -159,7 +181,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'not_found','path':path},404)
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
def do_POST(self):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == '/api/runtime/select':
|
||||
if not (_loopback(HOST) and _loopback(self.client_address[0])):
|
||||
self.send_json({'error':'loopback ingress required'},403); return
|
||||
a = self.require_write()
|
||||
if not a: return
|
||||
data = parse_body(self)
|
||||
try: runtime_selection(a, data)
|
||||
except (ValueError, PermissionError):
|
||||
self.send_json({'error':'invalid runtime selection identity'},403); return
|
||||
else:
|
||||
data = parse_body(self)
|
||||
init_db()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -213,7 +247,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not m: self.send_json({'error':'model not found'},404); return
|
||||
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
||||
if path == '/api/runtime/select':
|
||||
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
||||
username,role,tier=runtime_selection(a, data); risk=data.get('risk','normal')
|
||||
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
||||
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
||||
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
||||
@@ -268,5 +302,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
|
||||
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
||||
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# C4 capability contract provenance
|
||||
|
||||
The C4 checkout has no `aios_capability_contract.py`, although
|
||||
`active-runtime/app/aios_agent_task_protocol.py` imports it at module scope.
|
||||
The read-only QA capture used for the missing source closure is:
|
||||
|
||||
- source: `C:\\Users\\ImRuY\\.codex-restored\\work\\v2r2c5-release-20260926\\qa\\capability-contract.txt`
|
||||
- SHA256: `c97c9f8a3cf199a9c0348da1212aa26d161aad8ddaed84db534e46f490f2422f`
|
||||
- 140 lines
|
||||
|
||||
The contract preserves separate task and worker alias maps, maps unknown task
|
||||
names to `exact:<token>`, ignores unknown worker names, and returns false for
|
||||
unknown or mismatched eligibility. It is used by formal AgentTask poll and
|
||||
claim paths only. Generic `task_plane` matching remains outside this lane.
|
||||
|
||||
The authoritative runtime overlay is captured separately by QA:
|
||||
|
||||
- base SHA256: `953de1d491b4f1bc47c71f502027fd488e6c48ecc49da8a91dc9d8754c320d17`
|
||||
- overlay SHA256: `7555aa067fc01c6a25276a834dda4924b404f3b0c529ed3f99e9f1d83d18798e`
|
||||
|
||||
The consolidated module preserves the base mappings and adds only the seven
|
||||
explicit lifecycle labels: `agent-task:control`, `governed.task.create`,
|
||||
`governed.write.files`, `task.principal.issue`, `testing`, `verification`, and
|
||||
`writer.lease`. It does not depend on an absolute deployed path.
|
||||
|
||||
Validation logs:
|
||||
|
||||
- `PYTHONPATH=active-runtime/app python -m pytest -q tests/test_v2r2c5_capability_contract.py` -> 6 passed.
|
||||
- `PYTHONPATH=active-runtime/app;active-runtime python -m pytest -q tests/test_task_admission_bootstrap.py tests/test_task_admission_bootstrap_chain_contract.py` -> 45 passed.
|
||||
- `tests/test_task_admission_bootstrap_http.py` could not collect on Windows because the existing `gitea_tag_publication.py` imports unavailable `fcntl`; no capability assertion ran in that file.
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/authorization-bundle.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseAuthorizationBundle"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"bundle_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"bundle_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"REQUESTED",
|
||||
"ACTIVE",
|
||||
"REVOKED",
|
||||
"COMPLETED"
|
||||
]
|
||||
},
|
||||
"owner_intent_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"issuer_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"root_grant_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repositories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"environments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"component_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/action"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repositories",
|
||||
"environments",
|
||||
"component_ids",
|
||||
"actions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delegation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"child_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"successor_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"agent_transfer": {
|
||||
"const": true
|
||||
},
|
||||
"scope_must_not_expand": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"child_tasks",
|
||||
"successor_tasks",
|
||||
"agent_transfer",
|
||||
"scope_must_not_expand"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"validity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"not_before": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
},
|
||||
"until": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"RELEASE_CLOSED",
|
||||
"REVOKED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"revocation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"revocation_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"not_before",
|
||||
"until",
|
||||
"revocation_ref",
|
||||
"revocation_generation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"credentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maximum_ttl_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600
|
||||
},
|
||||
"renewable_within_goal": {
|
||||
"const": true
|
||||
},
|
||||
"issuer_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"maximum_ttl_seconds",
|
||||
"renewable_within_goal",
|
||||
"issuer_operation_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"forbidden": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"delete_database",
|
||||
"format_storage",
|
||||
"destroy_unique_backup",
|
||||
"change_root_trust",
|
||||
"disable_audit"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 5,
|
||||
"maxItems": 5
|
||||
},
|
||||
"owner_required_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/owner_reason"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 4,
|
||||
"maxItems": 4
|
||||
},
|
||||
"execution_binding": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"contract_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"task_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"attempt_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"lease_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"capability_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"contract_sha256",
|
||||
"content_key",
|
||||
"target_environment",
|
||||
"task_id",
|
||||
"attempt_id",
|
||||
"lease_generation",
|
||||
"capability_receipt_ref",
|
||||
"expires_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"bundle_id",
|
||||
"bundle_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"status",
|
||||
"owner_intent_ref",
|
||||
"issuer_ref",
|
||||
"root_grant_ref",
|
||||
"authority_receipt_ref",
|
||||
"scope",
|
||||
"delegation",
|
||||
"validity",
|
||||
"credentials",
|
||||
"risk_budget",
|
||||
"forbidden",
|
||||
"owner_required_reasons",
|
||||
"execution_binding",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"root_grant_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/common.schema.json",
|
||||
"$comment": "Local identifiers only; no network retrieval. LIVE_CONFIG is a declaration, never proof of authorization.",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"oid": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 3
|
||||
},
|
||||
"digest_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ref",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"stage": {
|
||||
"enum": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"source_update",
|
||||
"build",
|
||||
"test",
|
||||
"staging",
|
||||
"backup",
|
||||
"governed_merge",
|
||||
"production_switch",
|
||||
"rollback",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_update",
|
||||
"acceptance",
|
||||
"task_create",
|
||||
"agent_transfer",
|
||||
"credential_derive",
|
||||
"lease_renew",
|
||||
"evidence_append"
|
||||
]
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"OWNER",
|
||||
"AGENT",
|
||||
"SYSTEM"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fixture": {
|
||||
"enum": [
|
||||
"FIXTURE",
|
||||
"LIVE_CONFIG"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"CREATED",
|
||||
"PREPARING",
|
||||
"RUNNING",
|
||||
"WAITING_ENVIRONMENT",
|
||||
"WAITING_CAPACITY",
|
||||
"WAITING_AUTHORITY",
|
||||
"WAITING_AGENT",
|
||||
"WAITING_OWNER",
|
||||
"RECONCILING",
|
||||
"RECOVERING",
|
||||
"VERIFYING",
|
||||
"FINALIZING",
|
||||
"ACCEPTED",
|
||||
"FAILED",
|
||||
"ROLLED_BACK",
|
||||
"CLOSED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"activity": {
|
||||
"enum": [
|
||||
"EXECUTING",
|
||||
"WAIT_AUTHORITY",
|
||||
"WAIT_ENVIRONMENT",
|
||||
"WAIT_AGENT",
|
||||
"WAIT_REVIEW",
|
||||
"WAIT_OWNER",
|
||||
"RECOVERY",
|
||||
"QUEUED",
|
||||
"UNKNOWN"
|
||||
]
|
||||
},
|
||||
"owner_reason": {
|
||||
"enum": [
|
||||
"GOAL_CHANGE",
|
||||
"TRUST_BOUNDARY_CHANGE",
|
||||
"IRREVERSIBLE_OPERATION",
|
||||
"BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/release-contract.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseContract"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"DRAFT",
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"policy_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"catalog_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"authority_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"governed_commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repository_id",
|
||||
"authority_ref",
|
||||
"branch",
|
||||
"commit_oid",
|
||||
"tree_oid",
|
||||
"governed_commit_oid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"build": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"definition_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"output_kind": {
|
||||
"const": "runtime"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"definition_ref",
|
||||
"output_kind"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"artifact": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"runtime",
|
||||
"source_archive"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"host_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"service_unit": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_root": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"bind_address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"config_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"credential_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"owner_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"next_owner_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"health_probe_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"component_id",
|
||||
"host_ref",
|
||||
"service_unit",
|
||||
"runtime_root",
|
||||
"bind_address",
|
||||
"port",
|
||||
"config_ref",
|
||||
"credential_refs",
|
||||
"owner_ref",
|
||||
"next_owner_ref",
|
||||
"health_probe_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseline_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"artifact_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"restore_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"restore_evidence_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"data_compatibility": {
|
||||
"enum": [
|
||||
"VERIFIED",
|
||||
"UNKNOWN",
|
||||
"INCOMPATIBLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baseline_ref",
|
||||
"version",
|
||||
"artifact_sha256",
|
||||
"restore_operation_ref",
|
||||
"restore_evidence_ref",
|
||||
"data_compatibility"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"operations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resolve_contract": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"preflight": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"build_test": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"evidence_closure": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"candidate_freeze": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authorization_check": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_switch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"verify": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseline_registry": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"close": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"probe_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"required_receipt_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"runtime_identity",
|
||||
"business_acceptance",
|
||||
"authorization",
|
||||
"backup",
|
||||
"rollback_readiness",
|
||||
"tag",
|
||||
"baseline",
|
||||
"source_lineage"
|
||||
]
|
||||
},
|
||||
"minItems": 8,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"probe_refs",
|
||||
"required_receipt_kinds"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"test_report_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"review_report_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"closure_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content_key",
|
||||
"test_report_ref",
|
||||
"review_report_refs",
|
||||
"closure_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"workload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_modified": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"base_tree_oid",
|
||||
"files_added",
|
||||
"files_modified",
|
||||
"files_deleted",
|
||||
"lines_added",
|
||||
"lines_deleted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"state",
|
||||
"target_environment",
|
||||
"policy_ref",
|
||||
"catalog_ref",
|
||||
"source",
|
||||
"build",
|
||||
"artifact",
|
||||
"components",
|
||||
"rollback",
|
||||
"operations",
|
||||
"risk_budget",
|
||||
"acceptance",
|
||||
"evidence",
|
||||
"workload",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": [
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
"tree_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"const": "FROZEN"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"governed_commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "runtime"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"test_report_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"closure_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"properties": {
|
||||
"restore_evidence_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"data_compatibility": {
|
||||
"const": "VERIFIED"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"schema": "aios.component-build.v1",
|
||||
"components": [
|
||||
{
|
||||
"component_id": "formal-agent-task",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api_lineage_supersession.py",
|
||||
"active-runtime/app/aios_task_parameterized_recovery.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"active-runtime/migrations/005_task_lineage_supersession.sql",
|
||||
"active-runtime/migrations/006_task_lineage_supersession_initial_projection.sql"
|
||||
],
|
||||
"entrypoint": "aios_agent_task_protocol:TaskStore",
|
||||
"dependency_contract_version": "aios.agent-task.capability-contract.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "bridge",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_chatgpt_bridge_v2_server.py",
|
||||
"active-runtime/app/agent_context_api.py",
|
||||
"active-runtime/app/agent_observability_api.py",
|
||||
"active-runtime/app/agent_event_api.py",
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"active-runtime/app/aios_gitops_model_api.py",
|
||||
"active-runtime/app/aios_model_api_staging.py",
|
||||
"active-runtime/app/aios_control_plane_runtime_status.py",
|
||||
"active-runtime/app/governed_unattended_release.py",
|
||||
"active-runtime/app/silent_release_execution.py",
|
||||
"active-runtime/app/release_standing_authority.py",
|
||||
"active-runtime/app/release_host_authority.py",
|
||||
"active-runtime/app/release_campaign_authorization.py",
|
||||
"active-runtime/app/owner_authorization_broker.py",
|
||||
"active-runtime/app/task_center_api.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/systemd/aios-chatgpt-bridge-v2-staging.service",
|
||||
"adapters/gitops/aios_gitops_model_api.py",
|
||||
"contracts/release/v1/common.schema.json",
|
||||
"contracts/release/v1/release-contract.schema.json",
|
||||
"contracts/release/v1/authorization-bundle.schema.json",
|
||||
"contracts/release/v1/release-run-event.schema.json",
|
||||
"deploy/aios-v2/release-policy.v1.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/event_recovery.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/styles.css"
|
||||
],
|
||||
"entrypoint": "aios_chatgpt_bridge_v2_server:main",
|
||||
"dependency_contract_version": "aios.chatgpt-gitops-bridge.v2",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18892,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [
|
||||
"gitea-governance-executor-token"
|
||||
],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "task-plane",
|
||||
"roots": [
|
||||
"active-runtime/app/task_plane/api.py",
|
||||
"active-runtime/app/task_plane/c6_composition.py",
|
||||
"active-runtime/app/task_plane/host_patch_executor.py",
|
||||
"active-runtime/app/task_plane/host_receipt_readers.py",
|
||||
"active-runtime/app/task_plane/authorization_composition.py",
|
||||
"active-runtime/app/task_plane/patch_contract.py",
|
||||
"active-runtime/app/task_plane/evidence_verifier.py",
|
||||
"active-runtime/app/task_plane/decision_policy.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"scripts/autonomous-release/c6_host_lab.py",
|
||||
"deploy/aios-v2/c6-host-lab.requirements.lock",
|
||||
"deploy/aios-v2/c6-host-lab.config.json",
|
||||
"deploy/aios-v2/c6-host-lab.service"
|
||||
],
|
||||
"entrypoint": "app.task_plane.api:run_server",
|
||||
"dependency_contract_version": "aios.task-plane.v3",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false,
|
||||
"aro": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "health",
|
||||
"roots": [
|
||||
"active-runtime/app/v2r2c2_health_service.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
||||
],
|
||||
"entrypoint": "v2r1c6_privacy_tools:main",
|
||||
"dependency_contract_version": "aios.component-manifest.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18928,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"runtime_verification": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.c6-host-lab-config.v1",
|
||||
"activation": "DIAGNOSTIC_ONLY",
|
||||
"deployable": false,
|
||||
"production_deployed": false,
|
||||
"service_type": "simple",
|
||||
"network_listeners": true,
|
||||
"listen_mode": "DYNAMIC_LOOPBACK_PORT",
|
||||
"allowed_lab_root_regex": "^/var/tmp/aios-v2r2c6-lab-[0-9]{8}([-A-Za-z0-9_.]+)?$",
|
||||
"required_safety_properties": [
|
||||
"NoNewPrivileges",
|
||||
"ProtectSystem",
|
||||
"ProtectHome",
|
||||
"ReadOnlyPaths",
|
||||
"ReadWritePaths"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Linux x86_64 CPython 3.12 isolated C6 validation environment; no production readiness claim.
|
||||
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
|
||||
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||
anyio==4.15.1 --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101
|
||||
attrs==26.1.0 --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
|
||||
cffi==2.1.1 --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf
|
||||
click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360
|
||||
cryptography==50.0.1 --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a
|
||||
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
|
||||
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
|
||||
iniconfig==2.3.0 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
jsonschema==4.26.0 --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
||||
jsonschema-specifications==2025.9.1 --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
pycparser==3.0 --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
pydantic==2.13.5 --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73
|
||||
pydantic_core==2.46.5 --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2
|
||||
Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
|
||||
pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
|
||||
referencing==0.37.0 --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231
|
||||
rpds-py==2026.6.3 --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6
|
||||
starlette==1.7.0 --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e
|
||||
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
|
||||
typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
uvicorn==0.54.0 --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=AIOS C6 isolated provisional artifact diagnostic
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Render this reference with scripts/autonomous-release/c6_host_lab.py. The
|
||||
# generated unit contains the pinned artifact, receipt, runner and contract paths.
|
||||
NoNewPrivileges=true
|
||||
# The authorized isolated root is under /var/tmp, so systemd PrivateTmp would
|
||||
# hide the only permitted runtime path from this service.
|
||||
PrivateTmp=false
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.1/32
|
||||
UMask=0077
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user