@xema/omni-protocol 0.1.116 → 0.1.118

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.
@@ -9,7 +9,7 @@
9
9
  // Each list is pinned to its type both ways -- a member the type lacks, or a member the list
10
10
  // lacks, fails to compile -- so what the validators accept cannot drift from what the
11
11
  // declarations say.
12
- import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
12
+ import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -49,7 +49,7 @@ const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: tr
49
49
  const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
50
50
  const AUTHENTICATION_BROWSERS = membersOf({ system: true, omni: true });
51
51
  const AUTHENTICATION_STATUSES = membersOf({
52
- "signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
52
+ "signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true, terminated: true,
53
53
  });
54
54
  const BREAK_APPROVALS = membersOf({
55
55
  "not-requested": true, "awaiting-approval": true, granted: true, "starting-after-task": true, "on-break": true,
@@ -59,6 +59,7 @@ const TEAM_AVAILABILITIES = membersOf({
59
59
  });
60
60
  const HISTORY_STEPS = membersOf({
61
61
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
62
+ "callback-requested": true,
62
63
  });
63
64
  const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
64
65
  const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
@@ -1214,16 +1215,11 @@ function validateTaskInto(task, context, path, into) {
1214
1215
  if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
1215
1216
  validateNothingLeaksInto(task, context.locked, path, into);
1216
1217
  }
1217
- if (task.recording !== undefined) {
1218
- into.require(context.channel === "voice", "recording.task.channel", `${path}.recording`, "recording is voice-only");
1219
- if (!isPlainObject(task.recording))
1220
- into.add("recording.task.shape", `${path}.recording`, "expected provider recording state");
1221
- else {
1222
- for (const key of Object.keys(task.recording))
1223
- into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
1224
- if (task.recording.provider !== undefined)
1225
- into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
1226
- }
1218
+ // The provider's own recordings, a list; the host's are on its report, never on a provider's task.
1219
+ into.require(task.recording === undefined, "recording.task.renamed", `${path}.recording`, "a task carries the provider's recordings as recordings, a list with the side each follows; the former recording state is not accepted");
1220
+ if (task.recordings !== undefined) {
1221
+ into.require(context.channel === "voice", "recording.task.channel", `${path}.recordings`, "recording is voice-only");
1222
+ into.violations.push(...validateRecordings(task.recordings, `${path}.recordings`));
1227
1223
  }
1228
1224
  if (context.member !== true)
1229
1225
  validateBrowsers(task.browsers, `${path}.browsers`, into);
@@ -2786,7 +2782,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2786
2782
  if (!offered("recording"))
2787
2783
  break;
2788
2784
  const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
2789
- const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2785
+ const state = task.recordings ?? [];
2790
2786
  into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2791
2787
  if (command.action === "start" || command.action === "resume") {
2792
2788
  interaction();
@@ -3131,18 +3127,28 @@ export function validateAuthenticationState(state, path = "authentication", cont
3131
3127
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
3132
3128
  validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
3133
3129
  }
3134
- else if (state.status === "expired") {
3130
+ else if (state.status === "expired" || state.status === "terminated") {
3135
3131
  if (state.identity !== undefined)
3136
3132
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
3137
3133
  if (state.failure !== undefined) {
3138
3134
  validateAuthenticationFailureInto(state.failure, `${path}.failure`, into);
3139
3135
  }
3136
+ if (state.status === "terminated") {
3137
+ // Ended from outside this agent computer: who did it, and why, and nothing to retry.
3138
+ into.require(isUserId(state.by) || state.by === "provider", "authentication.terminated.by", `${path}.by`, "a terminated session says who ended it: the person by user id, or provider where the platform did");
3139
+ if (into.require(isPlainObject(state.failure), "authentication.terminated.failure", `${path}.failure`, "a terminated session carries the reason it was ended")) {
3140
+ into.require(state.failure.retryable === false, "authentication.terminated.retryable", `${path}.failure.retryable`, "a session ended from outside is over: its failure is not retryable");
3141
+ }
3142
+ }
3140
3143
  }
3141
3144
  else {
3142
3145
  into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
3143
3146
  }
3144
3147
  if (state.failure !== undefined) {
3145
- into.require(state.status === "expired", "authentication.failure.unexpected", `${path}.failure`, "only an expired state may carry a failure");
3148
+ into.require(state.status === "expired" || state.status === "terminated", "authentication.failure.unexpected", `${path}.failure`, "only an expired or terminated state may carry a failure");
3149
+ }
3150
+ if (state.by !== undefined) {
3151
+ into.require(state.status === "terminated", "authentication.by.unexpected", `${path}.by`, "only a terminated session says who ended it; the agent's own sign-out is signed-out");
3146
3152
  }
3147
3153
  if (state.expiresAt !== undefined) {
3148
3154
  into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
@@ -3153,7 +3159,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
3153
3159
  }
3154
3160
  return into.violations;
3155
3161
  }
3156
- // Independent current recording evidence and dispatch validation.
3162
+ // Recordings: published on change, restated whole, never expiring; and dispatch validation.
3157
3163
  const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3158
3164
  const filled = (v) => typeof v === "string" && v.trim().length > 0;
3159
3165
  function collector() {
@@ -3170,40 +3176,49 @@ function collector() {
3170
3176
  };
3171
3177
  return { violations, check, keys };
3172
3178
  }
3173
- /** Recording observations use canonical UTC millisecond instants, never native capture boundaries. */
3174
- function instant(v) {
3175
- if (typeof v !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v))
3176
- return false;
3177
- const n = Date.parse(v);
3178
- return Number.isFinite(n) && new Date(n).toISOString() === v;
3179
- }
3180
- export function validateRecordingState(value, path = "recording") {
3179
+ /** A recording that is, or may be, capturing: its side has no room for a second start. */
3180
+ const LIVE_RECORDING = ["starting", "recording", "paused", "unknown"];
3181
+ /**
3182
+ * One recorder's recordings for one task, as published: each with its id, the side it follows,
3183
+ * its status, and a reason where it is paused or unknown; ids unique, and at most one recording
3184
+ * live on each side at once.
3185
+ */
3186
+ export function validateRecordings(value, path = "recordings") {
3181
3187
  const { violations, check, keys } = collector();
3182
- if (!object(value)) {
3183
- check(false, "state.shape", path, "expected recording state");
3184
- return violations;
3185
- }
3186
- if (value.status === "unknown") {
3187
- keys(value, ["status"], path);
3188
+ if (!Array.isArray(value)) {
3189
+ check(false, "list.shape", path, "recordings are a list, empty when nothing has been recorded");
3188
3190
  return violations;
3189
3191
  }
3190
- check(["inactive", "active", "paused"].includes(value.status), "state.status", `${path}.status`, "expected unknown, inactive, active or paused");
3191
- keys(value, ["status", "observationId", "observedAt", "validUntil", ...(value.status === "inactive" ? [] : ["recordingId"])], path);
3192
- check(filled(value.observationId), "observationId", `${path}.observationId`, "a confirming observation has an opaque identity");
3193
- check(instant(value.observedAt), "observedAt", `${path}.observedAt`, "expected canonical UTC millisecond observation instant");
3194
- check(instant(value.validUntil), "validUntil", `${path}.validUntil`, "expected canonical UTC millisecond expiry instant");
3195
- if (instant(value.observedAt) && instant(value.validUntil))
3196
- check(value.observedAt < value.validUntil, "expiry", path, "expiry must be strictly after observation");
3197
- if (value.status !== "inactive")
3198
- check(filled(value.recordingId), "recordingId", `${path}.recordingId`, "active and paused states identify the actual recording");
3192
+ const ids = new Set();
3193
+ const live = new Set();
3194
+ value.forEach((entry, index) => {
3195
+ const at = `${path}[${index}]`;
3196
+ if (!object(entry)) {
3197
+ check(false, "shape", at, "a recording is an object");
3198
+ return;
3199
+ }
3200
+ keys(entry, ["id", "follows", "status", "reason"], at);
3201
+ check(filled(entry.id), "id", `${at}.id`, "a recording names itself by its recorder's id");
3202
+ check(!ids.has(entry.id), "id.unique", `${at}.id`, "two recordings share an id");
3203
+ ids.add(entry.id);
3204
+ check(RECORDING_SIDES.includes(entry.follows), "follows", `${at}.follows`, "a recording follows the party's side or the agent's");
3205
+ if (!check(RECORDING_STATUSES.includes(entry.status), "status", `${at}.status`, "a recording is starting, recording, paused, not-recording or unknown"))
3206
+ return;
3207
+ const reasons = entry.status === "paused" ? RECORDING_PAUSED_REASONS
3208
+ : entry.status === "unknown" ? RECORDING_UNKNOWN_REASONS : undefined;
3209
+ if (reasons !== undefined) {
3210
+ check(reasons.includes(entry.reason), "reason", `${at}.reason`, `a ${String(entry.status)} recording says why: ${reasons.join(", ")}`);
3211
+ }
3212
+ else {
3213
+ check(entry.reason === undefined, "reason.unexpected", `${at}.reason`, "only a paused or unknown recording carries a reason");
3214
+ }
3215
+ if (LIVE_RECORDING.includes(entry.status)) {
3216
+ check(!live.has(entry.follows), "follows.live", `${at}.follows`, `the ${String(entry.follows)} side already has a recording that is, or may be, capturing`);
3217
+ live.add(entry.follows);
3218
+ }
3219
+ });
3199
3220
  return violations;
3200
3221
  }
3201
- /** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
3202
- export function effectiveRecordingState(state, now) {
3203
- if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
3204
- return { status: "unknown" };
3205
- return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
3206
- }
3207
3222
  export function validateRecordingPolicy(value, path = "capabilities.recording") {
3208
3223
  const { violations, check, keys } = collector();
3209
3224
  if (!object(value)) {
@@ -3276,12 +3291,12 @@ export function validateHostRecordings(value, path = "host.recordings") {
3276
3291
  check(false, "report.shape", at, "expected scoped report");
3277
3292
  return;
3278
3293
  }
3279
- keys(report, ["assignmentId", "state"], at);
3294
+ keys(report, ["assignmentId", "recordings"], at);
3280
3295
  check(filled(report.assignmentId), "report.scope", at, "report identifies the assignment");
3281
3296
  const key = JSON.stringify([report.assignmentId]);
3282
- check(!seen.has(key), "report.duplicate", at, "one host state per assignment");
3297
+ check(!seen.has(key), "report.duplicate", at, "one host report per assignment");
3283
3298
  seen.add(key);
3284
- violations.push(...validateRecordingState(report.state, `${at}.state`));
3299
+ violations.push(...validateRecordings(report.recordings, `${at}.recordings`));
3285
3300
  });
3286
3301
  return violations;
3287
3302
  }
@@ -3291,50 +3306,62 @@ export function validateRecordingCommandShape(value, source, path = "command") {
3291
3306
  check(false, "command.shape", path, "expected recording command");
3292
3307
  return violations;
3293
3308
  }
3294
- keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3309
+ const starting = value.action === "start";
3310
+ keys(value, ["type", "source", "action", starting ? "follows" : "recordingId"], path);
3295
3311
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
3296
3312
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
3297
- for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3298
- check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
3313
+ if (starting)
3314
+ check(RECORDING_SIDES.includes(value.follows), "command.follows", `${path}.follows`, "a start names the side to record: party or agent");
3315
+ else
3316
+ check(filled(value.recordingId), "command.identity", `${path}.recordingId`, "every action but start names the recording by its id");
3299
3317
  return violations;
3300
3318
  }
3301
3319
  /** One transition table shared by dispatch checks and outcome checks. */
3302
3320
  const RECORDING_TRANSITIONS = {
3303
- start: { from: ["inactive"], to: "active" },
3304
- pause: { from: ["active"], to: "paused" },
3305
- resume: { from: ["paused"], to: "active" },
3306
- stop: { from: ["active", "paused"], to: "inactive" },
3307
- cancel: { from: ["active", "paused"], to: "inactive" },
3321
+ pause: { from: ["recording"], to: "paused" },
3322
+ resume: { from: ["paused"], to: "recording" },
3323
+ stop: { from: ["recording", "paused"], to: "not-recording" },
3324
+ cancel: { from: ["recording", "paused"], to: "not-recording" },
3308
3325
  };
3309
3326
  function recordingTransition(action) {
3310
3327
  return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
3311
3328
  ? RECORDING_TRANSITIONS[action] : undefined;
3312
3329
  }
3313
- /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
3314
- export function validateRecordingCommandState(command, policy, state, path = "command") {
3330
+ const entries = (value) => Array.isArray(value) ? value.filter(object) : [];
3331
+ /**
3332
+ * Whether the action is permitted on this recorder, and whether the recordings as last published
3333
+ * let it act: a start on a side with nothing capturing, every other action on the recording it
3334
+ * names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
3335
+ */
3336
+ export function validateRecordingCommandState(command, policy, recordings, path = "command") {
3315
3337
  const { violations, check } = collector();
3316
3338
  if (!object(command)) {
3317
3339
  check(false, "command.shape", path, "expected recording command");
3318
3340
  return violations;
3319
3341
  }
3320
- const transition = recordingTransition(command.action);
3321
- if (transition === undefined) {
3342
+ if (!RECORDING_ACTIONS.includes(command.action)) {
3322
3343
  check(false, "command.action", `${path}.action`, "unsupported recording action");
3323
3344
  return violations;
3324
3345
  }
3325
3346
  const permitted = object(policy) && policy[command.action] === true;
3326
3347
  check(permitted, "command.permission", path, "task does not permit this action on this recorder");
3327
- if (!object(state) || validateRecordingState(state).length) {
3328
- check(false, "command.state", path, "no validated current recorder observation");
3348
+ if (recordings !== undefined && validateRecordings(recordings).length) {
3349
+ check(false, "command.state", path, "the recordings as published are malformed");
3329
3350
  return violations;
3330
3351
  }
3331
- check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
3332
- check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
3333
- if (command.action !== "start")
3334
- check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
3352
+ const list = entries(recordings);
3353
+ if (command.action === "start") {
3354
+ check(!list.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "command.state", path, `the ${String(command.follows)} side already has a recording that is, or may be, capturing`);
3355
+ return violations;
3356
+ }
3357
+ const target = list.find(entry => entry.id === command.recordingId);
3358
+ if (!check(target !== undefined, "command.recordingId", path, "no recording on this task has that id"))
3359
+ return violations;
3360
+ const transition = recordingTransition(command.action);
3361
+ check(transition.from.includes(target.status), "command.state", path, `${String(command.action)} needs the recording ${transition.from.join(" or ")}, and it is ${String(target.status)}`);
3335
3362
  return violations;
3336
3363
  }
3337
- /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
3364
+ /** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
3338
3365
  export function validateRecordingRequest(request, task, context, path = "request") {
3339
3366
  const { violations, check, keys } = collector();
3340
3367
  if (!object(request) || !object(task)) {
@@ -3352,58 +3379,69 @@ export function validateRecordingRequest(request, task, context, path = "request
3352
3379
  const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
3353
3380
  violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
3354
3381
  const policy = object(caps) ? caps[context.source] : undefined;
3355
- let state = object(task.recording) ? task.recording.provider : undefined;
3382
+ let recordings = task.recordings ?? [];
3356
3383
  if (context.source === "host") {
3357
3384
  violations.push(...validateHostRecording(context.host, context.softphone === true));
3358
3385
  check(object(context.host), "host.required", path, "host did not declare recording support");
3359
3386
  const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
3360
3387
  violations.push(...validateHostRecordings(reports));
3361
- state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
3388
+ const report = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId) : undefined;
3389
+ recordings = object(report) ? report.recordings : [];
3362
3390
  if (object(context.host)) {
3363
3391
  check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
3364
3392
  check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
3365
3393
  }
3366
3394
  }
3367
- const fresh = effectiveRecordingState(state, context.now);
3368
- check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
3369
- violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
3395
+ violations.push(...validateRecordingCommandState(command, policy, recordings, `${path}.command`));
3370
3396
  const opening = command.action === "start" || command.action === "resume";
3371
3397
  check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
3372
3398
  return violations;
3373
3399
  }
3374
- /** Check recording-specific result semantics against the owner's confirming observations. */
3400
+ /**
3401
+ * A recording command's answer against the recordings published before and after it: applied
3402
+ * means the recorder confirmed what was asked -- a new recording on the side for a start, the
3403
+ * same recording in its new status for anything else -- and failed means nothing changed there.
3404
+ */
3375
3405
  export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
3376
3406
  const { violations, check } = collector();
3377
- violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
3378
- if (!object(command) || !object(before) || !object(after)) {
3379
- check(false, "outcome.shape", path, "command and both observations required");
3407
+ violations.push(...validateRecordings(before, `${path}.before`), ...validateRecordings(after, `${path}.after`));
3408
+ if (!object(command) || !Array.isArray(before) || !Array.isArray(after)) {
3409
+ check(false, "outcome.shape", path, "command and both lists of recordings required");
3380
3410
  return violations;
3381
3411
  }
3382
3412
  violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
3383
- // A rejected promise has no result: any independently evidenced state may follow it.
3413
+ // A rejected promise has no result: any state its recorder publishes may follow it.
3384
3414
  if (result === undefined)
3385
3415
  return violations;
3386
3416
  violations.push(...validateResult(result, "execute", `${path}.result`));
3387
3417
  if (!object(result))
3388
3418
  return violations;
3389
3419
  check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
3420
+ const prior = entries(before);
3421
+ const next = entries(after);
3422
+ if (command.action === "start") {
3423
+ const fresh = next.filter(entry => entry.follows === command.follows && !prior.some(old => old.id === entry.id));
3424
+ if (result.status === "applied") {
3425
+ check(fresh.some(entry => entry.status === "recording"), "outcome.state", path, "an applied start shows a new recording on that side, recording");
3426
+ check(!prior.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "outcome.origin", path, "applied cannot claim a start on a side already capturing");
3427
+ }
3428
+ else if (result.status === "failed") {
3429
+ check(!fresh.some(entry => LIVE_RECORDING.includes(entry.status)), "outcome.noEffect", path, "a failed start leaves nothing capturing on that side");
3430
+ }
3431
+ return violations;
3432
+ }
3433
+ const was = prior.find(entry => entry.id === command.recordingId);
3434
+ const now = next.find(entry => entry.id === command.recordingId);
3390
3435
  if (result.status === "failed") {
3391
- check(before.status !== "unknown" && after.status === before.status && after.recordingId === before.recordingId, "outcome.noEffect", path, "failed promises confirmed no effect; ambiguous or partial effects have no result");
3436
+ check(was !== undefined && now !== undefined && now.status === was.status, "outcome.noEffect", path, "failed confirms no effect: the recording stands as it was; a partial effect is unknown, unsettled");
3392
3437
  }
3393
3438
  else if (result.status === "applied") {
3394
3439
  const transition = recordingTransition(command.action);
3395
3440
  if (transition === undefined)
3396
3441
  return violations; // shape validation already reported the unknown action
3397
- check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
3398
- check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
3399
- check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
3400
- check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
3401
- if (command.action !== "start")
3402
- check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
3403
- if (command.action === "pause" || command.action === "resume")
3404
- check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
3405
- if (instant(before.observedAt) && instant(after.observedAt))
3406
- check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
3442
+ check(now !== undefined, "outcome.continuity", path, "the recording keeps its id through the action");
3443
+ check(now?.status === transition.to, "outcome.state", path, `applied requires the recording ${transition.to}`);
3444
+ check(was !== undefined && transition.from.includes(was.status), "outcome.origin", path, "applied cannot claim an impossible transition");
3407
3445
  }
3408
3446
  return violations;
3409
3447
  }
package/guide/agent.md CHANGED
@@ -384,10 +384,14 @@ Migration from the earlier spellings:
384
384
  | the phone unspoken | `PhoneState` -- the device, its mute, its channels -- on `Snapshot.phone`, `phone-updated` and `TeamMember.phone`, from a manifest declaring `phoneStatus` (`snapshot.phone.required`, `.unexpected`, `phone.*`, `phone.channel.*`, `event.phone.capability`) |
385
385
  | the agent's readiness for the next call read into setCapacity | the agent's own queue: `capabilities.nextCall`, `requestNextCall`/`cancelNextCall`/`releaseLinedUp`, `Snapshot.nextCall` and `linedUp`, the `next-call` and `lined-up` events (`snapshot.nextCall.idle`, `nextCall.*`, `linedUp.*`) |
386
386
  | a harness that exercised an adapter, drove a call and evaluated rules | the harness reads as a test: `testAdapter`, `withCall`, `timeoutMs`, `notTested`, `rulesTested`, rules `test.*` (`test.timeout`, `test.offer.expired`, `test.preview.deadline`, ...) |
387
+ | a `callback-requested` step, and a callback task starting its history afresh | a `callback-requested` step (`by` when an agent arranged it); a callback's task carries the earlier call's steps, oldest first |
388
+ | a recording state with an observation id, an observation instant and a validity window, and the helpers that aged it | `recordings` on the task and on `HostRecordingReport`: a list, each recording with `id`, `follows` (`party` or `agent`), `status` (`starting`, `recording`, `paused`, `not-recording`, `unknown`) and a `reason` where paused or unknown; nothing expires; commands name `follows` for a start and `recordingId` otherwise; `validateRecordings` (`recording.task.renamed` refuses the former field) |
389
+ | a refusal on hold under `omni.command-not-permitted` | `omni.on-hold` |
387
390
  | an `end-call` while the caller was on hold | refused: a `paused` task is resumed first (`command.endCall.held`), the agent computer shows End call disabled on hold, the provider answers `failed`, and the test names an adapter that ends from hold (`test.command.held`); `terminate-call` is not gated, it ends the caller's channel too |
388
391
  | the handset spelled by its place on a desk | `hardphone`, on the manifest's `phones` and the login's `phone`; the former spelling is refused |
389
392
  | the desk | the agent computer, in the guide's prose |
390
393
  | the agent application's Mute on hold | unavailable while the task is `paused`, promised at login by a softphone login's agent application (`host.guarantees.muteUnavailableOnHold`, required there and refused of a hardphone login); a host mute beginning on a paused task is refused (`historyReport.muted.held`) and the test names an adapter that records one (`test.recordStep.held`) |
394
+ | a session ended from outside the agent computer, reported as `expired` | `terminated`, with `by` (a person or `provider`) and a reason never retryable; `signed-out` is only the agent's own Sign out here; an ended session's secrets are deleted before the ending is published (`authentication.secrets.retained`), kept only for an `expired` whose failure is retryable; the agent application clears the store after, and resumes no stored token |
391
395
 
392
396
  Update producers, consumers, saved task snapshots, and validation-rule assertions together.
393
397
  History and report rule names use `history` and `historyReport`; assignment rules use
@@ -425,7 +429,20 @@ something else, which will arrive under its own name and must not be folded in h
425
429
  It rides in the snapshot and is replaced whole like everything else there.
426
430
 
427
431
  Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
428
- `unanswered`, and each is defined on `TaskHistoryStep`.
432
+ `unanswered`, `callback-requested`, and each is defined on `TaskHistoryStep`.
433
+
434
+ **A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
435
+ arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
436
+ is absent when the caller chose it in the queue. The callback reaches an agent as a new task, and that
437
+ task's history carries the earlier call's steps, oldest first, the `callback-requested` step among them,
438
+ then the callback's own -- as a hand-over carries the steps before it. The agent opening a callback
439
+ sees that the caller waited and asked, or whose promise they are keeping, and a second try reads as
440
+ one, the way contact-centre platforms keep a callback with its original contact.
441
+
442
+ **One call has one history.** Every task the call reaches restates the earlier steps exactly as they
443
+ stood: the same step, instant, person, dial and destination, and a duration once stated never
444
+ changes. `assertHistoryAgreesAcrossTasks(earlier, later)` holds two tasks of one call to that, for an
445
+ adopter's own test that drives a hand-over, take-over or callback.
429
446
 
430
447
  **The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
431
448
  when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
@@ -1098,6 +1115,7 @@ react rather than only display the message:
1098
1115
  | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
1099
1116
  | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
1100
1117
  | `omni.recording-unsettled` | A recording command the provider could not settle either way: a settled `failed`, never a promise left unresolved. See **Independent task recording** in `guide/voice.md`. |
1118
+ | `omni.on-hold` | Refused because the caller is on hold: the agent application's own Mute begun on a `paused` task, or `end-call` there. The agent application says resume first. The test expects this code on both (`test.recordStep.held`, `test.command.held`). |
1101
1119
  | `omni.break-forced-by-provider` | A lead asked to lift a break the platform imposed; the platform lifts its own. See **Forced breaks** in `guide/breaks.md`. |
1102
1120
 
1103
1121
  They are published as `OMNI_FAILURE_CODES`.
package/guide/phone.md CHANGED
@@ -238,7 +238,7 @@ softphone the agent application's own Mute is unavailable while the task is `pau
238
238
  promised at login (`muteUnavailableOnHold`): mute then hold is ordinary, and the muted leg runs on
239
239
  across the held one; hold then mute is not, and a `muted` leg with `mutedBy: "host"` beginning on a
240
240
  paused task is held back by the agent application (`historyReport.muted.held`) and refused by a
241
- provider that receives one anyway. The test sends one past the validator while the task is held
241
+ provider that receives one anyway, `failed` with `omni.on-hold`. The test sends one past the validator while the task is held
242
242
  and names an adapter that records it (`test.recordStep.held`). A station mute the agent
243
243
  application observes on hold -- the headset's slider, the operating system -- is a fact, reported
244
244
  with `mutedBy: "station"`, and stands. The agent application reports every such period