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:
calternal action run get_note --input '{"path":{"id":"<note-id>"}}' --jsonEach 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:
calternal action notes retitle <note-id> --if-match '<etag>' --title 'Plan' --dry-runcalternal action calendar range --from 2026-10-01 --to 2026-10-08 --jsoncalternal action photos timeline --days 7 --jsoncalternal completions <bash|zsh|fish|elvish|powershell> prints a completion
script for the full tree, which includes the registry areas.
Write confirmation
Section titled “Write confirmation”One rule applies to every command (#838):
- A registry action (
action runoraction <area> <verb>) changes data only with--confirm, because a generic name does not show the effect. Use--dry-runfirst: it prints the exact method, URI, headers and body and sends nothing. It needs no credential. - A named command, for example
rm,mvortask done, is an explicit intent. It runs without--confirmwhen 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-revokeandcalendar subscription-remove.sync resumeof 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.
Transfer input and output
Section titled “Transfer input and output”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.
MCP and WebMCP
Section titled “MCP and WebMCP”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.
Checks and remaining work
Section titled “Checks and remaining work”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:
python3 scripts/parity_matrix.py --check --fixture-evidence artifacts/parity-smoke-coverage.jsonThis 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.
Release parity check (#834)
Section titled “Release parity check (#834)”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.
Account authority
Section titled “Account authority”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.