Skip to content

Spec: capability-derived tool catalogs #209

Description

@George-RD

Part of #182. Spec for wayfinder design ticket #191 (Immune-system lane). Promises: Immune system, Permissions (CONTEXT.md). Users: Bell, Lyra (STORIES.md). Design-only, per DIRECTION.md's delivery vehicle; landing code is the to-tickets tickets that follow.

Grilling round with the owner (the orchestration coordinator, standing in per the #182 owner ruling) recorded on #191 (2026-08-20) settles every decision below.

Problem Statement

An OpenSpine worker decides what to do by reading model output; the model's reachable action surface is whatever the worker chooses to put in front of it. Today there is no kernel-derived, capability-scoped tool surface the worker receives before inference:

  • The model gateway (crates/openspine-kernel/src/model_gateway/) sends a provider only instructions (trusted system text) plus user/assistant text turns. No tools/function schema is ever transmitted — codex.rs's tool_choice:"auto" / parallel_tool_calls:true are vestigial with no tools array. Orchestration is text-in / text-out; the untrusted shell parses model text and POSTs actions to POST /v1/actions.
  • GET /v1/task (api/task.rs::TaskViewBody) already projects the grant's allowed_actions / approval_required_actions / denied_actions to the untrusted shell — but as bare action-id strings, with no descriptions and no invocation schema. It is a capability list, not a catalog a model can be presented with.
  • The kernel ActionCatalog (action_catalog.rs, D-053) carries per-action descriptors (egress declarations, token-requiring types, counterparty-facing, delegation/implementation) but no per-action parameter schema — action payloads are free-form JSON validated per handler.

For Bell, whose conversational principals are unauthenticated strangers, this is the concrete gap behind invariant I2 (a session's toolset is fixed by verified role before inference; privileged tools are structurally absent from untrusted sessions, not refused). A privileged tool (email.send, worker.commission, filesystem.host_write) that a stranger-facing session was never granted must not appear in that session's tool surface at all — the model cannot be steered (research #190's branch-steering residual) into naming a call it can never see. For Lyra, the assistant must see exactly the tools its owner-composed grant permits — including approval-required ones it may propose — and no more, so authority never silently widens (Lyra failure mode: learning silently becomes permission).

The Immune system promise ("external content fills parameters, never gives instructions") and the Permissions promise ("agents do only what was explicitly granted") both require that the shape of what the model can attempt is derived from the grant, deterministically, before the model runs.

Solution

Introduce a capability-derived tool catalog: a new pure, deterministic pre-inference projection in openspine-authority, computed from an already-composed TaskGrant plus the kernel ActionCatalog's per-action tool descriptors, that produces the exact set of model-consumable tool definitions a worker receives — and nothing for any action the grant did not carry.

Three parts:

  1. A new per-action tool-descriptor axis on the kernel ActionCatalog (mirroring the existing egress_declarations axis and its fail-closed completeness discipline). Each descriptor carries the LLM-facing name, a one-line description, a parameter JSON Schema, and presentation flags (approval-required, selection-token-required). This is kernel-owned catalog metadata, never shell-spoofable, never on the grant.

  2. A pure projection function in openspine-authority (beside compose): TaskGrant + ActionCatalog descriptors -> CatalogView (an ordered list of tool descriptors). It is policy-free: it projects exactly what compose granted, and adds no second authority decision. It performs no I/O and holds no state.

  3. Serving the catalog through the existing /v1/task seam. The kernel extends TaskViewBody with the projected CatalogView; the untrusted shell/worker renders it into whatever its provider needs. No new endpoint; compose() and gate() decision logic are untouched.

Projection rule (the I2 core):

  • allowed_actions -> presented as callable tools.
  • approval_required_actions -> presented, annotated "requires owner approval" (so the assistant can still propose them; the existing approval/gate flow handles the pause).
  • denied_actions and any action not in the grant at all -> structurally absent: no name, no description, no schema is ever emitted to the worker.

Structural absence is attenuation, gate() is the sole enforcement. A tool's absence from the catalog is defense-in-depth before inference — it removes the surface a compromised or steered model could name — but it is never an enforcement mechanism. An adversarial or buggy worker can still POST any action id to /v1/actions; gate() (openspine-gate, D-055/D-033, exact-match, fail-closed) remains the single mandatory refusal path for any action a worker attempts. The catalog reduces attack surface; the gate provides the guarantee.

Identity step-up is a recompute, not a persisted session. Grants are composed per event. A rise in verification signal (IdentityResolution.source_verified, ChannelTrust) re-resolves the route -> agent/workflow/pack -> a freshly composed grant -> a freshly projected catalog. There is no persisted session store, no cross-turn cached catalog, and no "verified-role object" — introducing one would conflict with D-006 (identity is not authority) and the stateless per-event pipeline.

User Stories

  1. As Bell, I want a stranger-facing session's model to be handed only the tools that session's grant carried, so a privileged tool it was never granted (e.g. worker.commission, email.send) is absent from the prompt and cannot be named at all — closing invariant I2 at the kernel/worker boundary, not by a runtime refusal after the fact.
  2. As Bell, I want structural absence to be the guarantee for denied and ungranted actions alike, so that no matter how a stranger's crafted input steers the model (research Research: design inputs for the Immune-system lane #190's branch-steering residual), it can only ever select among tools already authorized for that session.
  3. As Lyra, I want my assistant to see exactly the tools my owner-composed grant permits — including approval-required tools it may propose to me — and no others, so its authority never silently widens beyond what I granted (learning silently becomes permission).
  4. As Lyra, I want an approval-required tool to be visible but flagged, so the assistant can offer it to me and the existing approval flow pauses for my ratification, rather than the tool being hidden (losing the capability) or callable without approval (widening authority).
  5. As the Auditor, I want the catalog to be a deterministic pure projection of the composed grant with no second policy decision inside it, so that "what tools this session could see" is reconstructible months later from the grant alone, never from projection-time state.
  6. As a developer adding a new dispatchable action, I want a granted action that lacks a tool descriptor to be caught by a failing test in the gate — not a silent omission — so that a capability gap is something a human consciously accepts, never something that silently ships.
  7. As a developer integrating a non-Rust (TypeScript) worker later, I want the kernel-projected catalog shaped so it renders cleanly as a standard provider tool schema (name / description / parameters), so the I2 boundary holds identically regardless of how the worker presents tools to its model.
  8. As the coordinator maintaining the Immune-system lane, I want this pre-inference design to sit cleanly beside the runtime lanes (disclosure gating Spec: disclosure gating on external egress #204/Design disclosure gating on external egress #192, provenance labels Design provenance labels with typed identity #193) with no overlap, so the three promise-4 mechanisms compose rather than collide.

Implementation Decisions

  • D1 — Projection home (pure, in authority). A new pure, deterministic function in openspine-authority (beside compose) maps TaskGrant + ActionCatalog tool descriptors -> an ordered CatalogView. It has no I/O and no state, exactly like compose. The kernel serves the result through the existing /v1/task TaskView; the shell/worker renders it. Rejected: a new endpoint (the seam already exists); shell-side derivation (the shell is untrusted — it must not decide its own surface).
  • D2 — Tool-descriptor axis on the kernel catalog. The kernel ActionCatalog gains a per-action tool-descriptor map (LLM-facing name, one-line description, parameter JSON Schema, presentation flags for approval/selection-token requirement), mirroring the existing egress_declarations axis and owned identically (kernel const, never shell-spoofable, never on the grant). No per-action parameter schema exists today (payloads are free-form JSON validated per handler); this axis introduces it for the projection surface.
  • D3 — Completeness is a failing gate test, not a log line. A granted, dispatchable action that lacks a tool descriptor is omitted from the projected catalog (a capability gap, not a security hole — the gate still enforces it if attempted) and is caught by a failing completeness test in the gate (scripts/check.sh), mirroring the existing fail-closed completeness rule for missing egress declarations. A granted-but-undescribed action is a gap a human must consciously accept.
  • D4 — The three-list projection rule. allowed_actions -> callable; approval_required_actions -> presented + annotated "requires owner approval"; denied_actions and any ungranted action -> structurally absent. This is invariant I2 for the untrusted case and keeps approval-required tools proposable for Lyra.
  • D5 — Projection is policy-free. Projection projects exactly what compose granted and makes no second authority decision. Whether a stranger-facing route ever composes approval_required_actions at all is authority/compose policy (Bell tenancy work, fenced post-fit-review, DIRECTION.md) — it is decided in composition, never re-decided inside projection. There is exactly one policy decision point (compose), never two.
  • D6 — I2 binds at the kernel->worker projection. The guarantee "the worker never receives a name/description/schema for an ungranted or denied tool" binds at the /v1/task response, independent of provider. How a worker feeds granted tools to its model (native function-calling tools array vs. rendered into the prompt) is a worker concern and out of scope, provided the descriptor renders as a standard tool schema.
  • D7 — Step-up = recompute. A rise in source_verified / ChannelTrust re-resolves route -> agent/pack -> recomposed grant -> re-projected catalog. No persisted session, no cached catalog, no verified-role object (would conflict with D-006 and the stateless pipeline).
  • D8 — Role/identity vocabulary binds to existing signals + spec Spec: Typed Owner Identity #197. "Verified role" maps to the existing (route x IdentityResolution.channel_trust x RelationshipKind x source_verified) resolution that selects agent/workflow/pack; the grant carries the typed PrincipalId from sibling spec Spec: Typed Owner Identity #197. No new role/trust-level type is introduced. Bell's per-tenant role is tenancy work (roadmap T21, fenced) and stays out of scope.
  • D9 — compose() and gate() decision logic are untouched. This design adds a read-only projection and a catalog metadata axis; it changes neither how authority is composed nor how actions are mediated.

Testing Decisions

  • Tests exercise external behavior only: given a composed grant and a catalog, does the projection emit exactly the granted tools (allowed callable, approval-required annotated) and nothing for denied/ungranted ids? Never assert internal call sequencing.
  • I2 structural-absence test (MUST): an untrusted (stranger-facing) grant projects a catalog that contains no name/description/schema for any denied or ungranted action — asserted positively (the privileged id is not present), the way the research ruling requires.
  • Step-up test (MUST): two events differing only in verification signal (source_verified/channel_trust) that resolve to different grants project different catalogs; the higher-verification catalog contains tools absent from the lower one, with no shared session state between them.
  • Approval-annotation test: an approval_required_actions entry is present in the catalog and flagged; an allowed_actions entry is present and unflagged.
  • Completeness test in the gate (MUST, failing-by-default): a granted, dispatchable action with no tool descriptor fails a gate completeness test — the same fail-closed discipline as missing egress declarations — rather than silently omitting.
  • Policy-free projection test: projection of a grant is a pure function of (grant, catalog) — identical inputs give identical catalogs regardless of surrounding state; projection introduces no allow/deny decision the grant did not already carry.
  • Gate-remains-enforcement test: an action absent from a session's projected catalog, if POSTed directly to /v1/actions by the worker, is still denied by gate() — proving absence is attenuation, not enforcement.
  • Prior art to extend, not fork: openspine-authority's compose tests (pure-function, table-driven) and the kernel catalog completeness tests (action_catalog_tests.rs).
  • No build/lint/test commands specified here — this ticket is design-only; scripts/check.sh is the gate for the implementation tickets that follow.

Out of Scope

  • Implementation. Design-only per the wayfinder map and DIRECTION.md; landing code is the to-tickets tickets that follow this spec.
  • Wiring the model gateway tools array / native function-calling. How a worker presents granted tools to its provider is a worker concern (D6). This design defines the kernel-side projection and its shape only.
  • Any persisted session store, cross-turn catalog cache, or verified-role object (D7) — would conflict with D-006 and the stateless per-event pipeline.
  • Composition policy for stranger-facing sessions — whether a route composes approval-required or privileged actions at all is authority/compose + Bell tenancy work (roadmap T21, fenced post-fit-review), decided in composition, never in projection (D5).
  • Multi-tenant catalog isolation — Bell v1 is instance-per-tenant (DIRECTION.md); the projection is single-owner-scoped, unchanged by this design.
  • Runtime egress/disclosure and provenance labels — the sibling Immune-system lanes (Spec: disclosure gating on external egress #204/Design disclosure gating on external egress #192, Design provenance labels with typed identity #193); this is the pre-inference lane and has no overlap with those runtime mechanisms.
  • A parameter-schema validation layer at dispatch — the descriptor's JSON Schema is a projection/presentation surface; per-handler payload validation stays as-is. Tightening dispatch-time validation to the descriptor schema is a possible later deliberate change, not this design.

Further Notes

  • This closes the pre-inference half of the Immune-system lane's Research: design inputs for the Immune-system lane #190 ruling: "Capability-derived tool catalogs -> pre-inference: projected statically from the grant before prompt formatting; the worker never receives schemas for ungranted tools." (research research/immune-system-design-inputs, CaMeL arXiv:2503.18813.)
  • Verbatim wording the owner directed into the spec (grilling Q5): "Structural absence is attenuation; gate() is the sole enforcement." Every implementation ticket inherits this framing.
  • Owner notes folded in (grilling, 2026-08-20): (A) the catalog-completeness check is a failing gate test, not a log line — a granted-but-undescribed action is a capability gap a human consciously accepts (D3); (B) projection stays policy-free — one policy decision point (compose), never a second inside projection (D5).
  • Identity/role vocabulary is bound to sibling spec Spec: Typed Owner Identity #197 (typed owner identity: PrincipalId(Ulid) + OwnerPrincipal); this design introduces no new identity or role type (D8).
  • Glossary proposal (for the map owner to fold into CONTEXT.md, not applied by this worker): "capability-derived tool catalog" — the pre-inference, grant-derived projection of the model-consumable tool surface; and "structural absence" — the Immune-system property that an ungranted/denied tool is omitted from the worker's tool surface entirely rather than refused at call time (Bell invariant I2).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions