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

@ -210,3 +210,35 @@ def test_root_gate_retains_verifiable_decision_before_native_write(tmp_path):
asyncio.run(client.aclose())
native.close()
backend.close()
def test_owner_receiver_deduplicates_immutable_committed_outcome(tmp_path):
from uuid import uuid4
from datetime import datetime, timezone
backend = OperationalReceiptFixture(str(tmp_path/'outcome-owner.db'))
event_id = str(uuid4())
event = {'id':event_id,'type':'hub.operation.committed','source':'hub-core',
'subject':'hub-outcome:'+event_id,'tenant':'tenant:platform',
'correlation_id':str(uuid4()),'occurred_at':datetime.now(timezone.utc).isoformat(),
'data':{'outcome':'committed','operation':'message.accepted','payload_hash':'a'*64,
'authorization':{'subject':'fixture-root','decision_id':'decision:fixture'}}}
credential = tmp_path/'sender'
credential.write_text('fixture-only')
codes = []
try:
with httpx.Client(transport=httpx.WSGITransport(receiver(backend))) as owner:
def handle(request):
response = owner.request(request.method,str(request.url),content=request.content,headers=request.headers)
if request.method == 'POST':
codes.append(response.status_code)
return httpx.Response(response.status_code,content=response.content)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
sink = AuditCoreSink(base_url='https://audit.fixture',token_file=credential,client=client)
await sink.append_outcome(event)
await sink.append_outcome(event)
asyncio.run(run())
assert codes == [202,200]
assert backend.get(event_id)['details']['data'] == event['data']
finally:
backend.close()

258
tests/test_outcome_audit.py Normal file
View file

