feat(case): KERNEL-2/3 lane resolution and mode-aware admission; CATALOG-1 draft lanes

nk48 1.3 owner review (owner-review-ops-warden.json):
- KERNEL-2: Executor.admit parses catalog_source and resolves descriptor.lane;
  refuses a missing, non-active, undelegated or ungraded lane, an owner outside
  owner_repo, and a mint/change effect on a lane not graded risk high.
  catalog-routing-acceptance still covers the whole catalog digest.
- KERNEL-3: dispatch mode is read from the type's follow_through step
  (warden_executes/attended). The step must declare both booleans and a lane
  and agree with the descriptor's lane and owner. Attended: human actor,
  WARDEN_AGENT_ID unset, parent-session revocation scope, caller lane equal to
  the step lane. Owner: native delegation, owner_repo == descriptor.owner, no
  Warden-executed lane and no Warden-brokered mint.
- CATALOG-1: draft entries keycape-attended-case-change (OW-1.3-C1),
  platform-secret-delivery, consumer-owner-deployment, apps-consumer-handover.
  Not promoted; promotion waits for owner confirmation.
- Fixture catalogs and type steps in tests; published routing_lanes coverage
  and the workload-join count follow the four new not-applicable lanes.

PARAMETER_PINS untouched (PIN-1 waits for founder acknowledgment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
This commit is contained in:
tegwick 2026-10-17 12:08:49 +02:00
parent 117fec9241
commit a14376cf62
8 changed files with 363 additions and 13 deletions

View file

@ -98,7 +98,7 @@ on_apply:
# 2026-10-08: platform-supplier-credentials added as a not-applicable path pattern,
# so routing not_applicable rose 16 -> 17.
classification_coverage:
measured: "2026-10-10"
measured: "2026-10-17"
attribution: self-measured
signing_targets:
resolved: 0
@ -107,7 +107,7 @@ classification_coverage:
routing_lanes:
resolved: 4
unknown: 22
not_applicable: 18
not_applicable: 22
verdict_caching: none
input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py"

View file

@ -10,22 +10,24 @@
# declares it, and is null where the field set has not been established --
# null means unknown, never 'one field'.
generated_at: "2026-10-14T20:20:19Z"
generated_at: "2026-10-17T10:04:57Z"
source: ops-warden/registry/routing/catalog.yaml
catalog_revision: "402b008986c3a710c4f5b41bb1007a55c8d4363e"
catalog_revision_date: "2026-10-14T22:20:15+02:00"
catalog_dirty: false
high_risk_lane_count: 35
catalog_dirty: true
high_risk_lane_count: 38
concrete_path_count: 17
# Graded high but not a single KV address -- a routing pattern, a broker
# grant, or a non-KV lane. Nothing here for a policy to deny.
no_concrete_path:
- consumer-owner-deployment
- database-dynamic-credentials
- flex-auth-decision-envelope-signing-key
- fluid-telegram-operator
- informed-decision-sitting-requester-login
- inter-hub-bootstrap-ssh
- keycape-attended-case-change
- keycape-client-secret-generate
- net-kingdom-lldap-bind-credential
- net-kingdom-privacyidea-admin-token
@ -35,6 +37,7 @@ no_concrete_path:
- openbao-shamir-recovery-ceremony
- openbao-structure-survey
- ops-warden-warden-sign-token
- platform-secret-delivery
- platform-supplier-credentials
- secrets-engine-approval-client-login
- secrets-engine-requester-login

View file

@ -1604,3 +1604,89 @@ entries:
automatable: false
steps:
- "This lane covers first generation only and refuses an existing path (exit 3). Rotation is a separate railiance-platform task that does not exist yet; route rotation needs to railiance-platform."
# CATALOG-1 (nk48 1.3 owner review, 2026-10-17): four lanes for the 1.3
# SecurityCaseType oidc-browser-relying-party. Pointer only, draft. Each
# stays draft until its owner confirms the pointer
# (wiki/playbooks/catalog-lane-promotion.md); the kernel refuses draft lanes.
- id: keycape-attended-case-change
title: SecurityCase attended KeyCape change (ensure-groups, bind-env, register-client)
# OW-1.3-C1: dedicated attended change authority replacing key-cape-oidc-login
# for kernel-dispatched KeyCape registry, group and Deployment env changes.
# The session may be established by a key-cape OIDC login; that login lane
# stays the identity step, not the change authority.
risk: high
workload_ref:
applicability: not-applicable
reason: "Per-case attended change lane; the consuming service is a case parameter."
need_keywords: [keycape-attended-case-change, ensure-groups, bind-env, register-client, attended keycape change]
owner_repo: key-cape/net-kingdom
subsystem: key-cape registry and Deployment env, net-kingdom groups
warden_executes: false
wiki_ref: wiki/playbooks/catalog-lane-promotion.md
canon_ref: net-kingdom/security-case-types/oidc-browser-relying-party/type.yaml
reviewed: "2026-10-17"
status: draft
delegation:
mode: interim
intended_owner: key-cape
blocked_on: "key-cape and net-kingdom confirm the pointer and the re-frozen 1.3 descriptors (OW-1.3-C1)."
reviewed: "2026-10-17"
verified: source-read
- id: platform-secret-delivery
title: SecurityCase deliver-secret, KeyCape client secret delivered by Platform
risk: high
workload_ref:
applicability: not-applicable
reason: "Per-case delivery lane; each case's consuming service gets its own client entry."
need_keywords: [platform-secret-delivery, deliver-secret, keycape client secret delivery]
owner_repo: railiance-platform
subsystem: railiance-platform External Secrets delivery
warden_executes: false
wiki_ref: wiki/playbooks/catalog-lane-promotion.md
canon_ref: railiance-platform/AGENTS.md
reviewed: "2026-10-17"
status: draft
delegation:
mode: native
intended_owner: railiance-platform
reviewed: "2026-10-17"
- id: consumer-owner-deployment
title: SecurityCase deliver-consumer-secret, consumer deployment by its owner
# Descriptor actor is agent: ADR-0004 read boundary applies. Owner-side
# delivery only; no raw value reaches the agent session. If that cannot be
# shown, the actor changes to human and the railiance-apps digest changes.
risk: high
workload_ref:
applicability: not-applicable
reason: "Per-case consumer deployment; the consumer is a case parameter."
need_keywords: [consumer-owner-deployment, deliver-consumer-secret]
owner_repo: railiance-apps
subsystem: railiance-apps consumer deployment
warden_executes: false
wiki_ref: wiki/playbooks/catalog-lane-promotion.md
canon_ref: railiance-apps/AGENTS.md
reviewed: "2026-10-17"
status: draft
delegation:
mode: native
intended_owner: railiance-apps
reviewed: "2026-10-17"
- id: apps-consumer-handover
title: SecurityCase hand-over to the consuming service owner
risk: standard
workload_ref:
applicability: not-applicable
reason: "Per-case hand-over; the consumer owner is a case parameter."
need_keywords: [apps-consumer-handover, hand-over, consumer handover]
owner_repo: railiance-apps
subsystem: railiance-apps consumer hand-over
warden_executes: false
wiki_ref: wiki/playbooks/catalog-lane-promotion.md
canon_ref: railiance-apps/AGENTS.md
reviewed: "2026-10-17"
status: draft
delegation:
mode: native
intended_owner: railiance-apps
reviewed: "2026-10-17"

View file

@ -553,6 +553,67 @@ class Journal:
raise
def _catalog_lane(catalog_source: bytes, descriptor: Descriptor):
"""KERNEL-2: the descriptor's lane must be an active, fitting catalog entry.
catalog-routing-acceptance still covers the whole catalog digest; this
ties the one lane the descriptor names to an entry inside that digest.
"""
from .routing.catalog import CatalogError, _parse_entry
try:
raw = yaml.safe_load(catalog_source)
items = raw["entries"]
if not isinstance(items, list):
raise TypeError
entries = {}
for index, item in enumerate(items):
parsed = _parse_entry(item, index)
if parsed.id in entries:
raise ValueError
entries[parsed.id] = parsed
except (yaml.YAMLError, CatalogError, KeyError, TypeError, ValueError):
raise Refused("invalid catalog source shape") from None
entry = entries.get(descriptor.lane)
if entry is None:
raise Refused("descriptor lane is not in the catalog")
if not entry.is_active:
raise Refused("descriptor lane is not active in the catalog")
if entry.delegation is None:
raise Refused("descriptor lane has no catalog delegation record")
if not entry.is_graded:
raise Refused("descriptor lane carries no risk grade")
if descriptor.owner not in entry.owner_repo.split("/"):
raise Refused("descriptor owner does not fit catalog lane owner_repo")
if descriptor.effect in ("mint", "change") and entry.risk != "high":
raise Refused("catalog lane risk does not cover the descriptor effect")
return entry
def _step_mode(case_type: dict, case: dict, descriptor: Descriptor) -> str:
"""KERNEL-3: warden, attended or owner, from the type step's own text."""
steps = [
step
for step in case_type.get("follow_through") or []
if isinstance(step, dict) and step.get("id") == descriptor.step
]
step = steps[0] if len(steps) == 1 else {}
executes, attended = step.get("warden_executes"), step.get("attended")
if (
type(executes) is not bool
or type(attended) is not bool
or (executes and attended)
or not isinstance(step.get("lane"), str)
):
raise Refused("type step does not declare how the kernel dispatches it")
owner = step.get("owner")
if isinstance(owner, str) and re.fullmatch(r"\{[a-z_]+\}", owner):
owner = (case.get("parameters") or {}).get(owner[1:-1])
if step["lane"] != descriptor.lane or owner != descriptor.owner:
raise Refused("type step lane or owner disagrees with descriptor")
return "warden" if executes else "attended" if attended else "owner"
class Executor:
"""Injected fixed owner adapters and provenance verifier; none ship by default.
@ -650,6 +711,21 @@ class Executor:
actor != "human" or os.getenv("WARDEN_AGENT_ID")
):
raise Refused("agent mint is forbidden")
entry = _catalog_lane(catalog_source, descriptor)
mode = _step_mode(case_type, case, descriptor)
if mode == "attended" and (
actor != "human"
or os.getenv("WARDEN_AGENT_ID")
or "parent-session" not in descriptor.revocation_scopes
):
raise Refused("attended step requires a human parent session")
if mode == "owner" and (
entry.effective_delegation.mode != "native"
or entry.owner_repo != descriptor.owner
or entry.warden_executes
or descriptor.effect == "mint"
):
raise Refused("owner step lane is not the owner's native path")
parameters = case.get("parameters", {})
declared = {
item["name"]: item

View file

@ -0,0 +1,135 @@
"""KERNEL-2 lane resolution and KERNEL-3 mode-aware admission (fixture only)."""
from dataclasses import asdict, replace
import json
import pytest
import yaml
from warden.case_receipt_state import Refused
from warden.case_run import Descriptor, digest
from tests.test_case_run import MINT_STEP, fixture_catalog, lane_entry, make_rig
@pytest.fixture
def rig(tmp_path):
return make_rig(tmp_path)
EVIDENCE = {"acceptance", "materialization", "descriptor", "session"}
def admit(rig, *, step=None, entries=None, catalog=None, desc=None, **over):
executor, _, _, sources, kwargs = rig
case, typ, descriptor, _ = sources
if desc is not None:
base = Descriptor.decode(descriptor)
descriptor = json.dumps(asdict(replace(base, **desc))).encode()
doc = yaml.safe_load(typ)
doc["spec"]["execution_descriptors"] = {"mint-secret": digest(descriptor)}
if step is not None:
doc["spec"]["follow_through"] = step
typ = yaml.safe_dump(doc).encode()
if catalog is None:
catalog = fixture_catalog(*(entries or [lane_entry()]))
executor.evidence_verifier = lambda purpose, source, evidence: evidence in EVIDENCE
return executor.admit(case, typ, descriptor, catalog, **{**kwargs, **over})
def mode_step(executes, attended):
return [dict(MINT_STEP, warden_executes=executes, attended=attended)]
def refused(message, rig, **kw):
with pytest.raises(Refused, match=message):
admit(rig, **kw)
# Positive modes
def test_warden_mode_admits(rig):
assert admit(rig)[1].lane == "issuer-lane"
def test_attended_mode_admits_human_parent_session(rig, monkeypatch):
monkeypatch.delenv("WARDEN_AGENT_ID", raising=False)
admitted = admit(
rig,
step=mode_step(False, True),
desc=dict(effect="change", revocation_scopes=("parent-session",)),
)
assert admitted[1].actor == "human"
def test_owner_mode_admits_native_owner_lane(rig):
admitted = admit(rig, step=mode_step(False, False), desc=dict(effect="change"))
assert admitted[1].owner == "owner"
# KERNEL-2
@pytest.mark.parametrize(
"entries, catalog, message",
[
(None, b"not: [a catalog", "invalid catalog source shape"),
([lane_entry("other-lane")], None, "descriptor lane is not in the catalog"),
([lane_entry(status="draft")], None, "descriptor lane is not active in the catalog"),
([{k: v for k, v in lane_entry().items() if k != "delegation"}], None,
"descriptor lane has no catalog delegation record"),
([{k: v for k, v in lane_entry().items() if k != "risk"}], None,
"descriptor lane carries no risk grade"),
([lane_entry(owner="someone-else")], None,
"descriptor owner does not fit catalog lane owner_repo"),
([lane_entry(risk="standard")], None,
"catalog lane risk does not cover the descriptor effect"),
],
)
def test_catalog_lane_refusals(rig, entries, catalog, message):
refused(message, rig, entries=entries, catalog=catalog)
# KERNEL-3: the step must declare dispatch and agree with the descriptor
@pytest.mark.parametrize(
"step, message",
[
([], "type step does not declare how the kernel dispatches it"),
(mode_step(True, True), "type step does not declare how the kernel dispatches it"),
([{k: v for k, v in MINT_STEP.items() if k != "lane"}],
"type step does not declare how the kernel dispatches it"),
([dict(MINT_STEP, warden_executes="yes")],
"type step does not declare how the kernel dispatches it"),
([dict(MINT_STEP, lane="other-lane")], "type step lane or owner disagrees with descriptor"),
([dict(MINT_STEP, owner="other")], "type step lane or owner disagrees with descriptor"),
],
)
def test_step_declaration_refusals(rig, step, message):
refused(message, rig, step=step)
def test_attended_refuses_agent_session(rig, monkeypatch):
monkeypatch.setenv("WARDEN_AGENT_ID", "agent-1")
refused("attended step requires a human parent session", rig,
step=mode_step(False, True), desc=dict(effect="change"))
def test_attended_refuses_agent_actor(rig):
refused("attended step requires a human parent session", rig,
step=mode_step(False, True), desc=dict(effect="change", actor="agent"),
actor="agent")
def test_attended_refuses_caller_lane_other_than_step_lane(rig):
refused("principal actor or lane mismatch", rig,
step=mode_step(False, True), desc=dict(effect="change"), lane="login-lane")
@pytest.mark.parametrize(
"entry, desc",
[
(lane_entry(delegation={"mode": "interim", "intended_owner": "owner",
"blocked_on": "fixture", "reviewed": "2026-10-17"}),
dict(effect="change")),
(lane_entry(owner="owner/other"), dict(effect="change")),
(lane_entry(warden_executes=True), dict(effect="change")),
(lane_entry(), {}), # owner step may not be a Warden-brokered mint
],
)
def test_owner_mode_refusals(rig, entry, desc):
refused("owner step lane is not the owner's native path", rig,
step=mode_step(False, False), entries=[entry], desc=desc)

View file

@ -44,6 +44,9 @@ def browser(rig, version="1.1", mutate=None):
case["spec"]["parameters"]["consumer_namespace"] = "forgejo"
# This extension and all native provenance are TEST ONLY, never source assent.
typ["spec"]["execution_descriptors"] = {"mint-secret": digest(sources[2])}
for step in typ["spec"]["follow_through"]:
if step["id"] == "mint-secret": # TEST ONLY: route to the fixture lane
step.update(owner="owner", lane="issuer-lane")
if mutate:
mutate(case, typ)
changed = (

View file

@ -12,8 +12,47 @@ from warden.case_receipt_state import Outcome, Refused
from warden.case_run import Adapter, Descriptor, Executor, Journal, OwnerResult, digest
@pytest.fixture
def rig(tmp_path):
def lane_entry(lane="issuer-lane", owner="owner", **over):
"""Fixture catalog entry (TEST ONLY; never a real routing pointer)."""
entry = {
"id": lane,
"title": "fixture lane",
"need_keywords": [lane],
"owner_repo": owner,
"subsystem": "fixture",
"warden_executes": False,
"wiki_ref": "wiki/fixture.md",
"canon_ref": "fixture/canon.md",
"reviewed": "2026-10-17",
"status": "active",
"risk": "high",
"workload_ref": {"applicability": "not-applicable", "reason": "fixture"},
"delegation": {
"mode": "native",
"intended_owner": owner,
"reviewed": "2026-10-17",
},
}
entry.update(over)
return entry
def fixture_catalog(*entries):
return yaml.safe_dump({"entries": list(entries) or [lane_entry()]}).encode()
CATALOG = fixture_catalog()
MINT_STEP = {
"id": "mint-secret",
"owner": "owner",
"lane": "issuer-lane",
"warden_executes": True,
"attended": False,
}
def make_rig(tmp_path):
desc = Descriptor(
"warden.case.execution.v1",
"mint-secret",
@ -43,6 +82,7 @@ def rig(tmp_path):
}
],
"execution_descriptors": {"mint-secret": digest(descriptor)},
"follow_through": [dict(MINT_STEP)],
}
}
).encode()
@ -106,7 +146,7 @@ def rig(tmp_path):
("ops-mason-materialization", digest(case), "materialization"),
("owner-descriptor-acceptance:owner", digest(descriptor), "descriptor"),
("type-machine-seam-acceptance", digest(typ), "descriptor"),
("catalog-routing-acceptance", digest(b"catalog"), "descriptor"),
("catalog-routing-acceptance", digest(CATALOG), "descriptor"),
("caller-session:human:operator:issuer-lane", digest(case), "session"),
("session-revocation:issuer-lane", digest(case), "session-revoked"),
("grant-revocation:issuer-lane", digest(case), "grant-revoked"),
@ -136,8 +176,13 @@ def rig(tmp_path):
descriptor_evidence="descriptor",
session_evidence="session",
)
admitted = executor.admit(case, typ, descriptor, b"catalog", **kwargs)
return (executor, admitted, calls, (case, typ, descriptor, b"catalog"), kwargs)
admitted = executor.admit(case, typ, descriptor, CATALOG, **kwargs)
return (executor, admitted, calls, (case, typ, descriptor, CATALOG), kwargs)
@pytest.fixture
def rig(tmp_path):
return make_rig(tmp_path)
def test_commit_running_is_visible_from_separate_connection_before_dispatch(rig):
@ -387,7 +432,7 @@ def test_changed_catalog_pins_cannot_inherit_old_success(rig):
executor, args, calls, sources, kwargs = rig
executor.run(*args)
with pytest.raises(Refused):
executor.admit(*sources[:3], b"changed-catalog", **kwargs)
executor.admit(*sources[:3], fixture_catalog(lane_entry(title="changed")), **kwargs)
assert len(calls) == 1
@ -601,7 +646,7 @@ def test_attempt_identity_changes_with_exact_catalog_revision_and_new_journal(
executor, args, calls, sources, kwargs = rig
executor.run(*args)
original_attempt = calls[-1].attempt_id
new_catalog = b"independently-reviewed-catalog-v2"
new_catalog = fixture_catalog(lane_entry(title="independently reviewed v2"))
old_verifier = executor.evidence_verifier
def verifier(purpose, source, evidence):

View file

@ -28,7 +28,9 @@ def test_repo_catalog_uses_only_explicit_workload_references():
# names fluid-telegram, which has published no workload declaration.
# not_applicable 18 since 2026-10-10: keycape-client-secret-generate is a
# per-case generation lane; each case's consumer gets its own entry.
assert len(report["not_applicable"]) == 18
# not_applicable 22 since 2026-10-17: the four CATALOG-1 draft lanes
# (nk48 1.3 owner review) are per-case lanes like the one above.
assert len(report["not_applicable"]) == 22
assert {row["lane"] for row in report["resolved"]} == {
"ops-warden-warden-sign-token",
"issue-core-ingestion-api-key",