@happyvertical/smrt-ui 0.43.4 → 0.43.6

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.
Files changed (108) hide show
  1. package/AGENTS.md +33 -0
  2. package/README.md +78 -4
  3. package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
  4. package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
  5. package/dist/components/display/CurrencyDisplay.contract.js +1 -0
  6. package/dist/components/display/CurrencyDisplay.svelte +148 -17
  7. package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
  8. package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
  9. package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
  10. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
  11. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
  12. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
  13. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
  14. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
  15. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
  16. package/dist/components/display/currency-metadata.d.ts +2 -0
  17. package/dist/components/display/currency-metadata.d.ts.map +1 -0
  18. package/dist/components/display/currency-metadata.js +14 -0
  19. package/dist/components/forms/Checkbox.svelte +15 -6
  20. package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/Combobox.svelte +45 -10
  22. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/FilePicker.svelte +5 -2
  24. package/dist/components/forms/Form.svelte +57 -0
  25. package/dist/components/forms/Form.svelte.d.ts +2 -0
  26. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  27. package/dist/components/forms/Input.svelte +59 -13
  28. package/dist/components/forms/Input.svelte.d.ts.map +1 -1
  29. package/dist/components/forms/Listbox.svelte +33 -6
  30. package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/MultiSelect.svelte +41 -19
  32. package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
  33. package/dist/components/forms/RadioGroup.svelte +14 -3
  34. package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
  35. package/dist/components/forms/RangeSlider.svelte +61 -6
  36. package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
  37. package/dist/components/forms/SegmentedControl.svelte +33 -6
  38. package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
  39. package/dist/components/forms/Select.svelte +32 -9
  40. package/dist/components/forms/Select.svelte.d.ts.map +1 -1
  41. package/dist/components/forms/Slider.svelte +26 -6
  42. package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
  43. package/dist/components/forms/StagedControlReview.svelte +637 -0
  44. package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
  45. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/Switch.svelte +16 -7
  47. package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
  48. package/dist/components/forms/TagsInput.svelte +36 -8
  49. package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
  50. package/dist/components/forms/Textarea.svelte +20 -8
  51. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
  52. package/dist/components/forms/ToggleButton.svelte +20 -3
  53. package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
  54. package/dist/components/forms/__tests__/Form.test.js +106 -0
  55. package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
  56. package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
  57. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
  58. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
  59. package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
  60. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
  61. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
  62. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
  63. package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
  64. package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
  65. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
  66. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
  67. package/dist/components/forms/__tests__/core-controls.test.js +223 -2
  68. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
  69. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
  70. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
  71. package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
  72. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
  73. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
  74. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
  75. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
  76. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
  77. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
  78. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
  79. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
  80. package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
  81. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
  82. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
  83. package/dist/components/forms/control-interaction-context.d.ts +9 -1
  84. package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
  85. package/dist/components/forms/control-interaction-context.js +16 -0
  86. package/dist/components/forms/control-interaction.d.ts +97 -7
  87. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  88. package/dist/components/forms/control-interaction.js +1019 -92
  89. package/dist/components/forms/control-value-validation.d.ts +19 -0
  90. package/dist/components/forms/control-value-validation.d.ts.map +1 -0
  91. package/dist/components/forms/control-value-validation.js +152 -0
  92. package/dist/components/forms/index.d.ts +3 -1
  93. package/dist/components/forms/index.d.ts.map +1 -1
  94. package/dist/components/forms/index.js +2 -1
  95. package/dist/components/forms/staged-control-review.d.ts +21 -0
  96. package/dist/components/forms/staged-control-review.d.ts.map +1 -0
  97. package/dist/components/forms/staged-control-review.js +1 -0
  98. package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
  99. package/dist/components/forms/use-control-registration.svelte.js +23 -4
  100. package/dist/i18n/strings.d.ts +3 -0
  101. package/dist/i18n/strings.d.ts.map +1 -1
  102. package/dist/i18n/strings.js +3 -0
  103. package/dist/i18n/strings.ui.d.ts +18 -0
  104. package/dist/i18n/strings.ui.d.ts.map +1 -1
  105. package/dist/i18n/strings.ui.js +19 -0
  106. package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
  107. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  108. package/package.json +2 -2
@@ -5,18 +5,152 @@
5
5
  * DOM. Controls register serializable metadata plus small imperative handles;
6
6
  * adapters translate voice/chat/tutorial requests into commands.
7
7
  */
