feat(runner): authorize semantic tool dispatch (#12126)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package defines a provider-neutral protocol and semantic
action catalog.
> - Catalog membership alone must not grant access to an action.
> - Each run needs current company, actor, task, claim, mode, and
application-binding authority.
> - Mutating actions also need safe retry behavior and durable receipts.
> - This pull request adds a package-local authority and dispatch layer.
> - The benefit is a small and testable trust boundary before server
integration lands.

## Linked Issues or Issue Description

Refs #11962

This pull request replaces one bounded part of the archived large runner
change.

## What Changed

- Add run-scoped tool projection and optional tool discovery.
- Require an explicit application binding before an action is visible.
- Intersect actor claims with claims delegated to the run.
- Recheck company, actor, task, mode, state, role, claim, and policy
authority before each call.
- Validate action input and output with the canonical catalog schemas.
- Redact protected values and keep raw tool content out of semantic
receipts.
- Require atomic idempotency claims for mutating actions.
- Replay exact completed retries and reject changed or concurrent
retries.
- Recover a durable completed receipt if the primary receipt commit
fails, without re-executing the mutation.
- Add bounded authorization records and PRP semantic input and result
receipts.
- Document that this change adds no server binding or production tool
installation.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner check:all`
- `pnpm -r typecheck`
- `pnpm check:token-gates`
- `pnpm build`
- 60 package TypeScript tests pass.
- 56 Rust unit and integration tests pass.
- Protocol, replay, and cross-language conformance checks pass.
- `pnpm test:run` completed with 4,684 passing and 19 skipped tests. It
reproduced 32 local baseline failures across 9 unchanged server files;
all corresponding hosted test shards pass.
- Every applicable GitHub Actions gate passes. The Storybook job skipped
because this PR has no UI changes.
- Socket and Snyk pass with no findings. Superagent completed neutral
with zero annotations because its external sandbox did not start within
120 seconds.
- Greptile is 5/5 with no unresolved actionable comments.
- The diff changes 11 files.

## Risks

The main risk is an authorization or idempotency error at the tool
boundary. The dispatcher fails closed for malformed authority,
unavailable receipt storage, stale authority, unauthorized actions,
protected input, invalid binding output, and unrecoverable receipt
completion. The receipt store must recover a completed mutation outcome
idempotently if its primary commit fails; otherwise the claim remains
reserved for operator recovery rather than allowing automated
re-execution. Unbound actions are absent. No server or provider installs
these tools in this change. Existing adapters and application behavior
do not change.

## Model Used

OpenAI Codex with GPT-5. Agentic coding mode used repository tools, code
execution, and automated tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run the affected tests locally and they pass; full-suite
baseline exceptions are documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-24 17:28:54 -05:00 committed by GitHub
parent 23048f1219
commit 42b8f7ab2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2528 additions and 8 deletions

View File

@ -13,12 +13,13 @@ a short-lived lease. The Rust runner now includes a Codex-only app-server
provider bridge with durable thread resume, cancellation, structured questions,
and provider-neutral event normalization. No server code starts or invokes it.
The package also publishes the canonical semantic action declarations and
their input and output schemas. It does not add a server adapter,
semantic-action authorization, dispatch, or production Paperclip behavior.
their input and output schemas. Its package-local dispatcher projects only
bound, run-authorized actions and emits redacted semantic receipts. It does not
add application bindings, a server adapter, or production Paperclip behavior.
The first and only installed provider is Codex. Dynamic semantic tools remain
undiscoverable because catalog membership does not grant authority and the
run-scoped authorization layer has not landed. See
undiscoverable because no production application binding or server authority
has landed. Catalog membership alone does not grant authority. See
[`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary.
The root export is intentionally narrow. The `./testing` entry point and package

View File

@ -6,10 +6,12 @@ required claims, supported task modes, an effect class, and JSON Schema input
and output contracts.
The catalog is descriptive. Importing it or finding an operation in it does not
grant permission to show or call that operation. This change deliberately adds
no run-scoped projection, authorization decision, dispatcher, application
binding, credential, or server route. Until those layers land, Codex receives
no dynamic Paperclip tools.
grant permission to show or call that operation. A run-scoped dispatcher can
project only actions that have current actor, task, company, claim, mode, and
application-binding authority. It rechecks that authority before each call.
The package still adds no application binding, credential, server route, or
Codex tool installation. Until a later server integration supplies those
bindings, Codex receives no dynamic Paperclip tools.
The initial catalog excludes scenario-only and lab operations, other-provider
extensions, and a generic API escape hatch. Those additions need their own
@ -29,6 +31,17 @@ const writeDocument = paperclipSemanticAction("write_document");
`PAPERCLIP_SEMANTIC_ACTION_CATALOG` and every nested declaration are frozen.
`paperclipSemanticAction` returns `undefined` for unknown operation IDs.
## Run-scoped authority
`PaperclipSemanticDispatcher` accepts a current-context provider and an
explicit list of application bindings. Unbound actions are absent. Actor claims
and run-delegated claims are intersected. Optional discovery returns only bound
actions that pass the same authorization check. Mutation actions also require
an atomic idempotency store. The store must provide an idempotent recovery path
for a mutation that succeeds before its primary receipt commit fails. Raw tool
content never enters semantic receipts; receipts contain a digest and
allowlisted references only.
## Generated inventory
`generated/semantic-action-catalog.json` is a deterministic projection of the

View File

@ -5,4 +5,5 @@ export * from "./protocol/replay-contract.js";
export * from "./protocol/replay-loader.js";
export * from "./protocol/result-normalization.js";
export * from "./reducer/session-reducer.js";
export * from "./semantic-tools/index.js";
export * from "./tracer/replay.js";

View File

@ -0,0 +1,226 @@
import type { PaperclipSemanticActionDescriptor } from "../catalog/semantic-action-types.js";
import type {
PaperclipSemanticAuthorizationDecision,
PaperclipSemanticAuthorizationPhase,
PaperclipSemanticDenialCode,
PaperclipSemanticRunContext,
} from "./types.js";
const TERMINAL_TASK_STATES = new Set(["done", "cancelled", "canceled"]);
export function decidePaperclipSemanticAuthorization(
descriptor: PaperclipSemanticActionDescriptor,
context: PaperclipSemanticRunContext,
phase: PaperclipSemanticAuthorizationPhase,
requestedRunId: string,
input?: unknown,
): PaperclipSemanticAuthorizationDecision {
if (!validAuthorityContext(context, requestedRunId)) {
return {
allowed: false,
phase,
operationId: descriptor.operationId,
code: "authority_context_invalid",
reason: "The run authority context is malformed.",
effectiveClaims: [],
};
}
const effectiveClaims = intersectClaims(
context.actor.claims,
context.delegatedClaims,
);
const base = {
phase,
operationId: descriptor.operationId,
effectiveClaims,
} as const;
const deny = (
code: PaperclipSemanticDenialCode,
reason: string,
): PaperclipSemanticAuthorizationDecision => ({
...base,
allowed: false,
code,
reason,
});
if (context.runId !== requestedRunId) {
return deny(
"run_mismatch",
"The authority context belongs to another run.",
);
}
if (
context.actor.companyId !== context.companyId ||
context.activeTask.companyId !== context.companyId
) {
return deny(
"company_mismatch",
"The actor and active task must belong to the run company.",
);
}
if (context.actor.status !== "active") {
return deny("actor_inactive", "The run actor is not active.");
}
if (context.activeTask.assigneeActorId !== context.actor.id) {
return deny(
"task_ownership_denied",
"The run actor no longer owns the active task.",
);
}
if (context.activeTask.executionRunId !== context.runId) {
return deny(
"task_ownership_denied",
"The active task is no longer bound to this run.",
);
}
if (!descriptor.allowedModes.includes(context.activeTask.workMode)) {
return deny(
"task_mode_denied",
"The action is unavailable in the active task mode.",
);
}
if (
descriptor.effect !== "read" &&
TERMINAL_TASK_STATES.has(context.activeTask.status)
) {
return deny("task_state_denied", "The active task is already terminal.");
}
if (
context.policy?.deniedOperationIds?.includes(descriptor.operationId) ===
true
) {
return deny("policy_denied", "Run policy denies this action.");
}
if (
descriptor.allowedRoles !== undefined &&
!descriptor.allowedRoles
.map(normalize)
.includes(normalize(context.actor.role))
) {
return deny("actor_role_denied", "The actor role cannot use this action.");
}
if (
descriptor.requiredClaims.some(
(required) => !effectiveClaims.includes(required),
)
) {
return deny(
"required_claim_missing",
"The run lacks an explicitly delegated actor claim required by this action.",
);
}
if (
phase === "invocation" &&
descriptor.operationId === "request_human_input" &&
context.policy?.allowedInteractionKinds !== undefined
) {
const interactionKind = stringProperty(input, "interactionKind");
if (
interactionKind !== undefined &&
!context.policy.allowedInteractionKinds.includes(interactionKind)
) {
return deny(
"interaction_kind_denied",
"Run policy denies this interaction kind.",
);
}
}
return {
...base,
allowed: true,
code: "allowed",
reason: "The current run authority allows this action.",
};
}
function validAuthorityContext(
context: unknown,
requestedRunId: string,
): context is PaperclipSemanticRunContext {
if (
!isRecord(context) ||
!isRecord(context.actor) ||
!isRecord(context.activeTask) ||
(context.policy !== undefined && !isRecord(context.policy))
) {
return false;
}
const actor = context.actor;
const task = context.activeTask;
const policy = context.policy;
const stableIds = [
requestedRunId,
context.runId,
context.companyId,
actor.id,
actor.companyId,
task.id,
task.companyId,
...(task.assigneeActorId === null ? [] : [task.assigneeActorId]),
...(task.executionRunId === null ? [] : [task.executionRunId]),
];
return (
stableIds.every(isStableId) &&
isBoundedString(actor.status) &&
isBoundedString(actor.role) &&
isBoundedString(task.status) &&
isBoundedString(task.workMode) &&
validStringList(actor.claims) &&
validStringList(context.delegatedClaims) &&
(policy?.deniedOperationIds === undefined ||
validStringList(policy.deniedOperationIds)) &&
(policy?.allowedInteractionKinds === undefined ||
validStringList(policy.allowedInteractionKinds))
);
}
function isStableId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length >= 1 &&
value.length <= 240 &&
/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
);
}
function validStringList(value: unknown): value is readonly string[] {
return (
Array.isArray(value) &&
value.length <= 1_000 &&
value.every(isBoundedString)
);
}
function isBoundedString(value: unknown): value is string {
return typeof value === "string" && value.length >= 1 && value.length <= 240;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function intersectClaims(
actorClaims: readonly string[],
delegatedClaims: readonly string[],
): readonly string[] {
const actor = new Set(actorClaims);
return Object.freeze(
[...new Set(delegatedClaims)]
.filter((claim) => actor.has(claim))
.sort((left, right) => left.localeCompare(right)),
);
}
function normalize(value: string): string {
return value.trim().toLowerCase();
}
function stringProperty(value: unknown, key: string): string | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const candidate = (value as Record<string, unknown>)[key];
return typeof candidate === "string" ? candidate : undefined;
}

