Skip to content

Action registry

Issue #484 and DESIGN §41 require one server action for each data operation. The adapters use the same API route. They do not write to a Home directly.

Run python3 scripts/action_registry.py after you change OpenAPI. This writes contracts/actions.json. Run with --check to check the generated file. contracts/action-overrides.json is optional. Each entry can set a descriptive name, help and legacy aliases for an operation ID. The canonical name and each alias call the same route with the same schema, authentication and scope. Scope and transport policy cannot be changed with an override.

The registry supports JSON, UTF-8 text, base64 byte chunks and finite SSE polls. Public-link data actions use the same public grant as the API. Sign-in ceremonies use the existing login flow. Live editor WebSockets have explicit presentation exclusions; the content actions remain available as tools. The generator marks Home path and Tag path parameters with x-calternal-allow-slash. These values can have /. The adapter encodes the separator inside the fixed route parameter. Other item IDs stay in one path segment. The API checks the Home path and Tag name.

Routes declare their required tus and revision headers in OpenAPI. The generator supplies stream headers from their existing handlers. These supplements do not change route behavior or authority. contracts/action-policy.json declares each operation’s authority (read, write, admin or credential), session scope, fresh assertion and replay policy. The server puts these declarations in OpenAPI as x-calternal-policy. It also declares browser-cookie and Installation-bearer security. Public link and sign-in routes keep their own capability checks. A declaration grants no access. The independent authorization tests still check the route handlers. A new operation without a declaration fails contract generation (#833).

Note and Task property DTOs have distinct schema names: NotePropertiesPatch and TaskPropertiesPatch. Note properties use properties; Task edits use task_id and fields. Note writes need the current If-Match. Use retitle to change a Note title. Null removes a User property. Unrelated frontmatter, body bytes and the Note identity stay intact.

calternal action list --json lists supported operation IDs, help, scopes and input schemas. This command needs no server connection. Use --area <area> and --search <text> to filter the list. calternal action areas lists the areas. calternal action explain <operation-id> shows one action with an example input and an example command (#838).

calternal action run <operation-id> --input '<json>' --json calls an operation. The input has the declared path, query, headers and body groups. --input @file.json, --input - (stdin) and --input-file <file|-> read the same input from a file. All input forms have the same 128 KiB limit and the same validation. For example, read a Note with:

Terminal window
calternal action run get_note --input '{"path":{"id":"<note-id>"}}' --json

Each registry area is also a command group: calternal action <area> <verb>. The registry generates these commands, so they never hold a second copy of route or business logic. Path parameters are positional arguments in route order. Query, header and top-level body fields are long flags. A repeated flag gives an array. A flag value for a nested object is JSON text. Flags override the matching fields of --input. The operation ID is an alias of the verb. calternal action <area> --help lists the verbs of an area. For example:

Terminal window
calternal action notes retitle <note-id> --if-match '<etag>' --title 'Plan' --dry-run
calternal action calendar range --from 2026-10-01 --to 2026-10-08 --json
calternal action photos timeline --days 7 --json

calternal completions <bash|zsh|fish|elvish|powershell> prints a completion script for the full tree, which includes the registry areas.

One rule applies to every command (#838):

  • A registry action (action run or action <area> <verb>) changes data only with --confirm, because a generic name does not show the effect. Use --dry-run first: it prints the exact method, URI, headers and body and sends nothing. It needs no credential.
  • A named command, for example rm, mv or task done, is an explicit intent. It runs without --confirm when the product can undo the change (Trash, move back, reopen, revoke a share).
  • A named command that removes data or a link permanently needs --confirm: calendar feed-remove, calendar feed-revoke and calendar subscription-remove. sync resume of a guarded pair also needs it.

--confirm is a global flag. A missing confirmation exits with code 2. The flag never adds authority: the server still checks the credential, the Role and any fresh assertion.

calternal login <server> --non-interactive never starts browser approval and never waits. It succeeds only with a working credential for that server (a stored one, or CALTERNAL_TOKEN bound by CALTERNAL_TOKEN_SERVER). Otherwise it exits with code 3 and tells the caller how to sign in.

The server checks the installation credential and the API and CLI switches. A generated response is limited to 1 MiB. Use page parameters only when the operation declares them. An operation without paging cannot supply a smaller page. Use the normal streaming transfer command for a large file. Byte-range downloads accept headers.Range where the server route supports ranges. Existing transfer commands keep their streaming behavior and support large files. CLI and MCP tool input is limited to 128 KiB; use upload chunks of 64 KiB to leave room for base64 and request metadata.

A text body is a raw string. A byte body is a base64 string. The registry’s request_encoding and request_content_type select the wire format. For tus, supply Tus-Resumable, Upload-Length and Upload-Metadata at creation. Read the returned headers.location for the upload ID. head reads its offset. Supply Upload-Offset for each patch. The server checks the offset and checksum.

Text responses use {text, headers, complete}. Byte responses use {base64, headers, complete}. Empty responses retain transfer headers and include ok: true. Base64 responses carry at most 720 KiB of raw bytes so the encoded value and metadata stay below the 1 MiB tool result limit. JSON responses retain their existing data shape. Only safe transfer headers are returned; session and cookie headers are never returned.

For Files and Mail downloads, request at most 720 KiB with headers.Range and advance from the returned headers.content-range. For ZIP downloads, set body.offset to zero and set body.chunk_size. Copy the first response’s headers.etag to body.revision on each next call. Add the decoded byte count to offset. A 409 means the source changed: discard the received bytes and restart at zero. If a body read fails, discard that chunk and retry it with the same revision. ZIP windows stop source reads at their end and on disconnect. They read the prefix again to compute ZIP checksums. A continuation inventory has at most 10,000 entries and 8 MiB of path text; use smaller selections for larger trees. Stop when the returned base64 value is empty. These inputs keep large downloads bounded without losing bytes.

SSE tools accept poll_ms from 1 to 10,000 (default 1,000). They return complete wire frames in events. complete: false means the poll window ended. The adapter cancels the response and releases its stream permit. Supply Last-Event-ID to resume a durable stream. Invalidation-only streams do not replay missed events: read the current data after a poll, as the UI does after reconnection. Tools do not create a permanent subscription.

Generated tools use the descriptive canonical name and keep the former calternal_api_<operationId> name as an alias. Both names use the same tool input schema from the registry used by the CLI. Each mutating MCP tool has destructiveHint: true.

Generated MCP tools declare an outputSchema (#836). A JSON route publishes its OpenAPI result. A route that returns an array or a string puts the value in result, because MCP structured content must be an object. Text, byte and event tools publish the shared {headers, complete, text|base64|events} envelope. The schema drops required lists and closed objects, so an omitted optional field cannot make a strict client reject a valid reply. Each result also has the same JSON as text content for older clients. A route without a declared JSON result keeps text-only output.

Invalid tool input stays a JSON-RPC invalid_params error. An HTTP failure from the route is a failed tool result (isError: true). Its structured content is {error: {status, code, message, details?, retry_after?}, payload?}. code is the API error code, or a status name such as conflict when the route sends none. payload keeps a typed failure body, for example an Undo conflict report. A malformed body, for example gateway HTML, is never echoed. Legacy hand-written tools keep their JSON-RPC errors for now.

Legacy Mail reader tools accept cursor as the next object returned by the previous page. The adapters map its timestamp and stable message ID to the two query fields that the Mail API uses.

MCP accepts App Passwords and installation bearer credentials. The adapter runs each API dispatch in a bounded task. This prevents nested router stack overflow on File moves. Task cancellation and response limits remain in force. Each dispatch uses the ordinary API session middleware with the original credential. A private marker selects the MCP protocol and surface switch. It cannot grant authority. The server runs core and plugin API handlers in a request-owned task. This keeps large handler futures outside the enclosing HTTP middleware stack. Cancel the request to cancel that task. A normal Tag rename caused a stack overflow without this boundary during round-3 verification. Admin tools are listed only for an admin-scoped credential and Role. That list is private and has no cache lifetime. A Home prefix still limits Files routes. Account and admin operations need the same session scopes and recent assertion as the API. App Passwords remain denied on account and admin routes, including those from an admin User.

WebMCP loads its registry only when the User enables that surface. Each call checks Apps & Devices again. Each write uses the existing confirmation sheet and checks access again after confirmation. Authority changes reuse the Settings passkey assertion flow. Admin tools are listed only after an admin API route accepts the browser session. The actual action checks that session again on the server.

python3 scripts/parity_matrix.py --check fails when a non-exempt operation lacks an adapter. An absent adapter cannot be accepted as an exemption. docs/parity-exceptions.json permits only presentation and onboarding entries with a reason. contracts/ui-intents.json binds static and template menu IDs, command IDs, shortcuts and Settings navigation groups. Every data binding names canonical operations. A local presentation exception needs a reason. The gate also checks typed and template requests, including the Notes request wrapper. A new unbound intent, missing route or absent adapter fails the check (#834).

Editor structure actions currently bind the whole-Note update_body route. This is partial block parity: the adapter must supply a complete body and current ETag. It is not a dedicated stable-block action or a co-edit session. Settings groups are navigation targets. Their form requests enter the same request inventory as other UI data changes.

The offline cross-User guard binds every generated tool entry point to the reviewed API route classification. This does not prove live authorization. apps/web/e2e/webmcp.mjs --transports-only calls generated tus, byte-range, ZIP, SSE and text tools through CLI, MCP and WebMCP on a real local server. Set CALTERNAL_SERVER_BIN and CALTERNAL_CLI_BIN to the built binaries. All writes use a throwaway Home. The full smoke suite still needs valid fixtures for every action, including provider-backed operations. The runner writes successful operation IDs and missing fixture IDs when PARITY_COVERAGE_OUT is set. --require-full-smoke fails if any generated tool lacks a successful call. Inventory coverage alone cannot pass this smoke check. The following evidence is historical. Before the current registry, the round-3 real-server run passed 170 distinct tools on each surface: 510 entry points in total. Each surface still needs fixtures for 118 tools. That historical run used 288 tools per surface. The current denominator comes from the registry. The full live cross-User and authorization matrices remain open.

--authorization-check registers a second User with the member Role. It checks all 39 admin tools: CLI and MCP must report API status 403; WebMCP must not register the tools. It also checks seven private Note routes on all three surfaces. User B must get API status 404. User A content, revision and path must stay the same. These checks do not cover the full live matrix.

--hls-fixtures adds native playlist and segment reads for the existing MP4 fixture. A rendition must return status 200 before the read counts as smoke coverage. During the local probe, the API returned status 202 for 60 seconds. The server stayed healthy. Native HLS verification remains open. The requested MCP and CLI adversarial round remains open.

--fixture-plan tests/parity/notes-smoke.json adds Note CRUD, block reminders, Tasks, saved searches and preferences. A plan has ordered cases with operation, input and optional save_as fields. Use {"$result": "read.etag"} to refer to a previous response field. Each surface executes its own plan. Responses stay in memory; the coverage report contains IDs only. A plan must supply real identities and current revisions. It must never bypass a route’s validation or authorization.

The local probe checks scope denial for every generated mutation. Shared smoke assertions test Note create, read, update and Trash over API, CLI and MCP in a throwaway Home. They also test stale ETag denial. The production browser test calls the same Note operations over WebMCP. Mutation schemas include the required revision headers; no adapter fetches a revision to bypass a conflict. This is not the requested complete smoke suite. It still needs valid fixtures for every action and external-provider test services.

MCP Events declarations live in contracts/action-events.json (#491, DESIGN §55). Each key is an action operation ID. The generator embeds these contracts in that action’s events field. events/list reads that field; it does not keep a separate list. A declaration must have a real producer. The server applies the declared filter and payload fields before delivery.

Replay, errors and live evidence (#834, #835)

Section titled “Replay, errors and live evidence (#834, #835)”

The contract declares safe_read or never for each operation. The shared CLI and Sync helper retries safe HTTP methods only. It does not replay writes after 429, 502, 503 or 504: a gateway can return one of these statuses after the server commits. A caller-supplied key or precondition does not establish a server deduplication guarantee. No new write retry is enabled. Read backoff is bounded. Retry-After waits are capped at 30 seconds.

CLI HTTP failures keep status, server code, details and Retry-After in error.http, alongside the existing version-1 message and exit code. Unknown server fields survive. This is compatible with the current error envelope. Problem+json is not added: a second server error representation would need a separate compatibility change.

The smoke runner writes version-1 fixture evidence with the source SHA, exact registry SHA-256, successful operation IDs and full per-surface denominators. A denial is not a successful fixture. To require complete live evidence, run:

Terminal window
python3 scripts/parity_matrix.py --check --fixture-evidence artifacts/parity-smoke-coverage.json

This check fails for stale evidence, an incorrect denominator, a missing surface or any eligible tool without a successful fixture. The offline UI binding check and live success check remain separate. The existing --require-full-smoke check remains mandatory for a full parity claim.

Use python3 scripts/parity_matrix.py --check --fixture-evidence <file>. The evidence must come from a complete run on clean source at the current SHA. The running server must report that SHA from its System info route. Set GIT_COMMIT to the current SHA when you build the test binary. A denial, an interrupted run, or an old registry does not count as success. All eligible tools need a successful fixture on each declared surface.

Use --check --contract-only for development checks of declarations. This mode does not prove live parity. The generated matrix names semantic UI bindings. It includes template IDs and editor block actions. A whole-Note write can carry an editor change; it is not a stable block edit API.

The direct CLI client and its transfer client use read_http_failure. This reader bounds the body at 128 KiB. It keeps status, code, details, Retry-After and typed conflict bodies. RemoteClient::with_http_details() selects this error form for CLI transfers. Default daemon clients keep their current cursor-expiry, upload-expiry and precondition recovery variants. Both clients use the same retry helper. No write retry is added.

A POST method does not prove a data change. Composer parse, Task parse, reminder preview and Tag rename preview are read-only actions. They do not need a change confirmation. They still declare no transport replay.

contracts/action-authority.json records the reviewed account requirements that OpenAPI does not yet declare (#739, #743, #774). Scopes are conjunctive: ["data", "account"] requires both. The generator rejects unknown action IDs. These reviewed declarations set enforce_scopes. The server checks their generated requirements before it parses the request body. Other routes keep their existing guards. This preserves public sign-in and capability routes. The route keeps its own User and resource checks.

Permanent Trash removal and public Calendar grant changes also set fresh_auth. A session must have a recent assertion for those actions (DESIGN §§21 and 27). Feed management reads need account authority but do not need a recent assertion. Ordinary moves to Trash and Calendar subscription reads keep data authority.