From 2e2a40dba905bd04db8bb8c7ead44951f27d1a91 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 09:27:52 -0500 Subject: [PATCH] docs(runner): reconcile production protocol bindings --- ...aperclip-runner-protocol-binding-roster.md | 31 +++++++++++++------ .../src/catalog/canonical-operations.ts | 31 +++++++++++++++++-- .../src/catalog/operation-groups-doc.ts | 2 +- .../src/catalog/reconciliation.test.ts | 8 +++-- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/doc/plans/paperclip-runner-protocol-binding-roster.md b/doc/plans/paperclip-runner-protocol-binding-roster.md index 10ff6b9c85b..0724e9c89c0 100644 --- a/doc/plans/paperclip-runner-protocol-binding-roster.md +++ b/doc/plans/paperclip-runner-protocol-binding-roster.md @@ -6,8 +6,9 @@ This roster is the implementation ledger for binding the canonical Paperclip Run - Canonical protocol actions: **41** - Actions in the Codex semantic catalog: **28** -- Actions with a real Paperclip service binding: **0** -- Actions audited end-to-end over production PRP: **0** +- Actions advertised by the production Paperclip authority: **13** +- Actions with a real Paperclip service binding: **13** +- Actions audited through the shared Paperclip-server PRP route: **13 service bindings; route smoke audit pending** - Existing semantic conformance bridge: five route-backed test operations (`report_progress`, `write_document`, confirmation-only `request_human_input`, `set_dependencies`, and `finish_task`). It is test-only and is not a production binding. The generated action metadata currently says `realServiceBinding: unbound` and `prpBindingStatus: audit_pending` for every action. `realBindingStatus: live_codex` means only that a Codex scenario exercised the mock semantic implementation; it must not be interpreted as a production Paperclip binding. @@ -16,15 +17,15 @@ The generated action metadata currently says `realServiceBinding: unbound` and ` | Group | Actions | Real authority target | Status | | --- | --- | --- | --- | -| Active task reads | `get_task_context`, `get_task_history`, `search_tasks` | issue, comment, project, and assignment services | unbound | -| Task mutations | `report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`, `set_dependencies`, `create_task` | issue/comment services and native status arbiter | unbound | -| Documents and deliverables | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `register_deliverable` | document and attachment services | unbound | -| Human interaction | `request_human_input` | interaction service and continuation scheduler | unbound | -| Agents | `list_agents`, `get_agent` | agent service with company scope | unbound | -| Approvals | `list_approvals`, `get_approval`, `get_approval_context`, `request_approval`, `decide_approval`, `comment_on_approval` | approval service | unbound | +| Active task reads | `get_task_context`, `get_task_history`, `search_tasks` | issue, comment, project, and assignment services | bound; run/issue/agent/company assignment checked on every call | +| Task mutations | `report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`, `set_dependencies`, `create_task` | issue/comment services and native status arbiter | `report_progress` bound with durable run receipt; remaining actions withheld pending mutation/status-arbiter audit | +| Documents and deliverables | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `register_deliverable` | document and attachment services | three reads bound; mutations withheld pending idempotency audit | +| Human interaction | `request_human_input` | interaction service and continuation scheduler | bound for confirmation, checkbox, questions, suggested tasks, and item verdicts | +| Agents | `list_agents`, `get_agent` | agent service with company scope | bound with credential/config redaction and company checks | +| Approvals | `list_approvals`, `get_approval`, `get_approval_context`, `request_approval`, `decide_approval`, `comment_on_approval` | approval service | three reads bound; mutations withheld. `decide_approval` is board-user-only in the current product and must not be agent-advertised | | Workspace | `get_workspace_runtime`, `control_workspace_service` | workspace runtime service | unbound | | Scheduling | `schedule_wake` | wakeup service | unbound | -| Discovery | tool search and tool schema lookup | authorized catalog projection | mock implementation only | +| Discovery | tool search and tool schema lookup | authorized catalog projection | runner-local authorized projection; production authority supplies only the 13 bound definitions | | Administrative surface | `administer_company`, `export_company`, `list_company_skills`, `sync_company_skills`, `list_projects`, `list_goals`, `list_routines`, `manage_routine`, `list_cases`, `upsert_case`, `inspect_operation_result` | existing company-scoped API/services; individually authorization-gated | protocol-only/scenario mock | | Secrets | `list_secret_metadata`, `read_secret_value` | secrets service; metadata/value claims separated and output redacted | protocol-only/scenario mock | | Escape hatch | `generic_api_request` | no production binding planned until a narrow allowlist is designed | test-only | @@ -42,4 +43,14 @@ An action moves from `unbound` only when all of the following are true: ## Cutover order -The first production vertical slice is `get_task_context` + `report_progress` + `finish_task`. Next are documents, interactions, task dependencies/creation, approvals, agent/project reads, workspace/scheduling, then the separately privileged administrative and secret surfaces. `generic_api_request` stays unavailable in production. +The first production vertical slice is now `get_task_context` + `report_progress` + all five structured `request_human_input` variants, plus bounded document/agent/approval reads. The next slice is document writes and dependency/child-task mutations with durable receipts. Terminal task actions must be reconciled with the native status arbiter before they are advertised. Approval decisions remain board-user-only. Workspace/scheduling follow, then the separately privileged administrative and secret surfaces. `generic_api_request` stays unavailable in production. + +## Production transport status + +Paperclip now owns a shared WebSocket upgrade route at +`/api/runner/v1/connect/:runId`. The native executor registers one run-bound +`DurablePrpControlPlane` authority, runnerd receives that loopback URL, and the +authority still authenticates the one-time bootstrap ticket and durable lease +inside PRP. Unknown runs and malformed run IDs fail closed before the protocol +handshake. The run ID is a path segment because runnerd deliberately rejects +query-bearing WebSocket URLs to avoid URL ambiguity. diff --git a/packages/paperclip-runner/src/catalog/canonical-operations.ts b/packages/paperclip-runner/src/catalog/canonical-operations.ts index 67464f25a16..bd6c896d3b0 100644 --- a/packages/paperclip-runner/src/catalog/canonical-operations.ts +++ b/packages/paperclip-runner/src/catalog/canonical-operations.ts @@ -10,13 +10,38 @@ export interface CapabilityCanonicalOperation { 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 realServiceBinding: "unbound"; - readonly prpEvidence: string; readonly prpBindingStatus: "audit_pending"; + readonly realBindingStatus: CapabilityRealBindingStatus; readonly realServiceBinding: string; + readonly prpEvidence: string; readonly prpBindingStatus: "audit_pending" | "bound"; readonly legacyAliases: readonly string[]; readonly note?: string; } +const PRODUCTION_BINDINGS: Readonly> = Object.freeze({ + get_task_context: { service: "PaperclipRunnerToolAuthority + issue/run assignment query", evidence: "shared PRP route + bound-context authorization test" }, + get_task_history: { service: "issueComments scoped by bound issue", evidence: "shared PRP route + bound-context authorization test" }, + search_tasks: { service: "issueService.list", evidence: "shared PRP route + company scope test" }, + report_progress: { service: "issueService.addComment + heartbeatRuns semanticToolReceipts", evidence: "real service mutation + idempotent replay test" }, + request_human_input: { service: "issueThreadInteractionService.create", evidence: "real structured interaction + continuation projection test" }, + list_documents: { service: "documentService.listIssueDocuments", evidence: "bound issue document read" }, + read_document: { service: "documentService.getIssueDocumentByKey", evidence: "bound issue/key document read" }, + list_document_revisions: { service: "documentService.listIssueDocumentRevisions", evidence: "bound issue/key revision read" }, + list_agents: { service: "agentService.list (redacted projection)", evidence: "company-scoped actor projection" }, + get_agent: { service: "agentService.getById (redacted projection)", evidence: "company equality + redacted actor projection" }, + list_approvals: { service: "approvalService.list", evidence: "company-scoped approval projection" }, + get_approval: { service: "approvalService.getById", evidence: "company equality check" }, + get_approval_context: { service: "approvalService.getById + issueApprovals", evidence: "company equality + linked-task projection" }, +}); + export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperation[] = Object.freeze( - PAPERCLIP_PROTOCOL_ACTIONS.map((action) => action.canonical as unknown as CapabilityCanonicalOperation) + PAPERCLIP_PROTOCOL_ACTIONS.map((action) => { + const operation = action.canonical as unknown as CapabilityCanonicalOperation; + const binding = PRODUCTION_BINDINGS[operation.operationId]; + return binding === undefined ? operation : { + ...operation, + realServiceBinding: binding.service, + prpEvidence: binding.evidence, + prpBindingStatus: "bound" as const, + }; + }) .sort((left, right) => left.operationId.localeCompare(right.operationId)), ); const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation])); diff --git a/packages/paperclip-runner/src/catalog/operation-groups-doc.ts b/packages/paperclip-runner/src/catalog/operation-groups-doc.ts index d97bab07fa4..30a3e6404f6 100644 --- a/packages/paperclip-runner/src/catalog/operation-groups-doc.ts +++ b/packages/paperclip-runner/src/catalog/operation-groups-doc.ts @@ -376,7 +376,7 @@ export function renderOperationGroupsDocument(input: OperationGroupsInput): stri "", "### Reconciled semantic-operation ledger", "", - "`live_codex` means a live provider dispatcher exists against the deterministic runner/mock port; it does **not** mean a production Paperclip service is bound. Every row remains `realServiceBinding: unbound` until the shared mock/real adapter work lands. `generic_api_request` is test-only and cannot satisfy product coverage.", + "`live_codex` means a live provider dispatcher exists. Production binding is tracked separately: `bound` rows are advertised by Paperclip's run-scoped authority over the shared PRP route, while `audit_pending` rows remain unavailable to production agents. `generic_api_request` is test-only and cannot satisfy product coverage.", "", "| Operation | Placement | Claims | Modes / roles | Side effect | Idempotency | Redacts | Mock | Catalogs / current runner | Production / PRP evidence |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index cb99223dd99..32c7c75fbd5 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -92,9 +92,13 @@ describe("canonical semantic-catalog reconciliation authority", () => { expect(["none", "required", "recommended"]).toContain(operation.idempotency); expect(typeof operation.redacts).toBe("boolean"); expect(["live_codex", "scenario_mock", "test_only"]).toContain(operation.realBindingStatus); - expect(operation.realServiceBinding).toBe("unbound"); + if (operation.prpBindingStatus === "bound") { + expect(operation.realServiceBinding).not.toBe("unbound"); + } else { + expect(operation.realServiceBinding).toBe("unbound"); + } expect(operation.prpEvidence.length).toBeGreaterThan(0); - expect(operation.prpBindingStatus).toBe("audit_pending"); + expect(["audit_pending", "bound"]).toContain(operation.prpBindingStatus); // Scenario-surface ops carry a mock mapping; live-only ops resolve inline. if (operation.surfaces.includes("scenario")) { expect(operation.mockCommandMapping).not.toBeNull();