feat: consume account authority and refresh blocked dependencies
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-10-04 12:12:09 +02:00
parent 6c7c899d75
commit 4153ad7305
11 changed files with 498 additions and 25 deletions

View file

@ -35,7 +35,10 @@ or use `/me` as an account-provisioning side effect.
The follow-up [owner facts contract](owner-facts-contract.md) pins the re-reviewed The follow-up [owner facts contract](owner-facts-contract.md) pins the re-reviewed
sources and owner decisions needed for actual HTTP readers. `PlatformFacts` now sources and owner decisions needed for actual HTTP readers. `PlatformFacts` now
provides the typed, bounded Hub-side join with live revocation and evidence provides the typed, bounded Hub-side join with live revocation and evidence
binding; it intentionally has no fabricated network endpoints or default grants. binding. As of 2026-10-04, `UserEngineAccountReader` implements the actual
owner HTTP route and accepted custody observation contract. Tenant HTTP admission,
real Hub credentials and complete live composition remain open; see the updated
contract for the exact dependencies. No default grant is supplied.
## Audit Core adapter and sender admission ## Audit Core adapter and sender admission

View file

@ -1,9 +1,12 @@
# Platform authority facts: owner review contract # Platform authority facts: owner review contract
HUB-WP-0012 T01/T02, 2026-09-28. Proposed owner contract and implemented Hub-side HUB-WP-0012 T01/T02, reviewed 2026-10-04. Hub-side composition and User Engine
composition; **no admitted HTTP adapter or production authority source**. HTTP client are implemented; **no real Hub caller admission or complete live
account/tenant composition is claimed**. The current client contract and remaining
owner requirements are in [the October 4 update](#implemented-account-reader--2026-10-04);
the earlier sections preserve the initial review.
## Reviewed source ## Historical source review — 2026-09-28
- User Engine `b4cef6446bc10555b598ea74de1743bc9b03e7ba`: - User Engine `b4cef6446bc10555b598ea74de1743bc9b03e7ba`:
`service.py::me` creates user/account/tenant-account/identity records for unknown `service.py::me` creates user/account/tenant-account/identity records for unknown
@ -33,7 +36,7 @@ This closes the read-only **service primitive**, not the wire contract or admitt
Hub workload. USER-WP-0037 owns root-grant/mapping disposition and authenticated Hub workload. USER-WP-0037 owns root-grant/mapping disposition and authenticated
HTTP exposure/private acceptance. No database, live grant or credential changed. HTTP exposure/private acceptance. No database, live grant or credential changed.
## Contract for owner disposition ## Initial contract for owner disposition — 2026-09-28
| Requirement | Owning decision | | Requirement | Owning decision |
| --- | --- | | --- | --- |
@ -51,7 +54,7 @@ The Python types below are an internal normalization seam, **not a proposed
HTTP endpoint that already exists**. Missing/unknown/ambiguous results must raise HTTP endpoint that already exists**. Missing/unknown/ambiguous results must raise
and deny; they must never synthesize an active account. and deny; they must never synthesize an active account.
## Implemented composition ## Initial composition — 2026-09-28
`hub_core.security.facts.PlatformFacts` implements `FactSource` with injected `hub_core.security.facts.PlatformFacts` implements `FactSource` with injected
`AccountReader` and `TenantReader`. The account reader returns a typed `AccountReader` and `TenantReader`. The account reader returns a typed
@ -81,7 +84,7 @@ and mapping references. Readers/owners must retain the referenced evidence for
reconstruction; this hash is a join identifier, not a signature or independent reconstruction; this hash is a join identifier, not a signature or independent
proof of owner authenticity. No account payload or credential enters the join. proof of owner authenticity. No account payload or credential enters the join.
## Acceptance cases and remaining work ## Initial acceptance cases and remaining work — 2026-09-28
Local tests cover exact bindings, malformed state, stale/future observations, Local tests cover exact bindings, malformed state, stale/future observations,
oldest-timestamp preservation, bounded outages, repeated lookup, entitlement and oldest-timestamp preservation, bounded outages, repeated lookup, entitlement and
@ -93,3 +96,77 @@ real authenticated readers, and isolated owner-source integration tests proving
unknown lookups create nothing. Private acceptance must then demonstrate root unknown lookups create nothing. Private acceptance must then demonstrate root
allow, ordinary/forged identity denial, next-request revocation and owner outages allow, ordinary/forged identity denial, next-request revocation and owner outages
with real admitted callers. No public exposure follows from this contract. with real admitted callers. No public exposure follows from this contract.
## Implemented account reader — 2026-10-04
The historical missing-endpoint assessment above is superseded by User Engine
`a9360842f84fae89857c2b1a298efc64d079ee7b`, USER-WP-0037 and accepted
NetKingdom ADR-0017 revision 2 (`45a189a39167eef62709df0e857309f7ddc59029`).
`UserEngineAccountReader` implements the existing
`POST /api/v1/platform/account-authority/lookup` wire contract. Its independently
configured bearer maps to the fixed admitted workload at User Engine; it never
forwards the human's Hub token or invents root entitlement.
A host injects a `UserEngineAccountReader(base_url=..., token_file=Path(...),
client=...)` into `PlatformFacts(accounts=..., tenants=reviewed_tenant_reader,
account_tenant="platform", mapping_evidence="NK-ADR-0017:2")`. The HTTP client
must be lifecycle-owned, with `trust_env=False` and no ambient authorization or
cookies (as in the existing security composition). The token file is dedicated
to this User Engine caller, not the PDP/audit/browser credential. Nothing is
automatically enabled by environment variables or this documentation example.
The reviewed custody adapter and its real service integration fixture require
lookup tenant **`platform`**. This differs from User Engine's legacy operator
context `platform:root`; neither is silently normalized by the reader. The
canonical Hub actor/target remains `tenant:platform`. Before live enablement,
owners must confirm the real account has the intended scoped account and that
this exact mapping is admitted; a missing scoped account is inactive.
The response must bind issuer, subject and lookup tenant, identity/account/user
references, decision reference, known global/scoped status, and timezone-aware
observation time. `root_grant:null` means no entitlement. A non-null grant must
match exact issuer/subject, `scope_type:tenant`, `scope_id:platform`, relation
`platform-root-custodian`, owner `net-kingdom-custody`, source record
`nk-custody:platform-root`, positive integer revision and valid timestamps.
Unknown/expired/revoked or differently scoped observations fail closed.
Hub deliberately keeps its stricter five-second source age on every read/write,
even though the canon permits up to fifteen minutes elsewhere. The earliest
account/custody timestamp is retained, and the grant's absolute expiry travels
through AccountObservation and LiveFacts to the final post-audit check. The
reader trusts User Engine's authenticated observation; User Engine owns durable
custody revision/rollback validation. A content digest identifies retained
account/grant evidence but does not independently authenticate the publisher.
No producer aliases are supplied by this contract, so the result has none.
Every call rereads a bounded workload credential, uses non-local HTTPS and a
three-second total budget, limits the response to 64 KiB, rejects redirects,
compressed responses and nonzero Age headers, and exposes sanitized failures.
No caching or same-token fallback exists. User Engine's current environment-based
receiver credential rotation still needs an owner rollout/revocation receipt.
## Remaining dependencies — 2026-10-04
| Hub tasks | Owner and required artifact | What unblocks Hub |
| --- | --- | --- |
| 0012-T01 | net-kingdom, user-engine, tenant-engine, access-engine and platform surface owners: reviewed action/audience/tenant/enforcement matrix with disputed rows resolved | Signed-off source/profile mapping; executable per-owner acceptance cases |
| 0012-T02 | user-engine USER-WP-0037-T03 + railiance-platform/rapp-user-engine: actual Hub workload identity, dedicated credential delivery/rotation reference, TLS origin and scoped account mapping | Wire this implemented client to an admitted private endpoint and prove allow/deny/withdrawal/outage without owner payloads or credentials in evidence |
| 0012-T02 | tenant-engine + deployment owner: authenticated Hub caller binding for authoritative lifecycle read, canonical identifier/record version and observation time | Implement TenantReader against that admitted response; do not treat actor query text or roles/live as workload authentication |
| 0012-T02 | net-kingdom/KeyCape + custody owner: actual immutable root identity, approved nonempty custody grant where intended, Hub audience/redirect registration and attended AAL2/recovery/revocation receipts | Real positive root journey; the accepted empty custody source proves denial only |
| 0012-T03 | access-engine + platform owner: Hub protected-system policy, authenticated workload grant, pinned trusted public keys and credential delivery; audit-core + platform owner: Hub sender classes including hub.operation.committed, operational custody and rotation/failure receipts | Compose real PDP/audit dependencies; existing FLEX-WP-0024/0025 source completion does not admit Hub |
| 0012-T04 | rapp-core-hub plus T02/T03 owners: pinned private image/migration/composition, network boundary, explicit owner-bound messaging/event producer aliases and rollback | Execute real browser/API/MCP, direct-service bypass, revocation and audit-outage acceptance |
| 0012-T05 | ops-hub first, other inventoried extensions, activity-core/Repo Manager/inbox caller owners: per-audience identity/delegation/tenant contract and host enforcement | Root and workload allow plus anonymous/non-root/wrong-audience/tenant denial with separate attribution |
| 0012-T06 | platform/Railiance owners after T04/T05: attended per-surface management and audit receipts | M1 requires every inventory row, not a navigation link or broad provisioning decision |
| 0012-T07 | rapp-core-hub RAPPCOREHUB-WP-0002-T05 after M1: explicit public-enable authorization, TLS/bypass/rollback packet | Package and enable only the reviewed public release |
| 0012-T08 | identity/tenant/policy owners after M1: roles, delegated principals and stored tenant provenance | Phase 2 implementation can start without inferring tenant authority from legacy data |
These requirements update the existing workplans; they are not messages claiming
external owners have accepted new work. No control was relaxed or live credential
retrieved in this review. Workplans 0006/0011 independently retain A5/A4 cutover
and inbox source-feed/retention/reader-admission requirements.
Local verification: `make ci-check` covers normalization, transport refusal,
rotation, withdrawal and post-audit expiry. To exercise the actual owner route
and serializer with synthetic authority, run
`HUB_CORE_USER_ENGINE_SOURCE=/home/worsch/user-engine .venv/bin/python -m pytest -q tests/test_user_engine_owner_contract.py`.
The owner suite is explicit opt-in and does not contact a live endpoint.

View file

@ -0,0 +1,101 @@
"""User Engine account-authority HTTP reader; no credential issuance or fallback."""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
from datetime import datetime
from pathlib import Path
import httpx
from hub_core.security.facts import AccountObservation
from hub_core.security.identity import AccessFailure, require_https
from hub_core.security.policy import parse_json
STATUSES = {'invited', 'active', 'disabled', 'suspended', 'deletion_pending', 'deleted'}
GRANT = {'scope_type': 'tenant', 'scope_id': 'platform',
'relation': 'platform-root-custodian', 'owner_system': 'net-kingdom-custody'}
def timestamp(value):
if not isinstance(value, str):
raise ValueError('owner timestamp required')
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
if result.utcoffset() is None:
raise ValueError('owner timestamp timezone required')
return result.timestamp()
def normalize_account(body, *, issuer, subject, tenant, now):
if not isinstance(body, dict) or (body['issuer'], body['subject'], body['tenant']) != (issuer, subject, tenant):
raise ValueError('account binding mismatch')
for key in ('identity_id', 'user_id', 'account_id', 'decision_id'):
if not isinstance(body[key], str) or not body[key].strip():
raise ValueError('owner evidence missing')
if body['account_status'] not in STATUSES or body['tenant_account_status'] not in STATUSES | {None}:
raise ValueError('unknown account state')
observed = timestamp(body['observed_at'])
if not 0 <= now-observed <= 5:
raise ValueError('stale account observation')
# An explicit null is a valid no-grant result. Missing field is an old or
# incomplete authority contract, not permission to infer entitlement.
grant = body['root_grant']
deadline = None
if grant is not None:
if tenant != 'platform' or set(grant) != {'subject','grant','source','observed_at','expires_at'}:
raise ValueError('unsupported grant profile')
if grant['subject'] != {'issuer':issuer,'subject':subject} or grant['grant'] != GRANT:
raise ValueError('grant identity or scope mismatch')
source = grant['source']
if (set(source) != {'record_id','revision'} or source['record_id'] != 'nk-custody:platform-root'
or type(source['revision']) is not int or source['revision'] < 1):
raise ValueError('invalid custody provenance')
grant_time = timestamp(grant['observed_at'])
deadline = timestamp(grant['expires_at'])
if not 0 <= now-grant_time <= 5 or not now < deadline <= grant_time+900:
raise ValueError('stale custody observation')
observed = min(observed, grant_time)
evidence = {key: body[key] for key in ('issuer','subject','tenant','identity_id','user_id',
'account_id','decision_id','account_status','tenant_account_status','observed_at','root_grant')}
digest = hashlib.sha256(json.dumps(evidence,sort_keys=True,separators=(',',':')).encode()).hexdigest()
return AccountObservation(issuer,subject,tenant,
body['account_status'] == body['tenant_account_status'] == 'active',
grant is not None, observed, 'user-engine:sha256:'+digest, valid_until=deadline)
class UserEngineAccountReader:
def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient):
require_https(base_url)
if not isinstance(token_file,Path) or not token_file.is_absolute():
raise ValueError('absolute dedicated workload token path required')
self.base_url, self.token_file, self.client = base_url.rstrip('/'), token_file, client
async def read(self, *, issuer: str, subject: str, tenant: str) -> AccountObservation:
try:
async with asyncio.timeout(3):
with self.token_file.open('rb') as stream:
raw = stream.read(16385)
if len(raw) > 16384:
raise ValueError('oversized credential')
token = raw.decode('ascii').strip()
if not token or any(ord(c) < 33 or ord(c) > 126 for c in token):
raise ValueError('invalid credential')
async with self.client.stream('POST',self.base_url+'/api/v1/platform/account-authority/lookup',
json={'issuer':issuer,'subject':subject,'tenant':tenant},
headers={'Authorization':'Bearer '+token,'Cache-Control':'no-cache',
'Accept-Encoding':'identity'},timeout=3,follow_redirects=False) as response:
if response.status_code != 200 or response.headers.get('age','0') != '0':
raise ValueError('owner lookup refused or cached')
if response.headers.get('content-encoding','identity') != 'identity':
raise ValueError('encoded authority response')
data = bytearray()
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > 65536:
raise ValueError('oversized authority response')
return normalize_account(parse_json(bytes(data)),issuer=issuer,subject=subject,
tenant=tenant,now=time.time())
except Exception as exc:
raise AccessFailure(503,'account_authority_unavailable_or_untrusted') from exc

