fvtt-world-cli 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/protocol.md CHANGED
@@ -1,17 +1,12 @@
1
1
  # Protocol
2
2
 
3
- This document describes the stable integration contract between CLI clients, the local daemon, and
4
- the Foundry bridge: what each party may assume, which guarantees survive reconnects and failures,
5
- and how errors and retries are classified. It deliberately contains no wire-level inventory. Exact
6
- message types, frame schemas, command names, request schemas, error codes, default timeouts, and
7
- size limits are defined by the protocol package and discoverable at runtime:
8
-
9
- - [`packages/protocol/src/commands.js`](../packages/protocol/src/commands.js)
10
- - [`packages/protocol/src/schemas/`](../packages/protocol/src/schemas/)
11
- - [`packages/protocol/src/constants.js`](../packages/protocol/src/constants.js)
12
- - [`packages/protocol/src/validation.js`](../packages/protocol/src/validation.js)
13
- - `fvtt-world-cli commands --json`, `fvtt-world-cli schema <command>`, and
14
- `fvtt-world-cli system info --json` for the installed checkout and the connected runtime
3
+ This reference defines the integration contract between local clients, the daemon, and the
4
+ Foundry bridge. For CLI workflows, use [Commands](commands.md).
5
+
6
+ Exact command and frame schemas come from the [protocol registry](../packages/protocol/src/commands.js)
7
+ and [family schemas](../packages/protocol/src/schemas/). The [constants](../packages/protocol/src/constants.js)
8
+ define error codes, timeouts, and limits. Inspect the installed version with `commands --json` and
9
+ `schema <command>`; use `system info --json` for connected-runtime details.
15
10
 
16
11
  ## Versioning
17
12
 
@@ -19,31 +14,16 @@ The protocol version equals the product release version. Every transport message
19
14
  components in one installation must match exactly. The daemon and Foundry module reject a mismatch
20
15
  instead of negotiating a subset of the contract.
21
16
 
22
- Recover by updating the older component. Restart the daemon after updating the CLI package, or reload
23
- the GM client after updating the Foundry module. A refused module load does not reconnect on its own.
24
- Mismatch errors identify the older component when the two versions can be ordered.
25
-
26
17
  ## Transport model
27
18
 
28
19
  The daemon listens on a loopback WebSocket endpoint. Credentials never appear in URLs. A local
29
- client, the CLI or a future Companion, has no browser Origin and must establish its role with its
30
- first message: the daemon enforces a short deadline for a valid first frame, closes malformed
31
- openings immediately, and never assigns a role before authentication has succeeded. Browser sockets
32
- are identified by their exact HTTP(S) Origin and participate only in pairing and bridge sessions;
33
- they cannot assume the local-client role.
34
-
35
- Every message type has a closed top-level schema. A malformed message yields a structured
36
- `INVALID_MESSAGE` error where a response is possible, and an authenticated local client that sends
37
- a malformed control request receives a correlated error and may keep using its connection.
20
+ client has no browser Origin and authenticates with `client.hello` as its first message. Browser
21
+ sockets require an exact HTTP(S) Origin and may only request pairing or a bridge session.
38
22
 
39
- The daemon accepts one active authenticated bridge at a time:
40
-
41
- - another pairing cannot displace the active bridge and is rejected as `BRIDGE_BUSY`;
42
- - a new socket from the same pairing takes the slot over, the tab-reload recovery path;
43
- - a clean goodbye releases the slot immediately, while an abnormal close reserves it briefly for
44
- the same pairing;
45
- - a daemon-initiated release is terminal for the released client: it does not reconnect on its
46
- own, and resuming is an explicit operator action.
23
+ The daemon imposes a first-frame deadline and assigns no role before authentication succeeds.
24
+ Every message has a closed top-level schema. Malformed openings close immediately; later malformed
25
+ messages return `INVALID_MESSAGE` where possible. An authenticated local client may continue after
26
+ a correlated control-request error.
47
27
 
48
28
  ## Size limits
49
29
 
@@ -64,65 +44,45 @@ Pairing is the one-time exchange that lets a GM browser become a bridge. Its gua
64
44
  exactly once, to the requesting socket, only after the digest has been persisted;