8
+ import { untrack } from 'svelte';
9
+ const localGestureBatchExecutors = new WeakMap();
10
+ // Intentionally module-private: enumerable so transparent object-spread
11
+ // registry adapters retain the same one-shot local-gesture proof path.
12
+ const localGestureBatchExecutor = Symbol('smrt.localGestureBatchExecutor');
13
+ const consumedFallbackGestureEvents = new WeakMap();
14
+ const eventIsTrustedGetter = typeof Event === 'undefined'
15
+ ? undefined
16
+ : Object.getOwnPropertyDescriptor(Event.prototype, 'isTrusted')?.get;
17
+ const eventPhaseGetter = typeof Event === 'undefined'
18
+ ? undefined
19
+ : Object.getOwnPropertyDescriptor(Event.prototype, 'eventPhase')?.get;
20
+ function nativeEventState(event) {
21
+ const ownIsTrusted = Object.getOwnPropertyDescriptor(event, 'isTrusted');
22
+ if (!eventPhaseGetter ||
23
+ Object.hasOwn(event, 'eventPhase') ||
24
+ (eventIsTrustedGetter && ownIsTrusted) ||
25
+ (!eventIsTrustedGetter &&
26
+ (!ownIsTrusted || ownIsTrusted.configurable || ownIsTrusted.writable))) {
27
+ return undefined;
28
+ }
29
+ try {
30
+ return {
31
+ isTrusted: eventIsTrustedGetter
32
+ ? Boolean(eventIsTrustedGetter.call(event))
33
+ : Boolean(ownIsTrusted && 'get' in ownIsTrusted
34
+ ? ownIsTrusted.get?.call(event)
35
+ : ownIsTrusted?.value),
36
+ eventPhase: Number(eventPhaseGetter.call(event)),
37
+ };
38
+ }
39
+ catch {
40
+ // Native prototype getters reject plain objects and incompatible receivers.
41
+ return undefined;
42
+ }
43
+ }
44
+ function cloneFailureResult(command) {
45
+ let action = 'stage';
46
+ let identity = { formId: '', controlId: '' };
47
+ try {
48
+ action = command.action;
49
+ }
50
+ catch {
51
+ // A hostile accessor must not turn clone rejection into a thrown error.
52
+ }
53
+ try {
54
+ const source = command.identity;
55
+ identity = {
56
+ formId: source.formId,
57
+ controlId: source.controlId,
58
+ ...(source.subject
59
+ ? {
60
+ subject: {
61
+ type: source.subject.type,
62
+ id: source.subject.id,
63
+ ...(source.subject.label === undefined
64
+ ? {}
65
+ : { label: source.subject.label }),
66
+ },
67
+ }
68
+ : {}),
69
+ };
70
+ }
71
+ catch {
72
+ // Preserve the structured failure shape without retaining caller aliases.
73
+ }
74
+ return {
75
+ ok: false,
76
+ action,
77
+ identity,
78
+ reason: 'command_failed',
79
+ };
80
+ }
81
+ function cloneCommandBatch(commands) {
82
+ try {
83
+ return cloneValue(commands);
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /** Execute one value-changing command from a local DOM event handler. */
90
+ export function executeLocalControlCommand(registry, command, event) {
91
+ return executeLocalControlBatch(registry, [command], event).then((batch) => batch.results[0]);
92
+ }
93
+ /** Execute an ordered best-effort batch from one local DOM event handler. */
94
+ export async function executeLocalControlBatch(registry, commands, event) {
95
+ const commandSnapshots = cloneCommandBatch(commands);
96
+ if (!commandSnapshots) {
97
+ const results = commands.map(cloneFailureResult);
98
+ return { ok: false, results };
99
+ }
100
+ const executor = localGestureBatchExecutors.get(registry) ??
101
+ registry[localGestureBatchExecutor];
102
+ if (!executor) {
103
+ const consumed = consumedFallbackGestureEvents.get(registry) ?? new WeakSet();
104
+ consumedFallbackGestureEvents.set(registry, consumed);
105
+ const eventState = nativeEventState(event);
106
+ if (!eventState?.isTrusted ||
107
+ eventState.eventPhase === 0 ||
108
+ consumed.has(event)) {
109
+ return {
110
+ ok: false,
111
+ results: commandSnapshots.map((command) => ({
112
+ ok: false,
113
+ action: command.action,
114
+ identity: cloneValue(command.identity),
115
+ reason: 'local_gesture_required',
116
+ })),
117
+ };
118
+ }
119
+ consumed.add(event);
120
+ const results = [];
121
+ for (const command of commandSnapshots) {
122
+ results.push(await registry.execute(command, {
123
+ source: 'user',
124
+ confirmed: true,
125
+ localGesture: true,
126
+ }));
127
+ }
128
+ return { ok: results.every((entry) => entry.ok), results };
129
+ }
130
+ return executor(registry, commandSnapshots, event);
131
+ }
8
132
  function identityKey(identity) {
9
- const subject = identity.subject
10
- ? `${identity.subject.type}:${identity.subject.id}`
11
- : '';
12
- return `${identity.formId}\u0000${identity.controlId}\u0000${subject}`;
133
+ return JSON.stringify([
134
+ identity.formId,
135
+ identity.controlId,
136
+ identity.subject?.type ?? null,
137
+ identity.subject?.id ?? null,
138
+ ]);
13
139
  }
14
140
  function isMutation(action) {
15
- return ['stage', 'apply', 'clear', 'undo'].includes(action);
141
+ return ['stage', 'apply', 'discard', 'clear', 'undo'].includes(action);
16
142
  }
17
143
  function isSecret(registration) {
18
144
  return registration.metadata.sensitivity === 'secret';
19
145
  }
146
+ function hasValueSetter(registration) {
147
+ return Boolean(registration.setValueWithContext || registration.setValue);
148
+ }
149
+ function setRegistrationValue(registration, value, context) {
150
+ return registration.setValueWithContext
151
+ ? registration.setValueWithContext(value, context)
152
+ : registration.setValue?.(value);
153
+ }
20
154
  function capabilitiesOf(registration) {
21
155
  const declared = registration.metadata.capabilities;
22
156
  const capabilities = declared ? [...declared] : [];
@@ -33,10 +167,13 @@ function capabilitiesOf(registration) {
33
167
  capabilities.push('highlight');
34
168
  if (registration.validate)
35
169
  capabilities.push('validate');
36
- if (registration.setValue && registration.metadata.writable !== false) {
37
- capabilities.push('stage', 'apply', 'undo');
170
+ if (hasValueSetter(registration)) {
171
+ capabilities.push('discard');
172
+ if (registration.metadata.writable !== false) {
173
+ capabilities.push('stage', 'apply', 'undo');
174
+ }
38
175
  }
39
- if ((registration.clear || registration.setValue) &&
176
+ if ((registration.clear || hasValueSetter(registration)) &&
40
177
  registration.metadata.writable !== false) {
41
178
  capabilities.push('clear');
42
179
  }
@@ -44,50 +181,305 @@ function capabilitiesOf(registration) {
44
181
  return capabilities.filter((capability) => {
45
182
  if (capability === 'read')
46
183
  return !isSecret(registration);
47
- if (isMutation(capability))
48
- return !isSecret(registration);
184
+ if (capability === 'discard')
185
+ return true;
186
+ if (isMutation(capability)) {
187
+ return (!isSecret(registration) && registration.metadata.writable !== false);
188
+ }
49
189
  return true;
50
190
  });
51
191
  }
52
192
  function redactsValue(registration) {
53
- return isSecret(registration) || registration.metadata.readable === false;
193
+ return (isSecret(registration) ||
194
+ registration.metadata.sensitivity === 'sensitive' ||
195
+ registration.metadata.readable === false);
54
196
  }
55
- function defaultPolicy(command, context, snapshot) {
197
+ function defaultPolicy(command, context, snapshot, localGestureConfirmed) {
56
198
  if (!isMutation(command.action))
57
199
  return { allowed: true };
58
- if (snapshot.metadata.sensitivity === 'secret') {
59
- return { allowed: false, reason: 'sensitive_control' };
200
+ if (command.action !== 'discard') {
201
+ if (snapshot.metadata.sensitivity === 'secret') {
202
+ return { allowed: false, reason: 'sensitive_control' };
203
+ }
204
+ if (snapshot.metadata.writable === false) {
205
+ return { allowed: false, reason: 'control_not_writable' };
206
+ }
207
+ if (snapshot.state.disabled || snapshot.state.readonly) {
208
+ return { allowed: false, reason: 'control_not_editable' };
209
+ }
60
210
  }
61
- if (snapshot.metadata.writable === false) {
62
- return { allowed: false, reason: 'control_not_writable' };
211
+ if (context.source === 'agent' && command.action !== 'stage') {
212
+ return { allowed: false, reason: 'human_confirmation_required' };
63
213
  }
64
- if (snapshot.state.disabled || snapshot.state.readonly) {
65
- return { allowed: false, reason: 'control_not_editable' };
214
+ if (['apply', 'discard', 'clear', 'undo'].includes(command.action) &&
215
+ !localGestureConfirmed) {
216
+ return { allowed: false, reason: 'local_gesture_required' };
66
217
  }
67
- if (context.source === 'agent' &&
68
- command.action !== 'stage' &&
69
- !context.confirmed) {
218
+ if (command.action !== 'stage' && !context.confirmed) {
70
219
  return { allowed: false, reason: 'consent_required' };
71
220
  }
72
221
  return { allowed: true };
73
222
  }
223
+ function cloneValue(value) {
224
+ try {
225
+ return structuredClone(value);
226
+ }
227
+ catch {
228
+ if (value === undefined)
229
+ return value;
230
+ const serialized = JSON.stringify(value, (_key, candidate) => {
231
+ if (typeof candidate === 'function' ||
232
+ typeof candidate === 'symbol' ||
233
+ typeof candidate === 'bigint') {
234
+ throw new DOMException('Value is not cloneable', 'DataCloneError');
235
+ }
236
+ return candidate;
237
+ });
238
+ if (serialized === undefined) {
239
+ throw new DOMException('Value is not cloneable', 'DataCloneError');
240
+ }
241
+ return JSON.parse(serialized);
242
+ }
243
+ }
244
+ function clonePublicValue(value) {
245
+ try {
246
+ return cloneValue(value);
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }
252
+ function valuesEqual(left, right) {
253
+ if (Object.is(left, right))
254
+ return true;
255
+ try {
256
+ return JSON.stringify(left) === JSON.stringify(right);
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ function userEditSuperseded(registration, previousUserEdit) {
263
+ if (!previousUserEdit)
264
+ return false;
265
+ const currentUserEdit = registration.getUserEditSnapshot?.();
266
+ return (currentUserEdit !== undefined &&
267
+ currentUserEdit.revision !== previousUserEdit.revision);
268
+ }
269
+ async function restoreRegistrationValue(registration, previousValue, previousUserEdit, invokeExtension) {
270
+ let observedUserEdit = cloneValue(registration.getUserEditSnapshot?.()) ?? null;
271
+ let value = previousUserEdit &&
272
+ observedUserEdit &&
273
+ observedUserEdit.revision !== previousUserEdit.revision
274
+ ? cloneValue(observedUserEdit.value)
275
+ : previousValue;
276
+ for (let attempt = 0; attempt < 8; attempt += 1) {
277
+ if (registration.restoreValue) {
278
+ await invokeExtension((context) => registration.restoreValue?.(value, context));
279
+ }
280
+ else {
281
+ await invokeExtension((context) => setRegistrationValue(registration, value, context));
282
+ }
283
+ const latestUserEdit = cloneValue(registration.getUserEditSnapshot?.()) ?? null;
284
+ if (!observedUserEdit ||
285
+ !latestUserEdit ||
286
+ latestUserEdit.revision === observedUserEdit.revision) {
287
+ return;
288
+ }
289
+ observedUserEdit = latestUserEdit;
290
+ value = cloneValue(latestUserEdit.value);
291
+ if (attempt === 7) {
292
+ // The bounded replay just observed a newer human value after the final
293
+ // restorer completed. Hand that value to the control once more without
294
+ // retrying. Keep using the infallible restoration path when one was
295
+ // supplied: the ordinary setter may be the fallible workflow that
296
+ // triggered rollback in the first place.
297
+ if (registration.restoreValue) {
298
+ await invokeExtension((context) => registration.restoreValue?.(value, context));
299
+ }
300
+ else if (hasValueSetter(registration)) {
301
+ await invokeExtension((context) => setRegistrationValue(registration, value, context));
302
+ }
303
+ return;
304
+ }
305
+ }
306
+ }
307
+ function redactedFailureReason(reason) {
308
+ return [
309
+ 'stale_revision',
310
+ 'staged_value_stale',
311
+ 'no_staged_value',
312
+ 'staged_value_invalid',
313
+ 'staged_value_rejected',
314
+ 'nothing_to_undo',
315
+ 'sensitive_control',
316
+ ].includes(reason)
317
+ ? reason
318
+ : 'command_failed';
319
+ }
320
+ async function validateProposedValue(registration, value, context) {
321
+ if (!registration.validateValue)
322
+ return {};
323
+ // Validators are untrusted extension points. Never expose the registry's
324
+ // stored proposal or the candidate that will later be passed to the setter.
325
+ const validation = await registration.validateValue(cloneValue(value), context);
326
+ if (typeof validation === 'boolean')
327
+ return { valid: validation };
328
+ if (typeof validation === 'string') {
329
+ return { valid: false, validationMessage: validation };
330
+ }
331
+ return {
332
+ valid: validation.valid,
333
+ validationMessage: validation.message,
334
+ };
335
+ }
74
336
  /** Create an isolated registry; apps choose where and how it is exposed. */
75
337
  export function createControlInteractionRegistry(options = {}) {
76
338
  const registrations = new Map();
339
+ const registrationGenerations = new Map();
77
340
  const staged = new Map();
78
341
  const undo = new Map();
342
+ const userEdits = new Map();
343
+ const registrationBaselines = new WeakMap();
344
+ const activeValueMutations = new Map();
79
345
  const listeners = new Set();
346
+ const now = options.now ?? Date.now;
347
+ // The context itself is never authority. This private, one-use grant is
348
+ // object-identity bound to the exact command handed to an adapter, retains a
349
+ // private immutable snapshot to detect in-place substitution, and is also
350
+ // bound to the registration generation present during the gesture.
351
+ const localConfirmationGrants = new WeakMap();
352
+ const internallyQueuedContexts = new WeakSet();
353
+ const consumedLocalGestureEvents = new WeakSet();
354
+ const mutationQueues = new Map();
355
+ const synchronousExtensionDepth = new Map();
356
+ let stagedRevision = 0;
357
+ const editAwareRegistration = (key, registration) => ({
358
+ ...registration,
359
+ getUserEditSnapshot: () => registration.getUserEditSnapshot?.() ??
360
+ userEdits.get(key) ?? {
361
+ revision: 0,
362
+ value: registration.getValue?.(),
363
+ },
364
+ });
365
+ const invokeExtension = (key, invoke) => {
366
+ synchronousExtensionDepth.set(key, (synchronousExtensionDepth.get(key) ?? 0) + 1);
367
+ const releaseDepth = (depths) => {
368
+ const remaining = (depths.get(key) ?? 1) - 1;
369
+ if (remaining === 0) {
370
+ depths.delete(key);
371
+ }
372
+ else {
373
+ depths.set(key, remaining);
374
+ }
375
+ };
376
+ const extensionContext = {
377
+ execute(command, context = { source: 'agent' }) {
378
+ if (isMutation(command.action) &&
379
+ identityKey(command.identity) === key) {
380
+ const registration = registrations.get(key);
381
+ return Promise.resolve(result(command, false, registration, registration && redactsValue(registration)
382
+ ? 'command_failed'
383
+ : 'reentrant_mutation'));
384
+ }
385
+ return registry.execute(command, context);
386
+ },
387
+ };
388
+ let returned;
389
+ try {
390
+ returned = invoke(extensionContext);
391
+ }
392
+ catch (error) {
393
+ releaseDepth(synchronousExtensionDepth);
394
+ throw error;
395
+ }
396
+ releaseDepth(synchronousExtensionDepth);
397
+ return returned;
398
+ };
399
+ const reconcileSupersededRegistration = async (key, generation, previousValue) => {
400
+ const current = registrations.get(key);
401
+ const currentGeneration = registrationGenerations.get(key);
402
+ if (!current || !currentGeneration || currentGeneration === generation) {
403
+ return false;
404
+ }
405
+ const currentWithEdits = editAwareRegistration(key, current);
406
+ const baseline = registrationBaselines.get(currentGeneration) ?? {
407
+ value: previousValue,
408
+ userEdit: { revision: 0, value: cloneValue(previousValue) },
409
+ };
410
+ await restoreRegistrationValue(currentWithEdits, cloneValue(baseline.value), cloneValue(baseline.userEdit), (invoke) => invokeExtension(key, invoke));
411
+ return true;
412
+ };
413
+ const enqueueMutation = (key, operation) => {
414
+ const previous = mutationQueues.get(key) ?? Promise.resolve();
415
+ const pending = previous.catch(() => undefined).then(operation);
416
+ const settled = pending.then(() => undefined, () => undefined);
417
+ mutationQueues.set(key, settled);
418
+ void settled.finally(() => {
419
+ if (mutationQueues.get(key) === settled)
420
+ mutationQueues.delete(key);
421
+ });
422
+ return pending;
423
+ };
424
+ const invokeTrackedValueMutation = async (key, registration, generation, previousValue, previousUserEdit, invoke) => {
425
+ const activeMutation = {
426
+ registration,
427
+ generation,
428
+ previousValue: cloneValue(previousValue),
429
+ immediateValue: cloneValue(previousValue),
430
+ previousUserEdit: cloneValue(previousUserEdit),
431
+ };
432
+ try {
433
+ activeValueMutations.set(key, activeMutation);
434
+ const pending = invokeExtension(key, invoke);
435
+ activeMutation.immediateValue = cloneValue(registration.getValue?.());
436
+ return await pending;
437
+ }
438
+ finally {
439
+ if (activeValueMutations.get(key) === activeMutation) {
440
+ activeValueMutations.delete(key);
441
+ }
442
+ }
443
+ };
80
444
  const emit = (event) => {
81
- const next = { ...event, timestamp: Date.now() };
82
- for (const listener of listeners)
83
- listener(next);
445
+ const next = { ...event, timestamp: now() };
446
+ for (const listener of listeners) {
447
+ try {
448
+ listener(cloneValue(next));
449
+ }
450
+ catch {
451
+ // Observers cannot change a command's committed result or registry state.
452
+ }
453
+ }
84
454
  };
85
455
  const snapshotOf = (registration) => {
86
456
  const key = identityKey(registration.identity);
87
457
  const redacted = redactsValue(registration);
88
- const hasStaged = staged.has(key);
458
+ const stagedEntry = staged.get(key);
459
+ const stale = stagedEntry
460
+ ? !valuesEqual(stagedEntry.baseValue, registration.getValue?.())
461
+ : false;
462
+ const runtimeState = registration.getState?.();
463
+ const publicStaged = stagedEntry &&
464
+ !isSecret(registration) &&
465
+ registration.metadata.writable !== false
466
+ ? {
467
+ value: redacted ? undefined : cloneValue(stagedEntry.value),
468
+ valueRedacted: redacted,
469
+ provenance: { ...stagedEntry.provenance },
470
+ stagedAt: stagedEntry.stagedAt,
471
+ revision: stagedEntry.revision,
472
+ stale,
473
+ valid: stagedEntry.valid,
474
+ validationMessage: redacted
475
+ ? undefined
476
+ : stagedEntry.validationMessage,
477
+ }
478
+ : undefined;
479
+ const rawValue = redacted ? undefined : registration.getValue?.();
480
+ const publicValue = redacted ? undefined : clonePublicValue(rawValue);
89
481
  return {
90
- identity: { ...registration.identity },
482
+ identity: cloneValue(registration.identity),
91
483
  metadata: {
92
484
  ...registration.metadata,
93
485
  capabilities: capabilitiesOf(registration),
@@ -99,32 +491,81 @@ export function createControlInteractionRegistry(options = {}) {
99
491
  })),
100
492
  },
101
493
  state: {
102
- ...registration.getState?.(),
103
- value: redacted ? undefined : registration.getValue?.(),
104
- valueRedacted: redacted,
105
- stagedValue: hasStaged && !redacted ? staged.get(key) : undefined,
106
- stagedValueRedacted: hasStaged ? redacted : undefined,
494
+ ...runtimeState,
495
+ validationMessage: redacted
496
+ ? undefined
497
+ : runtimeState?.validationMessage,
498
+ value: publicValue,
499
+ valueRedacted: redacted || (rawValue !== undefined && publicValue === undefined),
500
+ ...(publicStaged
501
+ ? {
502
+ stagedValue: publicStaged.value,
503
+ stagedValueRedacted: publicStaged.valueRedacted,
504
+ staged: publicStaged,
505
+ }
506
+ : {}),
107
507
  },
108
508
  };
109
509
  };
110
- const result = (command, ok, registration, reason) => ({
111
- ok,
112
- action: command.action,
113
- identity: { ...command.identity },
114
- snapshot: registration ? snapshotOf(registration) : undefined,
115
- reason,
116
- });
117
- return {
510
+ const result = (command, ok, registration, reason) => {
511
+ let snapshot;
512
+ try {
513
+ snapshot = registration ? snapshotOf(registration) : undefined;
514
+ }
515
+ catch {
516
+ // A broken control reader must not prevent a structured command result.
517
+ }
518
+ return {
519
+ ok,
520
+ action: command.action,
521
+ identity: cloneValue(command.identity),
522
+ snapshot,
523
+ reason,
524
+ };
525
+ };
526
+ const registry = {
118
527
  register(registration) {
119
528
  const key = identityKey(registration.identity);
529
+ const generation = {};
530
+ // Capture synchronously so an already-released superseded setter cannot
531
+ // overwrite the replacement generation before its baseline is recorded.
532
+ // `untrack` keeps this read out of a caller's reactive registration effect.
533
+ untrack(() => {
534
+ try {
535
+ const currentValue = cloneValue(registration.getValue?.());
536
+ const activeMutation = activeValueMutations.get(key);
537
+ const userEdit = cloneValue(registration.getUserEditSnapshot?.() ?? userEdits.get(key));
538
+ const newerUserEdit = activeMutation &&
539
+ userEdit &&
540
+ (!activeMutation.previousUserEdit ||
541
+ userEdit.revision !== activeMutation.previousUserEdit.revision);
542
+ const value = activeMutation &&
543
+ registrationGenerations.get(key) === activeMutation.generation &&
544
+ (registration === activeMutation.registration ||
545
+ valuesEqual(currentValue, activeMutation.immediateValue)) &&
546
+ !newerUserEdit
547
+ ? cloneValue(activeMutation.previousValue)
548
+ : currentValue;
549
+ registrationBaselines.set(generation, {
550
+ value,
551
+ userEdit: userEdit ?? { revision: 0, value: cloneValue(value) },
552
+ });
553
+ }
554
+ catch {
555
+ // A broken reader is handled by command execution, not registration.
556
+ }
557
+ });
120
558
  registrations.set(key, registration);
559
+ registrationGenerations.set(key, generation);
121
560
  emit({ type: 'registered', identity: registration.identity });
122
561
  return () => {
123
- if (registrations.get(key) !== registration)
562
+ if (registrationGenerations.get(key) !== generation)
124
563
  return;
125
564
  registrations.delete(key);
565
+ registrationGenerations.delete(key);
126
566
  staged.delete(key);
127
567
  undo.delete(key);
568
+ userEdits.delete(key);
128
569
  emit({ type: 'unregistered', identity: registration.identity });
129
570
  };
130
571
  },
@@ -132,10 +573,23 @@ export function createControlInteractionRegistry(options = {}) {
132
573
  const key = identityKey(identity);
133
574
  if (!registrations.delete(key))
134
575
  return;
576
+ registrationGenerations.delete(key);
135
577
  staged.delete(key);
136
578
  undo.delete(key);
579
+ userEdits.delete(key);
137
580
  emit({ type: 'unregistered', identity });
138
581
  },
582
+ recordUserEdit(identity) {
583
+ const key = identityKey(identity);
584
+ const registration = registrations.get(key);
585
+ if (!registration)
586
+ return;
587
+ const previous = userEdits.get(key);
588
+ userEdits.set(key, {
589
+ revision: (previous?.revision ?? 0) + 1,
590
+ value: cloneValue(registration.getValue?.()),
591
+ });
592
+ },
139
593
  list(formId) {
140
594
  return [...registrations.values()]
141
595
  .filter((registration) => formId ? registration.identity.formId === formId : true)
@@ -145,37 +599,160 @@ export function createControlInteractionRegistry(options = {}) {
145
599
  const registration = registrations.get(identityKey(identity));
146
600
  return registration ? snapshotOf(registration) : undefined;
147
601
  },
602
+ refresh(formId) {
603
+ for (const registration of registrations.values()) {
604
+ if (formId && registration.identity.formId !== formId)
605
+ continue;
606
+ emit({ type: 'refreshed', identity: registration.identity });
607
+ }
608
+ },
148
609
  async execute(command, context = { source: 'user', confirmed: true }) {
610
+ const suppliedCommand = command;
611
+ const localGrant = localConfirmationGrants.get(context);
612
+ if (localGrant)
613
+ localConfirmationGrants.delete(context);
614
+ if (!internallyQueuedContexts.has(context)) {
615
+ try {
616
+ command = cloneValue(command);
617
+ }
618
+ catch {
619
+ return cloneFailureResult(command);
620
+ }
621
+ }
149
622
  const key = identityKey(command.identity);
623
+ if (isMutation(command.action) &&
624
+ (synchronousExtensionDepth.get(key) ?? 0) > 0) {
625
+ const registration = registrations.get(key);
626
+ return result(command, false, registration, registration && redactsValue(registration)
627
+ ? 'command_failed'
628
+ : 'reentrant_mutation');
629
+ }
630
+ const localGestureConfirmed = Boolean(localGrant &&
631
+ localGrant.command === suppliedCommand &&
632
+ valuesEqual(localGrant.snapshot, command) &&
633
+ localGrant.generation === registrationGenerations.get(key));
634
+ if (isMutation(command.action) &&
635
+ !internallyQueuedContexts.has(context)) {
636
+ const queuedContext = { ...context };
637
+ internallyQueuedContexts.add(queuedContext);
638
+ const commandSnapshot = command;
639
+ if (localGestureConfirmed) {
640
+ localConfirmationGrants.set(queuedContext, {
641
+ command: commandSnapshot,
642
+ snapshot: cloneValue(commandSnapshot),
643
+ generation: localGrant?.generation,
644
+ });
645
+ }
646
+ return await enqueueMutation(key, () => this.execute(commandSnapshot, queuedContext));
647
+ }
150
648
  const registration = registrations.get(key);
151
649
  if (!registration)
152
650
  return result(command, false, undefined, 'not_found');
153
- const snapshot = snapshotOf(registration);
154
- const policyDecision = await (options.policy ?? defaultPolicy)(command, context, snapshot);
155
- if (!policyDecision.allowed) {
156
- const denied = result(command, false, registration, policyDecision.reason ?? 'denied');
157
- emit({
158
- type: 'command',
159
- identity: command.identity,
160
- command,
161
- context,
162
- result: denied,
163
- });
164
- return denied;
165
- }
166
- const capabilities = capabilitiesOf(registration);
167
- if (!capabilities.includes(command.action)) {
168
- const unsupported = result(command, false, registration, 'unsupported');
169
- emit({
170
- type: 'command',
171
- identity: command.identity,
172
- command,
173
- context,
174
- result: unsupported,
175
- });
176
- return unsupported;
177
- }
651
+ const generation = registrationGenerations.get(key);
652
+ if (!generation)
653
+ return result(command, false, undefined, 'not_found');
654
+ const registrationWithEdits = editAwareRegistration(key, registration);
655
+ const restoreMutationValue = async (previousValue, previousUserEdit) => {
656
+ if (await reconcileSupersededRegistration(key, generation, previousValue)) {
657
+ return;
658
+ }
659
+ await restoreRegistrationValue(registrationWithEdits, previousValue, previousUserEdit, (invoke) => invokeExtension(key, invoke));
660
+ };
661
+ let publicCommand = redactsValue(registration) &&
662
+ (command.action === 'stage' || command.action === 'apply')
663
+ ? cloneValue({ ...command, value: undefined })
664
+ : cloneValue(command);
665
+ const publicContext = {
666
+ source: context.source,
667
+ confirmed: context.confirmed,
668
+ localGesture: localGestureConfirmed,
669
+ actorId: context.actorId,
670
+ sessionId: context.sessionId,
671
+ };
672
+ const customPolicy = options.policy;
178
673
  try {
674
+ let snapshot = snapshotOf(registration);
675
+ let preparedStageValue;
676
+ let preparedStageBaseValue;
677
+ const commandUserEditSnapshot = command.action === 'apply' ||
678
+ command.action === 'clear' ||
679
+ command.action === 'undo'
680
+ ? (cloneValue(registrationWithEdits.getUserEditSnapshot?.()) ??
681
+ null)
682
+ : null;
683
+ let invariantDecision = defaultPolicy(command, publicContext, snapshot, publicContext.localGesture === true);
684
+ let policyDecision = invariantDecision;
685
+ if (invariantDecision.allowed && command.action === 'stage') {
686
+ // A control may resolve a proposal relative to its current value (for
687
+ // example, append-mode textareas). Custom policy must inspect the
688
+ // exact resolved value that will be staged. If a human changes the
689
+ // control while policy awaits, retry against the new stable base.
690
+ for (let attempt = 0; attempt < 8; attempt += 1) {
691
+ preparedStageBaseValue = cloneValue(registration.getValue?.());
692
+ preparedStageValue = cloneValue(registration.prepareValue
693
+ ? invokeExtension(key, () => registration.prepareValue?.(command.value))
694
+ : command.value);
695
+ snapshot = snapshotOf(registration);
696
+ publicCommand = redactsValue(registration)
697
+ ? cloneValue({ ...command, value: undefined })
698
+ : cloneValue({ ...command, value: preparedStageValue });
699
+ policyDecision = customPolicy
700
+ ? await invokeExtension(key, (extensionContext) => customPolicy(cloneValue(publicCommand), cloneValue(publicContext), cloneValue(snapshot), extensionContext))
701
+ : invariantDecision;
702
+ if (registrationGenerations.get(key) !== generation) {
703
+ throw new Error('staged_value_stale');
704
+ }
705
+ invariantDecision = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
706
+ if (!invariantDecision.allowed) {
707
+ throw new Error(invariantDecision.reason ?? 'denied');
708
+ }
709
+ if (valuesEqual(preparedStageBaseValue, registration.getValue?.())) {
710
+ break;
711
+ }
712
+ if (attempt === 7)
713
+ throw new Error('staged_value_stale');
714
+ }
715
+ }
716
+ else if (invariantDecision.allowed && customPolicy) {
717
+ policyDecision = await invokeExtension(key, (extensionContext) => customPolicy(cloneValue(publicCommand), cloneValue(publicContext), cloneValue(snapshot), extensionContext));
718
+ }
719
+ if (registrationGenerations.get(key) !== generation) {
720
+ throw new Error('staged_value_stale');
721
+ }
722
+ if (commandUserEditSnapshot &&
723
+ userEditSuperseded(registrationWithEdits, commandUserEditSnapshot)) {
724
+ throw new Error('staged_value_stale');
725
+ }
726
+ const postPolicyInvariant = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
727
+ if (!postPolicyInvariant.allowed) {
728
+ throw new Error(postPolicyInvariant.reason ?? 'denied');
729
+ }
730
+ if (!policyDecision.allowed) {
731
+ const denialReason = redactsValue(registration)
732
+ ? redactedFailureReason(policyDecision.reason ?? 'denied')
733
+ : (policyDecision.reason ?? 'denied');
734
+ const denied = result(command, false, registration, denialReason);
735
+ emit({
736
+ type: 'command',
737
+ identity: command.identity,
738
+ command: publicCommand,
739
+ context: publicContext,
740
+ result: denied,
741
+ });
742
+ return denied;
743
+ }
744
+ const capabilities = capabilitiesOf(registration);
745
+ if (!capabilities.includes(command.action)) {
746
+ const unsupported = result(command, false, registration, 'unsupported');
747
+ emit({
748
+ type: 'command',
749
+ identity: command.identity,
750
+ command: publicCommand,
751
+ context: publicContext,
752
+ result: unsupported,
753
+ });
754
+ return unsupported;
755
+ }
179
756
  switch (command.action) {
180
757
  case 'focus':
181
758
  await registration.focus?.();
@@ -189,40 +766,287 @@ export function createControlInteractionRegistry(options = {}) {
189
766
  case 'explain':
190
767
  break;
191
768
  case 'validate':
192
- await registration.validate?.();
769
+ await invokeExtension(key, (extensionContext) => registration.validate?.(extensionContext));
193
770
  break;
194
771
  case 'stage':
195
- staged.set(key, command.value);
196
- emit({
197
- type: 'staged',
198
- identity: command.identity,
199
- command,
200
- context,
201
- });
202
- break;
772
+ stagedRevision += 1;
773
+ {
774
+ const revision = stagedRevision;
775
+ const preparedValue = preparedStageValue;
776
+ const entry = {
777
+ value: cloneValue(preparedValue),
778
+ baseValue: cloneValue(preparedStageBaseValue),
779
+ provenance: {
780
+ source: context.source,
781
+ actorId: context.actorId,
782
+ sessionId: context.sessionId,
783
+ },
784
+ stagedAt: now(),
785
+ revision,
786
+ };
787
+ const previousEntry = staged.get(key);
788
+ staged.set(key, entry);
789
+ try {
790
+ const validation = await invokeExtension(key, (extensionContext) => validateProposedValue(registration, preparedValue, extensionContext));
791
+ if (registrationGenerations.get(key) !== generation ||
792
+ staged.get(key) !== entry) {
793
+ throw new Error('stale_revision');
794
+ }
795
+ const commitSnapshot = {
796
+ identity: cloneValue(registration.identity),
797
+ metadata: {
798
+ ...registration.metadata,
799
+ capabilities: capabilitiesOf(registration),
800
+ },
801
+ state: {
802
+ ...registration.getState?.(),
803
+ valueRedacted: redactsValue(registration),
804
+ },
805
+ };
806
+ const commitInvariant = defaultPolicy(command, publicContext, commitSnapshot, publicContext.localGesture === true);
807
+ if (!commitInvariant.allowed) {
808
+ throw new Error(commitInvariant.reason ?? 'denied');
809
+ }
810
+ if (!capabilitiesOf(registration).includes('stage')) {
811
+ throw new Error('unsupported');
812
+ }
813
+ Object.assign(entry, validation);
814
+ const stageSnapshot = snapshotOf(registration);
815
+ const completed = {
816
+ ok: true,
817
+ action: command.action,
818
+ identity: { ...command.identity },
819
+ snapshot: stageSnapshot,
820
+ };
821
+ emit({
822
+ type: 'staged',
823
+ identity: command.identity,
824
+ command: publicCommand,
825
+ context: publicContext,
826
+ staged: stageSnapshot.state.staged,
827
+ });
828
+ emit({
829
+ type: 'command',
830
+ identity: command.identity,
831
+ command: publicCommand,
832
+ context: publicContext,
833
+ result: completed,
834
+ });
835
+ return completed;
836
+ }
837
+ catch (error) {
838
+ if (staged.get(key) === entry) {
839
+ if (previousEntry) {
840
+ staged.set(key, previousEntry);
841
+ }
842
+ else {
843
+ staged.delete(key);
844
+ }
845
+ }
846
+ throw error;
847
+ }
848
+ }
203
849
  case 'apply': {
204
- const nextValue = 'value' in command && command.value !== undefined
205
- ? command.value
206
- : staged.get(key);
207
- if (nextValue === undefined && !staged.has(key)) {
850
+ const stagedEntry = staged.get(key);
851
+ if (command.revision !== undefined &&
852
+ stagedEntry?.revision !== command.revision) {
853
+ throw new Error('stale_revision');
854
+ }
855
+ if (stagedEntry &&
856
+ !valuesEqual(stagedEntry.baseValue, registration.getValue?.())) {
857
+ throw new Error('staged_value_stale');
858
+ }
859
+ const suppliedValue = 'value' in command && command.value !== undefined;
860
+ const usesExactStagedValue = suppliedValue &&
861
+ stagedEntry !== undefined &&
862
+ valuesEqual(command.value, stagedEntry.value);
863
+ const usesCanonicalReviewValue = suppliedValue &&
864
+ stagedEntry !== undefined &&
865
+ command.reviewedValueIsCanonical === true &&
866
+ localGestureConfirmed;
867
+ const nextValue = usesExactStagedValue
868
+ ? stagedEntry.value
869
+ : usesCanonicalReviewValue
870
+ ? registration.prepareReviewedValue
871
+ ? invokeExtension(key, () => registration.prepareReviewedValue?.(command.value))
872
+ : registration.prepareValue
873
+ ? invokeExtension(key, () => registration.prepareValue?.(command.value))
874
+ : command.value
875
+ : suppliedValue
876
+ ? registration.prepareValue
877
+ ? invokeExtension(key, () => registration.prepareValue?.(command.value))
878
+ : command.value
879
+ : stagedEntry?.value;
880
+ if (nextValue === undefined && !stagedEntry) {
208
881
  throw new Error('no_staged_value');
209
882
  }
883
+ const authorizedValue = cloneValue(nextValue);
884
+ const proposedValidation = await invokeExtension(key, (extensionContext) => validateProposedValue(registration, authorizedValue, extensionContext));
885
+ if (registrationGenerations.get(key) !== generation) {
886
+ throw new Error('staged_value_stale');
887
+ }
888
+ if (commandUserEditSnapshot &&
889
+ userEditSuperseded(registrationWithEdits, commandUserEditSnapshot)) {
890
+ throw new Error('staged_value_stale');
891
+ }
892
+ if (stagedEntry &&
893
+ (staged.get(key) !== stagedEntry ||
894
+ !valuesEqual(stagedEntry.baseValue, registration.getValue?.()))) {
895
+ throw new Error('stale_revision');
896
+ }
897
+ if (proposedValidation.valid === false) {
898
+ if (stagedEntry)
899
+ Object.assign(stagedEntry, proposedValidation);
900
+ throw new Error(redactsValue(registration)
901
+ ? 'staged_value_invalid'
902
+ : (proposedValidation.validationMessage ??
903
+ 'staged_value_invalid'));
904
+ }
905
+ const preMutationInvariant = defaultPolicy(command, publicContext, snapshotOf(registration), publicContext.localGesture === true);
906
+ if (!preMutationInvariant.allowed) {
907
+ throw new Error(preMutationInvariant.reason ?? 'denied');
908
+ }
909
+ const previousValue = cloneValue(registration.getValue?.());
210
910
  const history = undo.get(key) ?? [];
211
- history.push(registration.getValue?.());
911
+ const userEditSnapshot = commandUserEditSnapshot;
912
+ try {
913
+ await invokeTrackedValueMutation(key, registration, generation, previousValue, userEditSnapshot, (extensionContext) => setRegistrationValue(registration, cloneValue(authorizedValue), extensionContext));
914
+ }
915
+ catch (error) {
916
+ try {
917
+ await restoreMutationValue(previousValue, userEditSnapshot);
918
+ }
919
+ catch {
920
+ // Preserve the original setter failure for the command result.
921
+ }
922
+ throw error;
923
+ }
924
+ if (registrationGenerations.get(key) !== generation ||
925
+ userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
926
+ await restoreMutationValue(previousValue, userEditSnapshot);
927
+ throw new Error('staged_value_stale');
928
+ }
929
+ const appliedValue = cloneValue(registration.getValue?.());
930
+ if (!valuesEqual(appliedValue, authorizedValue)) {
931
+ try {
932
+ await restoreMutationValue(previousValue, userEditSnapshot);
933
+ }
934
+ catch {
935
+ // Preserve the rejected apply result and retain its proposal.
936
+ }
937
+ throw new Error('staged_value_rejected');
938
+ }
939
+ try {
940
+ const valid = await invokeExtension(key, (extensionContext) => registration.validate?.(extensionContext));
941
+ if (registrationGenerations.get(key) !== generation ||
942
+ userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
943
+ throw new Error('staged_value_stale');
944
+ }
945
+ if (!valuesEqual(registration.getValue?.(), appliedValue)) {
946
+ throw new Error('staged_value_stale');
947
+ }
948
+ if (valid === false) {
949
+ if (stagedEntry) {
950
+ stagedEntry.valid = false;
951
+ stagedEntry.validationMessage =
952
+ registration.getState?.().validationMessage ??
953
+ 'staged_value_invalid';
954
+ }
955
+ throw new Error('staged_value_invalid');
956
+ }
957
+ }
958
+ catch (error) {
959
+ let currentValue;
960
+ try {
961
+ currentValue = registration.getValue?.();
962
+ }
963
+ catch {
964
+ // A broken reader is reported without attempting a blind overwrite.
965
+ }
966
+ if (valuesEqual(currentValue, appliedValue)) {
967
+ try {
968
+ await restoreMutationValue(previousValue, userEditSnapshot);
969
+ }
970
+ catch {
971
+ // Preserve the original validation failure for the command result.
972
+ }
973
+ }
974
+ throw error;
975
+ }
976
+ history.push({
977
+ previousValue,
978
+ appliedValue,
979
+ });
212
980
  undo.set(key, history);
213
- await registration.setValue?.(nextValue);
981
+ if (!stagedEntry || staged.get(key) === stagedEntry) {
982
+ staged.delete(key);
983
+ }
984
+ break;
985
+ }
986
+ case 'discard': {
987
+ const stagedEntry = staged.get(key);
988
+ if (!stagedEntry)
989
+ throw new Error('no_staged_value');
990
+ if (command.revision !== undefined &&
991
+ stagedEntry.revision !== command.revision) {
992
+ throw new Error('stale_revision');
993
+ }
214
994
  staged.delete(key);
215
- await registration.validate?.();
216
995
  break;
217
996
  }
218
997
  case 'clear': {
998
+ const previousValue = cloneValue(registration.getValue?.());
999
+ let clearDecision;
1000
+ const userEditSnapshot = commandUserEditSnapshot;
1001
+ try {
1002
+ const decision = await invokeTrackedValueMutation(key, registration, generation, previousValue, userEditSnapshot, (extensionContext) => registration.clear
1003
+ ? registration.clear(extensionContext)
1004
+ : setRegistrationValue(registration, '', extensionContext));
1005
+ clearDecision =
1006
+ decision === true
1007
+ ? true
1008
+ : decision === false
1009
+ ? false
1010
+ : undefined;
1011
+ }
1012
+ catch (error) {
1013
+ try {
1014
+ await restoreMutationValue(previousValue, userEditSnapshot);
1015
+ }
1016
+ catch {
1017
+ // Preserve the original clear failure for the command result.
1018
+ }
1019
+ throw error;
1020
+ }
1021
+ if (registrationGenerations.get(key) !== generation ||
1022
+ userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
1023
+ await restoreMutationValue(previousValue, userEditSnapshot);
1024
+ throw new Error('staged_value_stale');
1025
+ }
1026
+ const clearedValue = registration.getValue?.();
1027
+ const rejected = clearDecision === false ||
1028
+ (clearDecision !== true &&
1029
+ valuesEqual(clearedValue, previousValue) &&
1030
+ previousValue !== '' &&
1031
+ previousValue !== null &&
1032
+ previousValue !== undefined &&
1033
+ previousValue !== false &&
1034
+ !(Array.isArray(previousValue) && previousValue.length === 0));
1035
+ if (rejected) {
1036
+ try {
1037
+ await restoreMutationValue(previousValue, userEditSnapshot);
1038
+ }
1039
+ catch {
1040
+ // The rejected command still retains its proposal for recovery.
1041
+ }
1042
+ throw new Error('staged_value_rejected');
1043
+ }
219
1044
  const history = undo.get(key) ?? [];
220
- history.push(registration.getValue?.());
1045
+ history.push({
1046
+ previousValue,
1047
+ appliedValue: cloneValue(clearedValue),
1048
+ });
221
1049
  undo.set(key, history);
222
- if (registration.clear)
223
- await registration.clear();
224
- else
225
- await registration.setValue?.('');
226
1050
  staged.delete(key);
227
1051
  break;
228
1052
  }
@@ -230,7 +1054,42 @@ export function createControlInteractionRegistry(options = {}) {
230
1054
  const history = undo.get(key) ?? [];
231
1055
  if (history.length === 0)
232
1056
  throw new Error('nothing_to_undo');
233
- await registration.setValue?.(history.pop());
1057
+ const undoEntry = history.at(-1);
1058
+ if (!undoEntry)
1059
+ throw new Error('nothing_to_undo');
1060
+ const currentValue = cloneValue(registration.getValue?.());
1061
+ const expectedPreviousValue = cloneValue(undoEntry.previousValue);
1062
+ const userEditSnapshot = commandUserEditSnapshot;
1063
+ if (!valuesEqual(currentValue, undoEntry.appliedValue)) {
1064
+ throw new Error('staged_value_stale');
1065
+ }
1066
+ try {
1067
+ await invokeTrackedValueMutation(key, registration, generation, currentValue, userEditSnapshot, (extensionContext) => setRegistrationValue(registration, cloneValue(expectedPreviousValue), extensionContext));
1068
+ }
1069
+ catch (error) {
1070
+ try {
1071
+ await restoreMutationValue(currentValue, userEditSnapshot);
1072
+ }
1073
+ catch {
1074
+ // Preserve the original setter failure for the command result.
1075
+ }
1076
+ throw error;
1077
+ }
1078
+ if (registrationGenerations.get(key) !== generation ||
1079
+ userEditSuperseded(registrationWithEdits, userEditSnapshot)) {
1080
+ await restoreMutationValue(currentValue, userEditSnapshot);
1081
+ throw new Error('staged_value_stale');
1082
+ }
1083
+ if (!valuesEqual(registration.getValue?.(), expectedPreviousValue)) {
1084
+ try {
1085
+ await restoreMutationValue(currentValue, userEditSnapshot);
1086
+ }
1087
+ catch {
1088
+ // Preserve the rejected undo result while retaining its history.
1089
+ }
1090
+ throw new Error('staged_value_rejected');
1091
+ }
1092
+ history.pop();
234
1093
  undo.set(key, history);
235
1094
  break;
236
1095
  }
@@ -239,27 +1098,95 @@ export function createControlInteractionRegistry(options = {}) {
239
1098
  emit({
240
1099
  type: 'command',
241
1100
  identity: command.identity,
242
- command,
243
- context,
1101
+ command: publicCommand,
1102
+ context: publicContext,
244
1103
  result: completed,
245
1104
  });
246
1105
  return completed;
247
1106
  }
248
1107
  catch (error) {
249
- const failed = result(command, false, registration, error instanceof Error ? error.message : 'command_failed');
1108
+ const errorReason = error instanceof Error ? error.message : 'command_failed';
1109
+ const failed = result(command, false, registration, redactsValue(registration)
1110
+ ? redactedFailureReason(errorReason)
1111
+ : errorReason);
250
1112
  emit({
251
1113
  type: 'command',
252
1114
  identity: command.identity,
253
- command,
254
- context,
1115
+ command: publicCommand,
1116
+ context: publicContext,
255
1117
  result: failed,
256
1118
  });
257
1119
  return failed;
258
1120
  }
259
1121
  },
1122
+ async executeBatch(commands, context = { source: 'user', confirmed: true }) {
1123
+ const commandSnapshots = cloneCommandBatch(commands);
1124
+ if (!commandSnapshots) {
1125
+ const results = commands.map(cloneFailureResult);
1126
+ return { ok: false, results };
1127
+ }
1128
+ let contextSnapshot;
1129
+ try {
1130
+ contextSnapshot = cloneValue(context);
1131
+ }
1132
+ catch {
1133
+ const results = commandSnapshots.map(cloneFailureResult);
1134
+ return { ok: false, results };
1135
+ }
1136
+ const results = [];
1137
+ for (const command of commandSnapshots) {
1138
+ results.push(await this.execute(command, contextSnapshot));
1139
+ }
1140
+ return { ok: results.every((entry) => entry.ok), results };
1141
+ },
260
1142
  subscribe(listener) {
261
1143
  listeners.add(listener);
262
1144
  return () => listeners.delete(listener);
263
1145
  },
264
1146
  };
1147
+ const localGestureExecutor = async (suppliedRegistry, commands, event) => {
1148
+ const eventState = nativeEventState(event);
1149
+ const isLocalGesture = options.isLocalGesture ?? (() => eventState?.isTrusted);
1150
+ if (!eventState ||
1151
+ !isLocalGesture(event) ||
1152
+ eventState.eventPhase === 0 ||
1153
+ consumedLocalGestureEvents.has(event)) {
1154
+ return {
1155
+ ok: false,
1156
+ results: commands.map((command) => result(command, false, registrations.get(identityKey(command.identity)), 'local_gesture_required')),
1157
+ };
1158
+ }
1159
+ consumedLocalGestureEvents.add(event);
1160
+ // Bind the entire already-snapshotted batch to the registration generations
1161
+ // present at gesture validation time. Earlier awaited commands must not be
1162
+ // able to authorize later commands against newly mounted replacements.
1163
+ const gestureGenerations = commands.map((command) => registrationGenerations.get(identityKey(command.identity)));
1164
+ const results = [];
1165
+ for (const [index, command] of commands.entries()) {
1166
+ const context = {
1167
+ source: 'user',
1168
+ confirmed: true,
1169
+ };
1170
+ localConfirmationGrants.set(context, {
1171
+ command,
1172
+ snapshot: cloneValue(command),
1173
+ generation: gestureGenerations[index],
1174
+ });
1175
+ try {
1176
+ results.push(await suppliedRegistry.execute(command, context));
1177
+ }
1178
+ finally {
1179
+ // Adapters may retain context references, but no authority survives the
1180
+ // terminal delegation/denial for this exact command.
1181
+ localConfirmationGrants.delete(context);
1182
+ }
1183
+ }
1184
+ return { ok: results.every((entry) => entry.ok), results };
1185
+ };
1186
+ localGestureBatchExecutors.set(registry, localGestureExecutor);
1187
+ Object.defineProperty(registry, localGestureBatchExecutor, {
1188
+ value: localGestureExecutor,
1189
+ enumerable: true,
1190
+ });
1191
+ return registry;
265
1192
  }