diff --git a/SCOPE.md b/SCOPE.md index 29e2780..6cc4fe8 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -123,15 +123,15 @@ Gap analysis: `history/2026-07-01-intent-scope-gap-analysis.md` (current); | NetKingdom evolution reflected in docs | Met | | Non-SSH secrets stay out of ops-warden | Met | | Workload posture / maturity model for secret-flow blockers | Met — two-axis standard + descriptors + conformance checker + dev doubles (WP-0015) | -| Every execution position explicitly permanent or interim with a named owner | **Not met** — 2 of 24 lanes carry `exec_owner`; 11 proxy lanes record no intended owner (WP-0030) | +| Every execution position explicitly permanent or interim with a named owner | **Met** — every catalog entry carries `delegation:`; `warden route gaps` lists the interim set (WP-0030) | -**Maturity vector:** `D5 / A5 / C4 / R4` (Discovery / Availability / Completeness / Reliability) +**Maturity vector:** `D5 / A5 / C5 / R4` (Discovery / Availability / Completeness / Reliability) | Dimension | Level | Meaning today | | --- | --- | --- | | D5 | Discovery | Routing wiki + security map + pointer catalog + NK canon cross-links | | A5 | Availability | CLI + `warden route` + `warden access` advisory & proxy front door + `warden policy` + opt-in policy gate + agent `--json` | -| C4 | Completeness | All ops-warden lanes shipped — SSH (prod), routing, access assist, posture conformance, cert_command pilot gate, disclosure hygiene, tenant custody, policy front door. **Down from C5:** delegation is unrecorded on 11 of 24 lanes, so ops-warden's interim positions are not distinguishable from designed ones (WP-0030) | +| C5 | Completeness | All ops-warden lanes shipped — SSH (prod), routing, access assist, posture conformance, cert_command pilot gate, disclosure hygiene, tenant custody, policy front door, delegation register (WP-0030) | | R4 | Reliability | Live OpenBao sign + credential-broker policy-gate smoke evidence on Railiance (2026-07-01) | --- @@ -350,16 +350,16 @@ Remaining production distance is also in other repos' lanes (see Known gaps). - **Policy front door:** WP-0029 shipped — `warden plan ""` (autonomous / founder_required / unroutable), `warden desk` founder interaction surface, declared `organization_posture: build` as a third posture axis, catalog freshness reporting -- **Delegation:** 24 catalog lanes — 1 executed by ops-warden (SSH), 2 delegated via - `exec_owner`, 11 proxied with **no intended owner recorded**, 10 route-only. Addressed - by WP-0030 (proposed) -- **Active work:** WP-0027 (`backlog`), WP-0030 (`proposed`); remaining production - distance is other repos' lanes +- **Delegation:** 27 catalog lanes carry `delegation:` (WP-0030). SSH is + `permanent`; owner-fronted lanes are `native`; interim proxies name + `intended_owner` + `blocked_on`. Query: `warden route gaps`. +- **Active work:** WP-0027 (`backlog`); remaining production distance is other + repos' lanes (and retiring interim covers as those owners ship front doors) - **Integration docs:** cert_command migration, token hygiene (broker-first), principals drift (`wiki/playbooks/`) - **Latest assessment:** `history/2026-08-11-delegation-surface-assessment.md` - **Latest workplans:** WP-0029 (policy front door) shipped July 2026; WP-0030 - (delegation register) proposed 2026-08-11 + (delegation register) shipped August 2026 --- diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 30083d2..5c19c72 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -27,6 +27,9 @@ # status active (surfaced by default) | draft (hidden unless --all) # steps ONLY when warden_executes: true # cert_command ONLY when warden_executes: true +# delegation WP-0030 register. mode: native | interim | permanent. +# intended_owner required unless permanent; blocked_on required +# when interim. Absence is implicit interim with unknown owner. version: 1 @@ -41,6 +44,9 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md#operational-ssh-path reviewed: "2026-06-18" status: active + delegation: + mode: permanent + reviewed: "2026-08-15" cert_command: "warden sign --pubkey " steps: - "Confirm the actor is in inventory (`warden inventory list`); add with `warden inventory add` if not — see wiki/ActorInventoryPatterns.md." @@ -58,6 +64,10 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-07-01" status: active + delegation: + mode: native + intended_owner: railiance-platform + reviewed: "2026-08-15" # Concrete broker lane — RAILIANCE-WP-0005 pilot (live 2026-07-01): # credential exec injects VAULT_TOKEN only into the child process; ops-warden # issues SSH certs and never mints or holds OpenBao tokens. @@ -87,6 +97,11 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-06-27" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # Structured handoff (WP-0014) — reference example. Templates only, no values. # ops-warden does not own this secret; it advises and (exec_capable) proxies the # fetch *as the caller* via `warden access`, never holding or persisting the value. @@ -115,6 +130,10 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-06-29" status: active + delegation: + mode: native + intended_owner: secrets-engine + reviewed: "2026-08-15" # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0001 (commit 8f617fc): # status=active, access_frontdoor.readiness=ready, resolvable=true; positive fetch # passed and negative (non-whynot) login denied. Zero-placeholder fetch: an automated @@ -152,6 +171,10 @@ entries: canon_ref: net-kingdom/docs/responsibility-map.md reviewed: "2026-06-18" status: active + delegation: + mode: native + intended_owner: flex-auth + reviewed: "2026-08-15" - id: key-cape-oidc-login title: Interactive login, OIDC token, or MFA @@ -163,6 +186,11 @@ entries: canon_ref: net-kingdom/docs/canon/standards/iam-profile_v0.2.md reviewed: "2026-06-27" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # Login lane (WP-0014 T4) — interactive auth bootstrap, not a secret read. No # secret-read gate (you have no identity yet) and no caller-auth precheck (the # point is to obtain one). warden runs it interactively as the caller and never @@ -182,6 +210,10 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md#operational-ssh-path reviewed: "2026-06-24" status: active + delegation: + mode: native + intended_owner: ops-bridge + reviewed: "2026-08-15" - id: railiance-infra-principals title: Host SSH principal file or force-command deployment @@ -193,6 +225,10 @@ entries: canon_ref: net-kingdom/docs/responsibility-map.md reviewed: "2026-06-18" status: active + delegation: + mode: native + intended_owner: railiance-infra + reviewed: "2026-08-15" - id: inter-hub-bootstrap-ssh title: Inter-Hub bootstrap SSH envelope @@ -204,6 +240,10 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md#operational-ssh-path reviewed: "2026-06-24" status: active + delegation: + mode: native + intended_owner: railiance-infra + reviewed: "2026-08-15" - id: activity-core-issue-sink title: activity-core IssueSink → issue-core REST emission @@ -215,6 +255,10 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-06-18" status: active + delegation: + mode: native + intended_owner: activity-core + reviewed: "2026-08-15" - id: issue-core-ingestion-api-key title: issue-core ingestion API key (OpenBao KV + ESO) @@ -226,6 +270,11 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-07-02" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0002 / RAILIANCE-WP-0009 # (promoted 2026-07-02): policy workload-kv-read-issue-core-runtime and k8s auth role # external-secrets-issue-core applied; ExternalSecret issue-core/issue-core-runtime @@ -258,6 +307,11 @@ entries: canon_ref: reuse-surface/specs/FederationHubAPI.md reviewed: "2026-07-07" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0005 / RAILIANCE-WP-0011 # (promoted 2026-07-07): policy workload-kv-read-reuse-surface-runtime; ExternalSecret # reuse/reuse-surface-runtime SecretSynced to reuse-surface-env on Railiance01; @@ -290,6 +344,11 @@ entries: canon_ref: net-kingdom/docs/platform-identity-security-architecture.md reviewed: "2026-07-02" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04). risk: high # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010 @@ -325,6 +384,11 @@ entries: canon_ref: railiance-platform/docs/workload-kv-access-lanes.md reviewed: "2026-07-16" status: active + delegation: + mode: interim + intended_owner: railiance-platform + blocked_on: "Rotation is re-establish, a multi-step procedure ops-warden only describes" + reviewed: "2026-08-11" # High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04). risk: high # CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07. @@ -365,6 +429,11 @@ entries: canon_ref: railiance-platform/docs/workload-kv-access-lanes.md reviewed: "2026-07-13" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-11" # High-risk: site-admin PAT (WP-0026 T04). risk: high # CCR-2026-0006: approved by platform-operator 2026-07-12; policy @@ -402,6 +471,11 @@ entries: canon_ref: railiance-platform/docs/workload-kv-access-lanes.md reviewed: "2026-07-17" status: active + delegation: + mode: interim + intended_owner: tenant-engine + blocked_on: "Custody at tenants/binky/... but rotation owner is binky-control — split lifecycle, no front door reconciling it" + reviewed: "2026-08-11" risk: high # CCR-2026-0007: tenants/ mount + policy + OIDC role applied; founder provisioned # values via UI (version ≥2, not placeholder). Capabilities-safe verify 2026-07-17: @@ -431,6 +505,11 @@ entries: canon_ref: binky-control/integrations/qonto-mcp.md reviewed: "2026-07-21" status: active + delegation: + mode: interim + intended_owner: tenant-engine + blocked_on: "Same split lifecycle as binky-company-email-imap; no tenant-engine front door" + reviewed: "2026-08-11" risk: high # CCR-2026-0008: policy + OIDC role applied; secret at tenants/binky/qonto-api # (fields API_KEY, API_USER). Map to QONTO_API_KEY / QONTO_ORGANIZATION_ID for @@ -461,6 +540,11 @@ entries: canon_ref: key-cape/docs/qonto-runtime-identity-contract.md reviewed: "2026-07-27" status: active + delegation: + mode: interim + intended_owner: key-cape + blocked_on: "client_secret_basic exchange is a key-cape protocol procedure, not a KV read; rotation already automatable" + reviewed: "2026-08-11" risk: high auth_method: "OpenBao platform workload lane; KeyCape client_secret_basic exchange" path_template: "platform/workloads/rapp-qonto/keycape-client" @@ -486,6 +570,11 @@ entries: canon_ref: binky-control/integrations/executor-worker-secrets.md reviewed: "2026-07-17" status: active + delegation: + mode: interim + intended_owner: railiance-platform + blocked_on: "re-establish plus an alternative host-local key path; two ways in, neither owner-fronted (also agent-harness)" + reviewed: "2026-08-11" risk: high # Provisioned 2026-07-17 on railiance01: ed25519 keypair on host, OpenBao copy at # platform/workloads/agent-harness/forgejo-deploy-key, write deploy key on @@ -516,6 +605,11 @@ entries: canon_ref: binky-control/integrations/executor-worker-secrets.md reviewed: "2026-07-17" status: active + delegation: + mode: interim + intended_owner: railiance-platform + blocked_on: "AppRole is a host-standing credential; no owner front door for minting or rotating role_id+secret_id" + reviewed: "2026-08-15" risk: high # Provisioned 2026-07-17: role agent-harness-binky-mail bound to existing policy # workload-kv-read-binky-company-email-imap; role_id/secret_id delivered to @@ -548,6 +642,10 @@ entries: canon_ref: net-kingdom/docs/object-storage-sts-credential-vending.md reviewed: "2026-06-24" status: draft + delegation: + mode: native + intended_owner: net-kingdom + reviewed: "2026-08-15" - id: database-dynamic-credentials title: Database dynamic credentials (OpenBao secrets engine) @@ -559,6 +657,10 @@ entries: canon_ref: rapp-postgres/docs/canon-drafts/shared-platform-relational-storage_v0.1-draft.md reviewed: "2026-08-10" status: active + delegation: + mode: native + intended_owner: rapp-postgres + reviewed: "2026-08-15" risk: high exec_capable: false @@ -572,6 +674,11 @@ entries: canon_ref: ops-mason/plans/rein-openweights-openrouter-approle.md reviewed: "2026-07-27" status: active + delegation: + mode: interim + intended_owner: ops-mason + blocked_on: "AppRole is a host-standing credential; no owner-fronted exec for the OpenRouter key read" + reviewed: "2026-08-15" risk: standard # Built 2026-07-27 by ops-mason (MASON-WP-0001-T05), approved by Bernd # Worsch 2026-07-27. Policy + AppRole live; reins/ KV v2 mount created @@ -621,6 +728,11 @@ entries: canon_ref: railiance-platform/docs/apps-pg.md reviewed: "2026-08-09" status: active + delegation: + mode: interim + intended_owner: railiance-apps + blocked_on: "Runtime Secret applied via railiance-apps make target; OpenBao path planned; confirm whether user-engine should front USER_ENGINE_PROXY_SECRET" + reviewed: "2026-08-15" risk: standard # K8s assembly is the live handoff today (same pattern as vergage-teilnahme-env). # OpenBao KV platform/workloads/coulomb/coulomb-social/runtime-env is the @@ -650,6 +762,10 @@ entries: canon_ref: audit-core/docs/operator-runbook.md reviewed: "2026-08-13" status: draft + delegation: + mode: native + intended_owner: ops-mason + reviewed: "2026-08-15" auth_method: "in-cluster generated Secret audit-core-senders; later OpenBao KV via Mason wrap-migrate" path_template: "platform/workloads/audit-core/senders" policy_ref: "external-secrets-audit-core" @@ -667,6 +783,11 @@ entries: canon_ref: railiance-platform/docs/workload-kv-access-lanes.md reviewed: "2026-08-12" status: active + delegation: + mode: interim + intended_owner: secrets-engine + blocked_on: "secrets-engine has not confirmed whether exec --catalog generalizes over arbitrary OpenBao lanes (asked 2026-08-11, msg 7d55d332)" + reviewed: "2026-08-15" risk: high # CCR-2026-0010 approved 2026-08-12; applied same day (EMAIL-WP-0004-T03): # policies external-secrets-email-connect + workload-kv-read-email-connect-transactional, @@ -703,6 +824,11 @@ entries: canon_ref: ops-mason/plans/reef-storage-scaleway-bootstrap.md reviewed: "2026-08-14" status: draft + delegation: + mode: interim + intended_owner: railiance-platform + blocked_on: "Founder bootstrap API key; draft until provisioned; no owner-fronted exec" + reviewed: "2026-08-15" risk: high # CCR-2026-0011. Values via founder paste-once or local tfvars ingest. # Not the Barman runtime key (platform-pg-backup-s3). diff --git a/src/warden/cli.py b/src/warden/cli.py index f2f5402..6419ce5 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -659,6 +659,8 @@ def _entry_summary(entry) -> dict: if entry.has_rotation else {} ), + # Delegation register (WP-0030) — implicit interim if the block is absent. + "delegation": entry.effective_delegation.to_dict(), } @@ -769,6 +771,85 @@ def route_list( ) +@route_app.command("gaps") +def route_gaps( + output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, + all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False, + stale_days: Annotated[ + int, + typer.Option( + "--stale-days", + help="Days since delegation review before an interim lane is stale (default 90)", + min=1, + ), + ] = 90, +) -> None: + """List interim lanes: intended owner, blocker, and age since review.""" + from warden.routing.catalog import days_since_review + + catalog = _load_catalog() + entries = catalog.gaps(include_draft=all_entries) + + if output_json: + payload = [] + for e in entries: + d = e.effective_delegation + reviewed = d.reviewed or e.reviewed + payload.append( + { + "id": e.id, + "title": e.title, + "status": e.status, + "mode": d.mode, + "intended_owner": d.intended_owner, + "blocked_on": d.blocked_on, + "reviewed": reviewed, + "days_since_review": days_since_review(reviewed), + "implicit": d.implicit, + "stale": days_since_review(reviewed) > stale_days, + } + ) + print(json.dumps(payload, indent=2)) + return + + if not entries: + console.print("No interim routing gaps (every execution position is classified).") + return + + table = Table(title="Interim delegation register") + table.add_column("ID") + table.add_column("Owner") + table.add_column("Blocked on") + table.add_column("Reviewed") + table.add_column("Days") + table.add_column("Status") + for e in entries: + d = e.effective_delegation + reviewed = d.reviewed or e.reviewed + days = days_since_review(reviewed) + reviewed_styled = f"[yellow]{reviewed}[/yellow]" if days > stale_days else reviewed + days_styled = f"[yellow]{days}[/yellow]" if days > stale_days else str(days) + status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]" + table.add_row( + e.id, + d.intended_owner or "[yellow]unknown[/yellow]", + d.blocked_on or "", + reviewed_styled, + days_styled, + status_styled, + ) + console.print(table) + stale_n = sum( + 1 + for e in entries + if days_since_review(e.effective_delegation.reviewed or e.reviewed) > stale_days + ) + if stale_n: + console.print( + f"[yellow]{stale_n} interim lane(s) past {stale_days}d review cadence.[/yellow]" + ) + + @route_app.command("show") def route_show( entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")], @@ -813,6 +894,17 @@ def route_show( console.print(f" wiki : {entry.wiki_ref}") console.print(f" canon : {entry.canon_ref}") console.print(f" reviewed : {entry.reviewed} status: {entry.status}") + d = entry.effective_delegation + owner = d.intended_owner or "unknown" + if d.mode == "interim": + console.print( + f" delegation: [yellow]interim[/yellow] → {owner}" + + (f" blocked: {d.blocked_on}" if d.blocked_on else "") + ) + elif d.mode == "native": + console.print(f" delegation: native → {owner} already fronts this") + else: + console.print(" delegation: permanent (ops-warden owns this front door)") if entry.warden_executes: console.print("\n[green]ops-warden issues this directly.[/green]") diff --git a/src/warden/routing/__init__.py b/src/warden/routing/__init__.py index ba8ca03..ac5d115 100644 --- a/src/warden/routing/__init__.py +++ b/src/warden/routing/__init__.py @@ -12,12 +12,13 @@ from warden.routing.catalog import ( find_catalog_path, load_catalog, ) -from warden.routing.models import RouteEntry +from warden.routing.models import Delegation, RouteEntry __all__ = [ "Catalog", "CatalogError", "CatalogFreshness", + "Delegation", "RouteEntry", "find_catalog_path", "load_catalog", diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index 36a23b8..c9543fc 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -23,7 +23,13 @@ from typing import List, Optional import yaml -from warden.routing.models import VALID_RISK, RotationGuide, RouteEntry +from warden.routing.models import ( + VALID_DELEGATION_MODES, + VALID_RISK, + Delegation, + RotationGuide, + RouteEntry, +) # Structured handoff string fields (WP-0014) — templates and pointers only. # Every one is scanned for accidental secret material; see _assert_no_secret_material. @@ -201,6 +207,25 @@ class Catalog: if is_review_stale(e.reviewed, threshold_days=threshold_days, today=today) ] + def gaps(self, include_draft: bool = False) -> List[RouteEntry]: + """Interim lanes — the queryable delegation register (WARDEN-WP-0030).""" + return [e for e in self.listed(include_draft=include_draft) if e.is_interim] + + def stale_gaps( + self, + include_draft: bool = False, + threshold_days: int = DEFAULT_STALE_DAYS, + *, + today: Optional[date] = None, + ) -> List[RouteEntry]: + """Interim lanes whose delegation review is past the cadence threshold.""" + out: List[RouteEntry] = [] + for e in self.gaps(include_draft=include_draft): + reviewed = e.effective_delegation.reviewed or e.reviewed + if is_review_stale(reviewed, threshold_days=threshold_days, today=today): + out.append(e) + return out + def freshness( self, *, @@ -236,6 +261,15 @@ class Catalog: f"{stale_count} catalog entr{'y' if stale_count == 1 else 'ies'} " f"past {stale_threshold_days}d review cadence" ) + stale_interim = len(self.stale_gaps( + include_draft=True, threshold_days=stale_threshold_days, today=today + )) + if stale_interim: + warnings.append( + f"{stale_interim} interim delegation" + f"{'' if stale_interim == 1 else 's'} past " + f"{stale_threshold_days}d review — see `warden route gaps`" + ) return CatalogFreshness( path=str(path), @@ -362,6 +396,66 @@ def _parse_rotation(entry_id: str, raw: Optional[dict]) -> Optional[RotationGuid ) +def _parse_delegation(entry_id: str, raw: Optional[dict]) -> Optional[Delegation]: + """Parse an optional ``delegation:`` block (WARDEN-WP-0030). + + Absence is allowed: the loader treats it as implicit interim with an + unknown owner. When the block *is* present, mode / owner / blocker rules + are enforced so a declared answer cannot be incomplete. + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise CatalogError(f"entry {entry_id!r} `delegation` must be a mapping") + + mode = str(raw.get("mode", "")).strip() + if mode not in VALID_DELEGATION_MODES: + raise CatalogError( + f"entry {entry_id!r} delegation.mode {mode!r} invalid " + f"(expected one of {VALID_DELEGATION_MODES})" + ) + + intended_owner = str(raw.get("intended_owner", "")).strip() or None + if mode != "permanent" and not intended_owner: + raise CatalogError( + f"entry {entry_id!r} delegation.intended_owner is required " + f"unless mode is permanent" + ) + + blocked_on = str(raw.get("blocked_on", "")).strip() or None + if mode == "interim" and not blocked_on: + raise CatalogError( + f"entry {entry_id!r} delegation.blocked_on is required when mode is interim" + ) + + reviewed = str(raw.get("reviewed", "")).strip() or None + if not reviewed: + raise CatalogError(f"entry {entry_id!r} delegation.reviewed is required") + try: + date.fromisoformat(reviewed) + except ValueError as e: + raise CatalogError( + f"entry {entry_id!r} delegation.reviewed {reviewed!r} is not YYYY-MM-DD" + ) from e + + if intended_owner: + _assert_no_secret_material( + entry_id, "delegation.intended_owner", intended_owner, prose=True + ) + if blocked_on: + _assert_no_secret_material( + entry_id, "delegation.blocked_on", blocked_on, prose=True + ) + + return Delegation( + mode=mode, + intended_owner=intended_owner, + blocked_on=blocked_on, + reviewed=reviewed, + implicit=False, + ) + + def _parse_entry(raw: dict, index: int) -> RouteEntry: if not isinstance(raw, dict): raise CatalogError(f"entry #{index} is not a mapping") @@ -452,6 +546,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: pointer_command=handoff["pointer_command"], rotation=_parse_rotation(entry_id, raw.get("rotation")), risk=risk, + delegation=_parse_delegation(entry_id, raw.get("delegation")), ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 9120f77..a264576 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -34,6 +34,40 @@ class RotationGuide: # standard — ordinary workload secrets (ESO-fed, non-escrow); normal least-privilege. VALID_RISK = ("standard", "high") +# Delegation modes (WARDEN-WP-0030). Absence of a block is implicit interim. +# native — intended owner already fronts the lane (route-primary / pointer) +# interim — ops-warden covers a gap; intended_owner + blocked_on required +# permanent — ops-warden is the designed owner of this front door (SSH only today) +VALID_DELEGATION_MODES = ("native", "interim", "permanent") + +IMPLICIT_DELEGATION_BLOCKED_ON = ( + "unclassified — no delegation block; treat as a question, not a settlement" +) + + +@dataclass +class Delegation: + """Who should own this front door, and what is missing (WARDEN-WP-0030). + + Pointer-layer only: names the intended owner and the blocker. Does not + restate how that owner will implement their front door. + """ + + mode: str # native | interim | permanent + intended_owner: Optional[str] = None + blocked_on: Optional[str] = None + reviewed: Optional[str] = None + implicit: bool = False + + def to_dict(self) -> dict: + return { + "mode": self.mode, + "intended_owner": self.intended_owner, + "blocked_on": self.blocked_on, + "reviewed": self.reviewed, + "implicit": self.implicit, + } + @dataclass class RouteEntry: @@ -78,6 +112,8 @@ class RouteEntry: rotation: Optional[RotationGuide] = None # Agent read-boundary risk class (WP-0026 T04). high → agents use wrap/out/exec only. risk: str = "standard" # "standard" | "high" + # Delegation register (WP-0030). None → implicit interim with unknown owner. + delegation: Optional[Delegation] = None @property def is_active(self) -> bool: @@ -111,6 +147,28 @@ class RouteEntry: """True when an owner-native exec front door is the primary path for this lane.""" return bool(self.exec_owner and self.exec_command) + @property + def effective_delegation(self) -> Delegation: + """Declared delegation, or implicit interim with an unknown owner. + + Absence of a ``delegation:`` block is a question (WP-0030), not a + settlement that ops-warden owns the front door. + """ + if self.delegation is not None: + return self.delegation + return Delegation( + mode="interim", + intended_owner=None, + blocked_on=IMPLICIT_DELEGATION_BLOCKED_ON, + reviewed=None, + implicit=True, + ) + + @property + def is_interim(self) -> bool: + """True when this lane is a tracked gap (explicit or implicit).""" + return self.effective_delegation.mode == "interim" + @property def has_handoff(self) -> bool: """True when structured assist fields are present (advisory richness).""" diff --git a/tests/test_routing.py b/tests/test_routing.py index a3f7587..7bef7d1 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -575,3 +575,137 @@ def test_route_show_json_includes_risk(): assert payload["high_risk"] is True assert payload["resolvable"] is True assert payload["status"] == "active" + + +# --------------------------------------------------------------------------- +# Delegation register (WARDEN-WP-0030) +# --------------------------------------------------------------------------- + +def test_every_catalog_entry_declares_delegation(): + catalog = load_catalog(_repo_catalog()) + missing = [e.id for e in catalog.entries if e.delegation is None] + assert missing == [], f"entries missing delegation block: {missing}" + + +def test_every_proxy_declares_delegation(): + """A new exec_capable proxy cannot land without answering the ownership question.""" + catalog = load_catalog(_repo_catalog()) + missing = [ + e.id + for e in catalog.entries + if e.exec_capable and not e.warden_executes and e.delegation is None + ] + assert missing == [], f"proxy lanes missing delegation: {missing}" + + +def test_ssh_lane_is_permanent_delegation(): + e = load_catalog(_repo_catalog()).get("ssh-cert-host-access") + assert e.delegation is not None + assert e.delegation.mode == "permanent" + assert e.delegation.intended_owner is None + assert e.is_interim is False + + +def test_native_exec_lanes_are_native_delegation(): + catalog = load_catalog(_repo_catalog()) + for eid, owner in ( + ("whynot-design-npm-publish", "secrets-engine"), + ("ops-warden-warden-sign-token", "railiance-platform"), + ): + e = catalog.get(eid) + assert e.delegation is not None + assert e.delegation.mode == "native" + assert e.delegation.intended_owner == owner + + +def test_founder_interim_lanes_classified(): + catalog = load_catalog(_repo_catalog()) + expected = { + "rapp-qonto-keycape-client": "key-cape", + "binky-company-email-imap": "tenant-engine", + "binky-qonto-api": "tenant-engine", + "railiance-backup-offsite-lane": "railiance-platform", + "agent-harness-forgejo-deploy": "railiance-platform", + } + for eid, owner in expected.items(): + e = catalog.get(eid) + assert e is not None and e.delegation is not None + assert e.delegation.mode == "interim" + assert e.delegation.intended_owner == owner + assert e.delegation.blocked_on + + +def test_missing_delegation_is_implicit_interim(tmp_path): + catalog = load_catalog(_write_catalog(tmp_path, [dict(ROUTED_ENTRY)])) + e = catalog.get("openbao-api-key") + d = e.effective_delegation + assert e.delegation is None + assert d.implicit is True + assert d.mode == "interim" + assert d.intended_owner is None + assert "unclassified" in (d.blocked_on or "") + + +def test_interim_without_blocked_on_rejected(tmp_path): + bad = dict( + ROUTED_ENTRY, + delegation={ + "mode": "interim", + "intended_owner": "secrets-engine", + "reviewed": "2026-08-15", + }, + ) + with pytest.raises(CatalogError, match="blocked_on"): + load_catalog(_write_catalog(tmp_path, [bad])) + + +def test_non_permanent_without_owner_rejected(tmp_path): + bad = dict( + ROUTED_ENTRY, + delegation={"mode": "native", "reviewed": "2026-08-15"}, + ) + with pytest.raises(CatalogError, match="intended_owner"): + load_catalog(_write_catalog(tmp_path, [bad])) + + +def test_invalid_delegation_mode_rejected(tmp_path): + bad = dict( + ROUTED_ENTRY, + delegation={"mode": "maybe", "intended_owner": "x", "reviewed": "2026-08-15"}, + ) + with pytest.raises(CatalogError, match="delegation.mode"): + load_catalog(_write_catalog(tmp_path, [bad])) + + +def test_catalog_gaps_lists_only_interim(): + catalog = load_catalog(_repo_catalog()) + gap_ids = {e.id for e in catalog.gaps(include_draft=True)} + assert "ssh-cert-host-access" not in gap_ids + assert "whynot-design-npm-publish" not in gap_ids + assert "binky-company-email-imap" in gap_ids + assert "openbao-api-key" in gap_ids + assert all(catalog.get(i).is_interim for i in gap_ids) + + +def test_cli_route_gaps_json(repo_catalog_env): + result = runner.invoke(app, ["route", "gaps", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data + ids = {row["id"] for row in data} + assert "binky-company-email-imap" in ids + assert "ssh-cert-host-access" not in ids + for row in data: + assert row["mode"] == "interim" + assert "intended_owner" in row + assert "blocked_on" in row + assert "days_since_review" in row + + +def test_cli_route_show_includes_delegation(repo_catalog_env): + result = runner.invoke(app, ["route", "show", "binky-qonto-api", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["delegation"]["mode"] == "interim" + assert data["delegation"]["intended_owner"] == "tenant-engine" + assert data["delegation"]["implicit"] is False diff --git a/wiki/AccessRouting.md b/wiki/AccessRouting.md index 26ada9c..6c99c83 100644 --- a/wiki/AccessRouting.md +++ b/wiki/AccessRouting.md @@ -76,6 +76,44 @@ boundary in `OperatorAccessAssist.md`. --- +## Interim custodianship + +ops-warden **works with, and never replaces or duplicates**, the NetKingdom +components that own identity, custody, authorization, and tenant/user lifecycle +(`INTENT.md` §9). Covering a gap is legitimate. Silently becoming the owner of +that gap is not. + +The only lane ops-warden executes with its own authority is **SSH issuance** +(`ssh-cert-host-access`). Every other execution position — including every +`warden access` proxy — is **interim by default**. A catalog entry without a +`delegation:` block is treated as interim with an unknown owner: absence is a +question, not a settlement. + +| `delegation.mode` | Meaning | +| --- | --- | +| `permanent` | ops-warden is the designed owner of this front door (SSH only, today) | +| `native` | the intended owner already fronts it; ops-warden routes (and may keep a proxy as fallback) | +| `interim` | ops-warden covers the gap until `intended_owner` ships the missing front door named in `blocked_on` | + +Delegation targets — the components that should own a front door once they have +one: + +| Target | What they should front | +| --- | --- | +| **secrets-engine** | owner-native secret-exec (`secrets-engine exec --catalog `) | +| **tenant-engine** | tenant/client secret custody and tenant-lane front door | +| **user-engine** | end-user / account-lifecycle secrets that belong with user identity | +| **railiance-platform** | OpenBao cluster, credential broker, platform workload procedure | +| **flex-auth** | authorization decisions (already native — ops-warden only points) | +| **key-cape** | identity login and client-credential protocol (OIDC, `client_secret_basic`) | + +Query the register with `warden route gaps`. An interim lane is retired by +setting `exec_owner` / `exec_command` (the WP-0019 pattern) and flipping +`delegation.mode` to `native` once the owner's front door is proven. Do not +delete a working proxy on the way. + +--- + ## Routing lookup CLI (`warden route`) Agents and operators query the pointer catalog directly instead of re-deriving @@ -86,6 +124,7 @@ material. ```bash warden route list [--json] [--all] [--tag ] # active-only unless --all warden route list --stale [--stale-days 90] [--all] [--json] # past review cadence +warden route gaps [--json] [--all] # interim register (owner + blocker) warden route show [--json] # owner + pointers; SSH adds steps warden route find "" [--json] [--all] # rank by keyword overlap ``` @@ -145,6 +184,7 @@ owner repo's shipped path. | **On canon change** | When net-kingdom security docs change, review affected `canon_ref` entries immediately | | **On owner ship** | When an owning repo merges a new OpenBao path or playbook, promote `draft` → `active` and bump `reviewed` | | **On agent confusion** | If `warden route find` misses a common query, add `need_keywords` or a playbook — do not restate owner procedure in the catalog | +| **On interim review** | Run `warden route gaps` — re-check each `blocked_on` against the intended owner; flip to `native` when their front door exists | ### Stale check (operators and agents) @@ -177,6 +217,7 @@ moved but anchors still resolve. - `CredentialRouting.md` — worker decision tree and routing table - `NetKingdomSecurityMap.md` — component literacy -- `INTENT.md` — steward mission ("issue SSH, route the rest") +- `INTENT.md` — steward mission ("issue SSH, route the rest"); §9 interim custodianship +- `workplans/WARDEN-WP-0030-delegation-register.md` — delegation register - `workplans/WARDEN-WP-0010-access-routing-charter.md` — charter + no-double-source rule - `net-kingdom/docs/platform-identity-security-architecture.md` — platform canon diff --git a/wiki/CredentialRouting.md b/wiki/CredentialRouting.md index 9dc5831..f6d4850 100644 --- a/wiki/CredentialRouting.md +++ b/wiki/CredentialRouting.md @@ -81,7 +81,9 @@ These needs are also carried in the machine-readable pointer catalog The catalog is a **pointer-and-assist layer**: it names the owner, links the doc, and carries secret-free handoff templates for `warden access`. Only the SSH row is something ops-warden executes with its own authority. Non-SSH `exec_capable` rows -run the owner's tool as the caller and preserve owner custody. +run the owner's tool as the caller and preserve owner custody. Every execution +position is classified `permanent` / `native` / `interim` (`warden route gaps` +lists the interim set — WARDEN-WP-0030). | Catalog `id` | What ops-warden answers | What the worker does next | | --- | --- | --- | diff --git a/wiki/playbooks/catalog-lane-promotion.md b/wiki/playbooks/catalog-lane-promotion.md index 4c713c6..c3f653a 100644 --- a/wiki/playbooks/catalog-lane-promotion.md +++ b/wiki/playbooks/catalog-lane-promotion.md @@ -24,10 +24,21 @@ Before changing `status: draft` → `status: active`: | 7 | **Review date** | Update `reviewed:` in catalog entry | | 8 | **Verification** | Positive + negative proof via **`bao token capabilities`** — never `bao kv get` (see below) | | 9 | **Rotation guidance** | Secret-vending lanes carry a `rotation:` block; `warden rotate-guide ` returns steps. Enforced by the `catalog_rotation_coverage` scorecard check (WP-0026 T06) | +| 10 | **Delegation** | Entry carries a `delegation:` block (WP-0030). `mode: permanent` only for ops-warden's own front door. `mode: native` when the owner already fronts it. `mode: interim` requires `intended_owner` and `blocked_on`; the promotion note must name both and the retirement condition | Promotion PR touches: `registry/routing/catalog.yaml`, playbook, optional `tests/test_routing.py`, and a one-line note in `wiki/CredentialRouting.md` draft table. +If `delegation.mode` is `interim`, the promotion note (PR body or State Hub +progress) must state: + +1. who the intended owner is +2. what is missing (`blocked_on`) +3. what would let ops-warden step back (`exec_owner` + proven owner front door) + +A proxy that works is not enough to promote without answering the ownership +question. `warden route gaps` must list the lane after merge if it is interim. + --- ## Capabilities-safe lane verification (WARDEN-WP-0026 T01) @@ -113,4 +124,6 @@ playbook prose alone. ## See also - `wiki/CredentialRouting.md` — draft table index -- `wiki/playbooks/ops-warden-warden-sign-token.md` — promotion reference \ No newline at end of file +- `wiki/playbooks/ops-warden-warden-sign-token.md` — promotion reference +- `wiki/AccessRouting.md#interim-custodianship` — delegation register doctrine +- `warden route gaps` — queryable interim register \ No newline at end of file diff --git a/workplans/WARDEN-WP-0030-delegation-register.md b/workplans/WARDEN-WP-0030-delegation-register.md index 6e13a4a..aaf97ba 100644 --- a/workplans/WARDEN-WP-0030-delegation-register.md +++ b/workplans/WARDEN-WP-0030-delegation-register.md @@ -4,13 +4,13 @@ type: workplan title: "Delegation register — make gap-covering interim, visible, and retirable" domain: infotech repo: ops-warden -status: proposed -owner: claude +status: finished +owner: grok topic_slug: custodian planning_priority: high planning_order: 30 created: "2026-08-11" -updated: "2026-08-11" +updated: "2026-08-15" state_hub_workstream_id: "29f3fcb3-e3c5-4f9d-bdfd-f76254754e21" --- @@ -87,7 +87,7 @@ to *"who should own this front door, and what is missing?"* ```task id: WARDEN-WP-0030-T01 -status: todo +status: done priority: high state_hub_task_id: "3d76f6c2-5007-4ad5-8d6f-360f9303c929" ``` @@ -103,7 +103,7 @@ targets. Cross-link INTENT.md §9. ```task id: WARDEN-WP-0030-T02 -status: todo +status: done priority: high state_hub_task_id: "8013020b-d0a5-4e89-8e0b-bc418a761c84" ``` @@ -123,11 +123,26 @@ Backfill all 24 entries. `exec_owner` lanes become `mode: native`. eleven undelegated proxies are classified per the founder review below — five `interim` now, six held pending secrets-engine's answer. +**Landed 2026-08-15.** Catalog is now 27 entries. All 27 carry `delegation:`. + +- `permanent` (1): `ssh-cert-host-access` +- `native` (10): both `exec_owner` lanes plus the route-only pointers +- `interim` (16): the five founder-classified now; the six held pending + secrets-engine (still unanswered as of 2026-08-15, msg `7d55d332`) recorded as + `interim` / `intended_owner: secrets-engine` / `blocked_on` the generic-vs-per-lane + question — **not** `permanent`; plus five new-since-survey lanes drafted the same + way (`email-connect-transactional` same held question; two AppRoles; + `coulomb-social-runtime-env`; draft `scaleway-bootstrap`) + +The six are not guessed permanent. Interim is the default until secrets-engine +answers. Reclassify to `permanent` only if they confirm `exec --catalog` stays +per-lane. + ### T03 — `warden route gaps` + conformance test ```task id: WARDEN-WP-0030-T03 -status: todo +status: done priority: medium state_hub_task_id: "e7755708-e1bf-4d42-999c-4a60330cf154" ``` @@ -142,7 +157,7 @@ entries in the existing drift/stale review cadence. ```task id: WARDEN-WP-0030-T04 -status: todo +status: done priority: medium state_hub_task_id: "ddc0268b-1279-4875-b223-0627c97d657a" ``` @@ -156,7 +171,7 @@ checklist. ```task id: WARDEN-WP-0030-T05 -status: todo +status: done priority: medium state_hub_task_id: "d8796e25-e896-463d-9476-f002e38b6897" ``` @@ -167,16 +182,29 @@ what ops-warden currently fronts on your behalf, here is what would let us step back. This is the artefact that converts a private ops-warden position into a fleet-visible capability gap. +**Landed 2026-08-15.** Register sent (no secret values): + +| To | Message | +| --- | --- | +| secrets-engine | `9c1d753f` — 1 native + 7 interim pending generic-exec answer | +| tenant-engine | `17c851b1` — 2 Binky tenant lanes | +| user-engine | `38aeccd5` — confirm USER_ENGINE_PROXY_SECRET ownership | +| railiance-platform | `e75793e3` — 1 native + 4 interim | +| net-kingdom | `3fea09df` — object-storage-sts remains native/draft | +| key-cape | `28ded56c` — rapp-qonto-keycape-client (founder-classified interim) | + ## Acceptance -- [ ] `wiki/AccessRouting.md` states the interim-custodianship boundary and names +- [x] `wiki/AccessRouting.md` states the interim-custodianship boundary and names the delegation targets -- [ ] All 24 catalog entries carry `delegation`; `warden route gaps` returns the - interim set with owner + blocker -- [ ] Routing test fails when a proxy lane omits `delegation` -- [ ] Promotion playbook gates draft→active on the ownership question -- [ ] Founder has reviewed the interim/permanent classification of the eleven -- [ ] Register delivered to the named owner repos +- [x] All catalog entries carry `delegation`; `warden route gaps` returns the + interim set with owner + blocker (27 entries; 15 active interim) +- [x] Routing test fails when a proxy lane omits `delegation` +- [x] Promotion playbook gates draft→active on the ownership question +- [x] Founder has reviewed the interim/permanent classification of the eleven + (2026-08-11); the six held lanes stay interim pending secrets-engine, not + marked permanent +- [x] Register delivered to the named owner repos (T05) ## Classification of the eleven (founder review, 2026-08-11)