feat: atomically journal and deliver authorized native operation outcomes
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / pytest-smoke (push) Failing after 4s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 14:00:26 +02:00
parent 3c5cbfbafe
commit f0eff0ac92
13 changed files with 583 additions and 28 deletions

View file

@ -0,0 +1,31 @@
"""Transaction-linked committed outcome delivery.
Revision ID: 0006_outcome_outbox
Revises: 0005_message_identity_aliases
"""
from alembic import op
import sqlalchemy as sa
revision = '0006_outcome_outbox'
down_revision = '0005_message_identity_aliases'
branch_labels = depends_on = None
def upgrade():
op.create_table('runtime_outcome_outbox',
sa.Column('id', sa.String(36), sa.ForeignKey('runtime_audit_ledger.id'), primary_key=True),
sa.Column('envelope', sa.JSON(), nullable=False),
sa.Column('created_at', sa.Float(), nullable=False),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('next_attempt', sa.Float(), nullable=False),
sa.Column('delivered_at', sa.Float(), nullable=True))
op.create_index('ix_runtime_outcome_pending','runtime_outcome_outbox',['delivered_at','next_attempt'])
def downgrade():
pending = op.get_bind().execute(sa.text(
'SELECT id FROM runtime_outcome_outbox WHERE delivered_at IS NULL LIMIT 1'))
if pending is None or pending.first() is not None:
raise RuntimeError('outcome downgrade requires an online check and a drained outbox')
op.drop_index('ix_runtime_outcome_pending',table_name='runtime_outcome_outbox')
op.drop_table('runtime_outcome_outbox')

View file