View File

@ -0,0 +1,179 @@
import { PAPERCLIP_SEMANTIC_ACTION_CATALOG } from "../catalog/semantic-action-catalog.js";
import type {
PaperclipSemanticActionDescriptor,
PaperclipSemanticActionId,
} from "../catalog/semantic-action-types.js";
import { decidePaperclipSemanticAuthorization } from "./authorization.js";
import type {
PaperclipSemanticDiscoveryResult,
PaperclipSemanticRunContext,
PaperclipSemanticToolDefinition,
} from "./types.js";
const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
Object.freeze({
get_task_context: "active_task",
get_task_history: "active_task",
list_documents: "documents",
read_document: "documents",
list_document_revisions: "documents",
report_progress: "active_task",
answer_status_question: "active_task",
write_document: "documents",
request_human_input: "documents",
register_deliverable: "documents",
finish_task: "active_task",
block_task: "active_task",
request_review: "active_task",
list_agents: "discovery",
get_agent: "discovery",
search_tasks: "discovery",
list_approvals: "governance",
get_approval: "governance",
get_approval_context: "governance",
get_workspace_runtime: "workspace",
control_workspace_service: "workspace",
set_dependencies: "delegation",
create_task: "delegation",
request_approval: "governance",
decide_approval: "governance",
comment_on_approval: "governance",
schedule_wake: "continuation",
});
export function paperclipSemanticActionNamespace(
operationId: PaperclipSemanticActionId,
): string {
return NAMESPACE[operationId];
}
export function projectPaperclipSemanticTools(input: {
readonly runId: string;
readonly context: PaperclipSemanticRunContext;
readonly boundOperationIds: ReadonlySet<PaperclipSemanticActionId>;
readonly placement?: PaperclipSemanticActionDescriptor["placement"];
}): readonly PaperclipSemanticToolDefinition[] {
return deepFreeze(authorizedBoundDescriptors(input).map(toToolDefinition));
}
export function discoverPaperclipSemanticTools(input: {
readonly runId: string;
readonly context: PaperclipSemanticRunContext;
readonly boundOperationIds: ReadonlySet<PaperclipSemanticActionId>;
readonly query: string;
readonly namespace?: string;
readonly limit?: number;
}): PaperclipSemanticDiscoveryResult {
const normalized = input.query.trim().toLowerCase();
if (normalized.length === 0 || normalized.length > 500) {
throw new Error("semantic_discovery_query_invalid");
}
const namespace = input.namespace?.trim().toLowerCase();
if (namespace !== undefined && !/^[a-z][a-z0-9_]{0,63}$/.test(namespace)) {
throw new Error("semantic_discovery_namespace_invalid");
}
const limit = Math.max(1, Math.min(Math.floor(input.limit ?? 5), 8));
const tokens = normalized
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 1);
const candidates = authorizedBoundDescriptors({
...input,
placement: "optional",
})
.filter(
(descriptor) =>
namespace === undefined ||
paperclipSemanticActionNamespace(descriptor.operationId) === namespace,
)
.map((descriptor) => ({
descriptor,
score: scoreDescriptor(descriptor, normalized, tokens),
}))
.filter((candidate) => candidate.score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.descriptor.operationId.localeCompare(right.descriptor.operationId),
);
return deepFreeze({
schema: "paperclip.semantic-discovery.v1",
query: input.query,
namespace: namespace ?? null,
operations: candidates
.slice(0, limit)
.map(({ descriptor }) => toToolDefinition(descriptor)),
truncated: candidates.length > limit,
});
}
export function toPaperclipSemanticToolDefinition(
descriptor: PaperclipSemanticActionDescriptor,
): PaperclipSemanticToolDefinition {
return deepFreeze(toToolDefinition(descriptor));
}
function authorizedBoundDescriptors(input: {
readonly runId: string;
readonly context: PaperclipSemanticRunContext;
readonly boundOperationIds: ReadonlySet<PaperclipSemanticActionId>;
readonly placement?: PaperclipSemanticActionDescriptor["placement"];
}): PaperclipSemanticActionDescriptor[] {
return PAPERCLIP_SEMANTIC_ACTION_CATALOG.filter(
(descriptor) =>
input.boundOperationIds.has(descriptor.operationId) &&
(input.placement === undefined ||
descriptor.placement === input.placement) &&
decidePaperclipSemanticAuthorization(
descriptor,
input.context,
"exposure",
input.runId,
).allowed,
);
}
function scoreDescriptor(
descriptor: PaperclipSemanticActionDescriptor,
query: string,
tokens: readonly string[],
): number {
const operationId = descriptor.operationId.toLowerCase();
const namespace = paperclipSemanticActionNamespace(descriptor.operationId);
const haystack =
`${operationId} ${descriptor.title} ${descriptor.description} ${namespace}`.toLowerCase();
let score = operationId === query ? 100 : namespace === query ? 50 : 0;
for (const token of tokens) {
if (operationId.includes(token)) score += 12;
if (namespace.includes(token)) score += 8;
if (haystack.includes(token)) score += 3;
}
return score;
}
function toToolDefinition(
descriptor: PaperclipSemanticActionDescriptor,
): PaperclipSemanticToolDefinition {
return {
name: descriptor.operationId,
description: descriptor.description,
inputSchema: descriptor.inputSchema,
outputSchema: descriptor.outputSchema,
annotations: {
semanticContract: descriptor.schema,
version: descriptor.version,
placement: descriptor.placement,
effect: descriptor.effect,
requiredClaims: descriptor.requiredClaims,
},
};
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}

View File

