refactor(runner): collapse semantic catalogs into the frozen authority

PAP-17039. Both catalog modules now derive their operation set, placement,
required claims, and task-mode policy from a single hand-authored source,
src/catalog/canonical-operations.ts; neither declares an independent op list.

- New canonical-operations.ts is the single 41-op source of truth. tools/ and
  semantic-tools/ project it and supply only per-surface presentation (title,
  description, schema, and the scenario-only mock mapping + redaction). A
  bidirectional parity check fails the build on any drift.
- Unify generic_api_request to one claim, test:generic_api_request (test-only,
  never product coverage); update scenario grants/plan/tests.
- One canonical task-mode policy: the live runtime policy unioned with
  skill_test. Resolves the ledger's 15 task-mode divergences to zero. The live
  contract golden is byte-identical (allowedModes is not serialized); the live
  runtime allowedModes gained skill_test for the 9 standard-only ops so the
  eval harness can drive them.
- reconciliation.ts inverts to derive from canonical-operations and recompute
  the drift ledger: zero claim/mode divergences; the sole surviving divergence
  is the reviewed intentional input-schema projection (idempotency in-band for
  the provider, out-of-band for the observable runtime).

Focused catalog, scenario, conformance, issue-thread, mock, and package-export
suites pass; golden verified byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-08-11 15:21:05 +00:00
1 parent 4d8297128d
commit 449e1a25c6
11 files changed
+834 -776

No files matched your search