View file

@ -41,6 +41,7 @@ class LiveFacts:
checked_at: float checked_at: float
evidence_id: str evidence_id: str
producer_addresses: frozenset[str] = frozenset() producer_addresses: frozenset[str] = frozenset()
valid_until: float | None = None
def __post_init__(self): def __post_init__(self):
for value in (self.account_active, self.actor_tenant_active, for value in (self.account_active, self.actor_tenant_active,
@ -49,6 +50,9 @@ class LiveFacts:
raise ValueError("authoritative state must be boolean") raise ValueError("authoritative state must be boolean")
if type(self.checked_at) not in {int, float} or not math.isfinite(self.checked_at): if type(self.checked_at) not in {int, float} or not math.isfinite(self.checked_at):
raise ValueError("finite fact observation time required") raise ValueError("finite fact observation time required")
if self.valid_until is not None and (type(self.valid_until) not in {int,float}
or not math.isfinite(self.valid_until)):
raise ValueError("finite fact authority deadline required")
for value in (self.issuer, self.subject, self.actor_tenant, for value in (self.issuer, self.subject, self.actor_tenant,
self.target_tenant, self.evidence_id, *self.producer_addresses): self.target_tenant, self.evidence_id, *self.producer_addresses):
if not isinstance(value, str) or not value: if not isinstance(value, str) or not value:
@ -203,7 +207,7 @@ class AccessController:
self._check_decision_deadline(decision) self._check_decision_deadline(decision)
if actor.expires_at <= time.time(): if actor.expires_at <= time.time():
raise AccessFailure(401, "expired_access_token") raise AccessFailure(401, "expired_access_token")
if time.time() - facts.checked_at > 5: if time.time() - facts.checked_at > 5 or (facts.valid_until is not None and time.time() >= facts.valid_until):
self._observations["facts"] = ("unavailable", time.monotonic()) self._observations["facts"] = ("unavailable", time.monotonic())
raise AccessFailure(503, "facts_expired_during_authorization") raise AccessFailure(503, "facts_expired_during_authorization")
return replace(context, decision_id=decision.decision_id, return replace(context, decision_id=decision.decision_id,
@ -220,6 +224,8 @@ class AccessController:
actor.issuer, actor.subject, actor.tenant actor.issuer, actor.subject, actor.tenant
) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id: ) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id:
raise AccessFailure(503, "untrusted_or_stale_facts") raise AccessFailure(503, "untrusted_or_stale_facts")
if facts.valid_until is not None and time.time() >= facts.valid_until:
raise AccessFailure(503, "expired_owner_facts")
return facts return facts

View file

@ -28,6 +28,7 @@ class AccountObservation:
checked_at: float checked_at: float
evidence_id: str evidence_id: str
producer_addresses: frozenset[str] = frozenset() producer_addresses: frozenset[str] = frozenset()
valid_until: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -91,6 +92,10 @@ class PlatformFacts:
or not math.isfinite(observed.checked_at) or not math.isfinite(observed.checked_at)
or not 0 <= now-observed.checked_at <= 5): or not 0 <= now-observed.checked_at <= 5):
raise ValueError('stale owner observation') raise ValueError('stale owner observation')
if account.valid_until is not None and (
type(account.valid_until) not in {int,float}
or not math.isfinite(account.valid_until) or now >= account.valid_until):
raise ValueError('expired owner authority')
if not isinstance(account.producer_addresses,frozenset): if not isinstance(account.producer_addresses,frozenset):
raise ValueError('immutable producer bindings required') raise ValueError('immutable producer bindings required')
for address in account.producer_addresses: for address in account.producer_addresses:
@ -102,7 +107,7 @@ class PlatformFacts:
return LiveFacts(actor.issuer,actor.subject,actor.tenant,'tenant:platform', return LiveFacts(actor.issuer,actor.subject,actor.tenant,'tenant:platform',
account.account_active,tenant.active,tenant.active, account.account_active,tenant.active,tenant.active,
account.root_entitled,min(account.checked_at,tenant.checked_at), account.root_entitled,min(account.checked_at,tenant.checked_at),
evidence,account.producer_addresses) evidence,account.producer_addresses,account.valid_until)
except Exception as exc: except Exception as exc:
raise AccessFailure(503,'owner_facts_unavailable_or_untrusted') from exc raise AccessFailure(503,'owner_facts_unavailable_or_untrusted') from exc