65
45
  - approving the same Origin/world/user/client again re-pairs that client's existing profile by
66
46
  rotating its credential instead of accumulating duplicates;
67
- - expiry, denial, socket close, and a granted pairing all end the attempt through one idempotent
68
- path, so the browser-side authorization UI is never left waiting after daemon shutdown or expiry.
47
+ - expiry, denial, socket close, and daemon shutdown end the pending attempt.
69
48
 
70
49
  ### Client identity
71
50
 
72
- The pairing request carries a `client` object inside its identity: `id` is the browser's persistent
73
- client identifier, and `label` is a human name for that browser. Pairing records are unique per
74
- (Origin, world, user, client id), so two browsers signed in as the same GM on the same world hold two
75
- independent records and neither re-pair disturbs the other.
76
-
77
- - `id` is bounded to hexadecimal characters and dashes, 8 to 64 characters long.
78
- - `label` is 1 to 64 characters of Unicode text. Whitespace-only values are rejected, as are control
79
- (C0/C1), zero-width, bidirectional-override, and Unicode tag characters. The schema is the
80
- enforcement point because any local process can send a pairing request and the label is later
81
- printed by `auth list` and `auth pending`.
82
- - Labels are not unique. Duplicate labels are accepted as they arrive, without suffixing.
83
- - A label is chosen once, in the browser, at pairing time. No control operation renames a stored
84
- record; re-pairing is the way to change a label.
85
-
86
- The bridge hello carries `clientId` at the top level, beside `pairingId` and `credential`, because it
87
- is authentication material rather than session content: the daemon rejects a hello whose client id
88
- does not match the stored pairing. The label is not resent on hello; the daemon's pairing record owns
89
- it, and the browser keeps a copy only to display it.
51
+ Pairing identity includes a `client` object with a persistent `id` and human-readable `label`.
52
+ Records are unique per Origin, world, user, and client id. Re-pairing one browser does not affect
53
+ another browser using the same GM account.
54
+
55
+ - `id` accepts 8 to 64 hexadecimal characters and dashes.
56
+ - `label` accepts 1 to 64 Unicode characters, excluding whitespace-only values, C0/C1 controls,
57
+ zero-width characters, bidirectional controls, and Unicode tags.
58
+ - Labels need not be unique. Changing a stored label requires re-pairing.
59
+
60
+ The bridge hello carries `clientId`, `pairingId`, and `credential`. A client id mismatch returns
61
+ `UNAUTHORIZED`. The label is not resent; the stored pairing owns it.
90
62
 
91
63
  ## Daemon control
92
64
 