@ -0,0 +1,258 @@
import asyncio
import json
import time
from dataclasses import replace
from uuid import uuid4
import httpx
import pytest
import sqlalchemy as sa
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.postgres_store import PostgresPortStore
from hub_core.runtime.tables import runtime_metadata, runtime_messages, runtime_audit_ledger, runtime_outcome_outbox
from hub_core.security.audit import AuditCoreSink
from hub_core.security.context import current_authorization
from test_access_boundary import Owners, HEADERS
from test_access_audit import READY
@pytest.fixture
def target(tmp_path):
url = f'sqlite+aiosqlite:///{tmp_path / "outcomes.db"}'
store = PostgresPortStore.from_url(url)
async def schema():
async with store.engine.begin() as connection:
await connection.run_sync(runtime_metadata.create_all)
asyncio.run(schema())
owners = Owners()
async def facts(actor, resource):
return replace(owners.facts, checked_at=time.time(), subject=actor.subject)
owners.resolve = facts
app = create_app(settings=RuntimeSettings(environment='test',access_mode='enforce',backend='postgresql',database_url=url),
port_store=store,access_controller=owners.controller())
with TestClient(app,raise_server_exceptions=False) as client:
yield client, store, owners, url
asyncio.run(store.aclose())
def message():
return {'schema_version':'0.1.0','correlation_id':str(uuid4()),'from_address':'agent:root',
'to_addresses':['agent:reader'],'body':'private body never archived'}
def rows(store, table):
async def read():
async with store.sessions() as session:
return [dict(r) for r in (await session.execute(sa.select(table))).mappings()]
return asyncio.run(read())
def test_committed_outcome_is_atomic_and_joins_authorization(target):
client,store,owners,_ = target
body = message()
response = client.post('/ports/messaging/messages',headers=HEADERS,json=body)
assert response.status_code == 202
ledger, = rows(store,runtime_audit_ledger)
pending, = rows(store,runtime_outcome_outbox)
envelope = pending['envelope']
authorization = envelope['data']['authorization']
assert envelope['id'] == ledger['id']
assert envelope['correlation_id'] == owners.records[0]['correlation_id'] == response.headers['x-correlation-id']
assert authorization == ledger['detail']['authorization']
assert authorization['decision_id'] == owners.records[0]['decision_id']
assert authorization['subject'] == 'immutable-root'
assert envelope['data']['business_correlation_id'] == body['correlation_id']
assert envelope['data']['subject_id'] == response.json()['id']
assert 'private body' not in json.dumps(envelope)
assert 'verified-root' not in json.dumps(envelope)
assert current_authorization.get() is None
def test_outbox_insert_failure_rolls_back_business_and_ledger(target):
client,store,owners,_ = target
def reject(connection,cursor,statement,parameters,context,many):
if statement.startswith('INSERT INTO runtime_outcome_outbox'):
raise RuntimeError('simulated storage failure')
sa.event.listen(store.engine.sync_engine,'before_cursor_execute',reject)
try:
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 500
finally:
sa.event.remove(store.engine.sync_engine,'before_cursor_execute',reject)
assert rows(store,runtime_messages) == rows(store,runtime_audit_ledger) == rows(store,runtime_outcome_outbox) == []
assert owners.records[0]['outcome'] == 'authorized' # Never claims commit.
assert current_authorization.get() is None
def test_denial_and_invalid_body_never_queue_committed_outcome(target):
client,store,owners,_ = target
assert client.post('/ports/messaging/messages',headers=HEADERS,json={}).status_code == 422
owners.allow = False
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 403
assert not rows(store,runtime_outcome_outbox)
assert not rows(store,runtime_messages)
def test_lost_receipt_retry_reopen_and_duplicate_delivery(target,tmp_path):
client,store,owners,url = target
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 202
credential = tmp_path/'sender'
credential.write_text('fixture-only')
archived, requests = {}, []
def receiver(request):
if request.url.path == '/readyz':
return httpx.Response(200,json=READY)
envelope = json.loads(request.content)
requests.append(bytes(request.content))
assert request.headers['idempotency-key'] == envelope['id']
if envelope['id'] not in archived:
archived[envelope['id']] = envelope
raise httpx.ReadTimeout('lost receipt after custody')
assert archived[envelope['id']] == envelope
return httpx.Response(200,json={'status':'duplicate','reference':'audit:'+envelope['id']})
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(receiver)) as http:
sink = AuditCoreSink(base_url='https://audit.example',token_file=credential,client=http)
assert await store.deliver_outcomes(sink) == 0
assert await store.deliver_outcomes(sink) == 0 # Backoff, no immediate retry.
assert len(requests) == 1
await store.aclose()
reopened = PostgresPortStore.from_url(url)
try:
async with reopened.sessions.begin() as session:
await session.execute(runtime_outcome_outbox.update().values(next_attempt=0))
assert await reopened.deliver_outcomes(sink) == 1
assert await reopened.deliver_outcomes(sink) == 0
finally:
await reopened.aclose()
asyncio.run(run())
assert len(archived) == 1 and len(requests) == 2 and requests[0] == requests[1]
pending, = rows(store,runtime_outcome_outbox)
assert pending['attempts'] == 2 and pending['delivered_at'] is not None
def test_cancellation_after_receipt_leaves_replayable_row(target):
client,store,_,_ = target
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 202
class Crash:
async def append_outcome(self,event):
raise asyncio.CancelledError()
async def run():
with pytest.raises(asyncio.CancelledError):
await store.deliver_outcomes(Crash())
asyncio.run(run())
pending, = rows(store,runtime_outcome_outbox)
assert pending['delivered_at'] is None and pending['attempts'] == 0
def test_stale_backlog_is_visible_and_clears_after_receipt(target):
client,store,_,_ = target
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 202
class Receipt:
async def append_outcome(self,event):
pass
async def run():
assert await store.outcome_readiness() == 'ok'
async with store.sessions.begin() as session:
await session.execute(runtime_outcome_outbox.update().values(created_at=time.time()-61))
assert await store.outcome_readiness() == 'stale'
await store.deliver_outcomes(Receipt())
assert await store.outcome_readiness() == 'ok'
asyncio.run(run())
def test_all_native_mutation_families_keep_attributed_outcomes(target):
from hub_core.conformance import ConformanceHarness
client,store,owners,_ = target
owners.facts = replace(owners.facts,producer_addresses=frozenset({'hub:ops-hub'}))
# Composition is frozen when the app is created; run the twelve business
# checks except dependency readiness, which separately reports no dispatcher.
harness = ConformanceHarness(client)
client.headers.update(HEADERS)
report = harness.run()
assert all(c.status == 'pass' for c in report.checks if c.check_id != 'C9'), report.to_dict()
assert next(c for c in report.checks if c.check_id == 'C9').status == 'fail'
assert client.get('/readyz').json()['checks']['outcome_delivery'] == 'unavailable'
pending = rows(store,runtime_outcome_outbox)
operations = {r['envelope']['data']['operation'] for r in pending}
assert {'message.accepted','event.progress.accepted','event.interaction.accepted'} <= operations
assert any(op.startswith('registry.') for op in operations)
assert all(r['envelope']['data']['authorization']['subject'] == 'immutable-root' for r in pending)
assert len(pending) == len(rows(store,runtime_audit_ledger))
def test_concurrent_requests_keep_separate_authorization_contexts(target):
from hub_core.security.identity import AccessFailure
client,store,owners,_ = target
async def authenticate(token):
if token not in {'workload:a','workload:b'}:
raise AccessFailure(401,'bad_fixture')
await asyncio.sleep(0)
return replace(owners.actor,subject=token,principal_type='service')
owners.authenticate = authenticate
async def run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=client.app),base_url='https://hub.example') as http:
async def send(subject):
result = await http.post('/ports/messaging/messages',headers={'Authorization':'Bearer '+subject},json=message())
assert result.status_code == 202
return result.json()['id'], subject
return dict(await asyncio.gather(send('workload:a'),send('workload:b')))
subjects = asyncio.run(run())
for row in rows(store,runtime_outcome_outbox):
data = row['envelope']['data']
assert data['authorization']['subject'] == subjects[data['subject_id']]
assert current_authorization.get() is None
def test_outcome_migration_creates_index_and_foreign_key(tmp_path):
import importlib
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('hub_core.migrations.versions.0006_outcome_outbox')
engine = sa.create_engine('sqlite:///' + str(tmp_path/'migration.db'))
try:
runtime_audit_ledger.create(engine)
with engine.begin() as connection:
with Operations.context(MigrationContext.configure(connection)):
migration.upgrade()
inspect = sa.inspect(connection)
assert {x['name'] for x in inspect.get_columns('runtime_outcome_outbox')} == set(runtime_outcome_outbox.c.keys())
assert inspect.get_foreign_keys('runtime_outcome_outbox')[0]['referred_table'] == 'runtime_audit_ledger'
assert inspect.get_indexes('runtime_outcome_outbox')[0]['name'] == 'ix_runtime_outcome_pending'
connection.execute(runtime_outcome_outbox.insert().values(id='fixture',envelope={},created_at=0,attempts=0,next_attempt=0))
with Operations.context(MigrationContext.configure(connection)):
with pytest.raises(RuntimeError,match='drained outbox'):
migration.downgrade()
connection.execute(runtime_outcome_outbox.delete())
with Operations.context(MigrationContext.configure(connection)):
migration.downgrade()
assert 'runtime_outcome_outbox' not in sa.inspect(connection).get_table_names()
finally:
engine.dispose()
def test_runtime_dispatcher_drains_pending_outcome_and_stops(target):
from hub_core.runtime.app import _deliver_outcomes
client,store,_,_ = target
assert client.post('/ports/messaging/messages',headers=HEADERS,json=message()).status_code == 202
async def run():
class Receipt:
async def append_outcome(self,envelope):
pass
task = asyncio.create_task(_deliver_outcomes(store,Receipt()))
try:
async with asyncio.timeout(3):
while True:
async with store.sessions() as session:
count = (await session.execute(sa.select(sa.func.count()).select_from(runtime_outcome_outbox)
.where(runtime_outcome_outbox.c.delivered_at.is_not(None)))).scalar_one()
if count == 1:
break
await asyncio.sleep(.01)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(run())