@xema/omni-protocol 0.1.81 → 0.1.83
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/dist/index.d.ts +60 -22
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +9 -6
- package/dist/testing.js +67 -56
- package/dist/validation.d.ts +36 -1
- package/dist/validation.js +360 -80
- package/guide.md +453 -97
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -85,7 +85,7 @@ const MEMBER_BREAKS = membersOf({
|
|
|
85
85
|
const OFFERABLE_PHASES = membersOf({
|
|
86
86
|
pending: true,
|
|
87
87
|
});
|
|
88
|
-
const PREVIEW_DEADLINES = membersOf({ calls: true,
|
|
88
|
+
const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
|
|
89
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
90
90
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
|
|
91
91
|
const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
|
|
@@ -395,6 +395,78 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
395
395
|
function manifestDials(manifest) {
|
|
396
396
|
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
397
397
|
}
|
|
398
|
+
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
399
|
+
export function validateProviderTimeEstimate(value, scope, path = "estimate") {
|
|
400
|
+
const into = new Collector();
|
|
401
|
+
if (!isPlainObject(scope)) {
|
|
402
|
+
into.add("timeEstimate.scope", path, "provider/login scope is required");
|
|
403
|
+
return into.violations;
|
|
404
|
+
}
|
|
405
|
+
for (const key of ["providerId", "loginId"])
|
|
406
|
+
into.filled(scope[key], "timeEstimate.scope", `scope.${key}`, "explicit provider/login scope is required");
|
|
407
|
+
if (value === undefined)
|
|
408
|
+
return into.violations;
|
|
409
|
+
if (!isPlainObject(value)) {
|
|
410
|
+
into.add("timeEstimate.shape", path, "expected a provider time estimate or undefined");
|
|
411
|
+
return into.violations;
|
|
412
|
+
}
|
|
413
|
+
for (const key of ["providerId", "loginId"])
|
|
414
|
+
into.require(value[key] === scope[key], "timeEstimate.scope", `${path}.${key}`, "estimate must match the requested provider/login");
|
|
415
|
+
into.timestamp(value.at, "timeEstimate.at", `${path}.at`);
|
|
416
|
+
into.filled(value.clockId, "timeEstimate.clock", `${path}.clockId`, "estimate must identify its clock domain");
|
|
417
|
+
if (value.uncertaintyMs !== undefined)
|
|
418
|
+
into.require(typeof value.uncertaintyMs === "number" && Number.isFinite(value.uncertaintyMs) && value.uncertaintyMs >= 0, "timeEstimate.uncertainty", `${path}.uncertaintyMs`, "estimated uncertainty must be finite and nonnegative");
|
|
419
|
+
for (const key of Object.keys(value))
|
|
420
|
+
into.require(["providerId", "loginId", "at", "clockId", "uncertaintyMs"].includes(key), "timeEstimate.field", `${path}.${key}`, "unsupported estimate field");
|
|
421
|
+
return into.violations;
|
|
422
|
+
}
|
|
423
|
+
/** Validate host-local periodic clock-check configuration. Omission disables checking. */
|
|
424
|
+
export function validateProviderTimeCheckPolicy(value, path = "timeCheck") {
|
|
425
|
+
const into = new Collector();
|
|
426
|
+
if (value === undefined)
|
|
427
|
+
return into.violations;
|
|
428
|
+
if (!isPlainObject(value)) {
|
|
429
|
+
into.add("timeCheck.policy.shape", path, "expected an explicit clock-check policy");
|
|
430
|
+
return into.violations;
|
|
431
|
+
}
|
|
432
|
+
for (const field of ["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"]) {
|
|
433
|
+
into.require(typeof value[field] === "number" && Number.isSafeInteger(value[field]) && value[field] > 0, "timeCheck.policy.duration", `${path}.${field}`, "expected positive safe integer milliseconds");
|
|
434
|
+
}
|
|
435
|
+
for (const key of Object.keys(value))
|
|
436
|
+
into.require(["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"].includes(key), "timeCheck.policy.field", `${path}.${key}`, "unsupported policy field");
|
|
437
|
+
if (!into.violations.length)
|
|
438
|
+
into.require(value.maxRoundTripMs <= value.timeoutMs && value.timeoutMs <= value.intervalMs, "timeCheck.policy.order", path, "maxRoundTripMs <= timeoutMs <= intervalMs is required");
|
|
439
|
+
return into.violations;
|
|
440
|
+
}
|
|
441
|
+
export function validateProviderTimeCheckRequest(value, path = "request") {
|
|
442
|
+
const into = new Collector();
|
|
443
|
+
if (!isPlainObject(value)) {
|
|
444
|
+
into.add("timeCheck.request.shape", path, "expected a clock-check request");
|
|
445
|
+
return into.violations;
|
|
446
|
+
}
|
|
447
|
+
into.filled(value.requestId, "timeCheck.request.id", `${path}.requestId`, "a check needs a fresh request ID");
|
|
448
|
+
for (const key of Object.keys(value))
|
|
449
|
+
into.require(key === "requestId", "timeCheck.request.field", `${path}.${key}`, "unsupported request field");
|
|
450
|
+
return into.violations;
|
|
451
|
+
}
|
|
452
|
+
/** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
|
|
453
|
+
export function validateProviderTimeCheckResult(value, request, loginId, path = "result") {
|
|
454
|
+
const into = new Collector();
|
|
455
|
+
into.violations.push(...validateProviderTimeCheckRequest(request));
|
|
456
|
+
into.filled(loginId, "timeCheck.login", "loginId", "current authenticated login is required");
|
|
457
|
+
if (!isPlainObject(value)) {
|
|
458
|
+
into.add("timeCheck.result.shape", path, "expected a clock-check response");
|
|
459
|
+
return into.violations;
|
|
460
|
+
}
|
|
461
|
+
into.filled(value.requestId, "timeCheck.result.id", `${path}.requestId`, "response must identify its request");
|
|
462
|
+
into.require(isPlainObject(request) && value.requestId === request.requestId, "timeCheck.result.request", `${path}.requestId`, "response must match the outstanding check");
|
|
463
|
+
into.require(value.loginId === loginId, "timeCheck.result.login", `${path}.loginId`, "response belongs to the current login");
|
|
464
|
+
into.filled(value.clockId, "timeCheck.result.clock", `${path}.clockId`, "provider clock domain/incarnation is required");
|
|
465
|
+
into.timestamp(value.providerTime, "timeCheck.result.time", `${path}.providerTime`);
|
|
466
|
+
for (const key of Object.keys(value))
|
|
467
|
+
into.require(["requestId", "loginId", "clockId", "providerTime"].includes(key), "timeCheck.result.field", `${path}.${key}`, "unsupported response field");
|
|
468
|
+
return into.violations;
|
|
469
|
+
}
|
|
398
470
|
export function validateManifest(manifest, path = "manifest") {
|
|
399
471
|
const into = new Collector();
|
|
400
472
|
if (!isPlainObject(manifest)) {
|
|
@@ -430,6 +502,10 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
430
502
|
}
|
|
431
503
|
if (channelValid)
|
|
432
504
|
validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
|
|
505
|
+
if (manifest.timestampAuthority !== undefined)
|
|
506
|
+
into.require(manifest.timestampAuthority === "provider", "manifest.timestampAuthority", `${path}.timestampAuthority`, "provider timestamp authority must be explicit; omission makes no trust promise");
|
|
507
|
+
if (manifest.timeCheck !== undefined)
|
|
508
|
+
into.require(manifest.timeCheck === true, "manifest.timeCheck", `${path}.timeCheck`, "declare true or omit unsupported clock checks");
|
|
433
509
|
validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
|
|
434
510
|
validatePhones(manifest, `${path}.phones`, into);
|
|
435
511
|
if (manifest.phaseLabels !== undefined) {
|
|
@@ -853,7 +929,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
853
929
|
into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
|
|
854
930
|
}
|
|
855
931
|
if (entry.step === "muted") {
|
|
856
|
-
into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is
|
|
932
|
+
into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this handling's media has ended: the provider closes its open leg and states the duration; other channels may continue");
|
|
857
933
|
}
|
|
858
934
|
}
|
|
859
935
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
@@ -885,9 +961,9 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
885
961
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
886
962
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
887
963
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
888
|
-
/**
|
|
889
|
-
function
|
|
890
|
-
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" &&
|
|
964
|
+
/** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
|
|
965
|
+
function partyRingingBeforeWork(task) {
|
|
966
|
+
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
|
|
891
967
|
}
|
|
892
968
|
function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
893
969
|
if (value === undefined)
|
|
@@ -897,9 +973,9 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
|
|
|
897
973
|
if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
|
|
898
974
|
// Nothing is acquired while pending, and a preview has placed no call: media names a task whose
|
|
899
975
|
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
into.require(!WORK_NOT_BEGUN.includes(phase) ||
|
|
976
|
+
// Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
|
|
977
|
+
// A deadline alone is never evidence that media started.
|
|
978
|
+
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
|
|
903
979
|
}
|
|
904
980
|
}
|
|
905
981
|
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
@@ -1063,7 +1139,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1063
1139
|
into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
|
|
1064
1140
|
}
|
|
1065
1141
|
if (task.atDeadline === undefined) {
|
|
1066
|
-
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what
|
|
1142
|
+
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider calls, host calls, or waits for the agent -- an agent counting down has to know which");
|
|
1067
1143
|
}
|
|
1068
1144
|
else {
|
|
1069
1145
|
into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
|
|
@@ -1131,17 +1207,14 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1131
1207
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1132
1208
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
1133
1209
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
// A completing task's call is over: its media has ended, or it never started. Audio still stated
|
|
1138
|
-
// as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
|
|
1139
|
-
// the agent's notes -- and a task stating it contradicts itself.
|
|
1210
|
+
// Media and onCall describe this agent's handling, not the continuing caller journey.
|
|
1211
|
+
// Once the handling's media ends or it enters wrap, its live room is cleared. Other
|
|
1212
|
+
// channels may remain connected under another handling or in IVR/queue stages.
|
|
1140
1213
|
if (task.phase === "completing" && task.media === "started") {
|
|
1141
|
-
into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started
|
|
1214
|
+
into.add("task.media.completing", `${path}.media`, "a completing task's handling media has ended or never started; it cannot still be started during wrap");
|
|
1142
1215
|
}
|
|
1143
1216
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1144
|
-
into.add("task.onCall.ended", `${path}.onCall`, `
|
|
1217
|
+
into.add("task.onCall.ended", `${path}.onCall`, `this handling is no longer connected (${task.phase === "completing" ? "the task is completing" : "its media ended"}); clear its onCall view without implying that other channels ended`);
|
|
1145
1218
|
}
|
|
1146
1219
|
validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
|
|
1147
1220
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
@@ -1235,6 +1308,184 @@ function validateImposedBreak(value, path, into) {
|
|
|
1235
1308
|
into.add("break.imposed.endsAutomatically", `${path}.endsAutomatically`, "an imposed break must say whether it ends automatically");
|
|
1236
1309
|
}
|
|
1237
1310
|
}
|
|
1311
|
+
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
1312
|
+
export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
1313
|
+
const into = new Collector();
|
|
1314
|
+
validateBreakState(state, `${path}.break`, into);
|
|
1315
|
+
into.require(Array.isArray(tasks), "break.tasks.shape", `${path}.tasks`, "the complete current task list is required");
|
|
1316
|
+
if (Array.isArray(tasks))
|
|
1317
|
+
tasks.forEach((task, index) => {
|
|
1318
|
+
into.require(isPlainObject(task), "break.task.shape", `${path}.tasks[${index}]`, "each retained task must be an object");
|
|
1319
|
+
});
|
|
1320
|
+
// A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
|
|
1321
|
+
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1322
|
+
// lead listening to a call during a break that is work of another sort -- coaching,
|
|
1323
|
+
// administrative, training -- and on no other.
|
|
1324
|
+
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1325
|
+
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1326
|
+
// finishing" is the plausible nought.
|
|
1327
|
+
ruleEvaluated("break.starting-after-task.tasks");
|
|
1328
|
+
if (isPlainObject(state) && state.approval === "starting-after-task"
|
|
1329
|
+
&& Array.isArray(tasks) && tasks.length === 0) {
|
|
1330
|
+
into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
|
|
1331
|
+
}
|
|
1332
|
+
if (isPlainObject(state) && state.approval === "in-effect"
|
|
1333
|
+
&& Array.isArray(tasks) && tasks.length > 0) {
|
|
1334
|
+
const listening = tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
|
|
1335
|
+
if (!listening) {
|
|
1336
|
+
into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1340
|
+
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1341
|
+
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1342
|
+
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return into.violations;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Checks lead break dispatch using current authentication, transport, roster (`team`) and,
|
|
1349
|
+
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
1350
|
+
* and recheck the decision atomically; a roster is not authority to act after it has changed.
|
|
1351
|
+
*/
|
|
1352
|
+
export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
|
|
1353
|
+
const into = new Collector();
|
|
1354
|
+
if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
|
|
1355
|
+
into.add("team.break.command.shape", path, "a command and current authentication/transport/team context are required");
|
|
1356
|
+
return into.violations;
|
|
1357
|
+
}
|
|
1358
|
+
const auth = isPlainObject(context.authentication) ? context.authentication : {};
|
|
1359
|
+
into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
|
|
1360
|
+
const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
|
|
1361
|
+
into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.break.command.connection", path, "lead commands require a live login and active transport");
|
|
1362
|
+
into.require(isPlainObject(caps.team) && caps.team.breakControl === true, "team.break.command.capability", path, "the login must declare team.breakControl");
|
|
1363
|
+
const command = request.command;
|
|
1364
|
+
const allowed = {
|
|
1365
|
+
decide: ["type", "memberId", "decision", "reason"], policy: ["type", "policy"],
|
|
1366
|
+
place: ["type", "memberId", "reasonId", "reason"], release: ["type", "memberId"],
|
|
1367
|
+
};
|
|
1368
|
+
const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
|
|
1369
|
+
if (!fields) {
|
|
1370
|
+
into.add("team.break.command.type", path, "unknown lead break command");
|
|
1371
|
+
return into.violations;
|
|
1372
|
+
}
|
|
1373
|
+
for (const key of Object.keys(request))
|
|
1374
|
+
into.require(key === "command", "team.break.request.field", `${path}.${key}`, "only command is supported");
|
|
1375
|
+
for (const key of Object.keys(command))
|
|
1376
|
+
into.require(fields.includes(key), "team.break.command.field", `${path}.${key}`, "field is not supported for this command");
|
|
1377
|
+
if (command.reason !== undefined)
|
|
1378
|
+
into.filled(command.reason, "team.break.command.reason", path, "reason must not be empty");
|
|
1379
|
+
if (command.type === "policy") {
|
|
1380
|
+
into.require(["ask", "auto-approve", "suspended"].includes(command.policy), "team.break.command.policy", path, "unknown break policy");
|
|
1381
|
+
return into.violations;
|
|
1382
|
+
}
|
|
1383
|
+
into.filled(command.memberId, "team.break.command.member", path, "name the target member");
|
|
1384
|
+
const team = isPlainObject(context.team) ? context.team : {};
|
|
1385
|
+
const members = Array.isArray(team.members) ? team.members : [];
|
|
1386
|
+
const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
|
|
1387
|
+
const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
|
|
1388
|
+
into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized roster");
|
|
1389
|
+
if (command.type === "decide") {
|
|
1390
|
+
into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
|
|
1391
|
+
into.require(isPlainObject(member) && member.break === "awaiting-decision", "team.break.command.awaiting", path, "decide only a currently awaiting-decision request");
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1395
|
+
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1396
|
+
if (command.type === "release")
|
|
1397
|
+
into.require(state.imposed !== undefined && (state.approval === "in-effect" || state.approval === "starting-after-task"), "team.break.command.release", path, "release a currently imposed break");
|
|
1398
|
+
if (command.type === "place") {
|
|
1399
|
+
if (command.reasonId !== undefined)
|
|
1400
|
+
into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
|
|
1401
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1402
|
+
into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.break.command.reasonId", path, "place uses the target's currently published reason codes");
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return into.violations;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Validate immediately before dispatch against current provider state. Context must contain
|
|
1409
|
+
* the current authentication and transport. This cannot fence a concurrent backend change;
|
|
1410
|
+
* the provider must recheck atomically. Result validation never replaces authoritative state.
|
|
1411
|
+
*/
|
|
1412
|
+
export function validateBreakCommand(method, request, state, context, path = "breakCommand") {
|
|
1413
|
+
const into = new Collector();
|
|
1414
|
+
validateBreakState(state, `${path}.state`, into);
|
|
1415
|
+
if (!isPlainObject(context)) {
|
|
1416
|
+
into.add("break.command.context", path, "current authentication and transport are required");
|
|
1417
|
+
return into.violations;
|
|
1418
|
+
}
|
|
1419
|
+
into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`));
|
|
1420
|
+
const auth = isPlainObject(context.authentication) ? context.authentication : {};
|
|
1421
|
+
into.require(auth.status === "authenticated" || auth.status === "refreshing", "break.command.authentication", path, "break commands require a live authenticated login");
|
|
1422
|
+
into.require(isPlainObject(auth.capabilities) && auth.capabilities.breaks === true, "break.command.capability", path, "the login must declare breaks");
|
|
1423
|
+
into.require(context.transport === "active", "break.command.transport", path, "break commands require active transport");
|
|
1424
|
+
if (!["requestBreak", "commitBreak", "cancelBreak", "endBreak"].includes(method)) {
|
|
1425
|
+
into.add("break.command.method", path, "unknown break method");
|
|
1426
|
+
return into.violations;
|
|
1427
|
+
}
|
|
1428
|
+
if (!isPlainObject(state) || into.violations.length)
|
|
1429
|
+
return into.violations;
|
|
1430
|
+
const approval = state.approval;
|
|
1431
|
+
if (method === "requestBreak") {
|
|
1432
|
+
into.require(approval === "not-requested", "break.command.request.pending", path, "a new request starts only from not-requested");
|
|
1433
|
+
if (!isPlainObject(request)) {
|
|
1434
|
+
into.add("break.request.shape", path, "requestBreak takes a request object");
|
|
1435
|
+
return into.violations;
|
|
1436
|
+
}
|
|
1437
|
+
for (const key of Object.keys(request))
|
|
1438
|
+
into.require(key === "reason" || key === "reasonId", "break.request.field", `${path}.${key}`, "a break request carries only reason and reasonId");
|
|
1439
|
+
for (const key of ["reason", "reasonId"])
|
|
1440
|
+
if (request[key] !== undefined)
|
|
1441
|
+
into.filled(request[key], `break.request.${key}`, `${path}.${key}`, "a supplied reason must not be empty");
|
|
1442
|
+
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1443
|
+
const selected = reasons.find((r) => isPlainObject(r) && r.id === request.reasonId);
|
|
1444
|
+
if (state.reasons !== undefined)
|
|
1445
|
+
into.require(selected !== undefined, "break.request.reasonId", path, "choose a currently published reasonId; free text is not a substitute");
|
|
1446
|
+
else
|
|
1447
|
+
into.require(request.reasonId === undefined, "break.request.reasonId.unexpected", path, "no reason codes were published");
|
|
1448
|
+
into.require(state.mayAsk === true || (isPlainObject(selected) && selected.alwaysAvailable === true), "break.request.mayAsk", path, "asking is disabled except for the selected alwaysAvailable reason");
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
into.require(request === undefined, "break.command.arguments", path, "this break method takes no arguments");
|
|
1452
|
+
if (method === "commitBreak")
|
|
1453
|
+
into.require(approval === "granted" || approval === "starting-after-task" || approval === "in-effect", "break.command.commit.grant", path, "commit requires a grant; repeated committed state is idempotent");
|
|
1454
|
+
if (method === "cancelBreak")
|
|
1455
|
+
into.require(approval === "awaiting-decision" || approval === "granted", "break.command.cancel.precommit", path, "cancel only a pre-commit request; a raced commit requires commit recovery");
|
|
1456
|
+
if (method === "endBreak") {
|
|
1457
|
+
into.require(approval === "starting-after-task" || approval === "in-effect", "break.command.end.started", path, "end a committed break, including a returning provider still finishing work");
|
|
1458
|
+
into.require(state.imposed === undefined, "break.command.end.imposed", path, "an agent cannot end an imposed break; an authorized lead releases it");
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return into.violations;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Checks two complete break states within one provider/login's ordered event stream.
|
|
1465
|
+
* Call before replacing accepted state; report violations and reconcile on failure.
|
|
1466
|
+
* This is not a freshness check: validate a fresh authoritative snapshot separately and
|
|
1467
|
+
* use it as a new baseline, never as an event transition. Source ordering and connection
|
|
1468
|
+
* fencing remain required; neither timestamps nor approval rank identify a break attempt.
|
|
1469
|
+
*/
|
|
1470
|
+
export function validateBreakTransition(before, after, path = "break") {
|
|
1471
|
+
const into = new Collector();
|
|
1472
|
+
validateBreakState(before, `${path}.before`, into);
|
|
1473
|
+
validateBreakState(after, `${path}.after`, into);
|
|
1474
|
+
if (into.violations.length || !isPlainObject(before) || !isPlainObject(after))
|
|
1475
|
+
return into.violations;
|
|
1476
|
+
const from = before.approval;
|
|
1477
|
+
const to = after.approval;
|
|
1478
|
+
const committed = to === "starting-after-task" || to === "in-effect";
|
|
1479
|
+
if (committed && (from === "not-requested" || from === "awaiting-decision") && after.imposed === undefined) {
|
|
1480
|
+
into.add("stream.breakState.commitBeforeGrant", `${path}.after.approval`, `${to} follows a commit, and a commit follows granted; the break stood at ${from}`);
|
|
1481
|
+
}
|
|
1482
|
+
const backwards = (from === "in-effect" && (to === "awaiting-decision" || to === "granted" || to === "starting-after-task")) ||
|
|
1483
|
+
(from === "starting-after-task" && (to === "awaiting-decision" || to === "granted")) ||
|
|
1484
|
+
(from === "granted" && to === "awaiting-decision");
|
|
1485
|
+
if (backwards)
|
|
1486
|
+
into.add("stream.breakState.backwards", `${path}.after.approval`, `a break does not go from ${from} back to ${to}; a new request passes through not-requested`);
|
|
1487
|
+
return into.violations;
|
|
1488
|
+
}
|
|
1238
1489
|
function validateBreakState(value, path, into) {
|
|
1239
1490
|
if (!isPlainObject(value)) {
|
|
1240
1491
|
into.add("break.shape", path, "break state must be an object");
|
|
@@ -1417,7 +1668,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1417
1668
|
&& context.loginId !== undefined) {
|
|
1418
1669
|
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
1419
1670
|
}
|
|
1420
|
-
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
1421
1671
|
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
1422
1672
|
// inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
|
|
1423
1673
|
if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
|
|
@@ -1459,32 +1709,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1459
1709
|
}
|
|
1460
1710
|
});
|
|
1461
1711
|
}
|
|
1462
|
-
|
|
1463
|
-
// describes a state the agent cannot be in, whichever half is stale. The one exception is a
|
|
1464
|
-
// lead listening to a call during a break that is work of another sort -- coaching,
|
|
1465
|
-
// administrative, training -- and on no other.
|
|
1466
|
-
// The converse: a committed break with nothing outstanding has begun. starting-after-task beside
|
|
1467
|
-
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1468
|
-
// finishing" is the plausible nought.
|
|
1469
|
-
ruleEvaluated("break.starting-after-task.tasks");
|
|
1470
|
-
if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
|
|
1471
|
-
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
|
|
1472
|
-
into.add("break.starting-after-task.tasks", `${path}.break.approval`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is in-effect");
|
|
1473
|
-
}
|
|
1474
|
-
if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
|
|
1475
|
-
&& Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
|
|
1476
|
-
const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
|
|
1477
|
-
if (!listening) {
|
|
1478
|
-
into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
|
|
1479
|
-
}
|
|
1480
|
-
else {
|
|
1481
|
-
const state = snapshot.break;
|
|
1482
|
-
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
1483
|
-
const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
|
|
1484
|
-
const kind = isPlainObject(active) ? active.kind : undefined;
|
|
1485
|
-
into.require(typeof kind === "string" && MONITORING_BREAK_KINDS.includes(kind), "snapshot.monitoring.break", `${path}.tasks`, `a lead listens during a ${MONITORING_BREAK_KINDS.join(", ")} break and no other; the active reason is ${kind === undefined ? "unstated" : describeValue(kind)}`);
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1712
|
+
into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
|
|
1488
1713
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
1489
1714
|
// capability for is data Omni would show against a control the agent does not have.
|
|
1490
1715
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
@@ -2020,6 +2245,22 @@ export function validateHostReport(report, path = "host") {
|
|
|
2020
2245
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2021
2246
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
|
|
2022
2247
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2248
|
+
/** Validate the exact handling/allocation target before provider command dispatch. */
|
|
2249
|
+
export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
|
|
2250
|
+
const into = new Collector();
|
|
2251
|
+
if (!isPlainObject(request) || !isPlainObject(task)) {
|
|
2252
|
+
into.add("command.request.shape", path, "a request and the current published task are required");
|
|
2253
|
+
return into.violations;
|
|
2254
|
+
}
|
|
2255
|
+
into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the handling task");
|
|
2256
|
+
into.filled(request.allocationId, "command.request.allocationId", `${path}.allocationId`, "name its allocation");
|
|
2257
|
+
into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact handling");
|
|
2258
|
+
into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
|
|
2259
|
+
for (const key of Object.keys(request))
|
|
2260
|
+
into.require(["taskId", "allocationId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
|
|
2261
|
+
into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
|
|
2262
|
+
return into.violations;
|
|
2263
|
+
}
|
|
2023
2264
|
/**
|
|
2024
2265
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2025
2266
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
@@ -2027,7 +2268,7 @@ const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
|
2027
2268
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
2028
2269
|
* for a control the task never offered is the host's error, and this names it.
|
|
2029
2270
|
*/
|
|
2030
|
-
export function validateTaskCommand(command, task, path = "command") {
|
|
2271
|
+
export function validateTaskCommand(command, task, path = "command", taskContext) {
|
|
2031
2272
|
const into = new Collector();
|
|
2032
2273
|
if (!isPlainObject(command)) {
|
|
2033
2274
|
into.add("command.shape", path, "a command must be an object");
|
|
@@ -2044,6 +2285,36 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2044
2285
|
if (type !== "custom" && !into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${describeValue(type)}` : `a ${channel} task has no ${describeValue(type)} command`)) {
|
|
2045
2286
|
return into.violations;
|
|
2046
2287
|
}
|
|
2288
|
+
// Built-in commands carry only their declared fields; custom controls own their payload.
|
|
2289
|
+
// Recording already applies its action-specific field check below.
|
|
2290
|
+
if (type !== "custom" && type !== "recording") {
|
|
2291
|
+
let fields = ["type"];
|
|
2292
|
+
switch (type) {
|
|
2293
|
+
case "call":
|
|
2294
|
+
case "connect-back":
|
|
2295
|
+
fields.push("dialId");
|
|
2296
|
+
break;
|
|
2297
|
+
case "complete":
|
|
2298
|
+
fields.push("disposition", "notes");
|
|
2299
|
+
break;
|
|
2300
|
+
case "transfer":
|
|
2301
|
+
fields.push("action");
|
|
2302
|
+
if (command.action === "cold" || command.action === "warm")
|
|
2303
|
+
fields.push("dialId", "destinationId");
|
|
2304
|
+
break;
|
|
2305
|
+
case "lead-assist":
|
|
2306
|
+
fields.push("action");
|
|
2307
|
+
if (command.action === "request")
|
|
2308
|
+
fields.push("note");
|
|
2309
|
+
break;
|
|
2310
|
+
case "conference":
|
|
2311
|
+
fields.push("action", "destinationId");
|
|
2312
|
+
fields.push(command.action === "add" ? "dialId" : "party");
|
|
2313
|
+
break;
|
|
2314
|
+
}
|
|
2315
|
+
for (const key of Object.keys(command))
|
|
2316
|
+
into.require(fields.includes(key), "command.field", `${path}.${key}`, "field is not supported for this command");
|
|
2317
|
+
}
|
|
2047
2318
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
2048
2319
|
switch (type) {
|
|
2049
2320
|
case "call":
|
|
@@ -2100,7 +2371,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2100
2371
|
// checking against a task nobody has -- so the task is validated first, and a command is held
|
|
2101
2372
|
// only to a task that stands.
|
|
2102
2373
|
const published = new Collector();
|
|
2103
|
-
validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2374
|
+
validateTaskInto(task, { ...taskContext, channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2104
2375
|
if (published.violations.length > 0 || !isPlainObject(task)) {
|
|
2105
2376
|
into.add("command.task", `${path}.task`, `the task the command is held to is not one the wire published (${published.violations.map(v => v.rule).join(", ")}): pass the task as the provider sent it, or validate the command's shape alone`);
|
|
2106
2377
|
return into.violations;
|
|
@@ -2141,8 +2412,8 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2141
2412
|
};
|
|
2142
2413
|
const inPhase = (rule, ...phases) => into.require(typeof task.phase === "string" && phases.includes(task.phase), rule, path, `${describeValue(type)} belongs to ${phases.join(" or ")}, and the task is ${describeValue(task.phase)}`);
|
|
2143
2414
|
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
2144
|
-
// to act on yet, and in completing
|
|
2145
|
-
//
|
|
2415
|
+
// to act on yet, and in completing this agent's handling has ended. The caller and other
|
|
2416
|
+
// channels may continue elsewhere; this task's phase no longer permits these controls.
|
|
2146
2417
|
const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
|
|
2147
2418
|
// A destination is one the directory offered: the id Omni sends is the id the provider published.
|
|
2148
2419
|
const listed = (name) => {
|
|
@@ -2173,7 +2444,8 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2173
2444
|
handling();
|
|
2174
2445
|
break;
|
|
2175
2446
|
case "recording": {
|
|
2176
|
-
offered("recording")
|
|
2447
|
+
if (!offered("recording"))
|
|
2448
|
+
break;
|
|
2177
2449
|
const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
|
|
2178
2450
|
const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
|
|
2179
2451
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
@@ -2326,6 +2598,8 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2326
2598
|
else {
|
|
2327
2599
|
into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
|
|
2328
2600
|
}
|
|
2601
|
+
if (method === "recordStep")
|
|
2602
|
+
into.timestamp(result.at, "result.recordStep.at", `${path}.at`);
|
|
2329
2603
|
}
|
|
2330
2604
|
else if (statuses.failure !== undefined && result.status === statuses.failure) {
|
|
2331
2605
|
if (result.failure === undefined) {
|
|
@@ -2589,7 +2863,6 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
2589
2863
|
// Independent current recording evidence and dispatch validation.
|
|
2590
2864
|
const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2591
2865
|
const filled = (v) => typeof v === "string" && v.trim().length > 0;
|
|
2592
|
-
const effects = ["retain", "discard"];
|
|
2593
2866
|
function collector() {
|
|
2594
2867
|
const violations = [];
|
|
2595
2868
|
const check = (ok, rule, path, message) => {
|
|
@@ -2660,14 +2933,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
|
|
|
2660
2933
|
for (const action of RECORDING_ACTIONS) {
|
|
2661
2934
|
if (policy[action] === undefined)
|
|
2662
2935
|
continue;
|
|
2663
|
-
|
|
2664
|
-
const cancel = policy.cancel;
|
|
2665
|
-
check(object(cancel) && effects.includes(cancel.effect), "cancel.effect", `${at}.cancel`, "cancel explicitly retains or discards this recording's captured audio");
|
|
2666
|
-
if (object(cancel))
|
|
2667
|
-
keys(cancel, ["effect"], `${at}.cancel`);
|
|
2668
|
-
}
|
|
2669
|
-
else
|
|
2670
|
-
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2936
|
+
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2671
2937
|
}
|
|
2672
2938
|
if (source === "host")
|
|
2673
2939
|
check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
|
|
@@ -2683,8 +2949,10 @@ export function validateHostRecording(value, softphone, path = "host.recording")
|
|
|
2683
2949
|
check(false, "host.shape", path, "expected host recording declaration and executor");
|
|
2684
2950
|
return violations;
|
|
2685
2951
|
}
|
|
2686
|
-
keys(value, ["actions", "
|
|
2687
|
-
|
|
2952
|
+
keys(value, ["actions", "destinationIds", "execute", "announcesToCaller"], path);
|
|
2953
|
+
if (value.announcesToCaller !== undefined)
|
|
2954
|
+
check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
|
|
2955
|
+
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["destinationIds", undefined]]) {
|
|
2688
2956
|
const list = value[key];
|
|
2689
2957
|
if (!Array.isArray(list)) {
|
|
2690
2958
|
check(false, "host.list", `${path}.${key}`, "expected explicit array");
|
|
@@ -2692,11 +2960,9 @@ export function validateHostRecording(value, softphone, path = "host.recording")
|
|
|
2692
2960
|
}
|
|
2693
2961
|
check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
|
|
2694
2962
|
check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
|
|
2695
|
-
|
|
2696
|
-
check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
|
|
2963
|
+
check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
|
|
2697
2964
|
}
|
|
2698
|
-
if (Array.isArray(value.actions)
|
|
2699
|
-
check(value.actions.includes("cancel") === (value.cancelEffects.length > 0), "host.cancel", path, "cancel support and explicit effects must agree");
|
|
2965
|
+
if (Array.isArray(value.actions)) {
|
|
2700
2966
|
check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
|
|
2701
2967
|
}
|
|
2702
2968
|
check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
|
|
@@ -2732,32 +2998,47 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
2732
2998
|
check(false, "command.shape", path, "expected recording command");
|
|
2733
2999
|
return violations;
|
|
2734
3000
|
}
|
|
2735
|
-
keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])
|
|
3001
|
+
keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
|
|
2736
3002
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
2737
3003
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
2738
3004
|
for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
2739
3005
|
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
2740
|
-
if (value.action === "cancel")
|
|
2741
|
-
check(effects.includes(value.cancelEffect), "cancel.effect", `${path}.cancelEffect`, "explicit captured-audio disposition required");
|
|
2742
3006
|
return violations;
|
|
2743
3007
|
}
|
|
3008
|
+
/** One transition table shared by dispatch checks and outcome checks. */
|
|
3009
|
+
const RECORDING_TRANSITIONS = {
|
|
3010
|
+
start: { from: ["inactive"], to: "active" },
|
|
3011
|
+
pause: { from: ["active"], to: "paused" },
|
|
3012
|
+
resume: { from: ["paused"], to: "active" },
|
|
3013
|
+
stop: { from: ["active", "paused"], to: "inactive" },
|
|
3014
|
+
cancel: { from: ["active", "paused"], to: "inactive" },
|
|
3015
|
+
};
|
|
3016
|
+
function recordingTransition(action) {
|
|
3017
|
+
return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
|
|
3018
|
+
? RECORDING_TRANSITIONS[action] : undefined;
|
|
3019
|
+
}
|
|
2744
3020
|
/** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
|
|
2745
3021
|
export function validateRecordingCommandState(command, policy, state, path = "command") {
|
|
2746
3022
|
const { violations, check } = collector();
|
|
2747
|
-
if (!object(command))
|
|
3023
|
+
if (!object(command)) {
|
|
3024
|
+
check(false, "command.shape", path, "expected recording command");
|
|
3025
|
+
return violations;
|
|
3026
|
+
}
|
|
3027
|
+
const transition = recordingTransition(command.action);
|
|
3028
|
+
if (transition === undefined) {
|
|
3029
|
+
check(false, "command.action", `${path}.action`, "unsupported recording action");
|
|
2748
3030
|
return violations;
|
|
2749
|
-
|
|
3031
|
+
}
|
|
3032
|
+
const permitted = object(policy) && policy[command.action] === true;
|
|
3033
|
+
check(permitted, "command.permission", path, "task does not permit this action on this recorder");
|
|
2750
3034
|
if (!object(state) || validateRecordingState(state).length) {
|
|
2751
3035
|
check(false, "command.state", path, "no validated current recorder observation");
|
|
2752
3036
|
return violations;
|
|
2753
3037
|
}
|
|
2754
|
-
|
|
2755
|
-
check(allowed.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
|
|
3038
|
+
check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
|
|
2756
3039
|
check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
|
|
2757
3040
|
if (command.action !== "start")
|
|
2758
3041
|
check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
|
|
2759
|
-
if (command.action === "cancel" && object(policy))
|
|
2760
|
-
check(object(policy.cancel) && command.cancelEffect === policy.cancel.effect, "command.cancelEffect", path, "cancel disposition must match task policy");
|
|
2761
3042
|
return violations;
|
|
2762
3043
|
}
|
|
2763
3044
|
/** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
|
|
@@ -2767,7 +3048,7 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
2767
3048
|
check(false, "request.shape", path, "request and current task required");
|
|
2768
3049
|
return violations;
|
|
2769
3050
|
}
|
|
2770
|
-
violations.push(...validateTask(task, { channel: "voice" }, `${path}.task`));
|
|
3051
|
+
violations.push(...validateTask(task, { ...context.taskContext, channel: "voice" }, `${path}.task`));
|
|
2771
3052
|
keys(request, ["taskId", "allocationId", "command"], path);
|
|
2772
3053
|
check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
|
|
2773
3054
|
check(task.channel === "voice", "request.channel", path, "recording is voice-only");
|
|
@@ -2788,8 +3069,6 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
2788
3069
|
if (object(context.host)) {
|
|
2789
3070
|
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
2790
3071
|
check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
|
|
2791
|
-
if (command.action === "cancel")
|
|
2792
|
-
check(Array.isArray(context.host.cancelEffects) && context.host.cancelEffects.includes(command.cancelEffect), "host.cancelEffect", path, "host does not support cancel disposition");
|
|
2793
3072
|
}
|
|
2794
3073
|
}
|
|
2795
3074
|
const fresh = effectiveRecordingState(state, context.now);
|
|
@@ -2819,12 +3098,13 @@ export function validateRecordingOutcome(command, before, after, result, path =
|
|
|
2819
3098
|
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");
|
|
2820
3099
|
}
|
|
2821
3100
|
else if (result.status === "applied") {
|
|
2822
|
-
const
|
|
2823
|
-
|
|
3101
|
+
const transition = recordingTransition(command.action);
|
|
3102
|
+
if (transition === undefined)
|
|
3103
|
+
return violations; // shape validation already reported the unknown action
|
|
3104
|
+
check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
|
|
2824
3105
|
check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
|
|
2825
3106
|
check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
|
|
2826
|
-
|
|
2827
|
-
check(origin.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
3107
|
+
check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
2828
3108
|
if (command.action !== "start")
|
|
2829
3109
|
check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
|
|
2830
3110
|
if (command.action === "pause" || command.action === "resume")
|