@ -68,6 +68,9 @@ def create_app(
browser = BrowserSessions(settings=browser_settings, controller=access_controller) if browser_settings else None
resolved_store = port_store or _create_store(resolved_settings)
owns_store = port_store is None
outcome_delivery = (access_controller is not None
and callable(getattr(access_controller.audit, "append_outcome", None))
and callable(getattr(resolved_store, "deliver_outcomes", None)))
resolved_repo_projection_client = repo_projection_client
owns_repo_projection_client = False
if (
@ -111,9 +114,14 @@ def create_app(
await workload_projection.refresh()
except WorkloadProjectionRejected:
pass
outcome_task = asyncio.create_task(_deliver_outcomes(resolved_store, access_controller.audit)) if outcome_delivery else None
try:
yield
finally:
if outcome_task is not None:
outcome_task.cancel()
with suppress(asyncio.CancelledError):
await outcome_task
if browser is not None:
browser.clear()
if refresh_task is not None:
@ -166,6 +174,8 @@ def create_app(
}
if resolved_settings.enforce_access:
dependency_checks["access_profile"] = "ok" if access_controller else "unavailable"
if callable(getattr(resolved_store, "deliver_outcomes", None)):
dependency_checks["outcome_delivery"] = (await resolved_store.outcome_readiness()) if outcome_delivery else "unavailable"
ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
value in {"ok", "not_applicable"} for value in dependency_checks.values()
)
@ -191,6 +201,16 @@ def create_app(
return app
async def _deliver_outcomes(store, sink) -> None:
while True:
try:
await store.deliver_outcomes(sink)
except Exception:
# DB outages leave rows durable; readiness exposes missing/stale data.
pass
await asyncio.sleep(1)
async def _refresh_repository_projection(
service: RepositoryNavigationService,
interval_seconds: float,

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
import time
import hashlib
import json
from collections.abc import Mapping
@ -12,6 +14,7 @@ import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from hub_core.contracts import CONTRACT_VERSION
from hub_core.security.context import current_authorization
from hub_core.runtime.models import (
EventCommand,
MessageCommand,
@ -28,6 +31,7 @@ from hub_core.runtime.tables import (
compat_api_keys,
compat_hubs,
runtime_audit_ledger,
runtime_outcome_outbox,
runtime_interaction_events,
runtime_messages,
runtime_progress_events,
@ -62,6 +66,7 @@ class PostgresPortStore:
# tables that protected traffic actually depends on.
for table in (
runtime_audit_ledger,
runtime_outcome_outbox,
compat_hubs,
compat_api_keys,
runtime_repository_navigation_state,
@ -531,19 +536,85 @@ class PostgresPortStore:
correlation_id: UUID | None,
value: Mapping[str, Any],
) -> None:
ledger_id = str(uuid4())
recorded_at = _now()
context = current_authorization.get()
detail = {"schema_version": CONTRACT_VERSION}
if context is not None:
if not context.decision_id:
raise ValueError("verified decision required for outcome attribution")
detail["authorization"] = {
"correlation_id": context.correlation_id, "issuer": context.actor.issuer,
"subject": context.actor.subject, "principal_type": context.actor.principal_type,
"actor_tenant": context.actor.tenant, "target_tenant": context.facts.target_tenant,
"decision_id": context.decision_id, "policy_version": context.policy_version,
"policy_caller": context.policy_caller, "action": context.action,
"request_digest": context.request_digest,
}
await session.execute(
runtime_audit_ledger.insert().values(
id=str(uuid4()),
id=ledger_id,
action=action,
subject_type=subject_type,
subject_id=subject_id,
correlation_id=str(correlation_id) if correlation_id else None,
payload_hash=_hash(value),
detail={"schema_version": CONTRACT_VERSION},
recorded_at=_now(),
detail=detail,
recorded_at=recorded_at,
)
)
if context is not None:
envelope = {
"id": ledger_id, "type": "hub.operation.committed", "source": "hub-core",
"subject": "hub-outcome:" + ledger_id, "tenant": context.facts.target_tenant,
"correlation_id": context.correlation_id, "occurred_at": recorded_at.isoformat(),
"data": {"outcome": "committed", "operation": action,
"subject_type": subject_type, "subject_id": subject_id,
"business_correlation_id": str(correlation_id) if correlation_id else None,
"payload_hash": _hash(value), "authorization": detail["authorization"]},
}
await session.execute(runtime_outcome_outbox.insert().values(
id=ledger_id, envelope=envelope, created_at=time.time(), attempts=0, next_attempt=0))
async def deliver_outcomes(self, sink, *, limit: int = 25) -> int:
"""At-least-once delivery; PostgreSQL workers lock distinct pending rows.
Locks span one bounded receiver call. A crash/lost receipt leaves the same
immutable envelope eligible for replay, including its idempotency key.
"""
delivered = 0
for _ in range(limit):
async with self.sessions.begin() as session:
row = (await session.execute(sa.select(runtime_outcome_outbox).where(
runtime_outcome_outbox.c.delivered_at.is_(None),
runtime_outcome_outbox.c.next_attempt <= time.time(),
).order_by(runtime_outcome_outbox.c.created_at, runtime_outcome_outbox.c.id)
.limit(1).with_for_update(skip_locked=True))).mappings().first()
if row is None:
break
try:
async with asyncio.timeout(3):
await sink.append_outcome(row["envelope"])
except Exception:
values = {"attempts": row["attempts"] + 1,
"next_attempt": time.time() + min(60, 2 ** min(row["attempts"], 6))}
else:
values = {"attempts": row["attempts"] + 1, "delivered_at": time.time()}
delivered += 1
await session.execute(runtime_outcome_outbox.update().where(
runtime_outcome_outbox.c.id == row["id"]).values(**values))
return delivered
async def outcome_readiness(self) -> str:
try:
async with self.sessions() as session:
oldest = (await session.execute(sa.select(sa.func.min(runtime_outcome_outbox.c.created_at))
.where(runtime_outcome_outbox.c.delivered_at.is_(None)))).scalar_one()
return "ok" if oldest is None or time.time() - oldest <= 60 else "stale"
except Exception:
return "unavailable"
def _record(self, kind: str, value: dict[str, Any]) -> PortRecord:
record_id = str(value.get("id") or kind)
return PortRecord(

View file

@ -235,3 +235,15 @@ runtime_import_runs = sa.Table(
sa.Column("status", sa.String(40), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
runtime_outcome_outbox = sa.Table(
"runtime_outcome_outbox", runtime_metadata,
sa.Column("id", sa.String(36), sa.ForeignKey("runtime_audit_ledger.id"), primary_key=True),
sa.Column("envelope", sa.JSON(), nullable=False),
sa.Column("created_at", sa.Float(), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, default=0),
sa.Column("next_attempt", sa.Float(), nullable=False, default=0),
sa.Column("delivered_at", sa.Float(), nullable=True),
sa.Index("ix_runtime_outcome_pending", "delivered_at", "next_attempt"),
)

View file

@ -16,7 +16,7 @@ class AuditCoreSink:
"""No allow returns before durable remote custody acknowledges its record.
A lost receipt blocks execution, even if the archive already stored the
attempt. This is an authorization-attempt journal, not a mutation outbox.
attempt. Committed outcomes arrive separately from the transactional outbox.
"""
def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient):
@ -34,30 +34,32 @@ class AuditCoreSink:
raise ValueError("operational audit custody is required")
async def append(self, record: dict) -> None:
correlation, outcome = record.get("correlation_id"), record.get("outcome")
if not isinstance(correlation, str) or not correlation or outcome not in {"authorized", "denied", "refused"}:
raise AccessFailure(503, "audit_unavailable")
await self._deliver({
"id": str(uuid4()), "type": "hub.access." + outcome, "source": "hub-core",
"subject": "hub-access:" + correlation, "tenant": "tenant:platform",
"correlation_id": correlation, "occurred_at": datetime.now(timezone.utc).isoformat(),
"data": record,
})
async def append_outcome(self, event: dict) -> None:
# The transaction supplies the immutable ID, timestamp and full envelope.
if (event.get("type") != "hub.operation.committed" or event.get("source") != "hub-core"
or event.get("tenant") != "tenant:platform"
or not isinstance(event.get("id"), str) or not event["id"]
or set(event) != {"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data"}):
raise AccessFailure(503, "audit_unavailable")
await self._deliver(event)
async def _deliver(self, event: dict) -> None:
try:
async with asyncio.timeout(3):
# Probe each time, so a receiver's development fallback cannot
# be mistaken for admitted custody through a cached readiness.
await self.readiness()
token = self.token_file.read_text().strip()
if not token or not token.isascii() or any(c.isspace() for c in token):
raise ValueError("invalid sender credential")
correlation = record.get("correlation_id")
outcome = record.get("outcome")
if not isinstance(correlation, str) or not correlation or outcome not in {
"authorized", "denied", "refused",
}:
raise ValueError("invalid authorization audit record")
event = {
"id": str(uuid4()),
"type": "hub.access." + outcome,
"source": "hub-core",
"subject": "hub-access:" + correlation,
"tenant": "tenant:platform",
"correlation_id": correlation,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"data": record,
}
raw = json.dumps(event, ensure_ascii=False, allow_nan=False).encode()
if len(raw) > 256 * 1024:
raise ValueError("audit envelope exceeds receiver limit")

View file

@ -10,7 +10,7 @@ import hashlib
import json
import math
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from importlib.resources import files
from typing import Protocol
from uuid import uuid4
@ -20,6 +20,7 @@ from starlette.responses import JSONResponse
from starlette.routing import Match
from hub_core.security.identity import AccessFailure, Actor
from hub_core.security.context import current_authorization
PROFILE = "hub-core.access/1.0.0"
@ -59,6 +60,9 @@ class Authorization:
facts: LiveFacts
correlation_id: str
request_digest: str
decision_id: str = ""
policy_version: str = ""
policy_caller: str = ""
@dataclass(frozen=True)
@ -156,7 +160,8 @@ class AccessController:
raise AccessFailure(401, "expired_access_token")
if time.time() - facts.checked_at > 5:
raise AccessFailure(503, "facts_expired_during_authorization")
return context
return replace(context, decision_id=decision.decision_id,
policy_version=decision.policy_version, policy_caller=decision.caller)
def route_key(route, method: str) -> str:
@ -312,4 +317,8 @@ class AccessBoundary:
(b"x-correlation-id", correlation.encode())])
await send(message)
await self.app(scope, replay, protected_send)
binding = current_authorization.set(context)
try:
await self.app(scope, replay, protected_send)
finally:
current_authorization.reset(binding)

View file

@ -0,0 +1,8 @@
"""Request-scoped, verified authority for transaction attribution (never tokens)."""
from contextvars import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hub_core.security.boundary import Authorization
current_authorization: ContextVar['Authorization | None'] = ContextVar('hub_authorization', default=None)