93
- Authenticated local clients manage pairings and the active bridge through closed,
94
- operation-discriminated control requests; responses repeat both the correlation id and the
95
- operation. The operation registry lives in the protocol package. `auth.approve` takes only an
96
- optional pairing `code`; the approved record's label comes from the pairing request itself.
97
-
98
- `auth.await` is the long-poll behind the interactive pairing wait. It answers at once with the
99
- earliest live pending request, in the same public shape `auth.pending` serializes and with no
100
- credential material; when nothing is pending it parks the response until a request arrives, or until a
101
- bounded daemon-side cap elapses and the result carries no request. Every parked waiter is answered by
102
- the next arriving request, and a waiter is discarded when its client socket closes. The cap is the
103
- invariant that keeps a parked response inside the caller's own request timeout; `timeoutMs` may ask
104
- for a shorter park and is bounded by the cap, so no caller can park longer than the daemon allows.
105
-
106
- `auth.prune` deletes idle pairing records. Its optional `olderThanDays` is a non-negative integer and
107
- defaults to 30; a record is removed when its `lastSeenAt` is older than that many days before the
108
- call. `lastSeenAt` is stamped when a pairing is approved, when its browser passes the bridge hello,
109
- again when that bridge connection closes, and when a hello whose credential the daemon accepted is
110
- rejected with `BRIDGE_BUSY`, so the cutoff measures how long a record has been idle rather than how
111
- long ago it last connected. A hello rejected with `UNAUTHORIZED` proves nothing about the record and
112
- never stamps it. The pairing that owns the active bridge, and the holder of a live
113
- abnormal-disconnect lease, are excluded from removal regardless of their timestamps, so pruning can
114
- never unpair the browser that is connected or the one the daemon is still holding a slot for. The
115
- config is rewritten only when at least one record is removed. The result is `{ olderThanDays, pruned }`,
116
- where `olderThanDays` is the cutoff the daemon applied, including the default when the caller omitted
117
- it, and `pruned` carries the removed records in the same public, digest-free shape `auth.list`
118
- serializes. The daemon computes the set at execution time; a caller that previewed candidates from
119
- `auth.list` holds an advisory list, not the outcome, and the executed set may be wider than that
120
- preview when a record crosses the cutoff between the two calls.
121
-
122
- The active bridge itself may use
123
- exactly one control operation: revoking its own pairing. A browser Unpair deletes its stored
124
- credential only after a correlated successful revocation; on failure the credential is retained for
125
- retry, and discarding it locally is a separate deliberate action.
65
+ Authenticated local clients manage pairings and bridge ownership through closed control requests.
66
+ Responses repeat the correlation id and operation. The protocol package defines the operation
67
+ registry; the [command guide](commands.md#authorization-commands) covers operator use.
68
+
69
+ `auth.await` returns the earliest live pending request in the public `auth.pending` shape, without
70
+ credentials. With no pending request, it parks until one arrives or a bounded wait expires with an
71
+ empty result. `timeoutMs` can shorten but not exceed the daemon's poll cap. A new request answers
72
+ all parked waiters; socket closure removes that client's waiter.
73
+
74
+ `auth.prune` accepts a non-negative `olderThanDays`, defaulting to 30. It removes records older than
75
+ the cutoff according to `lastSeenAt`, excluding the active pairing and a live reconnect-lease
76
+ holder. Approval, authenticated hellos, and disconnects refresh this timestamp. A valid hello
77
+ rejected with `BRIDGE_BUSY` refreshes it; an unauthorized hello does not.
78
+
79
+ The result is `{ olderThanDays, pruned }`, with removed records in the public `auth.list` shape.
80
+ The daemon computes the set at execution time, so an earlier candidate list is advisory and can
81
+ omit a record that later crosses the cutoff.
82
+
83
+ The active authenticated bridge may use only one control operation: revoking its own pairing.
84
+ It deletes its local credential only after a correlated successful revocation response. A failed
85
+ revocation retains the credential for retry.
126
86
 
127
87
  ## Bridge sessions
128
88
 
@@ -137,35 +97,147 @@ active session.
137
97
  - `BRIDGE_BUSY` is terminal for that client instance but preserves the stored credential; the
138
98
  operator releases the current owner and retries explicitly rather than pairing again.
139
99
  - Only the exact active authenticated socket can release ownership with a goodbye.
140
- - A same-pairing takeover immediately completes requests owned by the displaced socket with an
141
- indeterminate-delivery error: the operation may already have committed, so callers inspect world
142
- state before retrying.
100
+ - Another pairing cannot displace the active bridge. A same-pairing connection may take over;
101
+ requests forwarded to the displaced socket then fail with indeterminate delivery.
102
+ - A clean goodbye releases ownership immediately. An abnormal close reserves it briefly for the
103
+ same pairing. A daemon-initiated release clears ownership and stops automatic reconnects.
143
104
  - If the connected user loses GM authority, the bridge answers the pending command with a
144
105
  correlated `PERMISSION_DENIED` without dispatching it, so the caller knows the rejected command
145
106
  started no mutation.
146
107
 
108
+ ## Client-side status signal
109
+
110
+ The module emits `fvtt-world-cli.statusChanged` for macros and other modules in the GM client.
111
+ It fires when transport status or handshake acknowledgement changes and receives the same bridge
112
+ snapshot exposed by `system info`:
113
+
114
+ `status`, `url`, `helloAcknowledged`, `hasEstablishedSession`, `lastConnectedAt`,
115
+ `reconnectAttempts`, `terminalStopReason`, and `protocolVersionMismatch`.
116
+
117
+ Readiness requires both `status === "connected"` and `helloAcknowledged`. The socket opens before
118
+ the daemon acknowledges the handshake, and losing it clears the acknowledgement.
119
+ `protocolVersionMismatch` is normally `null`; on a mismatch it reports both versions and identifies
120
+ the older component as `module`, `cli-daemon`, or `unknown`.
121
+
122
+ This hook is local to the GM client. Credential changes do not trigger it, and it has no wire
123
+ protocol meaning.
124
+
147
125
  ## Commands and correlation
148
126
 
149
- Commands are explicit typed names registered in the protocol package; there is no generic RPC
150
- method. A request carries a caller-chosen correlation id and schema-validated parameters, validated
151
- in the CLI before connecting and again in the bridge before dispatch. The command must be both
152
- registered and advertised by the active bridge. The response repeats the correlation id and is
153
- exclusive: success carries a result, failure carries a structured error.
127
+ A request supplies a caller-chosen correlation id, a registered command name, and schema-validated
128
+ parameters. Responses repeat the id and carry either a result or an error. Validate both the
129
+ request envelope and command parameters against the protocol registry.
130
+
131
+ ### Request and response example
132
+
133
+ After the local client has authenticated, it can send this request to the daemon. Replace
134
+ `<release-version>` with the installed protocol version. The active bridge must advertise the command.
135
+
136
+ ```json
137
+ {
138
+ "protocolVersion": "<release-version>",
139
+ "type": "command.request",
140
+ "id": "check-bridge",
141
+ "command": "system.ping",
142
+ "params": {}
143
+ }
144
+ ```
145
+
146
+ A success response repeats the id and carries the result. This example omits the timestamp and
147
+ bridge-status fields inside `result`:
148
+
149
+ ```json
150
+ {
151
+ "protocolVersion": "<release-version>",
152
+ "type": "command.response",
153
+ "id": "check-bridge",
154
+ "ok": true,
155
+ "result": { "pong": true }
156
+ }
157
+ ```
158
+
159
+ If no bridge is ready, the response instead carries an error. Message wording is illustrative:
160
+
161
+ ```json
162
+ {
163
+ "protocolVersion": "<release-version>",
164
+ "type": "command.response",
165
+ "id": "check-bridge",
166
+ "ok": false,
167
+ "error": {
168
+ "code": "BRIDGE_NOT_READY",
169
+ "message": "No Foundry bridge is connected."
170
+ }
171
+ }
172
+ ```
173
+
174
+ The protocol package exports `REQUEST_SCHEMA` and `COMMAND_RESPONSE_SCHEMA` for envelope validation.
154
175
 
155
176
  ## Result conventions
156
177
 
157
- Document results live under a type-named key inside the result. Collection, action, and bulk
158
- results use their documented keys, which vary between commands but are stable for each one.
159
- Broadcast commands that change no document, pulling users to a scene and showing a journal entry
160
- or image, report `dispatched` rather than a confirmed post-state, because a socket broadcast offers
161
- nothing to read back; the result names the users it targeted and the active/inactive split where
162
- that is knowable. `macro.execute` reports the macro's returned value and observed chat messages,
163
- and a timeout there is indeterminate: the macro keeps running in the GM client, so `MACRO_TIMEOUT`
164
- callers verify effects by reads instead of retrying blindly.
165
- Serialized projections expose `id` as the public identifier; a source `_id` mirror may accompany
166
- it. A previewed new document has no persistent identity, and an id observed during a preview must
167
- not be reused. List-like responses that paginate return their collection with a total and a
168
- has-more flag, and filters apply before pagination.
178
+ Document results use type-named keys and expose `id` as the public identifier. A source `_id`
179
+ mirror may accompany it. List responses use smaller projections than single-document reads;
180
+ filters apply before pagination. Previewed documents have no persistent identity, so an id
181
+ observed during a preview must not be reused.
182
+
183
+ Broadcast actions report `dispatched` and target users rather than a confirmed player-side result.
184
+ The active/inactive split is included where knowable.
185
+
186
+ ### Macro results
187
+
188
+ `macro.execute` returns the macro's value and observed chat messages. `chatCapture` describes the
189
+ observation: `captured` for all expected messages, `not-created` when a chat macro created none,
190
+ `partial` for incomplete capture, or `unknown` when the client could not observe the chat log.
191
+
192
+ `MACRO_TIMEOUT` is indeterminate because the macro keeps running. A thrown error can follow partial
193
+ effects, while a macro that catches its own errors may return normally. Consumers must verify
194
+ world state when the return value does not establish the effect.
195
+
196
+ ### Setting results
197
+
198
+ Setting writes return the observed `value` and its `previous` value. Registered types or callbacks
199
+ may normalize the input. Confirmation establishes that the previous value changed, not that the
200
+ requested value was stored exactly. Writing an already-stored value succeeds as unchanged without
201
+ calling Foundry. `requiresReload: true` indicates that the GM client needs a reload.
202
+
203
+ ## Write confirmation
204
+
205
+ Document writes report success only after confirmation. Foundry can resolve a vetoed or invalid
206
+ write without throwing, and hooks can remove or rewrite part of a patch. Single and bulk updates
207
+ pass Foundry a private copy, then compare stored state with the original request.
208
+
209
+ A valid patch whose requested state is already stored remains a successful no-op. A validation
210
+ failure recovered after an unwritten update returns `INVALID_PARAMS`. Unconfirmed, partial, or
211
+ indeterminate updates can return `INTERNAL_ERROR` with these details:
212
+
213
+ - `fields`: requested fields whose state was not confirmed.
214
+ - `partial: true`: stored data changed in a requested field, but the full requested state was
215
+ not reached. A hook may have applied only part of a nested object or replaced a value.
216
+ - `changedFields`: requested top-level fields observed to change, including fields that changed
217
+ only in part. This does not attribute the change to this request rather than a concurrent write.
218
+ - `appliedFields`: changed top-level fields whose requested state was confirmed. This can be empty
219
+ even when `partial` is true.
220
+ - `indeterminate: true`: confirmation could not establish the outcome. Some or all of the write
221
+ may have persisted. `changedFields` is `null` when the before/after comparison was unavailable.
222
+
223
+ Parent patches that create embedded entries without `_id`, such as new `behaviors` in
224
+ `scene.region.update`, return an indeterminate error after writing because parent update results
225
+ cannot confirm those creations. A retry may duplicate them. Read the embedded collection first,
226
+ or use a dedicated embedded create command. Dry runs can still preview these patches.
227
+
228
+ After partial or indeterminate errors, read stored state and submit only remaining changes as a
229
+ new operation with a fresh idempotency key if using one.
230
+
231
+ ### Patch shape checks
232
+
233
+ Single updates, bulk updates, and dry runs reject ambiguous field spellings, invalid or discarded
234
+ array values, and dotted writes inside arrays with `INVALID_PARAMS` before mutation. Recognized
235
+ legacy fields are checked against their migrated destinations as well. Send whole arrays; ordinary
236
+ dotted object-property writes remain supported where the schema permits them.
237
+
238
+ Errors identify the `field` and may include `arrayField`, `requested`, `stored`, or migration
239
+ destination details. Bulk failures add the entry's `index` and `id`; single failures use document
240
+ coordinates. These details describe a rejected patch, not a write result.
169
241
 
170
242
  ## Dry run
171
243
 
@@ -178,66 +250,58 @@ world can change between preview and commit.
178
250
 
179
251
  ## Idempotency
180
252
 
181
- Commands with duplicate-creation or non-repeatable-action risk accept or require an idempotency
182
- key identifying one logical request. Reusing a key with a different command or payload is rejected
183
- as `IDEMPOTENCY_KEY_CONFLICT`. Idempotency memory is runtime state: bounded, and cleared by daemon
184
- restart, bridge replacement, world switch, or expiry. The daemon may also evict cached successes.
185
- Idempotency reduces duplicate effects across response loss. It is not a durable transaction, so an
186
- indeterminate delivery still ends with a world-state read.
253
+ An idempotency key identifies one logical request. Reusing it with another command or payload
254
+ returns `IDEMPOTENCY_KEY_CONFLICT`. The cache is bounded and temporary; it cannot guarantee exactly
255
+ once execution across state loss. Keys remain subject to the [delivery rules](#delivery-states-and-retries).
256
+
257
+ An idempotency key covers the request and its approval:
258
+
259
+ - After `APPROVAL_PENDING`, the daemon links the key to that approval. A byte-identical retry returns
260
+ the same pending response. A different request with that key returns
261
+ `IDEMPOTENCY_KEY_CONFLICT`.
262
+ - An approved outcome becomes the cached final response. A denial, timeout, or confirmed
263
+ cancellation removes the link, so the same request can start a new approval.
264
+ - If the daemon cannot read the approval outcome, the key remains indeterminate and returns
265
+ `APPROVAL_UNKNOWN` until expiry. Read world state before retrying under a fresh key.
266
+ - If a bridge session ends before the daemon receives the first response, the daemon retains the key
267
+ as lost in flight. Reuse returns `BRIDGE_DISCONNECTED` with `reason: "lost-in-flight"`. Read world
268
+ state, then use a fresh key if the operation still needs to run.
269
+ - The daemon reserves bounded space before forwarding a keyed request. If no slot is available, it
270
+ returns `IDEMPOTENCY_STORE_FULL` before Foundry receives the request. Retry after earlier keys
271
+ settle or expire.
272
+ - Daemon restart, world switch, pairing switch, and expiry clear runtime idempotency state. Cached
273
+ successes may also be evicted. Later requests can then reach Foundry as new operations.
187
274
 
188
275
  ## Batch requests and bulk writes
189
276
 
190
- Connection reuse (`exec --stdin`) is a client mechanism: each request keeps its own correlation id
191
- and success state, and failures are reported per request. Bulk write commands are ordinary typed
192
- commands over bounded arrays: elements are prevalidated, but Foundry persistence is not
193
- transactional, so the result reports overall completeness plus a per-element outcome, and every
194
- outcome carries its own meaning.
277
+ [`exec --stdin`](commands.md#send-a-batch-of-commands) is a CLI wrapper over individual requests.
278
+ Its input ids and line indexes belong to CLI output, not the wire contract.
279
+
280
+ Bulk writes accept bounded arrays and prevalidate their elements. Persistence is not transactional;
281
+ results report `complete` and per-element `outcomes`, all of which the caller must inspect.
195
282
 
196
- Batch reads (`get-many`) fail the whole request when any requested id cannot be read, with one
197
- exception: `setting.get-many` reports an unregistered key on its own result row (`SETTING_NOT_FOUND`)
198
- rather than failing the request, because a world routinely registers settings only for the systems
199
- and modules it has active, so a partially resolvable set of keys is the normal case rather than an
200
- error.
283
+ `get-many` fails the request if any requested id cannot be read. `setting.get-many` is the exception;
284
+ it reports an unregistered key as `SETTING_NOT_FOUND` on that row without failing the whole request.
201
285
 
202
286
  ## Error model
203
287
 
204
- Errors have a stable code, a human-readable message, and optional structured details. The code and
205
- documented detail fields are the stable contract for branching; message text is not. The exhaustive
206
- code set is exported by the protocol package. The classes consumers act on:
207
-
208
- | Class | Representative codes | Consumer response |
209
- |---|---|---|
210
- | Request/schema | `INVALID_PARAMS`, `UNKNOWN_COMMAND` | Correct the request or resolve version skew |
211
- | Authentication/permission | `UNAUTHORIZED`, `PERMISSION_DENIED` | Restore credentials or authority |
212
- | Pairing | `PAIRING_REQUIRED`, `PAIRING_EXPIRED`, `BRIDGE_BUSY` | Pair, retry revocation, or release the active owner as indicated |
213
- | Lookup | `*_NOT_FOUND`, `SETTING_UNREGISTERED` | Refresh ids and world state |
214
- | Safety/policy | `DELETE_FORBIDDEN`, `PATH_NOT_ALLOWED`, `SETTING_PROTECTED`, `USER_SELF_PROTECTED` | Change the requested operation |
215
- | Capability | `UNSUPPORTED_OPERATION` | Choose a supported workflow or runtime |
216
- | Size/resource | `PAYLOAD_TOO_LARGE`, `QUERY_TOO_BROAD`, `IDEMPOTENCY_STORE_FULL` | Reduce, page, or resend the request later |
217
- | Command policy | `COMMAND_DENIED` | Treat the command as unavailable on that GM client |
218
- | Approval | `APPROVAL_PENDING`, `APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_CANCELLED`, `APPROVAL_QUEUE_FULL`, `APPROVAL_UNKNOWN` | Apply the approval rules below |
219
- | Bridge state | `BRIDGE_NOT_READY`, `BRIDGE_TIMEOUT`, `BRIDGE_DISCONNECTED` | Apply the delivery rules below |
220
- | Indeterminate outcome | `MACRO_TIMEOUT` | Verify the effect by reads before retrying |
221
- | Unexpected | `INTERNAL_ERROR` | Preserve details and investigate |
222
-
223
- Foundry DataModel validation failures surface as parameter errors and are distinguished in details
224
- where available. A failed nested lookup identifies the level that failed.
225
-
226
- `UNSUPPORTED_PROTOCOL_VERSION` details carry `expectedVersion`, `actualVersion`, the rejecting
227
- `handshake`, and `staleComponent`. The component is `module`, `cli-daemon`, or `unknown`. Consumers
228
- can name the component that needs an update when the comparison identifies it. An unordered version
229
- or unidentified peer produces `unknown`.
230
-
231
- With JSON output, a failed command emits one structured error envelope on stdout and exits
232
- non-zero. The exit code is a coarse process classification; the structured error code is the
233
- authoritative automation signal. `exec --stdin` reports per-line errors and uses its own aggregate
234
- exit status.
288
+ Errors carry a stable `code`, a human-readable `message`, and optional `details`. Branch on codes
289
+ and documented detail fields, not message text. The [constants](../packages/protocol/src/constants.js)
290
+ export the code set. CLI exit codes are coarse classifications; the JSON error is authoritative.
291
+
292
+ Validation errors identify invalid parameters; nested lookup failures identify the failed level.
293
+ Approval and delivery errors need the [retry rules](#delivery-states-and-retries), since a failure
294
+ does not always mean that nothing ran.
295
+
296
+ `UNSUPPORTED_PROTOCOL_VERSION` details contain `expectedVersion`, `actualVersion`, `handshake`,
297
+ and `staleComponent`. The last field identifies the older component as `module`, `cli-daemon`,
298
+ or `unknown` when the peer or version ordering cannot be established.
235
299
 
236
300
  ## Approval flow
237
301
 
238
- A GM client's command policy can require approval before a command runs. Approval is the GM's
239
- decision about one invocation. Pairing approval grants a browser credential, while confirmation
240
- checks a completed write. The active GM client's policy controls the invocation.
302
+ A GM client's policy can hold an invocation for approval before dispatch. Pairing approval grants
303
+ a browser credential; command approval permits an invocation; confirmation checks a completed write.
304
+ See [Security](security.md#permissions-and-destructive-actions) for policy and review limits.
241
305
 
242
306
  The wait has two phases because the decision can outlast a normal request timeout:
243
307
 
@@ -265,6 +329,12 @@ The wait has two phases because the decision can outlast a normal request timeou
265
329
  - `APPROVAL_UNKNOWN` means the module no longer holds that approval. Reloading the GM client, ending
266
330
  its bridge session, or expiry can remove the state. The command may not have started, or it may have
267
331
  completed. Read world state before another write.
332
+ - For `macro.execute`, approval binds the body and type captured at admission. Drift or a missing
333
+ capture prevents execution and returns `APPROVAL_STALE` in the approved outcome's `response`.
334
+ Drift details include `macroId`, `requestedMacroId`, and `drifted`, which names `body`, `type`,
335
+ `existence`, or `identity`. A missing capture returns details with `command` instead. Read the
336
+ current macro before a fresh request, using a new idempotency key because the refusal can be
337
+ cached. No new approval is created automatically.
268
338
  - A dry run bypasses approval and reports `approvalRequired: true` when the real command would wait.
269
339
  The policy still refuses denied commands during a dry run.
270
340
  - `policy.snapshot` reports `{ approve: [names], deny: [names] }`. The result is advisory because the
@@ -275,25 +345,6 @@ are closed. `approval.await`, `approval.cancel`, and `policy.snapshot` do not ap
275
345
  `system.info` command inventory, or bridge status. `schema <command>` still returns their schemas.
276
346
  The bridge handshake advertises them because the daemon must forward them.
277
347
 
278
- An idempotency key covers the request and its approval:
279
-
280
- - After `APPROVAL_PENDING`, the daemon links the key to that approval. A byte-identical retry returns
281
- the same pending response. A different request with that key returns
282
- `IDEMPOTENCY_KEY_CONFLICT`.
283
- - An approved outcome becomes the cached final response. A denial, timeout, or confirmed
284
- cancellation removes the link, so the same request can start a new approval.
285
- - If the daemon cannot read the approval outcome, the key remains indeterminate and returns
286
- `APPROVAL_UNKNOWN` until expiry. Read world state before retrying under a fresh key.
287
- - If a bridge session ends before the daemon receives the first response, the daemon retains the key
288
- as lost in flight. Reuse returns `BRIDGE_DISCONNECTED` with `reason: "lost-in-flight"`. Read world
289
- state, then use a fresh key if the operation still needs to run.
290
- - The daemon reserves bounded space before forwarding a keyed request. If no slot is available, it
291
- returns `IDEMPOTENCY_STORE_FULL` before Foundry receives the request. Retry after earlier keys
292
- settle or expire.
293
- - Daemon restart, world switch, pairing switch, and expiry clear runtime idempotency state. A later
294
- request can reach Foundry as a new operation, so an indeterminate result still requires a state
295
- read first.
296
-
297
348
  ## Delivery states and retries
298
349
 
299
350
  Retry safety is a function of whether the request reached Foundry:
@@ -308,22 +359,12 @@ Retry safety is a function of whether the request reached Foundry:
308
359
  | `COMMAND_DENIED` | Refused before dispatch | Not executed; the command is unavailable on that GM client |
309
360
  | `APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_CANCELLED` | Reached Foundry, never dispatched | Not executed; the same request is safe to send again |
310
361
  | `APPROVAL_QUEUE_FULL` | Refused before admission | Not executed; safe to retry when the waiting decisions clear |
362
+ | `APPROVAL_STALE` | Allowed, refused before dispatch | Not executed; read the macro and request fresh approval with a new idempotency key if using one |
311
363
  | `IDEMPOTENCY_STORE_FULL` | No | Not executed; safe to retry when earlier keys settle or expire |
312
364
  | `APPROVAL_UNKNOWN` | Unknown | May have committed; inspect state, then re-request under a fresh idempotency key |
365
+ | Update error with `partial` or `indeterminate` details | Yes | Read stored state; send only remaining changes as a new operation |
313
366
  | Structured command rejection | Resolved with an error | Correct according to the code |
314
367
 
315
368
  The distinction between connection-phase and response-wait failures is carried in structured error
316
369
  details. Default waits, forward timeouts, heartbeats, and backoff bounds are defined in the
317
370
  protocol and CLI constants; runtime flags can override the client and daemon request timeouts.
318
-
319
- ## Compatibility rules
320
-
321
- - One release ships the CLI, daemon, and Foundry module as a compatible set. They share that release's
322
- version. The bridge refuses mixed-release operation and does not negotiate a subset.
323
- - Within a release line, additive result and handshake fields are how the contract evolves without
324
- changing the meaning of existing fields; request schemas remain explicit and versioned.
325
- - A bridge advertises the command set it can execute; the daemon forwards only advertised commands.
326
- - Unsupported version-dependent behavior produces a predictable error rather than a false success.
327
-
328
- Foundry-version behavior belongs in [Foundry compatibility](compatibility.md), while per-command
329
- request shape remains discoverable from the registry.