@ -0,0 +1,897 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import type { ValidateFunction } from "ajv/dist/2020.js";
import {
PAPERCLIP_SEMANTIC_ACTION_CATALOG,
paperclipSemanticAction,
} from "../catalog/semantic-action-catalog.js";
import type {
PaperclipJsonValue,
PaperclipSemanticActionDescriptor,
PaperclipSemanticActionId,
} from "../catalog/semantic-action-types.js";
import { decidePaperclipSemanticAuthorization } from "./authorization.js";
import {
discoverPaperclipSemanticTools,
projectPaperclipSemanticTools,
} from "./discovery.js";
import {
createPaperclipSemanticInputReceipt,
createPaperclipSemanticResultReceipt,
denialRetryable,
digestPaperclipSemanticContent,
isPaperclipSemanticStableId,
normalizePaperclipSemanticReferences,
paperclipSemanticAuthorizationBoundary,
paperclipSemanticOutcome,
} from "./receipts.js";
import {
inspectPaperclipSemanticValue,
redactPaperclipSemanticValue,
} from "./redaction.js";
import type {
PaperclipSemanticActionBinding,
PaperclipSemanticAuthorizationDecision,
PaperclipSemanticAuthorizationRecord,
PaperclipSemanticBindingResult,
PaperclipSemanticContextProvider,
PaperclipSemanticDenialCode,
PaperclipSemanticDiscoveryResult,
PaperclipSemanticIdempotencyClaim,
PaperclipSemanticIdempotencyStore,
PaperclipSemanticRunContext,
PaperclipSemanticStoredOutcome,
PaperclipSemanticToolCall,
PaperclipSemanticToolDefinition,
PaperclipSemanticToolDenial,
PaperclipSemanticToolResult,
PaperclipSemanticToolSuccess,
} from "./types.js";
export interface PaperclipSemanticDispatcherOptions {
readonly contextProvider: PaperclipSemanticContextProvider;
readonly bindings: readonly PaperclipSemanticActionBinding[];
readonly idempotencyStore?: PaperclipSemanticIdempotencyStore;
readonly maxAuthorizationRecords?: number;
}
interface ClaimedMutation {
readonly token: string;
readonly inputDigest: string;
readonly idempotencyKey: string;
}
export class PaperclipSemanticDispatcher {
readonly #contextProvider: PaperclipSemanticContextProvider;
readonly #bindings = new Map<
PaperclipSemanticActionId,
PaperclipSemanticActionBinding
>();
readonly #boundOperationIds: ReadonlySet<PaperclipSemanticActionId>;
readonly #inputValidators = new Map<
PaperclipSemanticActionId,
ValidateFunction
>();
readonly #outputValidators = new Map<
PaperclipSemanticActionId,
ValidateFunction
>();
readonly #idempotencyStore: PaperclipSemanticIdempotencyStore | undefined;
readonly #maxAuthorizationRecords: number;
readonly #authorizationRecords: PaperclipSemanticAuthorizationRecord[] = [];
#recordSequence = 0;
constructor(options: PaperclipSemanticDispatcherOptions) {
this.#contextProvider = options.contextProvider;
this.#idempotencyStore = options.idempotencyStore;
this.#maxAuthorizationRecords = Math.max(
1,
Math.min(Math.floor(options.maxAuthorizationRecords ?? 1_000), 10_000),
);
for (const binding of options.bindings) {
if (this.#bindings.has(binding.operationId)) {
throw new Error(
`duplicate semantic action binding: ${binding.operationId}`,
);
}
if (paperclipSemanticAction(binding.operationId) === undefined) {
throw new Error(
`unknown semantic action binding: ${binding.operationId}`,
);
}
this.#bindings.set(binding.operationId, binding);
}
this.#boundOperationIds = new Set(this.#bindings.keys());
const ajv = new Ajv2020({
allErrors: true,
allowUnionTypes: true,
strict: true,
});
for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) {
this.#inputValidators.set(
descriptor.operationId,
ajv.compile(descriptor.inputSchema),
);
this.#outputValidators.set(
descriptor.operationId,
ajv.compile(descriptor.outputSchema),
);
}
}
async listAlwaysAvailableTools(
runId: string,
): Promise<readonly PaperclipSemanticToolDefinition[]> {
const context = await this.#contextProvider(runId);
this.#recordExposureDecisions(runId, context, "always");
return projectPaperclipSemanticTools({
runId,
context,
boundOperationIds: this.#boundOperationIds,
placement: "always",
});
}
async discoverTools(input: {
readonly runId: string;
readonly query: string;
readonly namespace?: string;
readonly limit?: number;
}): Promise<PaperclipSemanticDiscoveryResult> {
const context = await this.#contextProvider(input.runId);
this.#recordExposureDecisions(input.runId, context, "optional");
return discoverPaperclipSemanticTools({
...input,
context,
boundOperationIds: this.#boundOperationIds,
});
}
authorizationRecords(): readonly PaperclipSemanticAuthorizationRecord[] {
return deepFreeze(structuredClone(this.#authorizationRecords));
}
async dispatch(
call: PaperclipSemanticToolCall,
): Promise<PaperclipSemanticToolResult> {
if (!validCallIdentity(call)) {
return this.#identityDenial(call);
}
const descriptor = paperclipSemanticAction(call.operationId);
const binding = descriptor && this.#bindings.get(descriptor.operationId);
if (descriptor === undefined || binding === undefined) {
return this.#denial(call, "operation_absent", null, null);
}
let context: PaperclipSemanticRunContext;
try {
context = await this.#contextProvider(call.runId);
} catch {
return this.#denial(call, "binding_failed", descriptor, null);
}
let decision = decidePaperclipSemanticAuthorization(
descriptor,
context,
"invocation",
call.runId,
call.input,
);
if (!decision.allowed) {
return this.#denial(
call,
denialCode(decision),
descriptor,
context,
decision,
);
}
const inputSafety = inspectPaperclipSemanticValue(call.input);
if (!inputSafety.withinBounds) {
decision = deniedDecision(
decision,
"input_invalid",
"Tool input exceeds safe bounds.",
);
return this.#denial(call, "input_invalid", descriptor, context, decision);
}
if (inputSafety.containsProtectedData) {
decision = deniedDecision(
decision,
"protected_data_denied",
"Protected data is not accepted by semantic actions.",
);
return this.#denial(
call,
"protected_data_denied",
descriptor,
context,
decision,
true,
);
}
const inputValidator = this.#inputValidators.get(descriptor.operationId);
const idempotencyKey = stringProperty(call.input, "idempotencyKey");
if (descriptor.effect !== "read" && idempotencyKey === undefined) {
decision = deniedDecision(
decision,
"idempotency_required",
"Mutation actions require an idempotency key.",
);
return this.#denial(
call,
"idempotency_required",
descriptor,
context,
decision,
);
}
if (inputValidator === undefined || !inputValidator(call.input)) {
decision = deniedDecision(
decision,
"input_invalid",
formatValidationError(inputValidator),
);
return this.#denial(call, "input_invalid", descriptor, context, decision);
}
const inputReceipt = createPaperclipSemanticInputReceipt({
operationId: descriptor.operationId,
callId: call.callId,
correlation: call.correlation,
idempotencyKey: idempotencyKey ?? null,
content: call.input,
});
const inputDigest = digestPaperclipSemanticContent(call.input);
let claim: ClaimedMutation | undefined;
if (descriptor.effect !== "read") {
if (this.#idempotencyStore === undefined) {
decision = deniedDecision(
decision,
"receipt_store_unavailable",
"No durable idempotency store is configured for mutation actions.",
);
return this.#denial(
call,
"receipt_store_unavailable",
descriptor,
context,
decision,
);
}
const scope = digestPaperclipSemanticContent([
context.companyId,
call.runId,
descriptor.operationId,
idempotencyKey,
]);
let claimed: PaperclipSemanticIdempotencyClaim;
try {
claimed = await this.#idempotencyStore.claim({
scope,
operationId: descriptor.operationId,
inputDigest,
});
} catch {
decision = deniedDecision(
decision,
"receipt_store_unavailable",
"The durable idempotency store is unavailable.",
);
return this.#denial(
call,
"receipt_store_unavailable",
descriptor,
context,
decision,
);
}
if (claimed.kind === "conflict") {
decision = deniedDecision(
decision,
"idempotency_conflict",
"The idempotency key was already used with different input.",
);
return this.#denial(
call,
"idempotency_conflict",
descriptor,
context,
decision,
);
}
if (claimed.kind === "in_progress") {
decision = deniedDecision(
decision,
"idempotency_in_progress",
"The original mutation is still in progress.",
);
return this.#denial(
call,
"idempotency_in_progress",
descriptor,
context,
decision,
);
}
if (claimed.kind === "duplicate") {
return this.#duplicate(
call,
descriptor,
context,
decision,
inputReceipt,
inputDigest,
claimed.outcome,
);
}
claim = {
token: claimed.token,
inputDigest,
idempotencyKey: idempotencyKey!,
};
}
// Re-read authority after any durable claim and immediately before the
// application binding. A stale projection can never authorize execution.
let currentContext: PaperclipSemanticRunContext;
try {
currentContext = await this.#contextProvider(call.runId);
} catch {
if (claim !== undefined && !(await this.#releaseClaim(claim.token))) {
return this.#denial(
call,
"receipt_store_unavailable",
descriptor,
context,
);
}
return this.#denial(call, "binding_failed", descriptor, context);
}
decision = decidePaperclipSemanticAuthorization(
descriptor,
currentContext,
"invocation",
call.runId,
call.input,
);
if (!decision.allowed) {
if (claim !== undefined && !(await this.#releaseClaim(claim.token))) {
return this.#denial(
call,
"receipt_store_unavailable",
descriptor,
currentContext,
deniedDecision(
decision,
"receipt_store_unavailable",
"The unused mutation claim could not be released.",
),
);
}
return this.#denial(
call,
denialCode(decision),
descriptor,
currentContext,
decision,
);
}
let executed: PaperclipSemanticBindingResult;
try {
executed = await binding.execute({
runId: call.runId,
companyId: currentContext.companyId,
actorId: currentContext.actor.id,
taskId: currentContext.activeTask.id,
callId: call.callId,
operationId: descriptor.operationId,
input: call.input as Readonly<Record<string, PaperclipJsonValue>>,
});
} catch {
// A mutation may have crossed the application boundary. Keep its claim
// reserved so an uncertain retry cannot execute it twice.
decision = deniedDecision(
decision,
"binding_failed",
"The action binding failed safely.",
);
return this.#denial(
call,
"binding_failed",
descriptor,
currentContext,
decision,
);
}
if (!isBindingResult(executed)) {
decision = deniedDecision(
decision,
"binding_output_invalid",
"The action binding returned an invalid result.",
);
return this.#denial(
call,
"binding_output_invalid",
descriptor,
currentContext,
decision,
);
}
const outputSafety = inspectPaperclipSemanticValue(executed.value);
const safeValue = redactPaperclipSemanticValue(executed.value);
const outputValidator = this.#outputValidators.get(descriptor.operationId);
if (
!outputSafety.withinBounds ||
!validOptionalCode(executed.code) ||
!validOptionalRevision(executed.stateRevision) ||
!validOptionalStableId(executed.auditReceiptId) ||
outputValidator === undefined ||
!outputValidator(safeValue)
) {
decision = deniedDecision(
decision,
"binding_output_invalid",
"The action binding returned an invalid result.",
);
return this.#denial(
call,
"binding_output_invalid",
descriptor,
currentContext,
decision,
outputSafety.containsProtectedData,
);
}
const code = executed.code ?? "ok";
const references = normalizePaperclipSemanticReferences(
executed.references,
);
const resultReceipt = createPaperclipSemanticResultReceipt({
operationId: descriptor.operationId,
callId: call.callId,
correlation: call.correlation,
idempotencyKey: idempotencyKey ?? null,
content: safeValue,
references,
redacted: outputSafety.containsProtectedData,
outcome: paperclipSemanticOutcome({ ok: true, code }),
code,
retryable: false,
authorizationBoundary: paperclipSemanticAuthorizationBoundary(code),
...(validRevision(executed.stateRevision)
? { currentRevision: executed.stateRevision }
: {}),
...(executed.auditReceiptId !== undefined
? { auditReceiptId: executed.auditReceiptId }
: {}),
});
const operationReceiptId = String(resultReceipt.operationReceiptId);
if (claim !== undefined) {
const stored: PaperclipSemanticStoredOutcome = {
operationId: descriptor.operationId,
inputDigest,
operationReceiptId,
value: safeValue,
code,
...(validRevision(executed.stateRevision)
? { stateRevision: executed.stateRevision }
: {}),
references,
...(executed.auditReceiptId !== undefined
? { auditReceiptId: executed.auditReceiptId }
: {}),
};
try {
await this.#idempotencyStore!.complete(claim.token, stored);
} catch {
try {
await this.#idempotencyStore!.recover(claim.token, stored);
} catch {
// The application effect may have happened. Keep the claim reserved
// and stop automatic retries. The store's operator recovery path can
// commit the same sanitized outcome without repeating the effect.
decision = deniedDecision(
decision,
"receipt_recovery_failed",
"The mutation receipt needs operator recovery.",
);
return this.#denial(
call,
"receipt_recovery_failed",
descriptor,
currentContext,
decision,
);
}
}
}
this.#record(
currentContext,
decision,
call.callId,
inputDigest,
operationReceiptId,
);
return deepFreeze({
ok: true,
operationId: descriptor.operationId,
callId: call.callId,
value: safeValue,
code,
duplicate: false,
...(validRevision(executed.stateRevision)
? { stateRevision: executed.stateRevision }
: {}),
inputReceipt,
resultReceipt,
} satisfies PaperclipSemanticToolSuccess);
}
#recordExposureDecisions(
runId: string,
context: PaperclipSemanticRunContext,
placement: PaperclipSemanticActionDescriptor["placement"],
): void {
for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) {
if (
descriptor.placement !== placement ||
!this.#boundOperationIds.has(descriptor.operationId)
) {
continue;
}
const decision = decidePaperclipSemanticAuthorization(
descriptor,
context,
"exposure",
runId,
);
this.#record(context, decision, null, null, null);
}
}
#duplicate(
call: PaperclipSemanticToolCall,
descriptor: PaperclipSemanticActionDescriptor,
context: PaperclipSemanticRunContext,
decision: PaperclipSemanticAuthorizationDecision,
inputReceipt: PaperclipSemanticToolSuccess["inputReceipt"],
inputDigest: string,
stored: PaperclipSemanticStoredOutcome,
): PaperclipSemanticToolResult {
if (!isStoredOutcome(stored)) {
const denied = deniedDecision(
decision,
"binding_output_invalid",
"The stored mutation receipt is invalid.",
);
return this.#denial(
call,
"binding_output_invalid",
descriptor,
context,
denied,
);
}
const outputValidator = this.#outputValidators.get(descriptor.operationId);
const outputSafety = inspectPaperclipSemanticValue(stored.value);
const safeValue = redactPaperclipSemanticValue(stored.value);
if (
stored.operationId !== descriptor.operationId ||
stored.inputDigest !== inputDigest ||
!isPaperclipSemanticStableId(stored.operationReceiptId) ||
!validCode(stored.code) ||
!validOptionalRevision(stored.stateRevision) ||
!validOptionalStableId(stored.auditReceiptId) ||
!outputSafety.withinBounds ||
outputValidator === undefined ||
!outputValidator(safeValue)
) {
const denied = deniedDecision(
decision,
"binding_output_invalid",
"The stored mutation receipt is invalid.",
);
return this.#denial(
call,
"binding_output_invalid",
descriptor,
context,
denied,
);
}
const references = normalizePaperclipSemanticReferences(stored.references);
const resultReceipt = createPaperclipSemanticResultReceipt({
operationId: descriptor.operationId,
callId: call.callId,
correlation: call.correlation,
idempotencyKey: stringProperty(call.input, "idempotencyKey") ?? null,
content: safeValue,
references,
redacted: outputSafety.containsProtectedData,
outcome: "duplicate",
code: stored.code,
retryable: false,
authorizationBoundary: paperclipSemanticAuthorizationBoundary(
stored.code,
),
operationReceiptId: stored.operationReceiptId,
duplicateOfReceiptId: stored.operationReceiptId,
...(validRevision(stored.stateRevision)
? { currentRevision: stored.stateRevision }
: {}),
...(stored.auditReceiptId !== undefined
? { auditReceiptId: stored.auditReceiptId }
: {}),
});
this.#record(
context,
decision,
call.callId,
inputDigest,
stored.operationReceiptId,
);
return deepFreeze({
ok: true,
operationId: descriptor.operationId,
callId: call.callId,
value: safeValue,
code: stored.code,
duplicate: true,
...(validRevision(stored.stateRevision)
? { stateRevision: stored.stateRevision }
: {}),
inputReceipt,
resultReceipt,
} satisfies PaperclipSemanticToolSuccess);
}
async #releaseClaim(token: string): Promise<boolean> {
try {
await this.#idempotencyStore!.release(token);
return true;
} catch {
return false;
}
}
#identityDenial(
call: PaperclipSemanticToolCall,
): PaperclipSemanticToolDenial {
return deepFreeze({
ok: false,
operationId: safeIdentity(call.operationId),
callId: safeIdentity(call.callId),
error: {
code: "input_invalid",
message: "The semantic call identity is invalid.",
retryable: false,
},
inputReceipt: null,
resultReceipt: null,
});
}
#denial(
call: PaperclipSemanticToolCall,
code: PaperclipSemanticDenialCode,
descriptor: PaperclipSemanticActionDescriptor | null,
context: PaperclipSemanticRunContext | null,
decision?: PaperclipSemanticAuthorizationDecision,
redacted = false,
): PaperclipSemanticToolDenial {
const idempotencyKey = stringProperty(call.input, "idempotencyKey") ?? null;
const inputReceipt = createPaperclipSemanticInputReceipt({
operationId: call.operationId,
callId: call.callId,
correlation: call.correlation,
idempotencyKey,
content: call.input,
redacted,
});
const resultReceipt = createPaperclipSemanticResultReceipt({
operationId: call.operationId,
callId: call.callId,
correlation: call.correlation,
idempotencyKey,
content: { code },
redacted,
outcome: paperclipSemanticOutcome({ ok: false, code }),
code,
retryable: denialRetryable(code),
authorizationBoundary: paperclipSemanticAuthorizationBoundary(code),
});
if (descriptor !== null && context !== null) {
const finalDecision =
decision ??
deniedDecision(
decidePaperclipSemanticAuthorization(
descriptor,
context,
"invocation",
call.runId,
call.input,
),
code,
denialMessage(code),
);
this.#record(
context,
finalDecision,
call.callId,
digestPaperclipSemanticContent(call.input),
String(resultReceipt.operationReceiptId),
);
}
return deepFreeze({
ok: false,
operationId: call.operationId,
callId: call.callId,
error: {
code,
message: denialMessage(code),
retryable: denialRetryable(code),
},
inputReceipt,
resultReceipt,
});
}
#record(
context: PaperclipSemanticRunContext,
decision: PaperclipSemanticAuthorizationDecision,
callId: string | null,
inputDigest: string | null,
operationReceiptId: string | null,
): void {
if (decision.code === "authority_context_invalid") return;
this.#recordSequence += 1;
this.#authorizationRecords.push(
deepFreeze({
schema: "paperclip.semantic-authorization-record.v1",
id: `semantic_auth:${String(this.#recordSequence).padStart(8, "0")}`,
runId: context.runId,
companyId: context.companyId,
actorId: context.actor.id,
taskId: context.activeTask.id,
callId,
...decision,
inputDigest,
operationReceiptId,
}),
);
if (this.#authorizationRecords.length > this.#maxAuthorizationRecords) {
this.#authorizationRecords.splice(
0,
this.#authorizationRecords.length - this.#maxAuthorizationRecords,
);
}
}
}
function validCallIdentity(call: PaperclipSemanticToolCall): boolean {
return (
call.correlation.runId === call.runId &&
[
call.runId,
call.callId,
call.operationId,
call.correlation.normalizedSessionId,
call.correlation.turnId,
call.correlation.itemId,
...(call.correlation.requestId === undefined
? []
: [call.correlation.requestId]),
].every(isPaperclipSemanticStableId)
);
}
function isBindingResult(
value: unknown,
): value is PaperclipSemanticBindingResult {
return typeof value === "object" && value !== null && "value" in value;
}
function isStoredOutcome(
value: unknown,
): value is PaperclipSemanticStoredOutcome {
return (
typeof value === "object" &&
value !== null &&
"operationId" in value &&
"inputDigest" in value &&
"operationReceiptId" in value &&
"value" in value &&
"code" in value &&
Array.isArray((value as { references?: unknown }).references)
);
}
function deniedDecision(
decision: PaperclipSemanticAuthorizationDecision,
code: PaperclipSemanticDenialCode,
reason: string,
): PaperclipSemanticAuthorizationDecision {
return { ...decision, allowed: false, code, reason };
}
function denialCode(
decision: PaperclipSemanticAuthorizationDecision,
): PaperclipSemanticDenialCode {
if (decision.code === "allowed") {
throw new Error("allowed authorization decision cannot create a denial");
}
return decision.code;
}
function denialMessage(code: PaperclipSemanticDenialCode): string {
switch (code) {
case "operation_absent":
return "The requested semantic action is not available.";
case "idempotency_in_progress":
return "The original mutation is still in progress.";
case "binding_failed":
return "The semantic action could not complete safely.";
default:
return "The requested semantic action was not executed.";
}
}
function formatValidationError(
validator: ValidateFunction | undefined,
): string {
const issue = validator?.errors?.[0];
if (issue === undefined)
return "Tool input does not match its action schema.";
return `Tool input ${issue.instancePath || "/"} ${issue.message ?? "is invalid"}.`;
}
function stringProperty(value: unknown, key: string): string | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const candidate = (value as Record<string, unknown>)[key];
return typeof candidate === "string" ? candidate : undefined;
}
function validCode(value: unknown): value is string {
return typeof value === "string" && /^[a-z][a-z0-9_.:-]{0,159}$/.test(value);
}
function validOptionalCode(value: unknown): value is string | undefined {
return value === undefined || validCode(value);
}
function validRevision(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function validOptionalRevision(value: unknown): value is number | undefined {
return value === undefined || validRevision(value);
}
function validOptionalStableId(value: unknown): value is string | undefined {
return (
value === undefined ||
(typeof value === "string" && isPaperclipSemanticStableId(value))
);
}
function safeIdentity(value: string): string {
return isPaperclipSemanticStableId(value) ? value : "invalid";
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}

View File

@ -0,0 +1,6 @@
export * from "./authorization.js";
export * from "./discovery.js";
export * from "./dispatcher.js";
export * from "./receipts.js";
export * from "./redaction.js";
export * from "./types.js";

View File

@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js";
import { redactPaperclipSemanticValue } from "./redaction.js";
import type {
PaperclipSemanticCorrelation,
PaperclipSemanticDenialCode,
PaperclipSemanticSafeReference,
} from "./types.js";
export type PaperclipSemanticAuthorizationBoundary =
| "company"
| "actor"
| "active_task"
| "grant"
| "governed_action"
| "lock"
| "revision";
export type PaperclipSemanticToolOutcome =
"succeeded" | "denied" | "conflict" | "duplicate" | "unavailable" | "failed";
interface SemanticReceiptBase {
readonly operationId: string;
readonly callId: string;
readonly correlation: PaperclipSemanticCorrelation;
readonly idempotencyKey?: string | null;
readonly content: unknown;
readonly references?: readonly PaperclipSemanticSafeReference[];
readonly redacted?: boolean;
}
interface SemanticResultReceiptInput extends SemanticReceiptBase {
readonly outcome: PaperclipSemanticToolOutcome;
readonly code: string;
readonly retryable: boolean;
readonly authorizationBoundary: PaperclipSemanticAuthorizationBoundary;
readonly operationReceiptId?: string;
readonly auditReceiptId?: string;
readonly currentRevision?: number | string;
readonly duplicateOfReceiptId?: string;
}
export function createPaperclipSemanticInputReceipt(
input: SemanticReceiptBase,
): PrpSemanticToolEnvelope {
return {
schema: "paperclip.prp.semantic_tool.v1",
schemaVersion: 1,
phase: "input",
operationId: input.operationId,
callId: input.callId,
correlation: { ...input.correlation },
idempotencyKey: input.idempotencyKey ?? null,
content: safeContent(input),
} as PrpSemanticToolEnvelope;
}
export function createPaperclipSemanticResultReceipt(
input: SemanticResultReceiptInput,
): PrpSemanticToolEnvelope {
const operationReceiptId =
input.operationReceiptId ?? derivedOperationReceiptId(input);
return {
schema: "paperclip.prp.semantic_tool.v1",
schemaVersion: 1,
phase: "result",
operationId: input.operationId,
callId: input.callId,
correlation: { ...input.correlation },
idempotencyKey: input.idempotencyKey ?? null,
content: safeContent(input),
outcome: input.outcome,
code: input.code,
retryable: input.retryable,
authorizationBoundary: input.authorizationBoundary,
operationReceiptId,
...(input.auditReceiptId === undefined
? {}
: { auditReceiptId: input.auditReceiptId }),
...(input.currentRevision === undefined
? {}
: { currentRevision: input.currentRevision }),
...(input.duplicateOfReceiptId === undefined
? {}
: { duplicateOfReceiptId: input.duplicateOfReceiptId }),
} as PrpSemanticToolEnvelope;
}
export function digestPaperclipSemanticContent(value: unknown): string {
const safeValue = redactPaperclipSemanticValue(value);
return `sha256:${createHash("sha256").update(canonicalJson(safeValue)).digest("hex")}`;
}
export function paperclipSemanticAuthorizationBoundary(
code: string,
): PaperclipSemanticAuthorizationBoundary {
if (code.includes("company")) return "company";
if (code.includes("actor") || code.includes("role")) return "actor";
if (code.includes("claim") || code.includes("absent")) return "grant";
if (
code.includes("idempotency") ||
code.includes("revision") ||
code.includes("receipt")
) {
return "revision";
}
if (code.includes("ownership") || code.includes("run_mismatch")) {
return "lock";
}
if (code.includes("interaction") || code.includes("governance")) {
return "governed_action";
}
return "active_task";
}
export function paperclipSemanticOutcome(input: {
readonly ok: boolean;
readonly code: string;
readonly duplicate?: boolean;
}): PaperclipSemanticToolOutcome {
if (input.ok) return input.duplicate === true ? "duplicate" : "succeeded";
if (input.code.includes("conflict")) return "conflict";
if (input.code === "operation_absent") return "unavailable";
if (input.code.includes("binding") || input.code.includes("recovery")) {
return "failed";
}
return "denied";
}
export function isPaperclipSemanticStableId(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 240 &&
/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
);
}
export function normalizePaperclipSemanticReferences(
references: readonly PaperclipSemanticSafeReference[] | undefined,
): readonly PaperclipSemanticSafeReference[] {
if (!Array.isArray(references)) return Object.freeze([]);
const allowedKinds = new Set<PaperclipSemanticSafeReference["kind"]>([
"task",
"document_revision",
"interaction",
"approval",
"decision",
"artifact",
"work_product",
"wake",
"monitor",
"audit",
"operation",
]);
const unique = new Map<string, PaperclipSemanticSafeReference>();
for (const reference of references.slice(0, 200)) {
if (
typeof reference === "object" &&
reference !== null &&
allowedKinds.has(reference.kind) &&
isPaperclipSemanticStableId(reference.id)
) {
unique.set(`${reference.kind}:${reference.id}`, {
kind: reference.kind,
id: reference.id,
});
}
}
return Object.freeze([...unique.values()]);
}
function safeContent(input: SemanticReceiptBase) {
return {
digest: digestPaperclipSemanticContent(input.content),
redactionDisposition: input.redacted === true ? "redacted" : "digest_only",
references: [...normalizePaperclipSemanticReferences(input.references)],
};
}
function derivedOperationReceiptId(input: SemanticResultReceiptInput): string {
const identity =
input.idempotencyKey === undefined || input.idempotencyKey === null
? `${input.correlation.runId}:${input.operationId}:${input.callId}`
: `${input.correlation.runId}:${input.operationId}:${input.idempotencyKey}`;
return `semantic_receipt:${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
export function denialRetryable(code: PaperclipSemanticDenialCode): boolean {
return code === "idempotency_in_progress" || code === "binding_failed";
}

View File

@ -0,0 +1,161 @@
import type { PaperclipJsonValue } from "../catalog/semantic-action-types.js";
const SENSITIVE_KEY =
/(?:authorization|cookie|credential|password|passwd|private.?key|secret|token|api.?key|connection.?string)/i;
const SECRET_VALUE =
/(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\b(?:sk|pk|pcgw|ghp|github_pat)_[A-Za-z0-9_-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/gi;
const SECRET_QUERY =
/([?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi;
const SECRET_QUERY_DETECT =
/[?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=[^&#\s]+/i;
const MAX_DEPTH = 16;
const MAX_NODES = 10_000;
const MAX_ARRAY_ITEMS = 512;
const MAX_OBJECT_KEYS = 512;
const MAX_STRING_LENGTH = 200_000;
export const PAPERCLIP_SEMANTIC_REDACTED = "[REDACTED]";
export const PAPERCLIP_SEMANTIC_TRUNCATED = "[TRUNCATED]";
export interface PaperclipSemanticValueSafety {
readonly containsProtectedData: boolean;
readonly withinBounds: boolean;
}
export function inspectPaperclipSemanticValue(
value: unknown,
): PaperclipSemanticValueSafety {
const state = { nodes: 0, protected: false, withinBounds: true };
inspect(value, "", 0, state, new Set<object>());
return Object.freeze({
containsProtectedData: state.protected,
withinBounds: state.withinBounds,
});
}
export function redactPaperclipSemanticValue(
value: unknown,
): PaperclipJsonValue {
const state = { nodes: 0 };
return redact(value, "", 0, state, new Set<object>());
}
function inspect(
value: unknown,
key: string,
depth: number,
state: { nodes: number; protected: boolean; withinBounds: boolean },
ancestors: Set<object>,
): void {
state.nodes += 1;
if (state.nodes > MAX_NODES || depth > MAX_DEPTH) {
state.withinBounds = false;
return;
}
if (SENSITIVE_KEY.test(key)) state.protected = true;
if (typeof value === "string") {
if (value.length > MAX_STRING_LENGTH) state.withinBounds = false;
SECRET_VALUE.lastIndex = 0;
if (SECRET_VALUE.test(value) || SECRET_QUERY_DETECT.test(value)) {
state.protected = true;
}
SECRET_VALUE.lastIndex = 0;
return;
}
if (Array.isArray(value)) {
if (value.length > MAX_ARRAY_ITEMS) state.withinBounds = false;
if (ancestors.has(value)) {
state.withinBounds = false;
return;
}
ancestors.add(value);
for (const child of value.slice(0, MAX_ARRAY_ITEMS)) {
inspect(child, "", depth + 1, state, ancestors);
}
ancestors.delete(value);
return;
}
if (typeof value === "object" && value !== null) {
if (ancestors.has(value)) {
state.withinBounds = false;
return;
}
const entries = Object.entries(value);
if (entries.length > MAX_OBJECT_KEYS) state.withinBounds = false;
ancestors.add(value);
for (const [childKey, child] of entries.slice(0, MAX_OBJECT_KEYS)) {
inspect(child, childKey, depth + 1, state, ancestors);
}
ancestors.delete(value);
return;
}
if (
value !== null &&
typeof value !== "number" &&
typeof value !== "boolean" &&
typeof value !== "undefined"
) {
state.withinBounds = false;
}
}
function redact(
value: unknown,
key: string,
depth: number,
state: { nodes: number },
ancestors: Set<object>,
): PaperclipJsonValue {
state.nodes += 1;
if (state.nodes > MAX_NODES || depth > MAX_DEPTH) {
return PAPERCLIP_SEMANTIC_TRUNCATED;
}
if (SENSITIVE_KEY.test(key)) return PAPERCLIP_SEMANTIC_REDACTED;
if (typeof value === "string") {
SECRET_VALUE.lastIndex = 0;
const redacted = value
.replace(SECRET_VALUE, PAPERCLIP_SEMANTIC_REDACTED)
.replace(SECRET_QUERY, `$1${PAPERCLIP_SEMANTIC_REDACTED}`);
SECRET_VALUE.lastIndex = 0;
return redacted.length <= MAX_STRING_LENGTH
? redacted
: `${redacted.slice(0, MAX_STRING_LENGTH)}${PAPERCLIP_SEMANTIC_TRUNCATED}`;
}
if (Array.isArray(value)) {
if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED;
ancestors.add(value);
const result = value
.slice(0, MAX_ARRAY_ITEMS)
.map((child) => redact(child, "", depth + 1, state, ancestors));
ancestors.delete(value);
if (value.length > MAX_ARRAY_ITEMS) {
result.push(PAPERCLIP_SEMANTIC_TRUNCATED);
}
return result;
}
if (typeof value === "object" && value !== null) {
if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED;
ancestors.add(value);
const entries = Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.slice(0, MAX_OBJECT_KEYS)
.map(
([childKey, child]) =>
[
childKey,
redact(child, childKey, depth + 1, state, ancestors),
] as const,
);
ancestors.delete(value);
const result: Record<string, PaperclipJsonValue> =
Object.fromEntries(entries);
if (Object.keys(value).length > MAX_OBJECT_KEYS) {
result.__paperclip_truncated__ = PAPERCLIP_SEMANTIC_TRUNCATED;
}
return result;
}
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean" || value === null) return value;
return PAPERCLIP_SEMANTIC_TRUNCATED;
}

View File

@ -0,0 +1,594 @@
import { describe, expect, it } from "vitest";
import type { PaperclipSemanticActionBinding } from "./types.js";
import { PaperclipSemanticDispatcher } from "./dispatcher.js";
import type {
PaperclipSemanticIdempotencyClaim,
PaperclipSemanticIdempotencyStore,
PaperclipSemanticRunContext,
PaperclipSemanticStoredOutcome,
PaperclipSemanticToolCall,
} from "./types.js";
import { PAPERCLIP_SEMANTIC_REDACTED } from "./redaction.js";
import {
validatePrpEvent,
type PrpEvent,
type PrpSemanticToolEnvelope,
} from "../protocol/replay-contract.js";
const correlation = {
runId: "run_semantic_test",
normalizedSessionId: "session_semantic_test",
turnId: "turn_semantic_test",
itemId: "item_semantic_test",
};
describe("run-scoped semantic tool authority", () => {
it("projects only bound and currently authorized actions", async () => {
let context = runContext({
actorClaims: ["discovery:tasks:read", "discovery:agents:read"],
delegatedClaims: ["discovery:tasks:read"],
});
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => context,
bindings: [
binding("get_task_context", { task: { id: "task_semantic_test" } }),
binding("search_tasks", { tasks: [] }),
],
});
await expect(
dispatcher.listAlwaysAvailableTools(correlation.runId),
).resolves.toMatchObject([{ name: "get_task_context" }]);
await expect(
dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }),
).resolves.toMatchObject({
operations: [{ name: "search_tasks" }],
truncated: false,
});
context = runContext({
actorClaims: ["discovery:tasks:read"],
delegatedClaims: [],
});
await expect(
dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }),
).resolves.toMatchObject({ operations: [] });
expect(JSON.stringify(dispatcher.authorizationRecords())).not.toContain(
"list_agents",
);
});
it("rechecks ownership after projection and before invocation", async () => {
let context = runContext();
let executions = 0;
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => context,
bindings: [
{
operationId: "get_task_context",
execute: () => {
executions += 1;
return { value: { task: "visible" } };
},
},
],
});
expect(
await dispatcher.listAlwaysAvailableTools(correlation.runId),
).toHaveLength(1);
context = runContext({ executionRunId: "run_replaced" });
const result = await dispatcher.dispatch(call("get_task_context", {}));
expect(result).toMatchObject({
ok: false,
error: { code: "task_ownership_denied" },
});
expect(executions).toBe(0);
});
it("rejects forged scope and protected input before a binding executes", async () => {
let executions = 0;
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () =>
runContext({
actorClaims: ["discovery:tasks:read"],
delegatedClaims: ["discovery:tasks:read"],
}),
bindings: [
{
operationId: "search_tasks",
execute: () => {
executions += 1;
return { value: { tasks: [] } };
},
},
],
});
await expect(
dispatcher.dispatch(call("search_tasks", { companyId: "forged" })),
).resolves.toMatchObject({ ok: false, error: { code: "input_invalid" } });
await expect(
dispatcher.dispatch(
call("search_tasks", { query: "work", apiKey: "sk_not-for-a-tool" }),
),
).resolves.toMatchObject({
ok: false,
error: { code: "protected_data_denied" },
});
expect(executions).toBe(0);
});
it("redacts binding output and emits schema-valid digest-only receipts", async () => {
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
bindings: [
binding("get_task_context", {
task: { id: "task_semantic_test" },
accessToken: "sk_should-never-cross",
}),
],
});
const result = await dispatcher.dispatch(call("get_task_context", {}));
expect(result).toMatchObject({
ok: true,
value: { accessToken: PAPERCLIP_SEMANTIC_REDACTED },
duplicate: false,
});
expect(JSON.stringify(result)).not.toContain("sk_should-never-cross");
if (!result.ok) throw new Error("expected success");
expect(
validatePrpEvent(event("mcp_app.tool_input", result.inputReceipt)).ok,
).toBe(true);
expect(
validatePrpEvent(event("mcp_app.tool_result", result.resultReceipt)).ok,
).toBe(true);
});
it("fails mutations closed when no idempotency store is configured", async () => {
let executions = 0;
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
bindings: [
{
operationId: "write_document",
execute: () => {
executions += 1;
return { value: mutationReceipt("write-command") };
},
},
],
});
const result = await dispatcher.dispatch(
call("write_document", writeDocumentInput()),
);
expect(result).toMatchObject({
ok: false,
error: { code: "receipt_store_unavailable" },
});
expect(executions).toBe(0);
});
it("replays exact mutation retries and rejects key reuse with changed input", async () => {
let executions = 0;
const store = new MemoryIdempotencyStore();
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
idempotencyStore: store,
bindings: [
{
operationId: "write_document",
execute: () => {
executions += 1;
return {
value: mutationReceipt("write-command"),
code: "document_written",
stateRevision: 7,
references: [
{ kind: "document_revision", id: "revision_semantic_test" },
],
};
},
},
],
});
const first = await dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_write_first"),
);
const retry = await dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_write_retry"),
);
const conflict = await dispatcher.dispatch(
call(
"write_document",
{ ...writeDocumentInput(), body: "Different body" },
"call_write_conflict",
),
);
expect(first).toMatchObject({
ok: true,
duplicate: false,
stateRevision: 7,
});
expect(retry).toMatchObject({
ok: true,
duplicate: true,
stateRevision: 7,
});
expect(conflict).toMatchObject({
ok: false,
error: { code: "idempotency_conflict" },
});
expect(executions).toBe(1);
if (!first.ok || !retry.ok) throw new Error("expected successes");
expect(retry.resultReceipt.operationReceiptId).toBe(
first.resultReceipt.operationReceiptId,
);
expect(retry.resultReceipt.duplicateOfReceiptId).toBe(
first.resultReceipt.operationReceiptId,
);
});
it("recovers a completed mutation when the primary receipt commit fails", async () => {
let executions = 0;
const store = new MemoryIdempotencyStore({ failCompleteOnce: true });
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
idempotencyStore: store,
bindings: [
{
operationId: "write_document",
execute: () => {
executions += 1;
return { value: mutationReceipt("write-recovered") };
},
},
],
});
const first = await dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_recovery_first"),
);
const retry = await dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_recovery_retry"),
);
expect(first).toMatchObject({ ok: true, duplicate: false });
expect(retry).toMatchObject({ ok: true, duplicate: true });
expect(executions).toBe(1);
expect(store.recoveryCount).toBe(1);
});
it("reports an in-progress retry without running a concurrent mutation", async () => {
const store = new MemoryIdempotencyStore();
let releaseExecution!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseExecution = resolve;
});
let executions = 0;
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
idempotencyStore: store,
bindings: [
{
operationId: "write_document",
execute: async () => {
executions += 1;
await blocked;
return { value: mutationReceipt("write-concurrent") };
},
},
],
});
const first = dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_concurrent_first"),
);
await store.claimed;
const retry = await dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_concurrent_retry"),
);
expect(retry).toMatchObject({
ok: false,
error: { code: "idempotency_in_progress", retryable: true },
});
expect(executions).toBe(1);
releaseExecution();
await expect(first).resolves.toMatchObject({ ok: true });
});
it("releases an unused mutation claim when authority changes before execution", async () => {
const store = new MemoryIdempotencyStore();
let lookups = 0;
let executions = 0;
const dispatcher = new PaperclipSemanticDispatcher({
contextProvider: () => {
lookups += 1;
return runContext({
executionRunId: lookups === 2 ? "run_reassigned" : correlation.runId,
});
},
idempotencyStore: store,
bindings: [
{
operationId: "write_document",
execute: () => {
executions += 1;
return { value: mutationReceipt("write-after-recheck") };
},
},
],
});
await expect(
dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_stale_authority"),
),
).resolves.toMatchObject({
ok: false,
error: { code: "task_ownership_denied" },
});
await expect(
dispatcher.dispatch(
call("write_document", writeDocumentInput(), "call_fresh_authority"),
),
).resolves.toMatchObject({ ok: true, duplicate: false });
expect(executions).toBe(1);
});
it("fails closed when durable storage or binding metadata is invalid", async () => {
let executions = 0;
const unavailableStore = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
idempotencyStore: {
claim: () => {
throw new Error("store unavailable");
},
complete: () => undefined,
recover: () => undefined,
release: () => undefined,
},
bindings: [
{
operationId: "write_document",
execute: () => {
executions += 1;
return { value: mutationReceipt("must-not-run") };
},
},
],
});
await expect(
unavailableStore.dispatch(call("write_document", writeDocumentInput())),
).resolves.toMatchObject({
ok: false,
error: { code: "receipt_store_unavailable" },
});
const invalidBinding = new PaperclipSemanticDispatcher({
contextProvider: () => runContext(),
bindings: [
{
operationId: "get_task_context",
execute: () => ({ value: {}, code: "invalid code" }) as never,
},
],
});
await expect(
invalidBinding.dispatch(call("get_task_context", {})),
).resolves.toMatchObject({
ok: false,
error: { code: "binding_output_invalid" },
});
expect(executions).toBe(0);
});
it("denies cross-company contexts and malformed protocol identities", async () => {
const crossCompany = new PaperclipSemanticDispatcher({
contextProvider: () => runContext({ actorCompanyId: "company_other" }),
bindings: [binding("get_task_context", {})],
});
await expect(
crossCompany.listAlwaysAvailableTools(correlation.runId),
).resolves.toEqual([]);
await expect(
crossCompany.dispatch(call("get_task_context", {})),
).resolves.toMatchObject({
ok: false,
error: { code: "company_mismatch" },
});
const malformed = await crossCompany.dispatch({
...call("get_task_context", {}),
callId: "bad call id",
});
expect(malformed).toMatchObject({
ok: false,
error: { code: "input_invalid" },
inputReceipt: null,
resultReceipt: null,
});
const malformedAuthority = new PaperclipSemanticDispatcher({
contextProvider: () =>
({ ...runContext(), companyId: "bad company id" }) as never,
bindings: [binding("get_task_context", {})],
});
await expect(
malformedAuthority.listAlwaysAvailableTools(correlation.runId),
).resolves.toEqual([]);
await expect(
malformedAuthority.dispatch(call("get_task_context", {})),
).resolves.toMatchObject({
ok: false,
error: { code: "authority_context_invalid" },
});
});
});
function runContext(
overrides: {
actorClaims?: readonly string[];
delegatedClaims?: readonly string[];
actorCompanyId?: string;
executionRunId?: string;
} = {},
): PaperclipSemanticRunContext {
return {
runId: correlation.runId,
companyId: "company_semantic_test",
actor: {
id: "actor_semantic_test",
companyId: overrides.actorCompanyId ?? "company_semantic_test",
status: "active",
role: "engineer",
claims: overrides.actorClaims ?? [],
},
activeTask: {
id: "task_semantic_test",
companyId: "company_semantic_test",
assigneeActorId: "actor_semantic_test",
executionRunId: overrides.executionRunId ?? correlation.runId,
status: "in_progress",
workMode: "standard",
},
delegatedClaims: overrides.delegatedClaims ?? [],
};
}
function binding(
operationId: PaperclipSemanticActionBinding["operationId"],
value: Record<string, unknown>,
): PaperclipSemanticActionBinding {
return {
operationId,
execute: () => ({ value: value as never }),
};
}
function call(
operationId: string,
input: unknown,
callId = `call_${operationId}`,
): PaperclipSemanticToolCall {
return { runId: correlation.runId, callId, operationId, correlation, input };
}
function writeDocumentInput() {
return {
idempotencyKey: "write_semantic_test",
key: "plan",
title: "Plan",
body: "Bounded body",
baseRevisionId: null,
};
}
function mutationReceipt(commandId: string) {
return {
commandId,
disposition: "applied",
stateRevision: 7,
entityRefs: ["task_semantic_test"],
scheduledWakeIds: [],
};
}
function event(
eventType: "mcp_app.tool_input" | "mcp_app.tool_result",
receipt: PrpSemanticToolEnvelope,
): PrpEvent {
return {
schema: "paperclip.prp.event.v1",
sourceEventId: `${eventType}:semantic_test`,
sourceSeq: eventType === "mcp_app.tool_input" ? 1 : 2,
sourceInstanceId: "runner_semantic_test",
sourceKind: "runner",
runId: correlation.runId,
normalizedSessionId: correlation.normalizedSessionId,
turnId: correlation.turnId,
itemId: correlation.itemId,
eventType,
schemaVersion: 1,
priority: 1,
emittedAt: "2026-08-24T12:00:00.000Z",
payload: { semantic_tool: receipt },
} as PrpEvent;
}
class MemoryIdempotencyStore implements PaperclipSemanticIdempotencyStore {
readonly #entries = new Map<
string,
{
digest: string;
token: string;
outcome?: PaperclipSemanticStoredOutcome;
}
>();
readonly #tokenToScope = new Map<string, string>();
#sequence = 0;
#failCompleteOnce: boolean;
recoveryCount = 0;
readonly claimed: Promise<void>;
#resolveClaimed!: () => void;
constructor(options: { failCompleteOnce?: boolean } = {}) {
this.#failCompleteOnce = options.failCompleteOnce ?? false;
this.claimed = new Promise<void>((resolve) => {
this.#resolveClaimed = resolve;
});
}
claim(input: {
scope: string;
operationId: PaperclipSemanticStoredOutcome["operationId"];
inputDigest: string;
}): PaperclipSemanticIdempotencyClaim {
const existing = this.#entries.get(input.scope);
if (existing !== undefined) {
if (existing.digest !== input.inputDigest) return { kind: "conflict" };
return existing.outcome === undefined
? { kind: "in_progress" }
: { kind: "duplicate", outcome: structuredClone(existing.outcome) };
}
const token = `claim:${++this.#sequence}`;
this.#entries.set(input.scope, { digest: input.inputDigest, token });
this.#tokenToScope.set(token, input.scope);
this.#resolveClaimed();
return { kind: "claimed", token };
}
complete(token: string, outcome: PaperclipSemanticStoredOutcome): void {
if (this.#failCompleteOnce) {
this.#failCompleteOnce = false;
throw new Error("primary receipt commit failed");
}
this.#storeOutcome(token, outcome);
}
recover(token: string, outcome: PaperclipSemanticStoredOutcome): void {
this.recoveryCount += 1;
this.#storeOutcome(token, outcome);
}
#storeOutcome(token: string, outcome: PaperclipSemanticStoredOutcome): void {
const scope = this.#tokenToScope.get(token);
const entry = scope === undefined ? undefined : this.#entries.get(scope);
if (scope === undefined || entry?.token !== token) {
throw new Error("unknown claim token");
}
entry.outcome = structuredClone(outcome);
}
release(token: string): void {
const scope = this.#tokenToScope.get(token);
if (scope !== undefined) this.#entries.delete(scope);
this.#tokenToScope.delete(token);
}
}