@@ -5,7 +5,14 @@ is a frozen set of JSON-schema descriptors — no HTTP method, path, or provider
detail. Provider-specific shapes are produced only by *bindings*, so every
provider sees the same operation set.
Sources: `src/tools/capability-semantic-tool-catalog.ts`,
The operation list, placement, required claims, and task-mode policy are
single-sourced from `src/catalog/canonical-operations.ts`
(`CAPABILITY_CANONICAL_OPERATIONS`); this catalog is a projection of it that adds
the scenario presentation (titles, descriptions, input schemas, mock mappings,
and redaction). See [catalog reconciliation](../spec/capability/catalog-reconciliation.md).
Sources: `src/catalog/canonical-operations.ts`,
`src/tools/capability-semantic-tool-catalog.ts`,
`src/tools/capability-semantic-tool-types.ts`, `src/tools/capability-tool-bindings.ts`,
barrel `src/tools/index.ts`.
@@ -1,17 +1,23 @@
# Semantic Catalog Reconciliation
**Document status:** Canonical reconciliation ledger (PAP-17025)<br>
**Document status:** Canonical reconciliation ledger (PAP-17025 gate + PAP-17039 collapse)<br>
**Date:** 2026-08-11<br>
**Authority:** `src/catalog/reconciliation.ts` (machine-readable), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.<br>
**Source of truth:** `src/catalog/canonical-operations.ts` (`CAPABILITY_CANONICAL_OPERATIONS`) — the single hand-authored 41-operation list. Both catalog modules derive their operation set, placement, required claims, and task-mode policy from it.<br>
**Drift authority:** `src/catalog/reconciliation.ts` (machine-readable ledger), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.<br>
**Parent plan:** Runner foundation review plan (PAP-17016), deliverable D and execution step 1.
## Purpose
The runner carried two independently hand-maintained semantic catalogs. This is
the first reconciliation gate of the foundation plan: establish one
machine-readable authority over both, name every operation's full metadata, and
record every divergence with an explicit disposition so no specification calls
either catalog canonical and no stale count or unreviewed divergence can land.
The runner carried two independently hand-maintained semantic catalogs. PAP-17025
established one machine-readable authority over both and recorded every
divergence. **PAP-17039 performed the physical collapse:** the authoritative
operation list, placement, required claims, and task-mode policy now live once in
`src/catalog/canonical-operations.ts`, and both catalog modules derive from it —
neither declares an independent operation list or an independent claim/mode
policy any longer. Each catalog supplies only its per-surface *presentation*
(title, description, input/output schema, and the scenario-only mock mapping and
redaction rules), and a bidirectional parity check fails the build if either
catalog's presentation set drifts from the canonical surface.
## The two catalogs
@@ -29,20 +35,19 @@ Reconciled op-set relationship (pinned by the drift test):
## Canonical authority decision
- **Descriptor schema:** the rich `src/tools/` descriptor is the canonical shape.
It is the only one that already names placement, side-effect class,
idempotency, redaction, and mock mapping — the per-operation facts the plan
requires. The canonical authority extends it with **real-binding status** and
**PRP-evidence shape**.
- **Machine-readable authority:** `src/catalog/reconciliation.ts` enumerates the
41-op union exactly once (`CAPABILITY_CANONICAL_CATALOG`), each entry naming
placement, claims, task modes, roles, side-effect class, idempotency,
redaction, mock mapping, real-binding status, PRP evidence, and legacy
aliases. `capabilityCatalogReconciliation()` computes the op-set summary and
the divergence ledger.
- No spec should call either `src/tools/` or `src/semantic-tools/` canonical.
Both remain in place this increment; the follow-on collapses them into
derivations of the authority.
- **Single source of truth:** `src/catalog/canonical-operations.ts` hand-authors
the 41-op union exactly once (`CAPABILITY_CANONICAL_OPERATIONS`), each entry
naming surfaces, placement, optional group, claims, task modes, roles,
side-effect class, idempotency, disabled-by-default, real-binding status, PRP
evidence, and legacy aliases. Both catalog modules import it and project it:
`src/tools/` renders the rich scenario descriptor and `src/semantic-tools/`
renders the live provider descriptor.
- **Drift ledger:** `src/catalog/reconciliation.ts` enriches the canonical list
with the scenario mock mapping and redaction flag (`CAPABILITY_CANONICAL_CATALOG`),
and `capabilityCatalogReconciliation()` recomputes the op-set summary and any
remaining metadata divergence between the two catalogs' actual descriptors.
- Neither `src/tools/` nor `src/semantic-tools/` is canonical; both are
projections of `canonical-operations.ts`.
## Real-binding status (before real Paperclip service binding)
@@ -58,28 +63,43 @@ current executability is classified as:
- **`test_only` (1):** `generic_api_request` — the escape hatch, which by rule
**cannot count as product capability coverage**.
## Divergence ledger
## Divergence ledger (after the PAP-17039 collapse)
For every shared operation the authority compares `requiredClaims`, task modes,
and input-schema shape, and records a disposition. The drift test fails if a new
divergence appears without one.
`requiredClaims` and task modes are single-sourced from
`canonical-operations.ts`, so the two catalogs cannot diverge on them; the drift
test asserts both divergence sets are now **empty**. The reconciliation ledger
recomputes divergences from the two catalogs' actual descriptors:
- **Claims (1):** `generic_api_request` requires `test:generic_api` (scenario)
vs `test:generic_api_request` (live). Same reviewed test-only grant; unify in
the follow-on.
- **Task modes (15):** `block_task`, `comment_on_approval`,
`control_workspace_service`, `create_task`, `decide_approval`, `finish_task`,
`get_workspace_runtime`, `list_agents`, `list_approvals`,
`register_deliverable`, `report_progress`, `request_approval`,
`request_review`, `search_tasks`, `set_dependencies`. The scenario catalog
scopes modes to the coverage the suite drives (adds `skill_test`, restricts
optional reads to `standard`); the live catalog encodes the runtime exposure
policy (opens optional reads to `ask`/`planning`, keeps terminal writes
`standard`-only). Reconcile to one canonical policy in the follow-on.
- **Input-schema shape (shared mutations and some reads):** the scenario
descriptor keeps idempotency out-of-band and omits provider bounds; the live
provider descriptor threads `idempotencyKey` in-band and adds length/pattern
bounds for the model. Unify the schema in the follow-on.
- **Claims (0):** unified. `generic_api_request` carries a single claim,
`test:generic_api_request`, on both surfaces.
- **Task modes (0):** unified to one canonical policy — **the live runtime
exposure policy unioned with `skill_test`**. Reads stay open in every mode;
work-writes (`report_progress`, `write_document`, `request_human_input`,
`register_deliverable`) allow `standard`/`planning`/`skill_test`; lifecycle
and state writes (`finish_task`, `block_task`, `request_review`, `create_task`,
`set_dependencies`, request/decide/comment approvals, `control_workspace_service`)
allow `standard`/`skill_test`. The `skill_test` mode makes every operation the
eval harness must drive drivable, while the live runtime keeps its intended
per-mode exposure.
- **Input-schema shape (reviewed intentional projection):** one input contract is
projected two ways by design — the live provider descriptor threads
`idempotencyKey` in-band with provider length/pattern bounds for the model,
while the observable scenario runtime threads idempotency out-of-band. This is
a single reviewed projection recorded in the ledger, not two independently
authored schemas.
### Effect on live provider schemas (QA gate)
- The generated live contract golden `generated/capability/semantic-tool-contracts.json`
is **byte-identical** after the collapse: it serializes name, description,
input/output schema, exposure, and required claims — none of which changed on
the live surface (`allowedModes` is not serialized).
- The live runtime **`allowedModes`** gained `skill_test` for the nine
previously `standard`-only operations (`finish_task`, `block_task`,
`request_review`, `create_task`, `set_dependencies`, `request_approval`,
`decide_approval`, `control_workspace_service`, `schedule_wake`). This is a
behavioral change to eval-harness exposure only; real live tasks never run in
`skill_test` mode.
## Legacy MCP aliases
@@ -92,33 +112,41 @@ operation for the well-known aliases (for example `paperclipListAgents` →
`list_agents`, `paperclipGetAgent` → `get_agent`, `paperclipListIssues` →
`search_tasks`, `paperclipCreateIssue` → `create_task`).
## Deliberate decisions recorded for the surface-ledger and eval tasks
## Collapse decisions executed by PAP-17039
This increment makes **no runtime behavior change** — both catalogs keep their
exact current surfaces, so the live golden and scenario suites are unchanged.
The reconciliation records the following decisions to be executed by the
schema-migration follow-on and consumed by the real-surface-ledger (deliverable
B) and eval (deliverable F) tasks:
1. **One source of truth.** `src/tools/` and `src/semantic-tools/` no longer
declare independent operation lists; both project
`CAPABILITY_CANONICAL_OPERATIONS`. Placement, optional group, required claims,
task modes, allowed roles, side-effect class, idempotency, and
disabled-by-default are single-sourced. Bidirectional parity checks fail the
build on any drift between a catalog's presentation and the canonical surface.
2. **`generic_api_request` claim unified** to `test:generic_api_request`
(scenario callers/tests updated). It stays `test_only` and is excluded from
product capability coverage.
3. **One canonical task-mode policy** (live runtime policy ∪ `skill_test`); the
ledger's 15 task-mode divergences are resolved to zero.
4. **Input schema unified as one contract, projected two ways** — idempotency
in-band with provider bounds for the model, out-of-band for the observable
scenario runtime — recorded as the single reviewed intentional projection.
5. **Placement of scenario-only (13) and live-only (4) operations retained.** The
13 scenario-only ops stay `scenario_mock` (eval-only via mock extensions); the
4 live-only reads/continuation ops (`get_agent`, `get_approval`,
`get_approval_context`, `schedule_wake`) stay `live_codex` with no scenario
projection. No operation crossed surfaces this increment; a future real-service
binding (deliverable G) is where scenario-only ops would gain a live binding.
1. Collapse `src/tools/` and `src/semantic-tools/` catalog lists into
derivations of `src/catalog/reconciliation.ts`, so there is one source of
truth and the second module is compatibility-only.
2. Unify the `generic_api_request` claim to a single test-only grant.
3. Choose one canonical task-mode policy per operation (the ledger's 15 task-mode
divergences).
4. Unify the input-schema shape (idempotency handling and provider bounds) —
this changes the live provider schemas the model sees and the generated
`semantic-tool-contracts.json` golden, and therefore requires live/browser
re-QA.
5. Decide the placement of the 13 scenario-only and 4 live-only operations on the
unified surface (which become always/optional, which stay scenario/eval-only,
which gain a live binding).
Consumed by the real-surface-ledger (deliverable B) and eval (deliverable F)
tasks: the live contract golden is unchanged; the only behavioral delta is the
`allowedModes` `skill_test` addition described above.
## Verification
- `src/catalog/reconciliation.test.ts` — pins the op-set relationship, requires a
reconciliation entry and full metadata for every union operation, classifies
real-binding status, and requires a disposition for every divergence.
- `src/catalog/reconciliation.test.ts` — pins the op-set relationship, asserts
both catalogs derive their operation set from the canonical source, asserts
claims/modes/placement are single-sourced (no divergence), and requires a
reviewed disposition for the surviving input-schema projection.
- `src/catalog/catalog-docs.test.ts` — recomputes the catalog counts and
membership in `docs/capability-semantic-tools.md` from the catalog so a stale
hand count fails.
- Live golden byte-identity checked by `check:semantic-contracts`; the scenario
and live runtime behavior is covered by the `test:scenarios` suites.
@@ -0,0 +1,272 @@
/**
* Canonical semantic-operation registry — the single source of truth.
*
* The runner historically carried two independently hand-maintained semantic
* catalogs:
*
* - `src/tools/` — the rich scenario/eval descriptor (placement, side-effect
* class, idempotency, redaction, mock mapping) consumed by the scenario and
* conformance layers and exported as the package-root default.
* - `src/semantic-tools/` — the live runtime engine (catalog + policy +
* dispatcher) that drives live Codex, the issue-thread projection, and the
* generated provider-neutral contracts.
*
* PAP-17025 froze a machine-readable authority over the union of both surfaces.
* PAP-17039 performs the physical collapse: this module hand-authors the
* 41-operation union exactly once, and **both** catalog modules now derive
* their operation list, placement, required claims, and task-mode policy from
* it. Neither catalog declares an independent operation list or an independent
* claim/mode policy any longer — they only supply per-surface presentation
* (title, description, input/output schema, and the scenario-only mock mapping
* and redaction rules).
*
* Divergences the collapse resolved (see `spec/capability/catalog-reconciliation.md`):
*
* 1. `generic_api_request` now carries a single claim, `test:generic_api_request`.
* 2. Task modes follow one canonical policy: the live runtime exposure policy
* unioned with `skill_test`, so every operation the eval harness must drive
* is drivable while the live runtime keeps its intended per-mode exposure.
* 3. Placement (`always`/`optional`), required claims, and allowed roles are
* single-sourced here; the two catalogs can no longer disagree on them.
*
* The only surviving cross-surface difference is input-schema *shape*: the live
* provider schema threads `idempotencyKey` in-band with provider bounds for the
* model, while the observable scenario runtime threads idempotency out-of-band.
* That is a single reviewed projection of one contract, not two independent
* schemas — recorded as an intentional disposition in the reconciliation ledger.
*/
import type {
CapabilitySideEffectClass,
CapabilityToolDisposition,
CapabilityOptionalCatalogGroup,
CapabilityToolTaskMode,
ScenarioChatdempotencyBehavior,
} from "../tools/capability-semantic-tool-types.js";
/** Which catalog surface(s) project this operation. */
export type CapabilityCatalogSurface = "scenario" | "live";
/** Where an operation is executable today, before real Paperclip binding. */
export type CapabilityRealBindingStatus = "live_codex" | "scenario_mock" | "test_only";
export interface CapabilityCanonicalOperation {
readonly operationId: string;
/** Catalog surface(s) that project this operation today. */
readonly surfaces: readonly CapabilityCatalogSurface[];
readonly placement: CapabilityToolDisposition;
/** Scenario optional-catalog group; `null` for always tools and live-only ops. */
readonly optionalGroup: CapabilityOptionalCatalogGroup | null;
readonly requiredClaims: readonly string[];
readonly taskModes: readonly CapabilityToolTaskMode[];
readonly allowedRoles?: readonly string[];
readonly sideEffectClass: CapabilitySideEffectClass;
readonly idempotency: ScenarioChatdempotencyBehavior;
/** Live-only exposure flag; kept off the tool surface until explicitly enabled. */
readonly disabledByDefault: boolean;
readonly realBindingStatus: CapabilityRealBindingStatus;
/**
* No semantic operation is bound to a real Paperclip service yet; the mock is
* the only backend (real-service binding is deliverable G of PAP-17016).
*/
readonly realServiceBinding: "unbound";
/** Intended PRP evidence family carrying this operation's effect. */
readonly prpEvidence: string;
/** The formal PRP expressiveness proof is deliverable C; entries stay pending. */
readonly prpBindingStatus: "audit_pending";
/** Legacy MCP alias ids folded into this operation, if any. */
readonly legacyAliases: readonly string[];
readonly note?: string;
}
const ALL_MODES: readonly CapabilityToolTaskMode[] = ["standard", "ask", "planning", "skill_test"];
const WORK_MODES: readonly CapabilityToolTaskMode[] = ["standard", "planning", "skill_test"];
const STANDARD_MODES: readonly CapabilityToolTaskMode[] = ["standard", "skill_test"];
const TEST_MODES: readonly CapabilityToolTaskMode[] = ["skill_test"];
function prpEvidenceForSideEffect(sideEffectClass: CapabilitySideEffectClass): string {
switch (sideEffectClass) {
case "read":
return "read projection surfaced via a tool-result item event; no control-plane state diff";
case "task_write":
return "semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events";
case "company_write":
return "semantic-operation item event plus company-entity state diff and audit record";
case "governance":
return "approval lifecycle plus governed-wait continuation and audit events";
case "workspace_control":
return "workspace service lifecycle event";
case "secret_read":
return "redacted tool-result item event; secret value never reaches the wire";
case "admin":
return "company admin/portability item event plus audit record";
case "test_escape_hatch":
return "test-only; excluded from product PRP evidence";
default:
return "audit_pending";
}
}
/** Compact authoring row; the exported catalog fills in derived fields. */
interface Row {
readonly operationId: string;
readonly surfaces: readonly CapabilityCatalogSurface[];
readonly placement: CapabilityToolDisposition;
readonly optionalGroup?: CapabilityOptionalCatalogGroup;
readonly requiredClaims?: readonly string[];
readonly taskModes: readonly CapabilityToolTaskMode[];
readonly allowedRoles?: readonly string[];
readonly sideEffectClass: CapabilitySideEffectClass;
readonly idempotency: ScenarioChatdempotencyBehavior;
readonly disabledByDefault?: boolean;
readonly realBindingStatus: CapabilityRealBindingStatus;
readonly legacyAliases?: readonly string[];
readonly note?: string;
}
const SHARED: readonly CapabilityCatalogSurface[] = ["scenario", "live"];
const SCENARIO_ONLY: readonly CapabilityCatalogSurface[] = ["scenario"];
const LIVE_ONLY: readonly CapabilityCatalogSurface[] = ["live"];
// ---------------------------------------------------------------------------
// The single canonical operation list. One row per union operation. Task modes
// follow the canonical policy (live runtime exposure policy ∪ `skill_test`);
// scenario-only and live-only operations keep their single existing policy
// because there is no second surface to reconcile against.
// ---------------------------------------------------------------------------
const ROWS: readonly Row[] = [
// --- always-present active-task surface ---
{ operationId: "get_task_context", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipMe"] },
{ operationId: "get_task_history", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "list_documents", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "read_document", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "list_document_revisions", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "report_progress", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipAddComment"] },
{ operationId: "answer_status_question", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "write_document", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "request_human_input", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "register_deliverable", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "finish_task", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "block_task", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "request_review", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" },
{
operationId: "inspect_operation_result", surfaces: SCENARIO_ONLY, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none",
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only re-read of a prior operation result; the live dispatcher returns results inline.",
},
// --- discovery ---
{ operationId: "search_tasks", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:tasks:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListIssues"] },
{ operationId: "list_agents", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:agents:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListAgents"] },
{
operationId: "get_agent", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["discovery:agents:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none",
realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipGetAgent"],
note: "Live-only single-actor read; the scenario suite covers actor discovery through list_agents.",
},
{ operationId: "list_projects", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:projects:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet." },
{ operationId: "list_goals", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:goals:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet." },
// --- delegation & dependencies ---
{ operationId: "create_task", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "delegation_dependencies", requiredClaims: ["delegation:tasks:create"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipCreateIssue"] },
{ operationId: "set_dependencies", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "delegation_dependencies", requiredClaims: ["dependencies:write"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "live_codex" },
// --- governance ---
{ operationId: "list_approvals", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "request_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:request"], taskModes: STANDARD_MODES, sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "decide_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:decide"], taskModes: STANDARD_MODES, allowedRoles: ["board", "approver", "security"], sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" },
{ operationId: "comment_on_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:comment"], taskModes: ALL_MODES, sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" },
{
operationId: "get_approval", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none",
realBindingStatus: "live_codex",
note: "Live-only single-approval read; the scenario suite covers approvals through list_approvals.",
},
{
operationId: "get_approval_context", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none",
realBindingStatus: "live_codex",
note: "Live-only approval-plus-linked-tasks read; no scenario counterpart.",
},
// --- workspace runtime ---
{ operationId: "get_workspace_runtime", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "workspace_runtime", requiredClaims: ["workspace:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" },
{ operationId: "control_workspace_service", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "workspace_runtime", requiredClaims: ["workspace:control"], taskModes: STANDARD_MODES, sideEffectClass: "workspace_control", idempotency: "required", realBindingStatus: "live_codex" },
// --- control-plane wake scheduling (live-only) ---
{
operationId: "schedule_wake", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["control_plane:wakes"], taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required",
realBindingStatus: "live_codex",
note: "Bounded, deterministic mock wake scheduling; requires the control_plane:wakes claim. Live-only continuation primitive.",
},
// --- scenario-only optional surfaces (mock extensions, no live binding) ---
{ operationId: "list_cases", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "cases", requiredClaims: ["cases:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "upsert_case", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "cases", requiredClaims: ["cases:write"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "list_routines", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "routines", requiredClaims: ["routines:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "manage_routine", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "routines", requiredClaims: ["routines:write"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "list_company_skills", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "company_skills", requiredClaims: ["company_skills:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "sync_company_skills", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "company_skills", requiredClaims: ["company_skills:write"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "list_secret_metadata", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "secrets", requiredClaims: ["secrets:metadata:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension; metadata only." },
{
operationId: "read_secret_value", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "secrets", requiredClaims: ["secrets:values:read"], taskModes: STANDARD_MODES, sideEffectClass: "secret_read", idempotency: "none",
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only mock extension; secret value redacted from every observable sink.",
},
{ operationId: "export_company", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "portability_admin", requiredClaims: ["portability:export"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
{ operationId: "administer_company", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "portability_admin", requiredClaims: ["company:admin"], taskModes: STANDARD_MODES, allowedRoles: ["board", "ceo", "admin"], sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
// --- test-only escape hatch (shared, never product coverage) ---
{
operationId: "generic_api_request", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "test_escape_hatch", requiredClaims: ["test:generic_api_request"], taskModes: TEST_MODES, sideEffectClass: "test_escape_hatch", idempotency: "required", disabledByDefault: true,
realBindingStatus: "test_only",
note: "Test-only escape hatch; explicitly cannot count as product capability coverage (PAP-17016 deliverable D). Single unified claim test:generic_api_request.",
},
];
function buildOperation(row: Row): CapabilityCanonicalOperation {
return Object.freeze({
operationId: row.operationId,
surfaces: Object.freeze([...row.surfaces]),
placement: row.placement,
optionalGroup: row.optionalGroup ?? null,
requiredClaims: Object.freeze([...(row.requiredClaims ?? [])]),
taskModes: Object.freeze([...row.taskModes]),
...(row.allowedRoles === undefined ? {} : { allowedRoles: Object.freeze([...row.allowedRoles]) }),
sideEffectClass: row.sideEffectClass,
idempotency: row.idempotency,
disabledByDefault: row.disabledByDefault ?? false,
realBindingStatus: row.realBindingStatus,
realServiceBinding: "unbound",
prpEvidence: prpEvidenceForSideEffect(row.sideEffectClass),
prpBindingStatus: "audit_pending",
legacyAliases: Object.freeze([...(row.legacyAliases ?? [])]),
...(row.note === undefined ? {} : { note: row.note }),
});
}
/** The single canonical catalog: the union of both surfaces, sorted by id. */
export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperation[] = Object.freeze(
[...ROWS].map(buildOperation).sort((left, right) => left.operationId.localeCompare(right.operationId)),
);
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
if (byId.size !== CAPABILITY_CANONICAL_OPERATIONS.length) {
throw new Error("duplicate canonical semantic operation id");
}
export function capabilityCanonicalOperation(
operationId: string,
): CapabilityCanonicalOperation | undefined {
return byId.get(operationId);
}
/** Canonical operations projected onto one catalog surface, sorted by id. */
export function capabilityCanonicalOperationsForSurface(
surface: CapabilityCatalogSurface,
): readonly CapabilityCanonicalOperation[] {
return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface));
}
/** Convenience for docs/generators: the canonical operation-id list, sorted. */
export function capabilityCanonicalOperationIds(): readonly string[] {
return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId);
}
@@ -1 +1,2 @@
export * from "./canonical-operations.js";
export * from "./reconciliation.js";
@@ -6,8 +6,11 @@ import { describe, expect, it } from "vitest";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG as SCENARIO_CATALOG } from "../tools/capability-semantic-tool-catalog.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG as LIVE_CATALOG } from "../semantic-tools/catalog.js";
import {
CAPABILITY_CANONICAL_CATALOG,
CAPABILITY_CANONICAL_OPERATIONS,
capabilityCanonicalOperation,
} from "./canonical-operations.js";
import {
CAPABILITY_CANONICAL_CATALOG,
capabilityCatalogReconciliation,
} from "./reconciliation.js";
@@ -45,18 +48,42 @@ describe("canonical semantic-catalog reconciliation authority", () => {
]);
});
it("defines exactly one canonical entry per union operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(41);
const ids = CAPABILITY_CANONICAL_CATALOG.map((operation) => operation.operationId);
expect(new Set(ids).size).toBe(ids.length);
const scenarioIds = new Set(SCENARIO_CATALOG.map((tool) => tool.operationId));
const liveIds = new Set(LIVE_CATALOG.map((tool) => tool.operationId));
for (const id of [...scenarioIds, ...liveIds]) {
expect(capabilityCanonicalOperation(id)).toBeDefined();
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(41);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
for (const tool of LIVE_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
// Every canonical operation is projected onto the surface(s) it names.
for (const operation of CAPABILITY_CANONICAL_OPERATIONS) {
if (operation.surfaces.includes("scenario")) {
expect(SCENARIO_CATALOG.some((tool) => tool.operationId === operation.operationId)).toBe(true);
}
if (operation.surfaces.includes("live")) {
expect(LIVE_CATALOG.some((tool) => tool.operationId === operation.operationId)).toBe(true);
}
}
});
it("single-sources claims and task modes so the two catalogs cannot diverge", () => {
for (const operation of CAPABILITY_CANONICAL_OPERATIONS) {
const scenario = SCENARIO_CATALOG.find((tool) => tool.operationId === operation.operationId);
const live = LIVE_CATALOG.find((tool) => tool.operationId === operation.operationId);
if (scenario !== undefined) {
expect([...scenario.requiredClaims].sort()).toEqual([...operation.requiredClaims].sort());
expect([...scenario.taskModes].sort()).toEqual([...operation.taskModes].sort());
expect(scenario.disposition).toBe(operation.placement);
}
if (live !== undefined) {
expect([...live.requiredClaims].sort()).toEqual([...operation.requiredClaims].sort());
expect([...live.allowedModes].sort()).toEqual([...operation.taskModes].sort());
expect(live.exposure).toBe(operation.placement === "always_agent_tool" ? "always" : "optional");
}
}
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(41);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -85,7 +112,16 @@ describe("canonical semantic-catalog reconciliation authority", () => {
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");
});
it("records a disposition for every metadata divergence between the two catalogs", () => {
it("unifies the generic_api_request claim to a single test-only grant", () => {
const canonical = capabilityCanonicalOperation("generic_api_request");
expect(canonical?.requiredClaims).toEqual(["test:generic_api_request"]);
const scenario = SCENARIO_CATALOG.find((tool) => tool.operationId === "generic_api_request");
const live = LIVE_CATALOG.find((tool) => tool.operationId === "generic_api_request");
expect(scenario?.requiredClaims).toEqual(["test:generic_api_request"]);
expect(live?.requiredClaims).toEqual(["test:generic_api_request"]);
});
it("leaves zero claim or task-mode divergence, and only the reviewed input-schema projection", () => {
const { divergences } = capabilityCatalogReconciliation();
// No divergence may be left unrecorded, regardless of field.
for (const divergence of divergences) {
@@ -97,33 +133,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
.map((entry) => entry.operationId)
.sort();
// The only claim divergence is the reviewed test-only escape-hatch grant.
expect(idsFor("requiredClaims")).toEqual(["generic_api_request"]);
// Claims and task modes are single-sourced from canonical-operations.ts, so
// there is nothing left to reconcile on either field.
expect(idsFor("requiredClaims")).toEqual([]);
expect(idsFor("taskModes")).toEqual([]);
// Task-mode policy diverges systematically between the two surfaces; the
// exact op-set is pinned so a new, unreviewed divergence fails the gate.
expect(idsFor("taskModes")).toEqual([
"block_task",
"comment_on_approval",
"control_workspace_service",
"create_task",
"decide_approval",
"finish_task",
"get_workspace_runtime",
"list_agents",
"list_approvals",
"register_deliverable",
"report_progress",
"request_approval",
"request_review",
"search_tasks",
"set_dependencies",
]);
// Input schemas legitimately differ across the two surfaces today; every one
// carries the shared schema-migration disposition.
for (const divergence of divergences.filter((entry) => entry.field === "inputSchemaShape")) {
expect(divergence.disposition).toMatch(/schema-migration follow-on/);
// Input schemas remain a single contract projected two ways (idempotency
// in-band for the provider, out-of-band for the observable runtime); every
// entry carries the reviewed projection disposition.
const inputSchema = divergences.filter((entry) => entry.field === "inputSchemaShape");
expect(inputSchema.length).toBeGreaterThan(0);
for (const divergence of inputSchema) {
expect(divergence.disposition).toMatch(/Reviewed intentional projection/);
}
});
@@ -143,7 +164,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
}
// Every legacy alias named by the canonical authority must be a real
// inventory row, so a native-mapping note cannot reference a phantom alias.
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
for (const operation of CAPABILITY_CANONICAL_OPERATIONS) {
for (const alias of operation.legacyAliases) {
expect(aliasIds.has(alias)).toBe(true);
}
@@ -1,72 +1,43 @@
/**
* Canonical semantic-catalog reconciliation authority.
* Semantic-catalog reconciliation ledger and drift authority.
*
* The runner historically carried two independently hand-maintained semantic
* catalogs:
* The single source of truth for the operation list, placement, required
* claims, and task-mode policy is `./canonical-operations.ts`
* (`CAPABILITY_CANONICAL_OPERATIONS`). Both catalog modules — `src/tools/`
* (scenario/eval) and `src/semantic-tools/` (live provider) — now derive their
* operation set and those facts from it (PAP-17039), so neither can declare an
* independent list or an independent claim/mode policy.
*
* - `src/tools/` — the rich scenario/eval descriptor (placement, side-effect
* class, idempotency, redaction, mock mapping) consumed by the scenario and
* conformance layers and exported as the package-root default.
* - `src/semantic-tools/` — the live runtime engine (catalog + policy +
* dispatcher) that drives live Codex, the issue-thread projection, and the
* generated provider-neutral contracts.
*
* PAP-17025 makes this module the single machine-readable authority over the
* union of both surfaces so no specification calls either catalog canonical.
* It enumerates every operation once, names its placement, claims, task modes,
* roles, side-effect class, idempotency rule, redaction, mock mapping, real
* binding status, and PRP-evidence shape, and computes every op-set and
* metadata divergence between the two catalogs so drift checks can fail on any
* unrecorded change. Physically collapsing the two catalog modules into
* derivations of this authority (which changes live tool schemas and generated
* goldens and needs live re-QA) is the sequenced follow-on within PAP-17025.
* This module is the drift gate on top of that source: it enriches the
* canonical list with the scenario mock mapping and redaction facts, and it
* recomputes every remaining metadata divergence between the two catalogs'
* actual descriptors so a drift check fails on any unrecorded change. After the
* collapse the only surviving divergence is input-schema *shape*: the live
* provider schema threads `idempotencyKey` in-band with provider bounds for the
* model, while the observable scenario runtime threads idempotency out-of-band.
* That is a single, reviewed projection of one contract, recorded below.
*/
import { CAPABILITY_SEMANTIC_TOOL_CATALOG as SCENARIO_CATALOG } from "../tools/capability-semantic-tool-catalog.js";
import type {
CapabilityMockCommandMapping,
CapabilitySideEffectClass,
CapabilityToolDisposition,
CapabilityToolTaskMode,
ScenarioChatdempotencyBehavior,
} from "../tools/capability-semantic-tool-types.js";
import type { CapabilityMockCommandMapping } from "../tools/capability-semantic-tool-types.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG as LIVE_CATALOG } from "../semantic-tools/catalog.js";
import {
CAPABILITY_CANONICAL_OPERATIONS,
capabilityCanonicalOperation,
type CapabilityCanonicalOperation,
type CapabilityRealBindingStatus,
} from "./canonical-operations.js";
/** Where an operation is executable today, before real Paperclip binding. */
export type CapabilityRealBindingStatus = "live_codex" | "scenario_mock" | "test_only";
export type { CapabilityCatalogSurface, CapabilityRealBindingStatus } from "./canonical-operations.js";
/** Which hand-maintained catalog module(s) currently define the operation. */
export type CapabilityCatalogSurface = "scenario" | "live";
export interface CapabilityCanonicalOperation {
readonly operationId: string;
/** Catalog module(s) that define this operation today. */
readonly surfaces: readonly CapabilityCatalogSurface[];
readonly placement: CapabilityToolDisposition;
readonly requiredClaims: readonly string[];
readonly taskModes: readonly CapabilityToolTaskMode[];
readonly allowedRoles?: readonly string[];
readonly sideEffectClass: CapabilitySideEffectClass;
readonly idempotency: ScenarioChatdempotencyBehavior;
/** True when the operation declares any redaction rule. */
/**
* The canonical operation enriched with the scenario mock mapping and redaction
* flag, for consumers and drift tests that assert full per-operation metadata.
*/
export interface CapabilityReconciledOperation extends CapabilityCanonicalOperation {
/** True when the scenario descriptor declares any redaction rule. */
readonly redacts: boolean;
/** Scenario mock-command mapping; `null` for live-only operations. */
readonly mockCommandMapping: CapabilityMockCommandMapping | null;
readonly realBindingStatus: CapabilityRealBindingStatus;
/**
* No semantic operation is bound to a real Paperclip service yet; the mock is
* the only backend. The real-service binding is deliverable G of the
* foundation plan (PAP-17016), tracked per operation once it lands.
*/
readonly realServiceBinding: "unbound";
/** Intended PRP evidence family carrying this operation's effect. */
readonly prpEvidence: string;
/**
* The formal PRP expressiveness proof is deliverable C of the foundation
* plan; until it lands every entry is `audit_pending` rather than proven.
*/
readonly prpBindingStatus: "audit_pending";
/** Legacy MCP alias ids folded into this operation, if any. */
readonly legacyAliases: readonly string[];
readonly note?: string;
}
export type CapabilityCatalogDivergenceField =
@@ -83,320 +54,82 @@ export interface CapabilityCatalogDivergence {
readonly disposition: string;
}
// ---------------------------------------------------------------------------
// Reconciliation table (hand-authored decisions, one row per union operation).
// Fields the two catalogs already carry are read from the descriptors; only
// facts neither catalog encodes live here: real binding status, PRP evidence,
// legacy aliases, and any side-effect/idempotency call for live-only ops.
// ---------------------------------------------------------------------------
const scenarioById = new Map(SCENARIO_CATALOG.map((tool) => [tool.operationId, tool]));
const liveById = new Map(LIVE_CATALOG.map((tool) => [tool.operationId as string, tool]));
interface ReconciliationRow {
readonly realBindingStatus: CapabilityRealBindingStatus;
readonly legacyAliases?: readonly string[];
readonly note?: string;
/** Required only for the four live-only ops absent from the rich catalog. */
readonly liveOnly?: {
readonly placement: CapabilityToolDisposition;
readonly sideEffectClass: CapabilitySideEffectClass;
readonly idempotency: ScenarioChatdempotencyBehavior;
readonly prpEvidence: string;
};
}
const RECONCILIATION: Readonly<Record<string, ReconciliationRow>> = {
// --- always-present active-task surface (shared) ---
get_task_context: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipMe"] },
get_task_history: { realBindingStatus: "live_codex" },
list_documents: { realBindingStatus: "live_codex" },
read_document: { realBindingStatus: "live_codex" },
list_document_revisions: { realBindingStatus: "live_codex" },
report_progress: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipAddComment"] },
answer_status_question: { realBindingStatus: "live_codex" },
write_document: { realBindingStatus: "live_codex" },
request_human_input: { realBindingStatus: "live_codex" },
register_deliverable: { realBindingStatus: "live_codex" },
finish_task: { realBindingStatus: "live_codex" },
block_task: { realBindingStatus: "live_codex" },
request_review: { realBindingStatus: "live_codex" },
// `inspect_operation_result` is scenario-only: the live dispatcher returns
// results inline rather than exposing a re-read tool.
inspect_operation_result: {
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only re-read of a prior operation result; the live dispatcher returns results inline.",
},
// --- discovery (shared + live-only) ---
search_tasks: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListIssues"] },
list_agents: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListAgents"] },
get_agent: {
realBindingStatus: "live_codex",
legacyAliases: ["mcp:paperclipGetAgent"],
liveOnly: {
placement: "optional_agent_tool",
sideEffectClass: "read",
idempotency: "none",
prpEvidence: "read projection surfaced via a tool-result item event; no control-plane state diff",
},
},
list_projects: {
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet.",
},
list_goals: {
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet.",
},
// --- delegation & dependencies (shared) ---
create_task: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipCreateIssue"] },
set_dependencies: { realBindingStatus: "live_codex" },
// --- governance (shared + live-only) ---
list_approvals: { realBindingStatus: "live_codex" },
request_approval: { realBindingStatus: "live_codex" },
decide_approval: { realBindingStatus: "live_codex" },
comment_on_approval: { realBindingStatus: "live_codex" },
get_approval: {
realBindingStatus: "live_codex",
liveOnly: {
placement: "optional_agent_tool",
sideEffectClass: "read",
idempotency: "none",
prpEvidence: "approval read projection surfaced via a tool-result item event; no state diff",
},
},
get_approval_context: {
realBindingStatus: "live_codex",
liveOnly: {
placement: "optional_agent_tool",
sideEffectClass: "read",
idempotency: "none",
prpEvidence: "approval-plus-linked-tasks read projection via a tool-result item event; no state diff",
},
},
// --- workspace runtime (shared) ---
get_workspace_runtime: { realBindingStatus: "live_codex" },
control_workspace_service: { realBindingStatus: "live_codex" },
// --- control-plane wake scheduling (live-only) ---
schedule_wake: {
realBindingStatus: "live_codex",
liveOnly: {
placement: "optional_agent_tool",
sideEffectClass: "task_write",
idempotency: "required",
prpEvidence: "wake scheduling recorded as a run/attention continuation event; deterministic delay",
},
note: "Bounded, deterministic mock wake scheduling; requires the control_plane:wakes claim.",
},
// --- scenario-only optional surfaces (mock extensions, no live binding) ---
list_cases: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
upsert_case: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
list_routines: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
manage_routine: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
list_company_skills: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
sync_company_skills: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
list_secret_metadata: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension; metadata only." },
read_secret_value: {
realBindingStatus: "scenario_mock",
note: "Scenario/eval-only mock extension; secret value redacted from every observable sink.",
},
export_company: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
administer_company: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." },
// --- test-only escape hatch (shared, never product coverage) ---
generic_api_request: {
realBindingStatus: "test_only",
note: "Test-only escape hatch; explicitly cannot count as product capability coverage (PAP-17016 deliverable D).",
},
};
// ---------------------------------------------------------------------------
// Derivation.
// ---------------------------------------------------------------------------
function prpEvidenceForSideEffect(sideEffectClass: CapabilitySideEffectClass): string {
switch (sideEffectClass) {
case "read":
return "read projection surfaced via a tool-result item event; no control-plane state diff";
case "task_write":
return "semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events";
case "company_write":
return "semantic-operation item event plus company-entity state diff and audit record";
case "governance":
return "approval lifecycle plus governed-wait continuation and audit events";
case "workspace_control":
return "workspace service lifecycle event";
case "secret_read":
return "redacted tool-result item event; secret value never reaches the wire";
case "admin":
return "company admin/portability item event plus audit record";
case "test_escape_hatch":
return "test-only; excluded from product PRP evidence";
default:
return "audit_pending";
}
}
type LiveDescriptor = (typeof LIVE_CATALOG)[number];
const scenarioById = new Map<string, (typeof SCENARIO_CATALOG)[number]>(
SCENARIO_CATALOG.map((tool) => [tool.operationId, tool]),
);
const liveById = new Map<string, LiveDescriptor>(
LIVE_CATALOG.map((tool) => [tool.operationId as string, tool]),
);
const UNION_IDS: readonly string[] = [
...new Set([...scenarioById.keys(), ...liveById.keys()]),
].sort();
function buildOperation(operationId: string): CapabilityCanonicalOperation {
const row = RECONCILIATION[operationId];
if (row === undefined) {
throw new Error(`Missing reconciliation decision for operation ${operationId}.`);
}
const scenario = scenarioById.get(operationId);
const live = liveById.get(operationId);
const surfaces: CapabilityCatalogSurface[] = [];
if (scenario !== undefined) surfaces.push("scenario");
if (live !== undefined) surfaces.push("live");
if (scenario !== undefined) {
return freeze({
operationId,
surfaces,
placement: scenario.disposition,
requiredClaims: [...scenario.requiredClaims],
taskModes: [...scenario.taskModes],
...(scenario.allowedRoles === undefined ? {} : { allowedRoles: [...scenario.allowedRoles] }),
sideEffectClass: scenario.sideEffectClass,
idempotency: scenario.idempotency,
redacts: scenario.redaction.length > 0,
mockCommandMapping: scenario.mockCommandMapping,
realBindingStatus: row.realBindingStatus,
realServiceBinding: "unbound",
prpEvidence: prpEvidenceForSideEffect(scenario.sideEffectClass),
prpBindingStatus: "audit_pending",
legacyAliases: row.legacyAliases ?? [],
...(row.note === undefined ? {} : { note: row.note }),
});
}
// Live-only operation: the rich catalog does not define it, so the
// reconciliation row must supply placement, side-effect class, and evidence.
if (live === undefined || row.liveOnly === undefined) {
throw new Error(`Live-only operation ${operationId} needs an explicit reconciliation row.`);
}
return freeze({
operationId,
surfaces,
placement: row.liveOnly.placement,
requiredClaims: [...live.requiredClaims],
taskModes: [...live.allowedModes] as CapabilityToolTaskMode[],
...(live.allowedRoles === undefined ? {} : { allowedRoles: [...live.allowedRoles] }),
sideEffectClass: row.liveOnly.sideEffectClass,
idempotency: row.liveOnly.idempotency,
redacts: false,
mockCommandMapping: null,
realBindingStatus: row.realBindingStatus,
realServiceBinding: "unbound",
prpEvidence: row.liveOnly.prpEvidence,
prpBindingStatus: "audit_pending",
legacyAliases: row.legacyAliases ?? [],
...(row.note === undefined ? {} : { note: row.note }),
function enrich(operation: CapabilityCanonicalOperation): CapabilityReconciledOperation {
const scenario = scenarioById.get(operation.operationId);
return Object.freeze({
...operation,
redacts: scenario !== undefined && scenario.redaction.length > 0,
mockCommandMapping: scenario?.mockCommandMapping ?? null,
});
}
/** The single canonical catalog: the union of both surfaces, sorted by id. */
export const CAPABILITY_CANONICAL_CATALOG: readonly CapabilityCanonicalOperation[] = Object.freeze(
UNION_IDS.map(buildOperation),
/** The single canonical catalog, enriched with scenario mock/redaction facts. */
export const CAPABILITY_CANONICAL_CATALOG: readonly CapabilityReconciledOperation[] = Object.freeze(
CAPABILITY_CANONICAL_OPERATIONS.map(enrich),
);
const canonicalById = new Map(
CAPABILITY_CANONICAL_CATALOG.map((operation) => [operation.operationId, operation]),
);
export function capabilityCanonicalOperation(
operationId: string,
): CapabilityCanonicalOperation | undefined {
return canonicalById.get(operationId);
}
// ---------------------------------------------------------------------------
// Divergence ledger: every metadata difference between the two catalogs for a
// shared operation, each with a recorded disposition. Drift checks pin this set
// so a new, unrecorded divergence fails.
// Divergence ledger. Placement, required claims, and task modes are now single
// sourced, so those fields cannot diverge. Input-schema shape is the one
// intentional per-surface projection; every entry carries its disposition.
// ---------------------------------------------------------------------------
function inputSchemaShape(schema: { properties?: Record<string, unknown>; required?: readonly string[] } | undefined): string {
function inputSchemaShape(
schema: { properties?: Record<string, unknown>; required?: readonly string[] } | undefined,
): string {
const properties = Object.keys(schema?.properties ?? {}).sort();
const required = [...(schema?.required ?? [])].sort();
return `properties=[${properties.join(",")}] required=[${required.join(",")}]`;
}
/**
* Input schemas differ by design between the two surfaces: the scenario
* descriptor keeps idempotency out-of-band and omits provider bounds, while the
* live provider descriptor threads `idempotencyKey` in-band and adds
* length/pattern bounds for the model. Unifying the schema is the
* schema-migration follow-on within PAP-17025.
* Input schemas are a single contract projected two ways by design: the live
* provider descriptor threads `idempotencyKey` in-band and carries provider
* length/pattern bounds for the model, while the observable scenario runtime
* threads idempotency out-of-band. This is a reviewed, intentional projection,
* not two independently authored schemas.
*/
const INPUT_SCHEMA_SHAPE_DISPOSITION =
"Input schema differs by design (scenario keeps idempotency out-of-band and omits provider bounds; the live provider descriptor threads idempotencyKey in-band with length/pattern bounds). Unify in the schema-migration follow-on.";
/**
* The two catalogs apply task-mode policy differently: the scenario/eval
* catalog scopes modes to the coverage the suite drives (adds `skill_test`,
* restricts optional reads to `standard`), while the live catalog encodes the
* runtime exposure policy (opens optional reads to `ask`/`planning`, keeps
* terminal writes `standard`-only). The canonical target — one task-mode policy
* per operation — is decided in the schema-migration follow-on within
* PAP-17025. The exact divergent op-set is pinned by the drift test.
*/
const TASK_MODE_DISPOSITION =
"Task-mode policy differs between the scenario suite's coverage modes and the live runtime exposure policy. Reconcile to one canonical policy in the schema-migration follow-on.";
const DIVERGENCE_DISPOSITIONS: Readonly<Record<string, string>> = {
"generic_api_request:requiredClaims":
"Test-only escape hatch. The scenario claim `test:generic_api` and the live claim `test:generic_api_request` are the same reviewed test-only grant; unified in the schema-migration follow-on.",
};
"Reviewed intentional projection of one input contract: the live provider descriptor threads idempotencyKey in-band with provider bounds for the model; the observable scenario runtime threads idempotency out-of-band. The operation list, claims, and task modes are single-sourced from canonical-operations.ts.";
function buildDivergences(): readonly CapabilityCatalogDivergence[] {
const divergences: CapabilityCatalogDivergence[] = [];
for (const operationId of UNION_IDS) {
const scenario = scenarioById.get(operationId);
const live = liveById.get(operationId);
for (const operation of CAPABILITY_CANONICAL_OPERATIONS) {
const scenario = scenarioById.get(operation.operationId);
const live = liveById.get(operation.operationId);
if (scenario === undefined || live === undefined) continue;
const record = (
field: CapabilityCatalogDivergenceField,
scenarioValue: string,
liveValue: string,
disposition: string,
): void => {
if (scenarioValue === liveValue) return;
const disposition =
field === "inputSchemaShape"
? INPUT_SCHEMA_SHAPE_DISPOSITION
: field === "taskModes"
? TASK_MODE_DISPOSITION
: (DIVERGENCE_DISPOSITIONS[`${operationId}:${field}`] ??
"UNRECORDED — reconcile or add a disposition.");
divergences.push({ operationId, field, scenario: scenarioValue, live: liveValue, disposition });
divergences.push({ operationId: operation.operationId, field, scenario: scenarioValue, live: liveValue, disposition });
};
record(
"requiredClaims",
[...scenario.requiredClaims].sort().join(","),
[...live.requiredClaims].sort().join(","),
"UNRECORDED — required claims are single-sourced from canonical-operations.ts and must not diverge.",
);
record(
"taskModes",
[...scenario.taskModes].sort().join(","),
[...live.allowedModes].sort().join(","),
"UNRECORDED — task modes are single-sourced from canonical-operations.ts and must not diverge.",
);
record(
"inputSchemaShape",
inputSchemaShape(scenario.inputSchema),
inputSchemaShape(live.inputSchema),
INPUT_SCHEMA_SHAPE_DISPOSITION,
);
record("inputSchemaShape", inputSchemaShape(scenario.inputSchema), inputSchemaShape(live.inputSchema));
}
return Object.freeze(divergences);
}
@@ -426,13 +159,13 @@ export function capabilityCatalogReconciliation(): CapabilityCatalogReconciliati
};
let alwaysCount = 0;
let optionalCount = 0;
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
for (const operation of CAPABILITY_CANONICAL_OPERATIONS) {
byRealBindingStatus[operation.realBindingStatus] += 1;
if (operation.placement === "always_agent_tool") alwaysCount += 1;
else if (operation.placement === "optional_agent_tool") optionalCount += 1;
}
return {
unionCount: UNION_IDS.length,
unionCount: CAPABILITY_CANONICAL_OPERATIONS.length,
scenarioCount: scenarioIds.size,
liveCount: liveIds.size,
sharedCount: [...scenarioIds].filter((id) => liveIds.has(id)).length,
@@ -445,11 +178,10 @@ export function capabilityCatalogReconciliation(): CapabilityCatalogReconciliati
};
}
/** Convenience for docs/generators: the canonical operation-id list, sorted. */
export function capabilityCanonicalOperationIds(): readonly string[] {
return UNION_IDS;
}
function freeze<T>(value: T): T {
return Object.freeze(value);
/** The reconciled operation for one id, or undefined. */
export function capabilityReconciledOperation(
operationId: string,
): CapabilityReconciledOperation | undefined {
const operation = capabilityCanonicalOperation(operationId);
return operation === undefined ? undefined : enrich(operation);
}
@@ -110,7 +110,7 @@ const FIXTURE_PROFILES: Record<string, Partial<CapabilityScenarioProfile>> = {
"skill-test-task": {
seed: "skill_test",
taskMode: "skill_test",
claims: ["test:generic_api"],
claims: ["test:generic_api_request"],
policy: {
allowGenericEscapeHatch: true,
genericEscapeHatchAllowlist: [{ method: "GET", path: "/api/issues/*" }],
@@ -500,7 +500,7 @@ function derivedWorkSteps(
const marker = `Capability scenario ${entry.id}`;
// Skill-test scenarios are the only place the generic escape hatch exists;
// exercising it here is what makes the test-only warning visible in the UI.
const escapeHatch: CapabilityPlanStep[] = entry.scenarioClaims.includes("test:generic_api")
const escapeHatch: CapabilityPlanStep[] = entry.scenarioClaims.includes("test:generic_api_request")
? [
read(
"generic_api_request",
@@ -1,12 +1,21 @@
import {
capabilityCanonicalOperationsForSurface,
type CapabilityCanonicalOperation,
} from "../catalog/canonical-operations.js";
import type {
CapabilityJsonSchema,
CapabilitySemanticOperationId,
CapabilitySemanticToolDescriptor,
} from "./types.js";
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
const STANDARD_MODE = ["standard"] as const;
// ---------------------------------------------------------------------------
// This catalog no longer declares an independent operation list, exposure, or
// claim/mode policy. Exposure (`always`/`optional`), required claims, allowed
// task modes, allowed roles, and the disabled-by-default flag are single-sourced
// from `src/catalog/canonical-operations.ts` (PAP-17039). This module supplies
// only the live provider *presentation*: title, description, and the in-band
// provider input/output schemas the model sees.
// ---------------------------------------------------------------------------
const text = (description: string, maxLength = 20_000): CapabilityJsonSchema => ({
type: "string",
@@ -45,82 +54,27 @@ const operationResult = object({
}, ["commandId", "disposition", "stateRevision", "entityRefs", "scheduledWakeIds"]);
const readResult: CapabilityJsonSchema = { type: "object", additionalProperties: true };
interface DescriptorInput {
operationId: CapabilitySemanticOperationId;
title: string;
description: string;
exposure?: "always" | "optional";
requiredClaims?: readonly string[];
allowedModes?: CapabilitySemanticToolDescriptor["allowedModes"];
allowedRoles?: readonly string[];
disabledByDefault?: boolean;
inputSchema?: CapabilityJsonSchema;
outputSchema?: CapabilityJsonSchema;
}
function descriptor(input: DescriptorInput): CapabilitySemanticToolDescriptor {
return {
schema: "paperclip.semantic-tool.v1",
operationId: input.operationId,
version: 1,
title: input.title,
description: input.description,
exposure: input.exposure ?? "always",
requiredClaims: input.requiredClaims ?? [],
allowedModes: input.allowedModes ?? ALL_MODES,
...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }),
...(input.disabledByDefault === true ? { disabledByDefault: true } : {}),
inputSchema: input.inputSchema ?? object(),
outputSchema: input.outputSchema ?? readResult,
};
}
const idempotency = { idempotencyKey: text("Caller-stable retry key.", 240) } as const;
const descriptors: readonly CapabilitySemanticToolDescriptor[] = [
descriptor({
operationId: "get_task_context",
title: "Get active task context",
description: "Read the active mock task, actor, wake, ancestors, budget, and interaction results.",
}),
descriptor({
operationId: "get_task_history",
title: "Get active task history",
description: "Read bounded comments on the active mock task.",
inputSchema: object({ limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }),
}),
descriptor({ operationId: "list_documents", title: "List task documents", description: "List revisioned documents on the active mock task." }),
descriptor({
operationId: "read_document",
title: "Read task document",
description: "Read the current revision of one active-task document.",
inputSchema: object({ key: text("Stable issue-document key.", 120) }, ["key"]),
}),
descriptor({
operationId: "list_document_revisions",
title: "List document revisions",
description: "Read bounded revision history for one active-task document.",
inputSchema: object({ key: text("Stable issue-document key.", 120), limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, ["key"]),
}),
descriptor({
operationId: "report_progress",
title: "Report durable progress",
description: "Append a durable progress comment to the active mock task.",
inputSchema: object({ ...idempotency, body: text("Multiline progress update.") }, ["idempotencyKey", "body"]),
outputSchema: operationResult,
}),
descriptor({
operationId: "answer_status_question",
title: "Answer status question",
description: "Append the answer to a status-only wake without changing task disposition.",
inputSchema: object({ ...idempotency, body: text("Concise status answer.") }, ["idempotencyKey", "body"]),
outputSchema: operationResult,
}),
descriptor({
operationId: "write_document",
title: "Write revisioned document",
description: "Create or update an active-task document with optimistic revision safety.",
allowedModes: WORK_MODES,
/** Per-operation live provider presentation, in golden declaration order. */
interface LivePresentation {
readonly operationId: CapabilitySemanticOperationId;
readonly title: string;
readonly description: string;
readonly inputSchema?: CapabilityJsonSchema;
readonly outputSchema?: CapabilityJsonSchema;
}
const PRESENTATION: readonly LivePresentation[] = [
{ operationId: "get_task_context", title: "Get active task context", description: "Read the active mock task, actor, wake, ancestors, budget, and interaction results." },
{ operationId: "get_task_history", title: "Get active task history", description: "Read bounded comments on the active mock task.", inputSchema: object({ limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) },
{ operationId: "list_documents", title: "List task documents", description: "List revisioned documents on the active mock task." },
{ operationId: "read_document", title: "Read task document", description: "Read the current revision of one active-task document.", inputSchema: object({ key: text("Stable issue-document key.", 120) }, ["key"]) },
{ operationId: "list_document_revisions", title: "List document revisions", description: "Read bounded revision history for one active-task document.", inputSchema: object({ key: text("Stable issue-document key.", 120), limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, ["key"]) },
{ operationId: "report_progress", title: "Report durable progress", description: "Append a durable progress comment to the active mock task.", inputSchema: object({ ...idempotency, body: text("Multiline progress update.") }, ["idempotencyKey", "body"]), outputSchema: operationResult },
{ operationId: "answer_status_question", title: "Answer status question", description: "Append the answer to a status-only wake without changing task disposition.", inputSchema: object({ ...idempotency, body: text("Concise status answer.") }, ["idempotencyKey", "body"]), outputSchema: operationResult },
{
operationId: "write_document", title: "Write revisioned document", description: "Create or update an active-task document with optimistic revision safety.",
inputSchema: object({
...idempotency,
key: text("Stable issue-document key.", 120),
@@ -130,12 +84,9 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [
changeSummary: nullableText("Optional revision summary."),
}, ["idempotencyKey", "key", "title", "body", "baseRevisionId"]),
outputSchema: operationResult,
}),
descriptor({
operationId: "request_human_input",
title: "Request structured human input",
description: "Create a typed, durable interaction on the active mock task.",
allowedModes: WORK_MODES,
},
{
operationId: "request_human_input", title: "Request structured human input", description: "Create a typed, durable interaction on the active mock task.",
inputSchema: object({
...idempotency,
interactionKind: { enum: ["confirmation", "checkbox", "questions", "suggest_tasks", "item_verdicts"] },
@@ -146,12 +97,9 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [
continuationPolicy: { enum: ["none", "wake_assignee", "wake_assignee_on_accept"] },
}, ["idempotencyKey", "interactionKind", "title", "prompt", "continuationPolicy"]),
outputSchema: operationResult,
}),
descriptor({
operationId: "register_deliverable",
title: "Register inspectable deliverable",
description: "Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.",
allowedModes: WORK_MODES,
},
{
operationId: "register_deliverable", title: "Register inspectable deliverable", description: "Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.",
inputSchema: object({
...idempotency,
filename: text("Display filename.", 500),
@@ -162,30 +110,70 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [
title: text("Work-product title.", 500),
}, ["idempotencyKey", "filename", "contentType", "byteSize", "sha256", "contentRef", "title"]),
outputSchema: operationResult,
}),
descriptor({ operationId: "finish_task", title: "Finish active task", description: "Finish the active mock task with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Completion summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }),
descriptor({ operationId: "block_task", title: "Block active task", description: "Block the active mock task with a durable reason and optional first-class dependencies.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: text("Block reason."), blockedByTaskIds: stringArray("Internal mock task ids that block this task.") }, ["idempotencyKey", "reason"]), outputSchema: operationResult }),
descriptor({ operationId: "request_review", title: "Request task review", description: "Move the active mock task to review with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Review handoff summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }),
descriptor({ operationId: "list_agents", title: "List company agents", description: "List redacted mock actor profiles.", exposure: "optional", requiredClaims: ["discovery:agents:read"] }),
descriptor({ operationId: "get_agent", title: "Get company agent", description: "Read one redacted mock actor profile.", exposure: "optional", requiredClaims: ["discovery:agents:read"], inputSchema: object({ actorId: text("Mock actor id.", 200) }, ["actorId"]) }),
descriptor({ operationId: "search_tasks", title: "Search company tasks", description: "Search mock tasks by text and status within the run company.", exposure: "optional", requiredClaims: ["discovery:tasks:read"], inputSchema: object({ query: { type: "string", maxLength: 500 }, statuses: { type: "array", items: { enum: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] }, maxItems: 7, uniqueItems: true }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) }),
descriptor({ operationId: "list_approvals", title: "List approvals", description: "List mock approvals in the run company.", exposure: "optional", requiredClaims: ["governance:approvals:read"] }),
descriptor({ operationId: "get_approval", title: "Get approval", description: "Read one mock approval without protected data.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }),
descriptor({ operationId: "get_approval_context", title: "Get approval context", description: "Read one approval, its comments, and linked mock tasks.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }),
descriptor({ operationId: "get_workspace_runtime", title: "Get workspace runtime", description: "Read active-task mock workspace services.", exposure: "optional", requiredClaims: ["workspace:read"] }),
descriptor({ operationId: "control_workspace_service", title: "Control workspace service", description: "Start, stop, or fault one active-task mock workspace service.", exposure: "optional", requiredClaims: ["workspace:control"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, serviceId: text("Mock workspace service id.", 200), action: { enum: ["start", "stop", "fail"] }, url: nullableText("Optional mock service URL.") }, ["idempotencyKey", "serviceId", "action"]), outputSchema: operationResult }),
descriptor({ operationId: "set_dependencies", title: "Set task dependencies", description: "Replace the active task's first-class blocker set.", exposure: "optional", requiredClaims: ["dependencies:write"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, blockedByTaskIds: stringArray("Replacement blocker task ids.") }, ["idempotencyKey", "blockedByTaskIds"]), outputSchema: operationResult }),
descriptor({ operationId: "create_task", title: "Create child task", description: "Create one child mock task under the active task.", exposure: "optional", requiredClaims: ["delegation:tasks:create"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, title: text("Child task title.", 500), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional mock actor assignee."), priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task ids.") }, ["idempotencyKey", "title"]), outputSchema: operationResult }),
descriptor({ operationId: "request_approval", title: "Request approval", description: "Create a governed mock approval and waiting posture.", exposure: "optional", requiredClaims: ["governance:approvals:request"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalType: text("Stable approval type.", 200), payload: jsonObject }, ["idempotencyKey", "approvalType", "payload"]), outputSchema: operationResult }),
descriptor({ operationId: "decide_approval", title: "Decide approval", description: "Decide a mock approval as an explicitly authorized approver.", exposure: "optional", requiredClaims: ["governance:approvals:decide"], allowedRoles: ["board", "approver", "security"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), decision: { enum: ["approved", "rejected", "cancelled"] }, note: text("Decision note.") }, ["idempotencyKey", "approvalId", "decision", "note"]), outputSchema: operationResult }),
descriptor({ operationId: "comment_on_approval", title: "Comment on approval", description: "Add a durable comment to a mock approval.", exposure: "optional", requiredClaims: ["governance:approvals:comment"], inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), body: text("Approval comment.") }, ["idempotencyKey", "approvalId", "body"]), outputSchema: operationResult }),
descriptor({ operationId: "schedule_wake", title: "Schedule bounded wake", description: "Schedule a deterministic mock continuation wake.", exposure: "optional", requiredClaims: ["control_plane:wakes"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: { enum: ["manual", "issue_commented", "interaction_resolved", "approval_resolved", "blockers_resolved", "scheduled_retry", "resume"] }, payload: jsonObject, delayTicks: { type: "integer", minimum: 1, maximum: 10_000 } }, ["idempotencyKey", "reason", "delayTicks"]), outputSchema: operationResult }),
descriptor({ operationId: "generic_api_request", title: "Generic API request", description: "Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.", exposure: "optional", requiredClaims: ["test:generic_api_request"], disabledByDefault: true, allowedModes: ["skill_test"], inputSchema: object({ method: { enum: ["GET", "POST", "PATCH"] }, path: { type: "string", pattern: "^/mock/", maxLength: 500 }, body: jsonObject }, ["method", "path"]) }),
},
{ operationId: "finish_task", title: "Finish active task", description: "Finish the active mock task with a durable summary.", inputSchema: object({ ...idempotency, summary: text("Completion summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult },
{ operationId: "block_task", title: "Block active task", description: "Block the active mock task with a durable reason and optional first-class dependencies.", inputSchema: object({ ...idempotency, reason: text("Block reason."), blockedByTaskIds: stringArray("Internal mock task ids that block this task.") }, ["idempotencyKey", "reason"]), outputSchema: operationResult },
{ operationId: "request_review", title: "Request task review", description: "Move the active mock task to review with a durable summary.", inputSchema: object({ ...idempotency, summary: text("Review handoff summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult },
{ operationId: "list_agents", title: "List company agents", description: "List redacted mock actor profiles." },
{ operationId: "get_agent", title: "Get company agent", description: "Read one redacted mock actor profile.", inputSchema: object({ actorId: text("Mock actor id.", 200) }, ["actorId"]) },
{ operationId: "search_tasks", title: "Search company tasks", description: "Search mock tasks by text and status within the run company.", inputSchema: object({ query: { type: "string", maxLength: 500 }, statuses: { type: "array", items: { enum: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] }, maxItems: 7, uniqueItems: true }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) },
{ operationId: "list_approvals", title: "List approvals", description: "List mock approvals in the run company." },
{ operationId: "get_approval", title: "Get approval", description: "Read one mock approval without protected data.", inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) },
{ operationId: "get_approval_context", title: "Get approval context", description: "Read one approval, its comments, and linked mock tasks.", inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) },
{ operationId: "get_workspace_runtime", title: "Get workspace runtime", description: "Read active-task mock workspace services." },
{ operationId: "control_workspace_service", title: "Control workspace service", description: "Start, stop, or fault one active-task mock workspace service.", inputSchema: object({ ...idempotency, serviceId: text("Mock workspace service id.", 200), action: { enum: ["start", "stop", "fail"] }, url: nullableText("Optional mock service URL.") }, ["idempotencyKey", "serviceId", "action"]), outputSchema: operationResult },
{ operationId: "set_dependencies", title: "Set task dependencies", description: "Replace the active task's first-class blocker set.", inputSchema: object({ ...idempotency, blockedByTaskIds: stringArray("Replacement blocker task ids.") }, ["idempotencyKey", "blockedByTaskIds"]), outputSchema: operationResult },
{ operationId: "create_task", title: "Create child task", description: "Create one child mock task under the active task.", inputSchema: object({ ...idempotency, title: text("Child task title.", 500), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional mock actor assignee."), priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task ids.") }, ["idempotencyKey", "title"]), outputSchema: operationResult },
{ operationId: "request_approval", title: "Request approval", description: "Create a governed mock approval and waiting posture.", inputSchema: object({ ...idempotency, approvalType: text("Stable approval type.", 200), payload: jsonObject }, ["idempotencyKey", "approvalType", "payload"]), outputSchema: operationResult },
{ operationId: "decide_approval", title: "Decide approval", description: "Decide a mock approval as an explicitly authorized approver.", inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), decision: { enum: ["approved", "rejected", "cancelled"] }, note: text("Decision note.") }, ["idempotencyKey", "approvalId", "decision", "note"]), outputSchema: operationResult },
{ operationId: "comment_on_approval", title: "Comment on approval", description: "Add a durable comment to a mock approval.", inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), body: text("Approval comment.") }, ["idempotencyKey", "approvalId", "body"]), outputSchema: operationResult },
{ operationId: "schedule_wake", title: "Schedule bounded wake", description: "Schedule a deterministic mock continuation wake.", inputSchema: object({ ...idempotency, reason: { enum: ["manual", "issue_commented", "interaction_resolved", "approval_resolved", "blockers_resolved", "scheduled_retry", "resume"] }, payload: jsonObject, delayTicks: { type: "integer", minimum: 1, maximum: 10_000 } }, ["idempotencyKey", "reason", "delayTicks"]), outputSchema: operationResult },
{ operationId: "generic_api_request", title: "Generic API request", description: "Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.", inputSchema: object({ method: { enum: ["GET", "POST", "PATCH"] }, path: { type: "string", pattern: "^/mock/", maxLength: 500 }, body: jsonObject }, ["method", "path"]) },
];
const canonicalById = new Map(
capabilityCanonicalOperationsForSurface("live").map((operation) => [operation.operationId, operation]),
);
function toDescriptor(
presentation: LivePresentation,
operation: CapabilityCanonicalOperation,
): CapabilitySemanticToolDescriptor {
return {
schema: "paperclip.semantic-tool.v1",
operationId: presentation.operationId,
version: 1,
title: presentation.title,
description: presentation.description,
exposure: operation.placement === "always_agent_tool" ? "always" : "optional",
requiredClaims: [...operation.requiredClaims],
allowedModes: [...operation.taskModes] as CapabilitySemanticToolDescriptor["allowedModes"],
...(operation.allowedRoles === undefined ? {} : { allowedRoles: [...operation.allowedRoles] }),
...(operation.disabledByDefault ? { disabledByDefault: true } : {}),
inputSchema: presentation.inputSchema ?? object(),
outputSchema: presentation.outputSchema ?? readResult,
};
}
// Bidirectional parity: presentation set === canonical live-surface set.
const presentedIds = new Set(PRESENTATION.map((entry) => entry.operationId));
if (presentedIds.size !== PRESENTATION.length) throw new Error("duplicate Capability semantic operation id");
for (const entry of PRESENTATION) {
if (!canonicalById.has(entry.operationId)) {
throw new Error(`Live presentation ${entry.operationId} is not a canonical live-surface operation.`);
}
}
for (const operationId of canonicalById.keys()) {
if (!presentedIds.has(operationId as CapabilitySemanticOperationId)) {
throw new Error(`Canonical live operation ${operationId} has no live presentation.`);
}
}
const descriptors: readonly CapabilitySemanticToolDescriptor[] = PRESENTATION.map((presentation) =>
toDescriptor(presentation, canonicalById.get(presentation.operationId)!),
);
const byId = new Map(descriptors.map((item) => [item.operationId, item]));
if (byId.size !== descriptors.length) throw new Error("duplicate Capability semantic operation id");
export const CAPABILITY_SEMANTIC_TOOL_CATALOG = deepFreeze(descriptors);
@@ -1,13 +1,24 @@
import {
capabilityCanonicalOperationsForSurface,
type CapabilityCanonicalOperation,
} from "../catalog/canonical-operations.js";
import type {
CapabilityJsonSchema,
CapabilityMockCommandMapping,
CapabilityOptionalCatalogGroup,
CapabilityRedactionRule,
CapabilitySemanticToolDescriptor,
CapabilityToolTaskMode,
} from "./capability-semantic-tool-types.js";
const ALL_MODES: CapabilityToolTaskMode[] = ["standard", "ask", "planning", "skill_test"];
const WORK_MODES: CapabilityToolTaskMode[] = ["standard", "planning", "skill_test"];
const STANDARD_MODES: CapabilityToolTaskMode[] = ["standard", "skill_test"];
// ---------------------------------------------------------------------------
// This catalog no longer declares an independent operation list. Placement,
// required claims, task modes, allowed roles, side-effect class, idempotency,
// and optional-group membership are single-sourced from
// `src/catalog/canonical-operations.ts` (PAP-17039). This module supplies only
// the scenario-surface *presentation*: title, description, input schema, the
// abstract mock-command mapping, and any redaction rules.
// ---------------------------------------------------------------------------
const RESULT_SCHEMA = objectSchema({
schema: stringSchema({ enum: ["paperclip.capability.tool-result.v1"] }),
ok: { type: "boolean" },
@@ -17,23 +28,23 @@ const RESULT_SCHEMA = objectSchema({
commandResult: {},
authorization: {},
}, ["schema", "ok", "operationId", "operationResultId", "value", "commandResult", "authorization"]);
const SECRET_REDACTION = [
const SECRET_REDACTION: CapabilityRedactionRule[] = [
{
path: "$.value",
replacement: "[SECRET_VALUE]" as const,
appliesTo: ["output", "error", "authorization_record"] as const,
replacement: "[SECRET_VALUE]",
appliesTo: ["output", "error", "authorization_record"],
},
];
const AUTH_REDACTION = [
const AUTH_REDACTION: CapabilityRedactionRule[] = [
{
path: "$.headers.authorization",
replacement: "[REDACTED]" as const,
appliesTo: ["input", "output", "error", "authorization_record"] as const,
replacement: "[REDACTED]",
appliesTo: ["input", "output", "error", "authorization_record"],
},
{
path: "$.headers.cookie",
replacement: "[REDACTED]" as const,
appliesTo: ["input", "output", "error", "authorization_record"] as const,
replacement: "[REDACTED]",
appliesTo: ["input", "output", "error", "authorization_record"],
},
];
@@ -52,161 +63,83 @@ function objectSchema(
return { type: "object", properties, required, additionalProperties: false };
}
function descriptor(
value: Omit<CapabilitySemanticToolDescriptor, "version" | "redaction"> & {
redaction?: CapabilitySemanticToolDescriptor["redaction"];
},
): CapabilitySemanticToolDescriptor {
const { redaction, ...rest } = value;
return { version: 1, ...rest, redaction: redaction ?? [] };
/** Per-operation scenario presentation, keyed by canonical operation id. */
interface ScenarioPresentation {
/** Bespoke title; optional tools default to a title derived from the id. */
readonly title?: string;
/** Bespoke description; optional tools default to a group-scoped sentence. */
readonly description?: string;
readonly inputSchema: CapabilityJsonSchema;
readonly mockCommandMapping: CapabilityMockCommandMapping;
readonly redaction?: CapabilityRedactionRule[];
}
const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [
descriptor({
operationId: "get_task_context",
const PRESENTATION: Readonly<Record<string, ScenarioPresentation>> = {
// --- always tools (bespoke titles/descriptions) ---
get_task_context: {
title: "Get task context",
description: "Read the active task, ancestors, wake context, linked results, and budget summary.",
inputSchema: objectSchema({}),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "context_read", projection: "active_task" },
}),
descriptor({
operationId: "get_task_history",
},
get_task_history: {
title: "Get task history",
description: "Read comments and audit-visible history for the active task only.",
inputSchema: objectSchema({ limit: { type: "integer", minimum: 1 } }),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_history" },
}),
descriptor({
operationId: "list_documents",
},
list_documents: {
title: "List task documents",
description: "List document metadata attached to the active task.",
inputSchema: objectSchema({}),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_documents" },
}),
descriptor({
operationId: "read_document",
},
read_document: {
title: "Read task document",
description: "Read one document on the active task by stable key.",
inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document" },
}),
descriptor({
operationId: "list_document_revisions",
},
list_document_revisions: {
title: "List document revisions",
description: "Inspect revision history for one active-task document.",
inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document_revisions" },
}),
descriptor({
operationId: "report_progress",
},
report_progress: {
title: "Report progress",
description: "Append a durable progress update to the active task.",
inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: WORK_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" },
}),
descriptor({
operationId: "answer_status_question",
},
answer_status_question: {
title: "Answer status question",
description: "Post an answer to a status question without changing task state.",
inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" },
}),
descriptor({
operationId: "finish_task",
},
finish_task: {
title: "Finish task",
description: "Finish the active task with a durable completion summary.",
inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: STANDARD_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "finish_task" },
}),
descriptor({
operationId: "block_task",
},
block_task: {
title: "Block task",
description: "Block the active task with a reason and optional first-class dependencies.",
inputSchema: objectSchema({
reason: stringSchema({ minLength: 1 }),
blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })),
}, ["reason"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: WORK_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "block_task" },
}),
descriptor({
operationId: "request_review",
},
request_review: {
title: "Request task review",
description: "Move the active task to review with a durable summary.",
inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: WORK_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "request_review" },
}),
descriptor({
operationId: "write_document",
},
write_document: {
title: "Write task document",
description: "Create or revision-safely update an active-task document.",
inputSchema: objectSchema({
@@ -216,17 +149,9 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [
baseRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] },
changeSummary: stringSchema(),
}, ["key", "title", "body", "baseRevisionId"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: WORK_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "write_document" },
}),
descriptor({
operationId: "request_human_input",
},
request_human_input: {
title: "Request human input",
description: "Create a typed confirmation, checkbox, question, task suggestion, or item-verdict request.",
inputSchema: objectSchema({
@@ -237,17 +162,9 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [
targetRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] },
continuationPolicy: stringSchema({ enum: ["none", "wake_assignee", "wake_assignee_on_accept"] }),
}, ["interactionKind", "title", "prompt", "continuationPolicy"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: WORK_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "request_human_input" },
}),
descriptor({
operationId: "register_deliverable",
},
register_deliverable: {
title: "Register deliverable",
description: "Register an inspectable artifact and its work product on the active task.",
inputSchema: objectSchema({
@@ -258,96 +175,186 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [
contentRef: stringSchema({ minLength: 1 }),
title: stringSchema({ minLength: 1 }),
}, ["filename", "contentType", "byteSize", "sha256", "contentRef", "title"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: STANDARD_MODES,
sideEffectClass: "task_write",
idempotency: "required",
mockCommandMapping: { kind: "semantic_command", commandKind: "register_deliverable" },
}),
descriptor({
operationId: "inspect_operation_result",
},
inspect_operation_result: {
title: "Inspect operation result",
description: "Read a prior semantic operation result from this run.",
inputSchema: objectSchema({ operationResultId: stringSchema({ minLength: 1 }) }, ["operationResultId"]),
outputSchema: RESULT_SCHEMA,
disposition: "always_agent_tool",
optionalGroup: null,
requiredClaims: [],
taskModes: ALL_MODES,
sideEffectClass: "read",
idempotency: "none",
mockCommandMapping: { kind: "operation_result" },
}),
];
},
function optional(
operationId: string,
group: CapabilityOptionalCatalogGroup,
requiredClaims: string[],
sideEffectClass: CapabilitySemanticToolDescriptor["sideEffectClass"],
mapping: CapabilitySemanticToolDescriptor["mockCommandMapping"],
inputSchema: CapabilityJsonSchema = objectSchema({}),
options: Partial<Pick<CapabilitySemanticToolDescriptor, "allowedRoles" | "redaction" | "taskModes" | "idempotency">> = {},
): CapabilitySemanticToolDescriptor {
const title = operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" ");
return descriptor({
operationId,
// --- optional tools (title/description derived from id + group) ---
search_tasks: {
inputSchema: objectSchema({ query: stringSchema(), status: stringSchema() }),
mockCommandMapping: { kind: "snapshot_read", projection: "company_tasks" },
},
list_agents: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "snapshot_read", projection: "company_actors" },
},
list_projects: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "discovery.projects" },
},
list_goals: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "discovery.goals" },
},
create_task: {
inputSchema: objectSchema({
title: stringSchema({ minLength: 1 }),
description: stringSchema(),
assigneeActorId: stringSchema(),
priority: stringSchema(),
blockedByTaskIds: arraySchema(stringSchema()),
}, ["title"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "create_task" },
},
set_dependencies: {
inputSchema: objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "set_dependencies" },
},
list_approvals: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "snapshot_read", projection: "company_approvals" },
},
request_approval: {
inputSchema: objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "request_approval" },
},
decide_approval: {
inputSchema: objectSchema({
approvalId: stringSchema({ minLength: 1 }),
decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }),
note: stringSchema({ minLength: 1 }),
}, ["approvalId", "decision", "note"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "decide_approval" },
},
comment_on_approval: {
inputSchema: objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "comment_on_approval" },
},
list_cases: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "cases.list" },
},
upsert_case: {
inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"]),
mockCommandMapping: { kind: "mock_extension", extension: "cases.upsert" },
},
get_workspace_runtime: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "snapshot_read", projection: "active_task_workspace" },
},
control_workspace_service: {
inputSchema: objectSchema({
serviceId: stringSchema({ minLength: 1 }),
action: stringSchema({ enum: ["start", "stop", "fail"] }),
url: stringSchema(),
}, ["serviceId", "action"]),
mockCommandMapping: { kind: "semantic_command", commandKind: "control_workspace_service" },
},
list_routines: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "routines.list" },
},
manage_routine: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "routines.manage" },
},
list_company_skills: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "company_skills.list" },
},
sync_company_skills: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "company_skills.sync" },
},
list_secret_metadata: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "secrets.metadata" },
},
read_secret_value: {
inputSchema: objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]),
mockCommandMapping: { kind: "mock_extension", extension: "secrets.value" },
redaction: [...SECRET_REDACTION],
},
export_company: {
inputSchema: objectSchema({}),
mockCommandMapping: { kind: "mock_extension", extension: "portability.export" },
},
administer_company: {
inputSchema: objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]),
mockCommandMapping: { kind: "mock_extension", extension: "company.admin" },
},
generic_api_request: {
inputSchema: objectSchema({
method: stringSchema({ minLength: 1 }),
path: stringSchema({ minLength: 1 }),
headers: { type: "object", additionalProperties: stringSchema() },
body: {},
}, ["method", "path"]),
mockCommandMapping: { kind: "mock_extension", extension: "test.generic_api" },
redaction: [...AUTH_REDACTION],
},
};
function titleFor(operationId: string): string {
return operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" ");
}
function descriptionFor(title: string, group: CapabilityOptionalCatalogGroup): string {
return `${title} through the Capability ${group.replaceAll("_", " ")} capability set.`;
}
function toDescriptor(operation: CapabilityCanonicalOperation): CapabilitySemanticToolDescriptor {
const presentation = PRESENTATION[operation.operationId];
if (presentation === undefined) {
throw new Error(`Missing scenario presentation for operation ${operation.operationId}.`);
}
const title = presentation.title ?? titleFor(operation.operationId);
const description = presentation.description
?? descriptionFor(title, operation.optionalGroup ?? "discovery");
return Object.freeze({
operationId: operation.operationId,
version: 1,
title,
description: `${title} through the Capability ${group.replaceAll("_", " ")} capability set.`,
inputSchema,
description,
inputSchema: presentation.inputSchema,
outputSchema: RESULT_SCHEMA,
disposition: "optional_agent_tool",
optionalGroup: group,
requiredClaims,
taskModes: options.taskModes ?? STANDARD_MODES,
sideEffectClass,
idempotency: options.idempotency ?? (sideEffectClass === "read" ? "none" : "required"),
redaction: options.redaction,
allowedRoles: options.allowedRoles,
mockCommandMapping: mapping,
disposition: operation.placement,
optionalGroup: operation.optionalGroup,
requiredClaims: [...operation.requiredClaims],
...(operation.allowedRoles === undefined ? {} : { allowedRoles: [...operation.allowedRoles] }),
taskModes: [...operation.taskModes],
sideEffectClass: operation.sideEffectClass,
idempotency: operation.idempotency,
redaction: presentation.redaction ?? [],
mockCommandMapping: presentation.mockCommandMapping,
});
}
const OPTIONAL_TOOLS: CapabilitySemanticToolDescriptor[] = [
optional("search_tasks", "discovery", ["discovery:tasks:read"], "read", { kind: "snapshot_read", projection: "company_tasks" }, objectSchema({ query: stringSchema(), status: stringSchema() })),
optional("list_agents", "discovery", ["discovery:agents:read"], "read", { kind: "snapshot_read", projection: "company_actors" }),
optional("list_projects", "discovery", ["discovery:projects:read"], "read", { kind: "mock_extension", extension: "discovery.projects" }),
optional("list_goals", "discovery", ["discovery:goals:read"], "read", { kind: "mock_extension", extension: "discovery.goals" }),
const SCENARIO_OPERATIONS = capabilityCanonicalOperationsForSurface("scenario");
const canonicalById = new Map(SCENARIO_OPERATIONS.map((operation) => [operation.operationId, operation]));
optional("create_task", "delegation_dependencies", ["delegation:tasks:create"], "company_write", { kind: "semantic_command", commandKind: "create_task" }, objectSchema({ title: stringSchema({ minLength: 1 }), description: stringSchema(), assigneeActorId: stringSchema(), priority: stringSchema(), blockedByTaskIds: arraySchema(stringSchema()) }, ["title"])),
optional("set_dependencies", "delegation_dependencies", ["dependencies:write"], "company_write", { kind: "semantic_command", commandKind: "set_dependencies" }, objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"])),
optional("list_approvals", "governance", ["governance:approvals:read"], "read", { kind: "snapshot_read", projection: "company_approvals" }),
optional("request_approval", "governance", ["governance:approvals:request"], "governance", { kind: "semantic_command", commandKind: "request_approval" }, objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"])),
optional("decide_approval", "governance", ["governance:approvals:decide"], "governance", { kind: "semantic_command", commandKind: "decide_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }), note: stringSchema({ minLength: 1 }) }, ["approvalId", "decision", "note"]), { allowedRoles: ["board", "approver", "security"] }),
optional("comment_on_approval", "governance", ["governance:approvals:comment"], "governance", { kind: "semantic_command", commandKind: "comment_on_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"])),
optional("list_cases", "cases", ["cases:read"], "read", { kind: "mock_extension", extension: "cases.list" }),
optional("upsert_case", "cases", ["cases:write"], "company_write", { kind: "mock_extension", extension: "cases.upsert" }, objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"])),
optional("get_workspace_runtime", "workspace_runtime", ["workspace:read"], "read", { kind: "snapshot_read", projection: "active_task_workspace" }),
optional("control_workspace_service", "workspace_runtime", ["workspace:control"], "workspace_control", { kind: "semantic_command", commandKind: "control_workspace_service" }, objectSchema({ serviceId: stringSchema({ minLength: 1 }), action: stringSchema({ enum: ["start", "stop", "fail"] }), url: stringSchema() }, ["serviceId", "action"])),
optional("list_routines", "routines", ["routines:read"], "read", { kind: "mock_extension", extension: "routines.list" }),
optional("manage_routine", "routines", ["routines:write"], "admin", { kind: "mock_extension", extension: "routines.manage" }),
optional("list_company_skills", "company_skills", ["company_skills:read"], "read", { kind: "mock_extension", extension: "company_skills.list" }),
optional("sync_company_skills", "company_skills", ["company_skills:write"], "admin", { kind: "mock_extension", extension: "company_skills.sync" }),
optional("list_secret_metadata", "secrets", ["secrets:metadata:read"], "read", { kind: "mock_extension", extension: "secrets.metadata" }),
optional("read_secret_value", "secrets", ["secrets:values:read"], "secret_read", { kind: "mock_extension", extension: "secrets.value" }, objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]), { redaction: [...SECRET_REDACTION], idempotency: "none" }),
optional("export_company", "portability_admin", ["portability:export"], "admin", { kind: "mock_extension", extension: "portability.export" }),
optional("administer_company", "portability_admin", ["company:admin"], "admin", { kind: "mock_extension", extension: "company.admin" }, objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]), { allowedRoles: ["board", "ceo", "admin"] }),
optional("generic_api_request", "test_escape_hatch", ["test:generic_api"], "test_escape_hatch", { kind: "mock_extension", extension: "test.generic_api" }, objectSchema({ method: stringSchema({ minLength: 1 }), path: stringSchema({ minLength: 1 }), headers: { type: "object", additionalProperties: stringSchema() }, body: {} }, ["method", "path"]), { taskModes: ["skill_test"], redaction: [...AUTH_REDACTION] }),
];
// Bidirectional parity: every presented operation is a canonical scenario-surface
// operation, and every canonical scenario-surface operation has a presentation.
for (const key of Object.keys(PRESENTATION)) {
if (!canonicalById.has(key)) {
throw new Error(`Scenario presentation ${key} is not a canonical scenario-surface operation.`);
}
}
for (const operation of SCENARIO_OPERATIONS) {
if (PRESENTATION[operation.operationId] === undefined) {
throw new Error(`Canonical scenario operation ${operation.operationId} has no scenario presentation.`);
}
}
// Declaration order follows the presentation map so the exported surface stays
// stable; the operation set itself is single-sourced from the canonical list.
export const CAPABILITY_SEMANTIC_TOOL_CATALOG: readonly CapabilitySemanticToolDescriptor[] = Object.freeze(
[...ALWAYS_TOOLS, ...OPTIONAL_TOOLS].map((tool) => Object.freeze(tool)),
Object.keys(PRESENTATION).map((operationId) => toDescriptor(canonicalById.get(operationId)!)),
);
export const CAPABILITY_OPTIONAL_TOOL_CATALOGS: Readonly<
@@ -370,5 +377,7 @@ export function capabilitySemanticTool(operationId: string): CapabilitySemanticT
}
function toolsInGroup(group: CapabilityOptionalCatalogGroup): readonly CapabilitySemanticToolDescriptor[] {
return Object.freeze(OPTIONAL_TOOLS.filter((tool) => tool.optionalGroup === group));
return Object.freeze(
CAPABILITY_SEMANTIC_TOOL_CATALOG.filter((tool) => tool.optionalGroup === group),
);
}
@@ -451,12 +451,12 @@ describe("Capability security policy", () => {
});
it("keeps the generic escape hatch test-only, explicitly granted, and allowlisted", async () => {
const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api"] });
const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api_request"] });
expect(denied.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("generic_api_request");
const { runtime } = await runtimeFor({
workMode: "skill_test",
scenarioGrants: ["test:generic_api"],
scenarioGrants: ["test:generic_api_request"],
policy: {
allowGenericEscapeHatch: true,
genericEscapeHatchAllowlist: [{ method: "GET", path: "/mock/health" }],