View file

@ -0,0 +1,141 @@
import asyncio
import time
from datetime import datetime, timezone
from types import SimpleNamespace
import httpx
import pytest
from hub_core.security.account_reader import GRANT, UserEngineAccountReader, normalize_account
from hub_core.security.facts import PlatformFacts, TenantObservation
from hub_core.security.identity import AccessFailure
from hub_core.security import boundary
from test_access_boundary import Owners
def iso(value):
return datetime.fromtimestamp(value,timezone.utc).isoformat()
def body(now):
return dict(issuer='https://issuer.example',subject='immutable-root',tenant='platform',
identity_id='identity:1',user_id='user:1',account_id='account:1',decision_id='decision:1',
account_status='active',tenant_account_status='active',observed_at=iso(now),root_grant={
'subject':{'issuer':'https://issuer.example','subject':'immutable-root'},
'grant':dict(GRANT),'source':{'record_id':'nk-custody:platform-root','revision':1},
'observed_at':iso(now),'expires_at':iso(now+900)})
def normalize(value,now):
return normalize_account(value,issuer='https://issuer.example',subject='immutable-root',
tenant='platform',now=now)
def test_current_owner_shape_preserves_time_scope_and_no_grant():
now=float(int(time.time()))
value=body(now)
value['root_grant']['observed_at']=iso(now-2)
value['root_grant']['expires_at']=iso(now+10)
result=normalize(value,now)
assert result.root_entitled and result.account_active
assert result.checked_at == pytest.approx(now-2)
assert result.valid_until == pytest.approx(now+10)
assert not result.producer_addresses
value['root_grant']=None
withdrawn=normalize(value,now)
assert not withdrawn.root_entitled and withdrawn.valid_until is None
assert withdrawn.evidence_id != result.evidence_id
@pytest.mark.parametrize('status',['disabled','suspended','deleted','invited','deletion_pending',None])
def test_inactive_or_missing_scoped_account_never_active(status):
now=float(int(time.time())); value=body(now); value['tenant_account_status']=status
assert not normalize(value,now).account_active
@pytest.mark.parametrize('mutate',[
lambda b: b.update(subject='other'), lambda b: b.update(issuer='other'),
lambda b: b.update(tenant='other'), lambda b: b.update(decision_id=None),
lambda b: b.update(account_status=True), lambda b: b.update(tenant_account_status='unknown'),
lambda b: b.update(observed_at=iso(time.time()-6)),
lambda b: b.update(observed_at=iso(time.time()+6)),
lambda b: b.update(observed_at='2026-10-04T10:00:00'),
lambda b: b.pop('root_grant'),
lambda b: b['root_grant']['subject'].update(subject='other'),
lambda b: b['root_grant']['grant'].update(scope_id='other'),
lambda b: b['root_grant']['grant'].update(owner_system='user-engine'),
lambda b: b['root_grant']['source'].update(revision=True),
lambda b: b['root_grant']['source'].update(revision=0),
lambda b: b['root_grant']['source'].update(record_id='other'),
lambda b: b['root_grant'].update(observed_at=iso(time.time()-6)),
lambda b: b['root_grant'].update(expires_at=iso(time.time()-1)),
lambda b: b['root_grant'].update(expires_at=iso(time.time()+901)),
lambda b: b['root_grant'].update(revoked_at=iso(time.time())),
])
def test_mismatched_stale_or_incomplete_owner_facts_refuse(mutate):
now=float(int(time.time())); value=body(now); mutate(value)
with pytest.raises((ValueError,KeyError,TypeError)):
normalize(value,now)
def test_http_rotates_credential_and_rechecks_withdrawal(tmp_path):
token=tmp_path/'workload'; token.write_text('fixture-first')
seen=[]; value=body(time.time())
def respond(request):
import json
assert request.url.path == '/api/v1/platform/account-authority/lookup'
assert json.loads(request.content) == dict(issuer=value['issuer'],subject=value['subject'],tenant=value['tenant'])
seen.append(request.headers['authorization'])
return httpx.Response(200,json=value)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(respond),trust_env=False) as client:
reader=UserEngineAccountReader(base_url='https://owner.example',token_file=token,client=client)
args={key:value[key] for key in ('issuer','subject','tenant')}
assert (await reader.read(**args)).root_entitled
token.write_text('fixture-second'); value['root_grant']=None
assert not (await reader.read(**args)).root_entitled
token.unlink()
with pytest.raises(AccessFailure): await reader.read(**args)
asyncio.run(run())
assert seen == ['Bearer fixture-first','Bearer fixture-second']
@pytest.mark.parametrize('response',[
httpx.Response(302,headers={'Location':'https://other.example'}),
httpx.Response(403,text='private details'), httpx.Response(404), httpx.Response(503),
httpx.Response(200,content=b'x'*65537), httpx.Response(200,content=b'{"a":1,"a":2}'),
httpx.Response(200,json={},headers={'Age':'1'}),
])
def test_http_refuses_redirect_cache_outage_or_invalid_body(tmp_path,response):
token=tmp_path/'workload'; token.write_text('fixture')
calls=[]
def respond(request): calls.append(request); return response
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(respond),trust_env=False) as client:
reader=UserEngineAccountReader(base_url='https://owner.example',token_file=token,client=client)
with pytest.raises(AccessFailure,match='account_authority_unavailable_or_untrusted'):
await reader.read(issuer='iss',subject='sub',tenant='platform')
asyncio.run(run()); assert len(calls)==1
def test_grant_deadline_survives_join_and_blocks_after_audit(monkeypatch):
now=float(int(time.time())); value=body(now); value['root_grant']['expires_at']=iso(now+1)
account=normalize(value,now)
class Accounts:
async def read(self,**kwargs): return account
class Tenants:
async def read(self,**kwargs): return TenantObservation('tenant:platform',True,now,'tenant:1')
owners=Owners(); controller=owners.controller()
controller.facts=PlatformFacts(accounts=Accounts(),tenants=Tenants(),
account_tenant='platform',mapping_evidence='NK-ADR-0017:2')
clock=SimpleNamespace(time=lambda:now,monotonic=time.monotonic)
monkeypatch.setattr(boundary,'time',clock)
original=owners.append
async def append(record):
await original(record); clock.time=lambda: now+2
owners.append=append
async def run():
with pytest.raises(AccessFailure,match='facts_expired_during_authorization'):
await controller.authorize('verified-root','hub.read','/docs','correlation','digest')
assert controller.readiness_checks()['access_facts']=='unavailable'
asyncio.run(run())

View file

@ -0,0 +1,60 @@
"""Opt-in real User Engine HTTP source; all identities/grants are local fixtures."""
import asyncio
import os
from pathlib import Path
import sys
import time
import httpx
import pytest
source = os.getenv('HUB_CORE_USER_ENGINE_SOURCE')
if not source:
pytest.skip('set HUB_CORE_USER_ENGINE_SOURCE for owner-source contract tests',allow_module_level=True)
sys.path.insert(0,str(Path(source)/'src'))
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
from hub_core.security.account_reader import UserEngineAccountReader
from hub_core.security.identity import AccessFailure
from test_account_reader import body
@pytest.mark.parametrize('grant_present',[False,True])
def test_real_owner_route_serialization_and_unknown_identity(tmp_path,grant_present):
from dataclasses import replace
from user_engine.domain import PrincipalType
fixture=body(float(int(time.time())))
class Custody:
def observe(self,**kwargs): return fixture['root_grant'] if grant_present else None
class FixturePolicy(LocalAuthorizationCheckPort):
def check(self,request):
# Synthetic PDP receipt; standalone owner defaults have no decision ID.
return replace(super().check(request),decision_id='fixture-owner-decision')
store=InMemoryUserEngineStore(); store.migrate()
service=UserEngineService(store=store,identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=FixturePolicy(),custody=Custody())
claims=human_actor_claims()
claims.update(iss=fixture['issuer'],sub=fixture['subject'],tenant='platform')
session=service.me(claims)
caller=replace(session.actor,subject='fixture-hub-workload',principal_type=PrincipalType.SERVICE)
token='fixture-hub-token-at-least-24-characters'
app=PortalApplication(service,trusted_proxy_secret='fixture-proxy-secret-at-least-24-characters',
login_url='https://issuer.example/login',hub_authority_caller=caller,hub_authority_token=token)
credential=tmp_path/'token'; credential.write_text(token)
before=repr(store.__dict__)
with httpx.Client(transport=httpx.WSGITransport(app=app)) as owner:
def send(request):
response=owner.post(str(request.url),headers=request.headers,content=request.content)
return httpx.Response(response.status_code,headers=response.headers,content=response.content)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(send),trust_env=False) as client:
reader=UserEngineAccountReader(base_url='https://owner.example',token_file=credential,client=client)
result=await reader.read(issuer=fixture['issuer'],subject=fixture['subject'],tenant='platform')
assert result.account_active and result.root_entitled == grant_present
with pytest.raises(AccessFailure):
await reader.read(issuer=fixture['issuer'],subject='unknown',tenant='platform')
asyncio.run(run())
assert repr(store.__dict__)==before

View file

@ -15,6 +15,7 @@ from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.inbox_projection import create_inbox_projection_router from hub_core.runtime.inbox_projection import create_inbox_projection_router
from hub_core.security.boundary import iter_routes, route_key from hub_core.security.boundary import iter_routes, route_key
from hub_core.security.account_reader import UserEngineAccountReader
from hub_core.security.browser import BROWSER_ROUTES, create_browser_router from hub_core.security.browser import BROWSER_ROUTES, create_browser_router

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-08-22" created: "2026-08-22"
updated: "2026-08-22" updated: "2026-10-04"
parent_project: prj-state-hub-retirement parent_project: prj-state-hub-retirement
parent_workplan: STATE-WP-0079 parent_workplan: STATE-WP-0079
related: related:
@ -148,8 +148,8 @@ tests.
```task ```task
id: HUB-WP-0006-T06 id: HUB-WP-0006-T06
status: wait status: wait
depends_on: [HUB-WP-0006-T02, HUB-WP-0006-T03, HUB-WP-0006-T04, HUB-WP-0006-T05, STATE-WP-0079] depends_on: [STATE-WP-0079-T04]
blocking_reason: "Waits on T02-T05 and the State Hub cutover window under STATE-WP-0079." blocking_reason: "Publisher admission is complete; State Hub and selected A5/A4 consumer owners must supply parity cases, caller/alias mapping, a bounded switch window and rollback/traffic receipts."
priority: high priority: high
state_hub_task_id: "7c8225f7-03dc-57b0-a668-c3d781bd828a" state_hub_task_id: "7c8225f7-03dc-57b0-a668-c3d781bd828a"
``` ```
@ -207,3 +207,22 @@ gate closes.
- [x] Rebuildable durable ingestion passes provenance and failure checks - [x] Rebuildable durable ingestion passes provenance and failure checks
- [x] HTTP/MCP navigation reads only the derived projection - [x] HTTP/MCP navigation reads only the derived projection
- [ ] A5/A4 consumer comparison and rollback evidence recorded - [ ] A5/A4 consumer comparison and rollback evidence recorded
## Dependency review — 2026-10-04
RAPPCOREHUB-WP-0003-T04/T05 are done. The owner's retained receipt
`rapp-core-hub/docs/evidence/2026-10-03-private-publisher-admission.json`
records Helm revision 31, 123/123 publisher acceptance with zero errors and
`repo_manager_projection: ok`; rollback revision 30. This supersedes the older
credential, registry correction, placement and candidate-admission blockers
above. These are reviewed owner receipts, not a new live probe this session.
The remaining T06 gate is STATE-WP-0079-T04's A5/A4 consumer cutover, as confirmed
by `state-hub/docs/retirement-cutover-slice-plan.md`. State Hub must enumerate the
actual callers/legacy responses, alias expectations and comparison cases; selected
UI/client owners must provide a candidate read smoke and reversible switch plan.
State Hub retains authority for the cutover window, old-route fallback and metered
acceptance. Resume T06 when that comparison packet exists; use the deployed
projection to run it and attach parity/rollback receipts. Do not count publisher
readiness as a caller switch. All local implementation tasks remain done and this
workplan remains blocked on the external acceptance packet.

