diff --git a/packages/paperclip-runner/docs/capability-semantic-tools.md b/packages/paperclip-runner/docs/capability-semantic-tools.md
index 5cfa1eb634c..8a320f06ab9 100644
--- a/packages/paperclip-runner/docs/capability-semantic-tools.md
+++ b/packages/paperclip-runner/docs/capability-semantic-tools.md
@@ -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`.
diff --git a/packages/paperclip-runner/spec/capability/catalog-reconciliation.md b/packages/paperclip-runner/spec/capability/catalog-reconciliation.md
index 274736d7f98..911c2ba4306 100644
--- a/packages/paperclip-runner/spec/capability/catalog-reconciliation.md
+++ b/packages/paperclip-runner/spec/capability/catalog-reconciliation.md
@@ -1,17 +1,23 @@
# Semantic Catalog Reconciliation
-**Document status:** Canonical reconciliation ledger (PAP-17025)
+**Document status:** Canonical reconciliation ledger (PAP-17025 gate + PAP-17039 collapse)
**Date:** 2026-08-11
-**Authority:** `src/catalog/reconciliation.ts` (machine-readable), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.
+**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.
+**Drift authority:** `src/catalog/reconciliation.ts` (machine-readable ledger), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.
**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.
diff --git a/packages/paperclip-runner/src/catalog/canonical-operations.ts b/packages/paperclip-runner/src/catalog/canonical-operations.ts
new file mode 100644
index 00000000000..74c7e1757f2
--- /dev/null
+++ b/packages/paperclip-runner/src/catalog/canonical-operations.ts
@@ -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);
+}
diff --git a/packages/paperclip-runner/src/catalog/index.ts b/packages/paperclip-runner/src/catalog/index.ts
index b7eeccae28e..a0a5bda6d13 100644
--- a/packages/paperclip-runner/src/catalog/index.ts
+++ b/packages/paperclip-runner/src/catalog/index.ts
@@ -1 +1,2 @@
+export * from "./canonical-operations.js";
export * from "./reconciliation.js";
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
index ba0a0ada434..cb99223dd99 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
@@ -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);
}
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.ts b/packages/paperclip-runner/src/catalog/reconciliation.ts
index bb9980e7d2b..5722ad302e2 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.ts
@@ -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> = {
- // --- 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(
- SCENARIO_CATALOG.map((tool) => [tool.operationId, tool]),
-);
-const liveById = new Map(
- 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; required?: readonly string[] } | undefined): string {
+function inputSchemaShape(
+ schema: { properties?: Record; 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> = {
- "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(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);
}
diff --git a/packages/paperclip-runner/src/scenarios/scenario-index.ts b/packages/paperclip-runner/src/scenarios/scenario-index.ts
index cee86693f9d..ee4db9fc975 100644
--- a/packages/paperclip-runner/src/scenarios/scenario-index.ts
+++ b/packages/paperclip-runner/src/scenarios/scenario-index.ts
@@ -110,7 +110,7 @@ const FIXTURE_PROFILES: Record> = {
"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/*" }],
diff --git a/packages/paperclip-runner/src/scenarios/scenario-plan.ts b/packages/paperclip-runner/src/scenarios/scenario-plan.ts
index 6d2b806a041..456e8a3982c 100644
--- a/packages/paperclip-runner/src/scenarios/scenario-plan.ts
+++ b/packages/paperclip-runner/src/scenarios/scenario-plan.ts
@@ -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",
diff --git a/packages/paperclip-runner/src/semantic-tools/catalog.ts b/packages/paperclip-runner/src/semantic-tools/catalog.ts
index f44b8c1db75..adf5e2f4f36 100644
--- a/packages/paperclip-runner/src/semantic-tools/catalog.ts
+++ b/packages/paperclip-runner/src/semantic-tools/catalog.ts
@@ -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);
diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts b/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts
index 0e692ebd276..e6aad90919c 100644
--- a/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts
+++ b/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts
@@ -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 & {
- 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> = {
+ // --- 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> = {},
-): 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),
+ );
}
diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts b/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts
index 491eb711557..6b78094a487 100644
--- a/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts
@@ -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" }],