View File

@ -0,0 +1,239 @@
import type {
PaperclipJsonSchema,
PaperclipJsonValue,
PaperclipSemanticActionDescriptor,
PaperclipSemanticActionEffect,
PaperclipSemanticActionId,
PaperclipSemanticActionMode,
} from "../catalog/semantic-action-types.js";
import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js";
export interface PaperclipSemanticRunContext {
readonly runId: string;
readonly companyId: string;
readonly actor: {
readonly id: string;
readonly companyId: string;
readonly status: string;
readonly role: string;
readonly claims: readonly string[];
};
readonly activeTask: {
readonly id: string;
readonly companyId: string;
readonly assigneeActorId: string | null;
readonly executionRunId: string | null;
readonly status: string;
readonly workMode: PaperclipSemanticActionMode;
};
/** Claims explicitly delegated to this run. Actor claims can only narrow them. */
readonly delegatedClaims: readonly string[];
readonly policy?: {
readonly deniedOperationIds?: readonly PaperclipSemanticActionId[];
readonly allowedInteractionKinds?: readonly string[];
};
}
export type PaperclipSemanticContextProvider = (
runId: string,
) => PaperclipSemanticRunContext | Promise<PaperclipSemanticRunContext>;
export interface PaperclipSemanticToolDefinition {
readonly name: PaperclipSemanticActionId;
readonly description: string;
readonly inputSchema: PaperclipJsonSchema;
readonly outputSchema: PaperclipJsonSchema;
readonly annotations: {
readonly semanticContract: "paperclip.semantic-action.v1";
readonly version: 1;
readonly placement: PaperclipSemanticActionDescriptor["placement"];
readonly effect: PaperclipSemanticActionEffect;
readonly requiredClaims: readonly string[];
};
}
export interface PaperclipSemanticDiscoveryResult {
readonly schema: "paperclip.semantic-discovery.v1";
readonly query: string;
readonly namespace: string | null;
readonly operations: readonly PaperclipSemanticToolDefinition[];
readonly truncated: boolean;
}
export type PaperclipSemanticAuthorizationPhase = "exposure" | "invocation";
export type PaperclipSemanticDenialCode =
| "operation_absent"
| "authority_context_invalid"
| "run_mismatch"
| "company_mismatch"
| "actor_inactive"
| "task_mode_denied"
| "task_state_denied"
| "task_ownership_denied"
| "required_claim_missing"
| "actor_role_denied"
| "policy_denied"
| "interaction_kind_denied"
| "protected_data_denied"
| "input_invalid"
| "idempotency_required"
| "idempotency_conflict"
| "idempotency_in_progress"
| "receipt_store_unavailable"
| "receipt_recovery_failed"
| "binding_failed"
| "binding_output_invalid";
export interface PaperclipSemanticAuthorizationDecision {
readonly allowed: boolean;
readonly phase: PaperclipSemanticAuthorizationPhase;
readonly operationId: PaperclipSemanticActionId;
readonly code: "allowed" | PaperclipSemanticDenialCode;
readonly reason: string;
readonly effectiveClaims: readonly string[];
}
export interface PaperclipSemanticAuthorizationRecord extends PaperclipSemanticAuthorizationDecision {
readonly schema: "paperclip.semantic-authorization-record.v1";
readonly id: string;
readonly runId: string;
readonly companyId: string;
readonly actorId: string;
readonly taskId: string;
readonly callId: string | null;
readonly inputDigest: string | null;
readonly operationReceiptId: string | null;
}
export interface PaperclipSemanticSafeReference {
readonly kind:
| "task"
| "document_revision"
| "interaction"
| "approval"
| "decision"
| "artifact"
| "work_product"
| "wake"
| "monitor"
| "audit"
| "operation";
readonly id: string;
}
export interface PaperclipSemanticBindingResult {
readonly value: PaperclipJsonValue;
readonly code?: string;
readonly stateRevision?: number;
readonly references?: readonly PaperclipSemanticSafeReference[];
readonly auditReceiptId?: string;
}
export interface PaperclipAuthorizedSemanticInvocation {
readonly runId: string;
readonly companyId: string;
readonly actorId: string;
readonly taskId: string;
readonly callId: string;
readonly operationId: PaperclipSemanticActionId;
readonly input: Readonly<Record<string, PaperclipJsonValue>>;
}
export interface PaperclipSemanticActionBinding {
readonly operationId: PaperclipSemanticActionId;
execute(
invocation: PaperclipAuthorizedSemanticInvocation,
): PaperclipSemanticBindingResult | Promise<PaperclipSemanticBindingResult>;
}
export interface PaperclipSemanticCorrelation {
readonly runId: string;
readonly normalizedSessionId: string;
readonly turnId: string;
readonly itemId: string;
readonly requestId?: string;
}
export interface PaperclipSemanticToolCall {
readonly runId: string;
readonly callId: string;
readonly operationId: string;
readonly correlation: PaperclipSemanticCorrelation;
readonly input: unknown;
}
export interface PaperclipSemanticStoredOutcome {
readonly operationId: PaperclipSemanticActionId;
readonly inputDigest: string;
readonly operationReceiptId: string;
readonly value: PaperclipJsonValue;
readonly code: string;
readonly stateRevision?: number;
readonly references: readonly PaperclipSemanticSafeReference[];
readonly auditReceiptId?: string;
}
export type PaperclipSemanticIdempotencyClaim =
| { readonly kind: "claimed"; readonly token: string }
| {
readonly kind: "duplicate";
readonly outcome: PaperclipSemanticStoredOutcome;
}
| { readonly kind: "conflict" }
| { readonly kind: "in_progress" };
/**
* The claim operation must be atomic. Production bindings must persist this
* store before they expose mutation actions. `complete` is the primary commit
* path. `recover` is a required, idempotent fallback that must durably resolve
* a claim to the same outcome when the primary commit reports an ambiguous or
* transient failure. A store without an independent recovery path cannot be
* used to expose mutation actions.
*/
export interface PaperclipSemanticIdempotencyStore {
claim(input: {
readonly scope: string;
readonly operationId: PaperclipSemanticActionId;
readonly inputDigest: string;
}):
| PaperclipSemanticIdempotencyClaim
| Promise<PaperclipSemanticIdempotencyClaim>;
complete(
token: string,
outcome: PaperclipSemanticStoredOutcome,
): void | Promise<void>;
recover(
token: string,
outcome: PaperclipSemanticStoredOutcome,
): void | Promise<void>;
release(token: string): void | Promise<void>;
}
export interface PaperclipSemanticToolSuccess {
readonly ok: true;
readonly operationId: PaperclipSemanticActionId;
readonly callId: string;
readonly value: PaperclipJsonValue;
readonly code: string;
readonly duplicate: boolean;
readonly stateRevision?: number;
readonly inputReceipt: PrpSemanticToolEnvelope;
readonly resultReceipt: PrpSemanticToolEnvelope;
}
export interface PaperclipSemanticToolDenial {
readonly ok: false;
readonly operationId: string;
readonly callId: string;
readonly error: {
readonly code: PaperclipSemanticDenialCode;
readonly message: string;
readonly retryable: boolean;
};
readonly inputReceipt: PrpSemanticToolEnvelope | null;
readonly resultReceipt: PrpSemanticToolEnvelope | null;
}
export type PaperclipSemanticToolResult =
PaperclipSemanticToolSuccess | PaperclipSemanticToolDenial;