View file

@ -9,7 +9,7 @@ flavor: residual
owner: codex owner: codex
topic_slug: infotech topic_slug: infotech
created: "2026-09-05" created: "2026-09-05"
updated: "2026-09-27" updated: "2026-10-04"
origin: residual origin: residual
origin_ref: HUB-WP-0010 origin_ref: HUB-WP-0010
related: related:
@ -22,10 +22,11 @@ state_hub_workstream_id: "3c8034fc-fd90-58f5-99bc-99b4f93e5ee1"
```task ```task
id: HUB-WP-0011-T01 id: HUB-WP-0011-T01
status: todo status: wait
flavor: residual flavor: residual
priority: high priority: high
state_hub_task_id: "588e4b58-4694-513c-8e7a-1fab38fb6ce3" state_hub_task_id: "588e4b58-4694-513c-8e7a-1fab38fb6ce3"
blocking_reason: "State Hub must define a consistent source revision/cursor, read/archive changes, explicit deletions/retention and recovery semantics for the derived inbox; packaging owner must admit refresh/staleness operation."
``` ```
The deployed inbox reader is an explicitly labeled one-time snapshot pilot. The deployed inbox reader is an explicitly labeled one-time snapshot pilot.
@ -57,6 +58,8 @@ status: wait
flavor: residual flavor: residual
priority: high priority: high
state_hub_task_id: "35c5ae8b-f357-512f-af40-bc5915f3c02d" state_hub_task_id: "35c5ae8b-f357-512f-af40-bc5915f3c02d"
depends_on: [HUB-WP-0012-T02, HUB-WP-0012-T03, HUB-WP-0012-T05]
blocking_reason: "Selected reader owner must specify principal, recipient/broadcast/alias scope; identity/platform owners must deliver a dedicated workload grant and credential under the shared access contract."
``` ```
Replace pilot operator authentication with the reviewed caller-specific access Replace pilot operator authentication with the reviewed caller-specific access
@ -80,6 +83,8 @@ status: wait
flavor: residual flavor: residual
priority: high priority: high
state_hub_task_id: "61c727a5-f4b1-54c0-b634-7d71a5416496" state_hub_task_id: "61c727a5-f4b1-54c0-b634-7d71a5416496"
depends_on: [HUB-WP-0011-T01, HUB-WP-0011-T02]
blocking_reason: "Needs fresh parity and caller-admission receipts plus a State Hub/client-owner authorized reversible reader switch."
``` ```
After T01/T02 pass, produce live freshness and parity receipts for one State Hub After T01/T02 pass, produce live freshness and parity receipts for one State Hub
@ -87,3 +92,21 @@ inbox reader; verify rollback to the current State Hub endpoint. Then execute
that bounded reader switch with the concrete deployment authorization. Retire that bounded reader switch with the concrete deployment authorization. Retire
its compatibility dependency only after metered acceptance. Message-writer its compatibility dependency only after metered acceptance. Message-writer
cutover and all other route families remain separate STATE-WP-0079 work. cutover and all other route families remain separate STATE-WP-0079 work.
## Dependency review — 2026-10-04
T01 is now `wait`, not runnable `todo`. STATE-WP-0079's T08/T09 record confirms
State Hub remains the message writer; that ownership is no longer an unanswered
question. The unprovided contract is how its mutable inbox is replicated:
| Receiving task | Required owner artifact | Resume/acceptance condition |
| --- | --- | --- |
| T01 | state-hub: versioned consistent snapshot/change-feed format, monotonic revision or cursor, read/archive changes, deletion versus retention semantics, replay/compaction recovery and source timestamp | Implement atomic derived refresh only against that contract; prove replay, out-of-order refusal, deletion treatment and freshness loss without writing source messages |
| T01 | rapp-core-hub: deployment/refresh runner, admitted source-read identity, selected maximum lag and stale-source behavior | Test restart/recovery and publish bounded freshness evidence; historical pilot hash is not freshness |
| T02 | Selected reader owner + NetKingdom/access-engine/platform: exact workload identity/audience/actions, recipient and broadcast visibility, canonical/alias resolution and delivered credential reference | Prove allowed scope and denied other recipients without the pilot operator token |
| T03 | state-hub + selected client owner: named single reader, parity sample/window, rollback endpoint and switch authorization | Rehearse rollback, execute only that switch, then retain metered acceptance |
No source feed or revised retention contract was found in the reviewed source
handoff. The existing one-shot importer correctly refuses changed rows; no
unconditional overwrite or speculative tombstone policy was added. All three
tasks now wait on explicit inputs, so the workplan remains blocked.

View file

@ -10,7 +10,7 @@ owner: codex
topic_slug: infotech topic_slug: infotech
status_reason: "All open tasks require owner-reviewed contracts, credentials, or live infrastructure outside hub-core; no further source progress is possible from this repo alone." status_reason: "All open tasks require owner-reviewed contracts, credentials, or live infrastructure outside hub-core; no further source progress is possible from this repo alone."
created: "2026-09-28" created: "2026-09-28"
updated: "2026-10-03" updated: "2026-10-04"
related: related:
- HUB-WP-0009 - HUB-WP-0009
- HUB-WP-0011 - HUB-WP-0011
@ -58,7 +58,7 @@ status: wait
blocked_on: message-from:net-kingdom blocked_on: message-from:net-kingdom
priority: high priority: high
state_hub_task_id: "204f4fb0-e240-5558-8790-5985517b85e0" state_hub_task_id: "204f4fb0-e240-5558-8790-5985517b85e0"
blocking_reason: "Contract reviewers' (NetKingdom, user-engine, tenant-engine, flex-auth) sign-off on the inventory and disputed rows" blocking_reason: "NetKingdom, User/Tenant Engine, access-engine and platform owners must review current per-surface action/audience/tenant mappings and return explicit dispositions for unresolved inventory rows."
``` ```
Owner: hub-core; contract reviewers: NetKingdom, user-engine, tenant-engine, Owner: hub-core; contract reviewers: NetKingdom, user-engine, tenant-engine,
@ -89,11 +89,11 @@ platform-root login or enforcement test is claimed by inventory validation.
```task ```task
id: HUB-WP-0012-T02 id: HUB-WP-0012-T02
status: wait status: wait
depends_on: [HUB-WP-0012-T01] depends_on: [HUB-WP-0012-T01, USER-WP-0037-T03]
blocked_on: message-from:net-kingdom blocked_on: message-from:net-kingdom
priority: high priority: high
state_hub_task_id: "9a955fbf-f289-51b7-9682-bbe471694595" state_hub_task_id: "9a955fbf-f289-51b7-9682-bbe471694595"
blocking_reason: "NetKingdom/KeyCape live root login, AAL2 enrollment acceptance, and User/Tenant Engine authenticated HTTP readers (contract published in docs/owner-facts-contract.md, no owner endpoint admitted yet)" blocking_reason: "Account HTTP source and custody mapping now exist; needs admitted Hub workload transport, Tenant Engine authenticated lifecycle contract, real immutable root/custody grant and attended issuer/AAL2/private-journey receipts."
``` ```
Live admission depends on T01; independently testable source may proceed. Owners: NetKingdom/KeyCape, user-engine and tenant-engine; Live admission depends on T01; independently testable source may proceed. Owners: NetKingdom/KeyCape, user-engine and tenant-engine;
@ -118,10 +118,10 @@ step-up implementation is actually required.
id: HUB-WP-0012-T03 id: HUB-WP-0012-T03
status: wait status: wait
depends_on: [HUB-WP-0012-T01] depends_on: [HUB-WP-0012-T01]
blocked_on: message-from:flex-auth blocked_on: message-from:access-engine
priority: high priority: high
state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c" state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c"
blocking_reason: "flex-auth production PDP deployment and signing-key delivery from platform owners (audit-core-senders routing entry to ops-mason/OpenBao is unresolved; no Hub sender credential minted)" blocking_reason: "access-engine must admit Hub protected-system policy/caller and signed-decision trust; platform and audit-core must deliver separate Hub PDP/audit credentials, keys and durable receiver admission with failure/rotation receipts."
``` ```
Depends on T01; live acceptance also needs T02. flex-auth owns the protected Depends on T01; live acceptance also needs T02. flex-auth owns the protected
@ -147,7 +147,7 @@ status: wait
depends_on: [HUB-WP-0012-T02, HUB-WP-0012-T03] depends_on: [HUB-WP-0012-T02, HUB-WP-0012-T03]
priority: high priority: high
state_hub_task_id: "15bc3cae-4575-56c9-afd2-e1e348109ca9" state_hub_task_id: "15bc3cae-4575-56c9-afd2-e1e348109ca9"
blocking_reason: "T02/T03 live admission; private full-root browser/API/MCP journeys need a real root login and PDP, which do not exist yet" blocking_reason: "Local boundary/client source is implemented; T02/T03 admission plus rapp-core-hub private pinned composition are required for real browser/API/MCP, bypass, revocation and rollback acceptance."
``` ```
Live admission depends on T02/T03; the default-deny source seam may proceed. Add the reusable verified actor/tenant context and local Live admission depends on T02/T03; the default-deny source seam may proceed. Add the reusable verified actor/tenant context and local
@ -172,7 +172,7 @@ status: wait
depends_on: [HUB-WP-0012-T04] depends_on: [HUB-WP-0012-T04]
priority: high priority: high
state_hub_task_id: "6d29854e-e894-5340-9e13-8866e80246f4" state_hub_task_id: "6d29854e-e894-5340-9e13-8866e80246f4"
blocking_reason: "T04 live admission" blocking_reason: "T04 admission and ops-hub/other extension owners must supply authenticated host composition, per-audience caller grants and root/non-root/wrong-tenant acceptance receipts."
``` ```
Depends on T04. Publish the versioned extension security profile and harness. Depends on T04. Publish the versioned extension security profile and harness.
@ -221,7 +221,7 @@ another service. Any missing backend integration keeps M1 open.
```task ```task
id: HUB-WP-0012-T07 id: HUB-WP-0012-T07
status: wait status: wait
depends_on: [RAPPCOREHUB-WP-0002-T05] depends_on: [HUB-WP-0012-T06, RAPPCOREHUB-WP-0002-T05]
priority: high priority: high
state_hub_task_id: "0c15cb82-7c59-5a44-8bf0-857ea4438642" state_hub_task_id: "0c15cb82-7c59-5a44-8bf0-857ea4438642"
blocking_reason: "M1 acceptance; owner: rapp-core-hub via RAPPCOREHUB-WP-0002-T05" blocking_reason: "M1 acceptance; owner: rapp-core-hub via RAPPCOREHUB-WP-0002-T05"
@ -244,9 +244,11 @@ Ingress just to repair the old public stabilization check ahead of this gate.
```task ```task
id: HUB-WP-0012-T08 id: HUB-WP-0012-T08
status: todo status: wait
priority: medium priority: medium
state_hub_task_id: "59a0672c-560e-5b43-9d83-9f90d498698f" state_hub_task_id: "59a0672c-560e-5b43-9d83-9f90d498698f"
depends_on: [HUB-WP-0012-T06]
blocking_reason: "Phase 2 starts after M1; requires owner-agreed tenant provenance, delegated actor/workload model and tenant-admin permissions. It is not an M1 prerequisite."
``` ```
Deferred until M1; not an M1 dependency. Hub Core with NetKingdom, User/Tenant Deferred until M1; not an M1 dependency. Hub Core with NetKingdom, User/Tenant
@ -534,7 +536,7 @@ T03 tracks authenticated workload HTTP exposure and private acceptance. Hub's
without treating it as a connected HTTP adapter. T01/T02 here remain `progress`. without treating it as a connected HTTP adapter. T01/T02 here remain `progress`.
No Tenant Engine source, live credential/grant or deployment was changed. No Tenant Engine source, live credential/grant or deployment was changed.
## Blocked pending owner action — 2026-09-28 ## Historical blocked assessment — 2026-09-28 (superseded below)
Reviewed every remaining task against hub-core's own tree for further source-only Reviewed every remaining task against hub-core's own tree for further source-only
implementation. `hub_core/security/facts.py` already exposes a typed implementation. `hub_core/security/facts.py` already exposes a typed
@ -562,3 +564,38 @@ change.
Planning completion is not implementation completion. No live authenticated root Planning completion is not implementation completion. No live authenticated root
session or entitlement was tested in the 2026-09-28 review. session or entitlement was tested in the 2026-09-28 review.
## Blocked-task review and local implementation — 2026-10-04
The September assertion that no account HTTP contract exists was stale. Reviewed
User Engine `a9360842` (USER-WP-0037-T02 done; T03 waiting), accepted
NK-ADR-0017 revision 2 and NetKingdom `45a189a3`. User Engine now exposes
`POST /api/v1/platform/account-authority/lookup` and returns optional current
custody evidence. Its October 3 live acceptance established an empty no-grant
source, not an actual root grant or an admitted Hub caller.
Implemented `UserEngineAccountReader` against that exact route: distinct rotating
workload credential file, non-local HTTPS, bounded response/deadline, no redirects
or cached-response fallback, exact issuer/subject/account state binding, explicit
null-grant denial, and strict custody owner/scope/source/revision/time checks.
The owner's current lookup tenant is `platform`; its grant scope is `platform`,
while Hub's actor/target tenant remains `tenant:platform`. Host mapping remains
explicit. No inferred `platform:root` substitution, producer alias or token role
grants authority. Account and custody observation ages remain bounded to five
seconds; custody expiry survives the facts join and is rechecked after audit.
No live credential was read and no endpoint was enabled or deployed. The remaining
owner requirements and exact resume artifacts are in
[the updated owner contract](../docs/owner-facts-contract.md#remaining-dependencies--2026-10-04).
T01-T08 are all `wait` after completing this available local slice, and the
workplan stays `blocked`. In particular, T08 cannot appear runnable before M1.
Existing signing/fact-provenance source work FLEX-WP-0024/0025 is finished; the
T03 dependency is Hub-specific access-engine admission, not reimplementation of
those finished workplans. A broad credential decision alone does not satisfy T06.
Validation for this review: `make ci-check` passed **469 ordinary tests**
(three opt-in modules skipped), inventory checks, build/isolated-wheel checks
and **six disposable PostgreSQL tests**. The explicitly enabled User Engine
source integration passed **two tests** against its real WSGI route/serializer
with fixture credentials, custody and a synthetic owner policy receipt. These
are local source-contract tests, not admission or positive live-root evidence.