@sublang/playbook 9.0.0 → 11.0.0
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/README.md +1 -1
- package/docs/cli.md +109 -21
- package/docs/configuration.md +89 -40
- package/docs/embedding.md +126 -12
- package/package.json +14 -3
- package/reference/sdlc/captain.md +14 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
- package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
- package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
- package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
- package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
- package/reference/sdlc/code.md +35 -16
- package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
- package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
- package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
- package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
- package/reference/sdlc/code.playbook/bin/run.js +669 -215
- package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
- package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
- package/reference/sdlc/code.playbook/code.fsm.js +74 -25
- package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
- package/reference/sdlc/code.playbook/code.gears.md +0 -2
- package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
- package/reference/sdlc/code.playbook/code.playbook.js +54 -2
- package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
- package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
- package/reference/sdlc/code.playbook/code.registry.js +10 -3
- package/reference/sdlc/code.playbook/code.registry.ts +23 -5
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
- package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
- package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
- package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
- package/reference/sdlc/code.playbook/session-store.js +113 -0
- package/reference/sdlc/decide.md +24 -16
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
- package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
- package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
- package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
- package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
- package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
- package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
- package/reference/sdlc/review.md +36 -18
- package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
- package/reference/sdlc/review.playbook/review.fsm.js +133 -12
- package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
- package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
- package/reference/sdlc/review.playbook/review.playbook.js +65 -2
- package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
- package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
- package/reference/sdlc/review.playbook/review.registry.js +10 -3
- package/reference/sdlc/review.playbook/review.registry.ts +23 -5
- package/slc/gears2fsm.md +6 -5
- package/slc/link.md +544 -41
- package/src/accepted-outcome.d.ts +18 -0
- package/src/accepted-outcome.js +94 -0
- package/src/accepted-outcome.ts +140 -0
- package/src/runtime.d.ts +164 -3
- package/src/runtime.ts +213 -2
- package/src/xstate-playbook-runtime.d.ts +149 -10
- package/src/xstate-playbook-runtime.js +2569 -270
- package/src/xstate-playbook-runtime.ts +4133 -490
- package/src/xstate-runtime.d.ts +59 -1
- package/src/xstate-runtime.js +866 -7
- package/src/xstate-runtime.ts +1397 -7
package/src/xstate-runtime.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import {
|
|
5
6
|
fromPromise,
|
|
6
7
|
waitFor,
|
|
@@ -18,6 +19,12 @@ import type {
|
|
|
18
19
|
PlaybookCallStart,
|
|
19
20
|
PlaybookPendingBossQuestion,
|
|
20
21
|
PlaybookPendingCall,
|
|
22
|
+
PlaybookEffectBoundary,
|
|
23
|
+
PlaybookEffectLedger,
|
|
24
|
+
PlaybookEffectLogicalOperation,
|
|
25
|
+
PlaybookRepositoryObservation,
|
|
26
|
+
PlaybookRepositoryDisposition,
|
|
27
|
+
PlaybookRepositoryReceipt,
|
|
21
28
|
PlaybookRuntimeSnapshot,
|
|
22
29
|
PlaybookSession,
|
|
23
30
|
PlaybookState,
|
|
@@ -869,6 +876,1231 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
869
876
|
'playbookCall',
|
|
870
877
|
] as const;
|
|
871
878
|
|
|
879
|
+
const EFFECT_UUID_PATTERN =
|
|
880
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
881
|
+
const EFFECT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
882
|
+
const EFFECT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
883
|
+
const EFFECT_DISPOSITIONS = new Set([
|
|
884
|
+
'unchanged',
|
|
885
|
+
'one-descendant-commit',
|
|
886
|
+
'deferred',
|
|
887
|
+
]);
|
|
888
|
+
const EFFECT_RECEIPT_CLASSIFICATIONS = new Set([
|
|
889
|
+
'unchanged',
|
|
890
|
+
'one-descendant-commit',
|
|
891
|
+
'multiple-commits',
|
|
892
|
+
'rewritten-or-non-descendant',
|
|
893
|
+
'worktree-only-change',
|
|
894
|
+
'concurrent-or-foreign-change',
|
|
895
|
+
'observation-ambiguous',
|
|
896
|
+
]);
|
|
897
|
+
|
|
898
|
+
function effectUuid(value: unknown, path: string): string {
|
|
899
|
+
const id = requireNonEmptyString(value, path);
|
|
900
|
+
if (!EFFECT_UUID_PATTERN.test(id)) {
|
|
901
|
+
throw new TypeError(`${path} must be a canonical UUID`);
|
|
902
|
+
}
|
|
903
|
+
return id;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function effectInteger(value: unknown, path: string, minimum = 0): number {
|
|
907
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
908
|
+
throw new TypeError(`${path} must be an integer greater than or equal to ${minimum}`);
|
|
909
|
+
}
|
|
910
|
+
return value as number;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean {
|
|
914
|
+
if (Object.is(left, right)) return true;
|
|
915
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
916
|
+
return (
|
|
917
|
+
Array.isArray(left) &&
|
|
918
|
+
Array.isArray(right) &&
|
|
919
|
+
left.length === right.length &&
|
|
920
|
+
left.every((entry, index) => jsonValuesEqual(entry, right[index]!))
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
if (isRecord(left) && isRecord(right)) {
|
|
924
|
+
const leftKeys = Object.keys(left).sort();
|
|
925
|
+
const rightKeys = Object.keys(right).sort();
|
|
926
|
+
return (
|
|
927
|
+
leftKeys.length === rightKeys.length &&
|
|
928
|
+
leftKeys.every(
|
|
929
|
+
(key, index) =>
|
|
930
|
+
key === rightKeys[index] &&
|
|
931
|
+
jsonValuesEqual(left[key] as JsonValue, right[key] as JsonValue),
|
|
932
|
+
)
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function projectionText(
|
|
939
|
+
projection: PlaybookRepositoryObservation['projection'],
|
|
940
|
+
): string {
|
|
941
|
+
return JSON.stringify(projection);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function projectionsEqual(
|
|
945
|
+
left: PlaybookRepositoryObservation,
|
|
946
|
+
right: PlaybookRepositoryObservation,
|
|
947
|
+
): boolean {
|
|
948
|
+
return (
|
|
949
|
+
left.projectionDigest === right.projectionDigest &&
|
|
950
|
+
projectionText(left.projection) === projectionText(right.projection)
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function projectionPreservesBaseline(
|
|
955
|
+
baseline: PlaybookRepositoryObservation,
|
|
956
|
+
after: PlaybookRepositoryObservation,
|
|
957
|
+
): boolean {
|
|
958
|
+
return Object.entries(baseline.projection).every(
|
|
959
|
+
([path, entry]) =>
|
|
960
|
+
own(after.projection, path) &&
|
|
961
|
+
JSON.stringify(entry) === JSON.stringify(after.projection[path]),
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function effectObservation(
|
|
966
|
+
value: unknown,
|
|
967
|
+
path: string,
|
|
968
|
+
): PlaybookRepositoryObservation {
|
|
969
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
970
|
+
rejectUnknownKeys(
|
|
971
|
+
value,
|
|
972
|
+
['worktree', 'gitDir', 'head', 'projection', 'projectionDigest'],
|
|
973
|
+
path,
|
|
974
|
+
);
|
|
975
|
+
requireNonEmptyString(value.worktree, `${path}.worktree`);
|
|
976
|
+
requireNonEmptyString(value.gitDir, `${path}.gitDir`);
|
|
977
|
+
const head = requireNonEmptyString(value.head, `${path}.head`);
|
|
978
|
+
if (!EFFECT_OID_PATTERN.test(head)) {
|
|
979
|
+
throw new TypeError(`${path}.head must be a canonical Git commit OID`);
|
|
980
|
+
}
|
|
981
|
+
if (!isRecord(value.projection)) {
|
|
982
|
+
throw new TypeError(`${path}.projection must be a path-keyed object`);
|
|
983
|
+
}
|
|
984
|
+
for (const key of Object.keys(value.projection)) {
|
|
985
|
+
if (key.length === 0) {
|
|
986
|
+
throw new TypeError(`${path}.projection must not contain an empty path`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const projectionDigest = requireNonEmptyString(
|
|
990
|
+
value.projectionDigest,
|
|
991
|
+
`${path}.projectionDigest`,
|
|
992
|
+
);
|
|
993
|
+
if (!EFFECT_DIGEST_PATTERN.test(projectionDigest)) {
|
|
994
|
+
throw new TypeError(`${path}.projectionDigest must be a canonical SHA-256 identity`);
|
|
995
|
+
}
|
|
996
|
+
const expectedDigest = `sha256:${createHash('sha256')
|
|
997
|
+
.update(JSON.stringify(value.projection))
|
|
998
|
+
.digest('hex')}`;
|
|
999
|
+
if (projectionDigest !== expectedDigest) {
|
|
1000
|
+
throw new TypeError(`${path}.projectionDigest does not match its projection`);
|
|
1001
|
+
}
|
|
1002
|
+
return value as unknown as PlaybookRepositoryObservation;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function assertObservationIdentity(
|
|
1006
|
+
observation: PlaybookRepositoryObservation,
|
|
1007
|
+
identity: { readonly worktree: string; readonly gitDir: string },
|
|
1008
|
+
path: string,
|
|
1009
|
+
): void {
|
|
1010
|
+
if (
|
|
1011
|
+
observation.worktree !== identity.worktree ||
|
|
1012
|
+
observation.gitDir !== identity.gitDir
|
|
1013
|
+
) {
|
|
1014
|
+
throw new TypeError(`${path} does not match its canonical worktree identity`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function effectReceipt(
|
|
1019
|
+
value: unknown,
|
|
1020
|
+
path: string,
|
|
1021
|
+
expectedBaseline?: PlaybookRepositoryObservation,
|
|
1022
|
+
expectedAfter?: PlaybookRepositoryObservation,
|
|
1023
|
+
matchExpectedAfter = false,
|
|
1024
|
+
dispositions?: readonly string[],
|
|
1025
|
+
): PlaybookRepositoryReceipt {
|
|
1026
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1027
|
+
rejectUnknownKeys(
|
|
1028
|
+
value,
|
|
1029
|
+
['classification', 'baseline', 'after', 'commitOid'],
|
|
1030
|
+
path,
|
|
1031
|
+
);
|
|
1032
|
+
if (
|
|
1033
|
+
typeof value.classification !== 'string' ||
|
|
1034
|
+
!EFFECT_RECEIPT_CLASSIFICATIONS.has(value.classification)
|
|
1035
|
+
) {
|
|
1036
|
+
throw new TypeError(`${path}.classification is not supported`);
|
|
1037
|
+
}
|
|
1038
|
+
const classification = value.classification;
|
|
1039
|
+
const baseline = effectObservation(value.baseline, `${path}.baseline`);
|
|
1040
|
+
const after = own(value, 'after')
|
|
1041
|
+
? effectObservation(value.after, `${path}.after`)
|
|
1042
|
+
: undefined;
|
|
1043
|
+
assertObservationIdentity(after ?? baseline, baseline, `${path}.after`);
|
|
1044
|
+
if (
|
|
1045
|
+
expectedBaseline !== undefined &&
|
|
1046
|
+
!jsonValuesEqual(
|
|
1047
|
+
baseline as unknown as JsonValue,
|
|
1048
|
+
expectedBaseline as unknown as JsonValue,
|
|
1049
|
+
)
|
|
1050
|
+
) {
|
|
1051
|
+
throw new TypeError(`${path}.baseline does not match its boundary baseline`);
|
|
1052
|
+
}
|
|
1053
|
+
if (matchExpectedAfter) {
|
|
1054
|
+
if (
|
|
1055
|
+
expectedAfter !== undefined &&
|
|
1056
|
+
(after === undefined ||
|
|
1057
|
+
!jsonValuesEqual(
|
|
1058
|
+
after as unknown as JsonValue,
|
|
1059
|
+
expectedAfter as unknown as JsonValue,
|
|
1060
|
+
))
|
|
1061
|
+
) {
|
|
1062
|
+
throw new TypeError(`${path}.after does not match its expected observation`);
|
|
1063
|
+
}
|
|
1064
|
+
if (expectedAfter === undefined && after !== undefined) {
|
|
1065
|
+
throw new TypeError(`${path}.after has no matching expected observation`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (classification !== 'observation-ambiguous' && after === undefined) {
|
|
1069
|
+
throw new TypeError(`${path}.after is required for ${classification}`);
|
|
1070
|
+
}
|
|
1071
|
+
const commitOid = own(value, 'commitOid')
|
|
1072
|
+
? requireNonEmptyString(value.commitOid, `${path}.commitOid`)
|
|
1073
|
+
: undefined;
|
|
1074
|
+
if (classification === 'one-descendant-commit') {
|
|
1075
|
+
if (
|
|
1076
|
+
commitOid === undefined ||
|
|
1077
|
+
!EFFECT_OID_PATTERN.test(commitOid) ||
|
|
1078
|
+
after?.head !== commitOid
|
|
1079
|
+
) {
|
|
1080
|
+
throw new TypeError(
|
|
1081
|
+
`${path}.commitOid must equal the after HEAD for one-descendant-commit`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
} else if (commitOid !== undefined) {
|
|
1085
|
+
throw new TypeError(
|
|
1086
|
+
`${path}.commitOid is permitted only for one-descendant-commit`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
if (
|
|
1090
|
+
classification === 'unchanged' &&
|
|
1091
|
+
after !== undefined &&
|
|
1092
|
+
!jsonValuesEqual(
|
|
1093
|
+
baseline as unknown as JsonValue,
|
|
1094
|
+
after as unknown as JsonValue,
|
|
1095
|
+
)
|
|
1096
|
+
) {
|
|
1097
|
+
throw new TypeError(`${path} classified unchanged observations that differ`);
|
|
1098
|
+
}
|
|
1099
|
+
if (
|
|
1100
|
+
classification === 'one-descendant-commit' &&
|
|
1101
|
+
after !== undefined &&
|
|
1102
|
+
(baseline.head === after.head ||
|
|
1103
|
+
!projectionsEqual(baseline, after))
|
|
1104
|
+
) {
|
|
1105
|
+
throw new TypeError(
|
|
1106
|
+
`${path} one-descendant-commit must change HEAD and preserve the projection`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
if (
|
|
1110
|
+
classification === 'worktree-only-change' &&
|
|
1111
|
+
after !== undefined &&
|
|
1112
|
+
(baseline.head !== after.head ||
|
|
1113
|
+
projectionsEqual(baseline, after) ||
|
|
1114
|
+
!projectionPreservesBaseline(baseline, after))
|
|
1115
|
+
) {
|
|
1116
|
+
throw new TypeError(
|
|
1117
|
+
`${path} worktree-only-change must preserve HEAD and change the projection`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
if (
|
|
1121
|
+
(classification === 'multiple-commits' ||
|
|
1122
|
+
classification === 'rewritten-or-non-descendant') &&
|
|
1123
|
+
after?.head === baseline.head
|
|
1124
|
+
) {
|
|
1125
|
+
throw new TypeError(`${path} ${classification} must change HEAD`);
|
|
1126
|
+
}
|
|
1127
|
+
if (
|
|
1128
|
+
(classification === 'one-descendant-commit' ||
|
|
1129
|
+
classification === 'multiple-commits' ||
|
|
1130
|
+
classification === 'rewritten-or-non-descendant' ||
|
|
1131
|
+
classification === 'worktree-only-change') &&
|
|
1132
|
+
!dispositions?.includes('one-descendant-commit')
|
|
1133
|
+
) {
|
|
1134
|
+
throw new TypeError(
|
|
1135
|
+
`${path}.classification is incompatible with its boundary dispositions`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
if (
|
|
1139
|
+
classification === 'concurrent-or-foreign-change' &&
|
|
1140
|
+
(!dispositions?.every((value) => value === 'unchanged') ||
|
|
1141
|
+
(after !== undefined &&
|
|
1142
|
+
baseline.head === after.head &&
|
|
1143
|
+
projectionsEqual(baseline, after)))
|
|
1144
|
+
) {
|
|
1145
|
+
throw new TypeError(
|
|
1146
|
+
`${path}.classification is incompatible with its boundary dispositions`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (
|
|
1150
|
+
classification === 'observation-ambiguous' &&
|
|
1151
|
+
after !== undefined &&
|
|
1152
|
+
baseline.head === after.head &&
|
|
1153
|
+
projectionsEqual(baseline, after)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new TypeError(
|
|
1156
|
+
`${path} observation-ambiguous requires an absent or changed after observation`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return value as unknown as PlaybookRepositoryReceipt;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Authority for one governed delegated-player output field (DR-040 §1). */
|
|
1163
|
+
export type PlaybookSemanticFieldAuthority =
|
|
1164
|
+
| 'presentation'
|
|
1165
|
+
| 'semantic'
|
|
1166
|
+
| 'effect'
|
|
1167
|
+
| 'runtime';
|
|
1168
|
+
|
|
1169
|
+
/** One already-validated state-local governed outcome declaration. */
|
|
1170
|
+
export interface PlaybookSemanticOutcomeSpec {
|
|
1171
|
+
readonly fields: Readonly<Record<string, PlaybookSemanticFieldAuthority>>;
|
|
1172
|
+
readonly repositoryDisposition: PlaybookRepositoryDisposition;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Evidence available when one governed semantic candidate is reconciled. */
|
|
1176
|
+
export interface PlaybookSemanticEvidenceInput {
|
|
1177
|
+
readonly outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>;
|
|
1178
|
+
readonly semanticCandidate: unknown;
|
|
1179
|
+
readonly finalText?: unknown;
|
|
1180
|
+
readonly receipt?: unknown;
|
|
1181
|
+
readonly runtimeFields?: unknown;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** The exact detached actor output admitted to FSM delivery. */
|
|
1185
|
+
export type PlaybookReconciledSemanticOutput = Readonly<
|
|
1186
|
+
Record<string, string>
|
|
1187
|
+
> & {
|
|
1188
|
+
readonly guard: string;
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
/** Retained presentation and semantic evidence, kept separate from output. */
|
|
1192
|
+
export interface PlaybookRetainedSemanticEvidence {
|
|
1193
|
+
readonly finalText?: string;
|
|
1194
|
+
readonly semanticCandidate: Readonly<Record<string, string>> & {
|
|
1195
|
+
readonly guard: string;
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export type PlaybookSemanticReconciliationReason =
|
|
1200
|
+
| 'missing-presentation-evidence'
|
|
1201
|
+
| 'missing-repository-receipt'
|
|
1202
|
+
| 'invalid-repository-receipt'
|
|
1203
|
+
| 'repository-disposition-mismatch'
|
|
1204
|
+
| 'missing-effect-evidence'
|
|
1205
|
+
| 'missing-runtime-evidence'
|
|
1206
|
+
| 'inconsistent-runtime-evidence';
|
|
1207
|
+
|
|
1208
|
+
/** A fail-closed semantic/effect reconciliation decision. */
|
|
1209
|
+
export type PlaybookSemanticReconciliation =
|
|
1210
|
+
| {
|
|
1211
|
+
readonly status: 'resolved' | 'deferred';
|
|
1212
|
+
readonly output: PlaybookReconciledSemanticOutput;
|
|
1213
|
+
readonly evidence: PlaybookRetainedSemanticEvidence;
|
|
1214
|
+
}
|
|
1215
|
+
| {
|
|
1216
|
+
readonly status: 'unresolved';
|
|
1217
|
+
readonly reason: PlaybookSemanticReconciliationReason;
|
|
1218
|
+
readonly evidence: PlaybookRetainedSemanticEvidence;
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* A judge candidate is structurally invalid, rather than merely awaiting or
|
|
1223
|
+
* conflicting with effect evidence. Callers use this distinction to spend at
|
|
1224
|
+
* most one durable correction budget before asking another hidden judge.
|
|
1225
|
+
*/
|
|
1226
|
+
export class PlaybookSemanticCandidateStructureError extends TypeError {
|
|
1227
|
+
constructor(message: string) {
|
|
1228
|
+
super(`semantic candidate ${message}`);
|
|
1229
|
+
this.name = 'PlaybookSemanticCandidateStructureError';
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function semanticCandidateStructureError(message: string): never {
|
|
1234
|
+
throw new PlaybookSemanticCandidateStructureError(message);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function snapshotSemanticCandidate(
|
|
1238
|
+
value: unknown,
|
|
1239
|
+
outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>,
|
|
1240
|
+
): {
|
|
1241
|
+
readonly candidate: Readonly<Record<string, string>> & {
|
|
1242
|
+
readonly guard: string;
|
|
1243
|
+
};
|
|
1244
|
+
readonly outcome: PlaybookSemanticOutcomeSpec;
|
|
1245
|
+
} {
|
|
1246
|
+
let detached: JsonValue;
|
|
1247
|
+
try {
|
|
1248
|
+
detached = snapshotJsonValue(value, 'semantic candidate');
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
const detail = error instanceof Error ? `: ${error.message}` : '';
|
|
1251
|
+
semanticCandidateStructureError(`must be detached plain JSON${detail}`);
|
|
1252
|
+
}
|
|
1253
|
+
if (!isRecord(detached)) {
|
|
1254
|
+
semanticCandidateStructureError('must be an object');
|
|
1255
|
+
}
|
|
1256
|
+
const guard = detached.guard;
|
|
1257
|
+
if (typeof guard !== 'string' || guard.length === 0) {
|
|
1258
|
+
semanticCandidateStructureError('must contain a nonempty string guard');
|
|
1259
|
+
}
|
|
1260
|
+
if (!own(outcomes, guard)) {
|
|
1261
|
+
semanticCandidateStructureError(
|
|
1262
|
+
`guard ${JSON.stringify(guard)} is not declared`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const outcome = outcomes[guard]!;
|
|
1266
|
+
const semanticFields = Object.entries(outcome.fields)
|
|
1267
|
+
.filter(([, authority]) => authority === 'semantic')
|
|
1268
|
+
.map(([field]) => field);
|
|
1269
|
+
const expected = new Set(['guard', ...semanticFields]);
|
|
1270
|
+
const actual = Object.keys(detached);
|
|
1271
|
+
const missing = [...expected].filter((field) => !actual.includes(field));
|
|
1272
|
+
const extra = actual.filter((field) => !expected.has(field));
|
|
1273
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1274
|
+
semanticCandidateStructureError(
|
|
1275
|
+
'must contain exactly its guard and semantic-owned fields' +
|
|
1276
|
+
(missing.length === 0 ? '' : `; missing ${missing.join(', ')}`) +
|
|
1277
|
+
(extra.length === 0 ? '' : `; extra or wrongly owned ${extra.join(', ')}`),
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
for (const field of semanticFields) {
|
|
1281
|
+
if (typeof detached[field] !== 'string') {
|
|
1282
|
+
semanticCandidateStructureError(
|
|
1283
|
+
`field ${JSON.stringify(field)} must be a string`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
candidate: detached as Readonly<Record<string, string>> & {
|
|
1289
|
+
readonly guard: string;
|
|
1290
|
+
},
|
|
1291
|
+
outcome,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function retainedSemanticEvidence(
|
|
1296
|
+
candidate: Readonly<Record<string, string>> & { readonly guard: string },
|
|
1297
|
+
finalText: unknown,
|
|
1298
|
+
): PlaybookRetainedSemanticEvidence {
|
|
1299
|
+
return Object.freeze({
|
|
1300
|
+
semanticCandidate: candidate,
|
|
1301
|
+
...(typeof finalText === 'string' ? { finalText } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function unresolvedSemanticEvidence(
|
|
1306
|
+
reason: PlaybookSemanticReconciliationReason,
|
|
1307
|
+
evidence: PlaybookRetainedSemanticEvidence,
|
|
1308
|
+
): PlaybookSemanticReconciliation {
|
|
1309
|
+
return Object.freeze({ status: 'unresolved', reason, evidence });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Reconcile one exact semantic candidate with host-owned effect evidence.
|
|
1314
|
+
* Presentation prose remains opaque: it is retained verbatim and only its
|
|
1315
|
+
* trimmed value is copied into linker-declared presentation fields. No
|
|
1316
|
+
* repository fact is inferred from that prose or from the semantic candidate.
|
|
1317
|
+
*/
|
|
1318
|
+
export function reconcilePlaybookSemanticEvidence(
|
|
1319
|
+
input: PlaybookSemanticEvidenceInput,
|
|
1320
|
+
): PlaybookSemanticReconciliation {
|
|
1321
|
+
const { candidate, outcome } = snapshotSemanticCandidate(
|
|
1322
|
+
input.semanticCandidate,
|
|
1323
|
+
input.outcomes,
|
|
1324
|
+
);
|
|
1325
|
+
const evidence = retainedSemanticEvidence(candidate, input.finalText);
|
|
1326
|
+
const finalText =
|
|
1327
|
+
typeof input.finalText === 'string' ? input.finalText.trim() : undefined;
|
|
1328
|
+
if (finalText === undefined || finalText.length === 0) {
|
|
1329
|
+
return unresolvedSemanticEvidence('missing-presentation-evidence', evidence);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (input.receipt === undefined) {
|
|
1333
|
+
return unresolvedSemanticEvidence('missing-repository-receipt', evidence);
|
|
1334
|
+
}
|
|
1335
|
+
let receipt: PlaybookRepositoryReceipt;
|
|
1336
|
+
try {
|
|
1337
|
+
const detachedReceipt = snapshotJsonValue(
|
|
1338
|
+
input.receipt,
|
|
1339
|
+
'semantic reconciliation receipt',
|
|
1340
|
+
);
|
|
1341
|
+
receipt = effectReceipt(
|
|
1342
|
+
detachedReceipt,
|
|
1343
|
+
'semantic reconciliation receipt',
|
|
1344
|
+
undefined,
|
|
1345
|
+
undefined,
|
|
1346
|
+
false,
|
|
1347
|
+
Object.values(input.outcomes).map(
|
|
1348
|
+
({ repositoryDisposition }) => repositoryDisposition,
|
|
1349
|
+
),
|
|
1350
|
+
);
|
|
1351
|
+
} catch {
|
|
1352
|
+
return unresolvedSemanticEvidence('invalid-repository-receipt', evidence);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const disposition = outcome.repositoryDisposition;
|
|
1356
|
+
const dispositionMatches =
|
|
1357
|
+
(disposition === 'unchanged' && receipt.classification === 'unchanged') ||
|
|
1358
|
+
(disposition === 'one-descendant-commit' &&
|
|
1359
|
+
receipt.classification === 'one-descendant-commit') ||
|
|
1360
|
+
(disposition === 'deferred' &&
|
|
1361
|
+
candidate.guard === 'needsBossReply' &&
|
|
1362
|
+
Object.entries(input.outcomes).some(
|
|
1363
|
+
([guard, declared]) =>
|
|
1364
|
+
guard !== candidate.guard &&
|
|
1365
|
+
declared.repositoryDisposition === 'one-descendant-commit',
|
|
1366
|
+
) &&
|
|
1367
|
+
(receipt.classification === 'unchanged' ||
|
|
1368
|
+
receipt.classification === 'worktree-only-change') &&
|
|
1369
|
+
receipt.after !== undefined &&
|
|
1370
|
+
receipt.after.head === receipt.baseline.head);
|
|
1371
|
+
if (!dispositionMatches) {
|
|
1372
|
+
return unresolvedSemanticEvidence(
|
|
1373
|
+
'repository-disposition-mismatch',
|
|
1374
|
+
evidence,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
let runtimeFields: Readonly<Record<string, JsonValue>> = Object.freeze({});
|
|
1379
|
+
if (input.runtimeFields !== undefined) {
|
|
1380
|
+
try {
|
|
1381
|
+
const detached = snapshotJsonValue(
|
|
1382
|
+
input.runtimeFields,
|
|
1383
|
+
'semantic reconciliation runtime fields',
|
|
1384
|
+
);
|
|
1385
|
+
if (!isRecord(detached)) {
|
|
1386
|
+
return unresolvedSemanticEvidence(
|
|
1387
|
+
'inconsistent-runtime-evidence',
|
|
1388
|
+
evidence,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
runtimeFields = detached;
|
|
1392
|
+
} catch {
|
|
1393
|
+
return unresolvedSemanticEvidence(
|
|
1394
|
+
'inconsistent-runtime-evidence',
|
|
1395
|
+
evidence,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const declaredRuntimeFields = Object.entries(outcome.fields)
|
|
1400
|
+
.filter(([, authority]) => authority === 'runtime')
|
|
1401
|
+
.map(([field]) => field);
|
|
1402
|
+
if (
|
|
1403
|
+
Object.keys(runtimeFields).some(
|
|
1404
|
+
(field) => !declaredRuntimeFields.includes(field),
|
|
1405
|
+
)
|
|
1406
|
+
) {
|
|
1407
|
+
return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const output: Record<string, string> = {};
|
|
1411
|
+
defineEnumerableDataProperty(output, 'guard', candidate.guard);
|
|
1412
|
+
for (const [field, authority] of Object.entries(outcome.fields)) {
|
|
1413
|
+
let value: string | undefined;
|
|
1414
|
+
if (authority === 'semantic') {
|
|
1415
|
+
value = candidate[field];
|
|
1416
|
+
} else if (authority === 'presentation') {
|
|
1417
|
+
value = finalText;
|
|
1418
|
+
} else if (authority === 'effect') {
|
|
1419
|
+
value = field === 'latestCommit' ? receipt.commitOid : undefined;
|
|
1420
|
+
if (value === undefined) {
|
|
1421
|
+
return unresolvedSemanticEvidence('missing-effect-evidence', evidence);
|
|
1422
|
+
}
|
|
1423
|
+
} else {
|
|
1424
|
+
const runtimeValue = runtimeFields[field];
|
|
1425
|
+
if (runtimeValue === undefined) {
|
|
1426
|
+
return unresolvedSemanticEvidence('missing-runtime-evidence', evidence);
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof runtimeValue !== 'string') {
|
|
1429
|
+
return unresolvedSemanticEvidence(
|
|
1430
|
+
'inconsistent-runtime-evidence',
|
|
1431
|
+
evidence,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
value = runtimeValue;
|
|
1435
|
+
}
|
|
1436
|
+
defineEnumerableDataProperty(output, field, value);
|
|
1437
|
+
}
|
|
1438
|
+
const detachedOutput = snapshotJsonValue(
|
|
1439
|
+
output,
|
|
1440
|
+
'reconciled semantic output',
|
|
1441
|
+
) as PlaybookReconciledSemanticOutput;
|
|
1442
|
+
return Object.freeze({
|
|
1443
|
+
status: disposition === 'deferred' ? 'deferred' : 'resolved',
|
|
1444
|
+
output: detachedOutput,
|
|
1445
|
+
evidence,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function effectPendingQuestion(
|
|
1450
|
+
value: unknown,
|
|
1451
|
+
path: string,
|
|
1452
|
+
): PlaybookPendingBossQuestion {
|
|
1453
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1454
|
+
rejectUnknownKeys(value, ['questionId', 'asker', 'question', 'sourceItem'], path);
|
|
1455
|
+
const questionId = requireNonEmptyString(
|
|
1456
|
+
value.questionId,
|
|
1457
|
+
`${path}.questionId`,
|
|
1458
|
+
);
|
|
1459
|
+
const question = requireNonEmptyString(value.question, `${path}.question`);
|
|
1460
|
+
if (!isRecord(value.asker)) {
|
|
1461
|
+
throw new TypeError(`${path}.asker must be an object`);
|
|
1462
|
+
}
|
|
1463
|
+
let asker: PlaybookPendingBossQuestion['asker'];
|
|
1464
|
+
if (value.asker.kind === 'captain') {
|
|
1465
|
+
rejectUnknownKeys(value.asker, ['kind'], `${path}.asker`);
|
|
1466
|
+
asker = { kind: 'captain' };
|
|
1467
|
+
} else if (value.asker.kind === 'role') {
|
|
1468
|
+
rejectUnknownKeys(value.asker, ['kind', 'roleId'], `${path}.asker`);
|
|
1469
|
+
asker = {
|
|
1470
|
+
kind: 'role',
|
|
1471
|
+
roleId: requireNonEmptyString(value.asker.roleId, `${path}.asker.roleId`),
|
|
1472
|
+
};
|
|
1473
|
+
} else {
|
|
1474
|
+
throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
|
|
1475
|
+
}
|
|
1476
|
+
return Object.freeze({
|
|
1477
|
+
questionId,
|
|
1478
|
+
asker: Object.freeze(asker),
|
|
1479
|
+
question,
|
|
1480
|
+
...(own(value, 'sourceItem')
|
|
1481
|
+
? {
|
|
1482
|
+
sourceItem: requireNonEmptyString(
|
|
1483
|
+
value.sourceItem,
|
|
1484
|
+
`${path}.sourceItem`,
|
|
1485
|
+
),
|
|
1486
|
+
}
|
|
1487
|
+
: {}),
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function effectBoundary(value: unknown, index: number): PlaybookEffectBoundary {
|
|
1492
|
+
const path = `effect ledger boundaries[${index}]`;
|
|
1493
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1494
|
+
rejectUnknownKeys(
|
|
1495
|
+
value,
|
|
1496
|
+
[
|
|
1497
|
+
'sequence',
|
|
1498
|
+
'boundaryId',
|
|
1499
|
+
'attemptId',
|
|
1500
|
+
'attemptNumber',
|
|
1501
|
+
'playbookId',
|
|
1502
|
+
'runtimeSessionId',
|
|
1503
|
+
'turnId',
|
|
1504
|
+
'callId',
|
|
1505
|
+
'roleId',
|
|
1506
|
+
'sourceStateId',
|
|
1507
|
+
'sourceOutcomeSchema',
|
|
1508
|
+
'dispositions',
|
|
1509
|
+
'canonicalWorktree',
|
|
1510
|
+
'baseline',
|
|
1511
|
+
'after',
|
|
1512
|
+
'physicalReceipt',
|
|
1513
|
+
'finalText',
|
|
1514
|
+
'semanticCandidate',
|
|
1515
|
+
'initialSemanticCandidate',
|
|
1516
|
+
'correctionBudget',
|
|
1517
|
+
'cohortId',
|
|
1518
|
+
'logicalOperationId',
|
|
1519
|
+
],
|
|
1520
|
+
path,
|
|
1521
|
+
);
|
|
1522
|
+
const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
|
|
1523
|
+
if (sequence !== index + 1) {
|
|
1524
|
+
throw new TypeError('effect ledger boundary sequence must be contiguous from one');
|
|
1525
|
+
}
|
|
1526
|
+
effectUuid(value.boundaryId, `${path}.boundaryId`);
|
|
1527
|
+
effectUuid(value.attemptId, `${path}.attemptId`);
|
|
1528
|
+
effectInteger(value.attemptNumber, `${path}.attemptNumber`, 1);
|
|
1529
|
+
requireNonEmptyString(value.playbookId, `${path}.playbookId`);
|
|
1530
|
+
effectUuid(value.runtimeSessionId, `${path}.runtimeSessionId`);
|
|
1531
|
+
effectInteger(value.turnId, `${path}.turnId`, 1);
|
|
1532
|
+
requireNonEmptyString(value.callId, `${path}.callId`);
|
|
1533
|
+
requireNonEmptyString(value.roleId, `${path}.roleId`);
|
|
1534
|
+
requireNonEmptyString(value.sourceStateId, `${path}.sourceStateId`);
|
|
1535
|
+
if (!own(value, 'sourceOutcomeSchema')) {
|
|
1536
|
+
throw new TypeError(`${path}.sourceOutcomeSchema is required`);
|
|
1537
|
+
}
|
|
1538
|
+
if (!Array.isArray(value.dispositions) || value.dispositions.length === 0) {
|
|
1539
|
+
throw new TypeError(`${path}.dispositions must be a nonempty array`);
|
|
1540
|
+
}
|
|
1541
|
+
for (const [dispositionIndex, disposition] of value.dispositions.entries()) {
|
|
1542
|
+
if (typeof disposition !== 'string' || !EFFECT_DISPOSITIONS.has(disposition)) {
|
|
1543
|
+
throw new TypeError(
|
|
1544
|
+
`${path}.dispositions[${dispositionIndex}] is not supported`,
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (new Set(value.dispositions).size !== value.dispositions.length) {
|
|
1549
|
+
throw new TypeError(`${path}.dispositions must not contain duplicates`);
|
|
1550
|
+
}
|
|
1551
|
+
if (!isRecord(value.canonicalWorktree)) {
|
|
1552
|
+
throw new TypeError(`${path}.canonicalWorktree must be an object`);
|
|
1553
|
+
}
|
|
1554
|
+
rejectUnknownKeys(
|
|
1555
|
+
value.canonicalWorktree,
|
|
1556
|
+
['worktree', 'gitDir'],
|
|
1557
|
+
`${path}.canonicalWorktree`,
|
|
1558
|
+
);
|
|
1559
|
+
const canonicalWorktree = {
|
|
1560
|
+
worktree: requireNonEmptyString(
|
|
1561
|
+
value.canonicalWorktree.worktree,
|
|
1562
|
+
`${path}.canonicalWorktree.worktree`,
|
|
1563
|
+
),
|
|
1564
|
+
gitDir: requireNonEmptyString(
|
|
1565
|
+
value.canonicalWorktree.gitDir,
|
|
1566
|
+
`${path}.canonicalWorktree.gitDir`,
|
|
1567
|
+
),
|
|
1568
|
+
};
|
|
1569
|
+
const baseline = effectObservation(value.baseline, `${path}.baseline`);
|
|
1570
|
+
assertObservationIdentity(baseline, canonicalWorktree, `${path}.baseline`);
|
|
1571
|
+
const after = own(value, 'after')
|
|
1572
|
+
? effectObservation(value.after, `${path}.after`)
|
|
1573
|
+
: undefined;
|
|
1574
|
+
if (after !== undefined) {
|
|
1575
|
+
assertObservationIdentity(after, canonicalWorktree, `${path}.after`);
|
|
1576
|
+
}
|
|
1577
|
+
if (after !== undefined && !own(value, 'physicalReceipt')) {
|
|
1578
|
+
throw new TypeError(`${path}.after requires an atomic physicalReceipt`);
|
|
1579
|
+
}
|
|
1580
|
+
if (own(value, 'physicalReceipt')) {
|
|
1581
|
+
effectReceipt(
|
|
1582
|
+
value.physicalReceipt,
|
|
1583
|
+
`${path}.physicalReceipt`,
|
|
1584
|
+
baseline,
|
|
1585
|
+
after,
|
|
1586
|
+
true,
|
|
1587
|
+
value.dispositions as readonly string[],
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
if (own(value, 'finalText') && typeof value.finalText !== 'string') {
|
|
1591
|
+
throw new TypeError(`${path}.finalText must be a string`);
|
|
1592
|
+
}
|
|
1593
|
+
if (own(value, 'initialSemanticCandidate')) {
|
|
1594
|
+
if (
|
|
1595
|
+
!own(value, 'semanticCandidate') ||
|
|
1596
|
+
!isRecord(value.correctionBudget) ||
|
|
1597
|
+
value.correctionBudget.spent !== true ||
|
|
1598
|
+
jsonValuesEqual(
|
|
1599
|
+
value.initialSemanticCandidate as JsonValue,
|
|
1600
|
+
value.semanticCandidate as JsonValue,
|
|
1601
|
+
)
|
|
1602
|
+
) {
|
|
1603
|
+
throw new TypeError(
|
|
1604
|
+
`${path}.initialSemanticCandidate requires a spent budget and a distinct current semanticCandidate`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (own(value, 'cohortId')) {
|
|
1609
|
+
effectUuid(value.cohortId, `${path}.cohortId`);
|
|
1610
|
+
}
|
|
1611
|
+
if (!isRecord(value.correctionBudget)) {
|
|
1612
|
+
throw new TypeError(`${path}.correctionBudget must be an object`);
|
|
1613
|
+
}
|
|
1614
|
+
rejectUnknownKeys(
|
|
1615
|
+
value.correctionBudget,
|
|
1616
|
+
['limit', 'spent'],
|
|
1617
|
+
`${path}.correctionBudget`,
|
|
1618
|
+
);
|
|
1619
|
+
if (
|
|
1620
|
+
value.correctionBudget.limit !== 1 ||
|
|
1621
|
+
typeof value.correctionBudget.spent !== 'boolean'
|
|
1622
|
+
) {
|
|
1623
|
+
throw new TypeError(
|
|
1624
|
+
`${path}.correctionBudget must contain exactly limit 1 and a boolean spent`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
if (own(value, 'logicalOperationId')) {
|
|
1628
|
+
effectUuid(value.logicalOperationId, `${path}.logicalOperationId`);
|
|
1629
|
+
}
|
|
1630
|
+
return value as unknown as PlaybookEffectBoundary;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function effectLogicalOperation(
|
|
1634
|
+
value: unknown,
|
|
1635
|
+
index: number,
|
|
1636
|
+
boundaryById: ReadonlyMap<string, PlaybookEffectBoundary>,
|
|
1637
|
+
): PlaybookEffectLogicalOperation {
|
|
1638
|
+
const path = `effect ledger logicalOperations[${index}]`;
|
|
1639
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1640
|
+
rejectUnknownKeys(
|
|
1641
|
+
value,
|
|
1642
|
+
[
|
|
1643
|
+
'sequence',
|
|
1644
|
+
'operationId',
|
|
1645
|
+
'playbookId',
|
|
1646
|
+
'runtimeSessionId',
|
|
1647
|
+
'boundaryIds',
|
|
1648
|
+
'originalBaseline',
|
|
1649
|
+
'checkpoint',
|
|
1650
|
+
'pendingQuestion',
|
|
1651
|
+
'playerContinuation',
|
|
1652
|
+
'checkpointRestorationEligible',
|
|
1653
|
+
'logicalReceipt',
|
|
1654
|
+
],
|
|
1655
|
+
path,
|
|
1656
|
+
);
|
|
1657
|
+
const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
|
|
1658
|
+
if (sequence !== index + 1) {
|
|
1659
|
+
throw new TypeError(
|
|
1660
|
+
'effect ledger logical-operation sequence must be contiguous from one',
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
const operationId = effectUuid(value.operationId, `${path}.operationId`);
|
|
1664
|
+
const playbookId = requireNonEmptyString(value.playbookId, `${path}.playbookId`);
|
|
1665
|
+
const runtimeSessionId = effectUuid(
|
|
1666
|
+
value.runtimeSessionId,
|
|
1667
|
+
`${path}.runtimeSessionId`,
|
|
1668
|
+
);
|
|
1669
|
+
if (!Array.isArray(value.boundaryIds) || value.boundaryIds.length === 0) {
|
|
1670
|
+
throw new TypeError(`${path}.boundaryIds must be a nonempty array`);
|
|
1671
|
+
}
|
|
1672
|
+
const boundaries = value.boundaryIds.map((boundaryId, boundaryIndex) => {
|
|
1673
|
+
const id = effectUuid(boundaryId, `${path}.boundaryIds[${boundaryIndex}]`);
|
|
1674
|
+
const boundary = boundaryById.get(id);
|
|
1675
|
+
if (boundary === undefined) {
|
|
1676
|
+
throw new TypeError(`${path}.boundaryIds[${boundaryIndex}] is dangling`);
|
|
1677
|
+
}
|
|
1678
|
+
if (
|
|
1679
|
+
boundary.playbookId !== playbookId ||
|
|
1680
|
+
boundary.runtimeSessionId !== runtimeSessionId ||
|
|
1681
|
+
boundary.logicalOperationId !== operationId
|
|
1682
|
+
) {
|
|
1683
|
+
throw new TypeError(
|
|
1684
|
+
`${path}.boundaryIds[${boundaryIndex}] does not belong to this logical operation`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
return boundary;
|
|
1688
|
+
});
|
|
1689
|
+
if (new Set(value.boundaryIds).size !== value.boundaryIds.length) {
|
|
1690
|
+
throw new TypeError(`${path}.boundaryIds must not contain duplicates`);
|
|
1691
|
+
}
|
|
1692
|
+
if (
|
|
1693
|
+
boundaries.some(
|
|
1694
|
+
(boundary, boundaryIndex) =>
|
|
1695
|
+
boundaryIndex > 0 &&
|
|
1696
|
+
boundaries[boundaryIndex - 1]!.sequence >= boundary.sequence,
|
|
1697
|
+
)
|
|
1698
|
+
) {
|
|
1699
|
+
throw new TypeError(`${path}.boundaryIds must follow physical boundary order`);
|
|
1700
|
+
}
|
|
1701
|
+
const originalBaseline = effectObservation(
|
|
1702
|
+
value.originalBaseline,
|
|
1703
|
+
`${path}.originalBaseline`,
|
|
1704
|
+
);
|
|
1705
|
+
if (
|
|
1706
|
+
!jsonValuesEqual(
|
|
1707
|
+
originalBaseline as unknown as JsonValue,
|
|
1708
|
+
boundaries[0]!.baseline as unknown as JsonValue,
|
|
1709
|
+
)
|
|
1710
|
+
) {
|
|
1711
|
+
throw new TypeError(`${path}.originalBaseline must equal its first boundary baseline`);
|
|
1712
|
+
}
|
|
1713
|
+
for (const [boundaryIndex, boundary] of boundaries.entries()) {
|
|
1714
|
+
assertObservationIdentity(
|
|
1715
|
+
boundary.baseline,
|
|
1716
|
+
originalBaseline,
|
|
1717
|
+
`${path}.boundaryIds[${boundaryIndex}] baseline`,
|
|
1718
|
+
);
|
|
1719
|
+
if (boundaryIndex === 0) continue;
|
|
1720
|
+
const previous = boundaries[boundaryIndex - 1]!;
|
|
1721
|
+
if (
|
|
1722
|
+
previous.physicalReceipt === undefined ||
|
|
1723
|
+
previous.after === undefined ||
|
|
1724
|
+
!jsonValuesEqual(
|
|
1725
|
+
boundary.baseline as unknown as JsonValue,
|
|
1726
|
+
previous.after as unknown as JsonValue,
|
|
1727
|
+
)
|
|
1728
|
+
) {
|
|
1729
|
+
throw new TypeError(
|
|
1730
|
+
`${path}.boundaryIds must form one completed checkpoint chain`,
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const checkpoint = own(value, 'checkpoint')
|
|
1735
|
+
? effectObservation(value.checkpoint, `${path}.checkpoint`)
|
|
1736
|
+
: undefined;
|
|
1737
|
+
const latestAfter = boundaries.at(-1)!.after;
|
|
1738
|
+
if (checkpoint !== undefined) {
|
|
1739
|
+
assertObservationIdentity(
|
|
1740
|
+
checkpoint,
|
|
1741
|
+
originalBaseline,
|
|
1742
|
+
`${path}.checkpoint`,
|
|
1743
|
+
);
|
|
1744
|
+
if (
|
|
1745
|
+
latestAfter === undefined ||
|
|
1746
|
+
!jsonValuesEqual(
|
|
1747
|
+
checkpoint as unknown as JsonValue,
|
|
1748
|
+
latestAfter as unknown as JsonValue,
|
|
1749
|
+
)
|
|
1750
|
+
) {
|
|
1751
|
+
throw new TypeError(`${path}.checkpoint must equal its latest boundary after`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const pendingQuestion = own(value, 'pendingQuestion')
|
|
1755
|
+
? effectPendingQuestion(value.pendingQuestion, `${path}.pendingQuestion`)
|
|
1756
|
+
: undefined;
|
|
1757
|
+
const bindingCount = [
|
|
1758
|
+
own(value, 'checkpoint'),
|
|
1759
|
+
own(value, 'pendingQuestion'),
|
|
1760
|
+
own(value, 'playerContinuation'),
|
|
1761
|
+
].filter(Boolean).length;
|
|
1762
|
+
if (bindingCount !== 0 && bindingCount !== 3) {
|
|
1763
|
+
throw new TypeError(
|
|
1764
|
+
`${path} checkpoint, pendingQuestion, and playerContinuation must be all present or all absent`,
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
if (typeof value.checkpointRestorationEligible !== 'boolean') {
|
|
1768
|
+
throw new TypeError(`${path}.checkpointRestorationEligible must be boolean`);
|
|
1769
|
+
}
|
|
1770
|
+
if (
|
|
1771
|
+
value.checkpointRestorationEligible &&
|
|
1772
|
+
(checkpoint === undefined ||
|
|
1773
|
+
pendingQuestion === undefined ||
|
|
1774
|
+
!own(value, 'playerContinuation'))
|
|
1775
|
+
) {
|
|
1776
|
+
throw new TypeError(
|
|
1777
|
+
`${path}.checkpointRestorationEligible requires checkpoint, pendingQuestion, and playerContinuation`,
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
if (own(value, 'logicalReceipt')) {
|
|
1781
|
+
if (boundaries.some((boundary) => boundary.physicalReceipt === undefined)) {
|
|
1782
|
+
throw new TypeError(
|
|
1783
|
+
`${path}.logicalReceipt requires every physical boundary receipt`,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
effectReceipt(
|
|
1787
|
+
value.logicalReceipt,
|
|
1788
|
+
`${path}.logicalReceipt`,
|
|
1789
|
+
originalBaseline,
|
|
1790
|
+
latestAfter,
|
|
1791
|
+
true,
|
|
1792
|
+
boundaries.at(-1)!.dispositions,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
return value as unknown as PlaybookEffectLogicalOperation;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** Return the canonical empty host-owned effect-ledger mirror. */
|
|
1799
|
+
export function emptyPlaybookEffectLedger(): PlaybookEffectLedger {
|
|
1800
|
+
return Object.freeze({
|
|
1801
|
+
schemaVersion: 1,
|
|
1802
|
+
revision: 0,
|
|
1803
|
+
boundaries: Object.freeze([]),
|
|
1804
|
+
logicalOperations: Object.freeze([]),
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** Validate, detach, and recursively freeze one effect-ledger mirror. */
|
|
1809
|
+
export function assertPlaybookEffectLedger(
|
|
1810
|
+
value: unknown,
|
|
1811
|
+
path = 'effect ledger',
|
|
1812
|
+
): PlaybookEffectLedger {
|
|
1813
|
+
const detached = snapshotJsonValue(value, path);
|
|
1814
|
+
if (!isRecord(detached)) throw new TypeError(`${path} must be an object`);
|
|
1815
|
+
rejectUnknownKeys(
|
|
1816
|
+
detached,
|
|
1817
|
+
['schemaVersion', 'revision', 'boundaries', 'logicalOperations'],
|
|
1818
|
+
path,
|
|
1819
|
+
);
|
|
1820
|
+
if (detached.schemaVersion !== 1) {
|
|
1821
|
+
throw new TypeError(`${path}.schemaVersion must equal 1`);
|
|
1822
|
+
}
|
|
1823
|
+
const revision = effectInteger(detached.revision, `${path}.revision`);
|
|
1824
|
+
if (!Array.isArray(detached.boundaries)) {
|
|
1825
|
+
throw new TypeError(`${path}.boundaries must be an array`);
|
|
1826
|
+
}
|
|
1827
|
+
if (!Array.isArray(detached.logicalOperations)) {
|
|
1828
|
+
throw new TypeError(`${path}.logicalOperations must be an array`);
|
|
1829
|
+
}
|
|
1830
|
+
const isEmpty =
|
|
1831
|
+
detached.boundaries.length === 0 && detached.logicalOperations.length === 0;
|
|
1832
|
+
if ((revision === 0) !== isEmpty) {
|
|
1833
|
+
throw new TypeError(
|
|
1834
|
+
`${path}.revision must be zero if and only if both ordered ledgers are empty`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
const boundaries = detached.boundaries.map(effectBoundary);
|
|
1838
|
+
const boundaryById = new Map(
|
|
1839
|
+
boundaries.map((boundary) => [boundary.boundaryId, boundary] as const),
|
|
1840
|
+
);
|
|
1841
|
+
if (boundaryById.size !== boundaries.length) {
|
|
1842
|
+
throw new TypeError(`${path}.boundaries must not reuse a boundaryId`);
|
|
1843
|
+
}
|
|
1844
|
+
const cohorts = new Map<string, PlaybookEffectBoundary[]>();
|
|
1845
|
+
for (const boundary of boundaries) {
|
|
1846
|
+
if (boundary.cohortId === undefined) continue;
|
|
1847
|
+
const members = cohorts.get(boundary.cohortId) ?? [];
|
|
1848
|
+
members.push(boundary);
|
|
1849
|
+
cohorts.set(boundary.cohortId, members);
|
|
1850
|
+
}
|
|
1851
|
+
for (const [cohortId, members] of cohorts) {
|
|
1852
|
+
const first = members[0]!;
|
|
1853
|
+
const commonKeys = [
|
|
1854
|
+
'attemptId',
|
|
1855
|
+
'attemptNumber',
|
|
1856
|
+
'playbookId',
|
|
1857
|
+
'runtimeSessionId',
|
|
1858
|
+
'turnId',
|
|
1859
|
+
'canonicalWorktree',
|
|
1860
|
+
'baseline',
|
|
1861
|
+
] as const;
|
|
1862
|
+
if (
|
|
1863
|
+
members.length < 2 ||
|
|
1864
|
+
members.some(
|
|
1865
|
+
(boundary, index) =>
|
|
1866
|
+
boundary.sequence !== first.sequence + index ||
|
|
1867
|
+
!boundary.dispositions.every(
|
|
1868
|
+
(disposition) => disposition === 'unchanged',
|
|
1869
|
+
) ||
|
|
1870
|
+
!commonKeys.every((key) =>
|
|
1871
|
+
jsonValuesEqual(
|
|
1872
|
+
boundary[key] as JsonValue,
|
|
1873
|
+
first[key] as JsonValue,
|
|
1874
|
+
),
|
|
1875
|
+
),
|
|
1876
|
+
) ||
|
|
1877
|
+
new Set(members.map((boundary) => boundary.roleId)).size !==
|
|
1878
|
+
members.length ||
|
|
1879
|
+
new Set(
|
|
1880
|
+
members.map((boundary) =>
|
|
1881
|
+
boundary.physicalReceipt === undefined ? 'started' : 'complete',
|
|
1882
|
+
),
|
|
1883
|
+
).size !== 1 ||
|
|
1884
|
+
(first.physicalReceipt !== undefined &&
|
|
1885
|
+
members.some(
|
|
1886
|
+
(boundary) =>
|
|
1887
|
+
!jsonValuesEqual(
|
|
1888
|
+
boundary.physicalReceipt as unknown as JsonValue,
|
|
1889
|
+
first.physicalReceipt as unknown as JsonValue,
|
|
1890
|
+
) ||
|
|
1891
|
+
!jsonValuesEqual(
|
|
1892
|
+
boundary.after as unknown as JsonValue,
|
|
1893
|
+
first.after as unknown as JsonValue,
|
|
1894
|
+
),
|
|
1895
|
+
))
|
|
1896
|
+
) {
|
|
1897
|
+
throw new TypeError(
|
|
1898
|
+
`effect ledger cohort ${JSON.stringify(cohortId)} is not one contiguous all-unchanged boundary group`,
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const logicalOperations = detached.logicalOperations.map((operation, index) =>
|
|
1903
|
+
effectLogicalOperation(operation, index, boundaryById),
|
|
1904
|
+
);
|
|
1905
|
+
const operationById = new Map(
|
|
1906
|
+
logicalOperations.map(
|
|
1907
|
+
(operation) => [operation.operationId, operation] as const,
|
|
1908
|
+
),
|
|
1909
|
+
);
|
|
1910
|
+
if (operationById.size !== logicalOperations.length) {
|
|
1911
|
+
throw new TypeError(`${path}.logicalOperations must not reuse an operationId`);
|
|
1912
|
+
}
|
|
1913
|
+
for (const [index, operation] of logicalOperations.entries()) {
|
|
1914
|
+
const firstBoundary = boundaryById.get(operation.boundaryIds[0]!)!;
|
|
1915
|
+
if (
|
|
1916
|
+
index > 0 &&
|
|
1917
|
+
boundaryById.get(logicalOperations[index - 1]!.boundaryIds[0]!)!
|
|
1918
|
+
.sequence >= firstBoundary.sequence
|
|
1919
|
+
) {
|
|
1920
|
+
throw new TypeError(
|
|
1921
|
+
`${path}.logicalOperations must follow their first physical boundary order`,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
for (const boundary of boundaries) {
|
|
1926
|
+
if (
|
|
1927
|
+
boundary.logicalOperationId !== undefined &&
|
|
1928
|
+
!operationById.has(boundary.logicalOperationId)
|
|
1929
|
+
) {
|
|
1930
|
+
throw new TypeError(
|
|
1931
|
+
`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId is dangling`,
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
if (
|
|
1935
|
+
boundary.logicalOperationId !== undefined &&
|
|
1936
|
+
!operationById
|
|
1937
|
+
.get(boundary.logicalOperationId)!
|
|
1938
|
+
.boundaryIds.includes(boundary.boundaryId)
|
|
1939
|
+
) {
|
|
1940
|
+
throw new TypeError(
|
|
1941
|
+
`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId has no reciprocal operation reference`,
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return detached as unknown as PlaybookEffectLedger;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function optionalEvidenceExtends(
|
|
1949
|
+
baseline: Record<string, unknown>,
|
|
1950
|
+
current: Record<string, unknown>,
|
|
1951
|
+
keys: readonly string[],
|
|
1952
|
+
): boolean {
|
|
1953
|
+
return keys.every(
|
|
1954
|
+
(key) =>
|
|
1955
|
+
!own(baseline, key) ||
|
|
1956
|
+
(own(current, key) &&
|
|
1957
|
+
jsonValuesEqual(
|
|
1958
|
+
baseline[key] as JsonValue,
|
|
1959
|
+
current[key] as JsonValue,
|
|
1960
|
+
)),
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/** Whether current preserves every durable fact in baseline and only extends it. */
|
|
1965
|
+
export function isPlaybookEffectLedgerMonotonicExtension(
|
|
1966
|
+
baselineValue: unknown,
|
|
1967
|
+
currentValue: unknown,
|
|
1968
|
+
): boolean {
|
|
1969
|
+
let baseline: PlaybookEffectLedger;
|
|
1970
|
+
let current: PlaybookEffectLedger;
|
|
1971
|
+
try {
|
|
1972
|
+
baseline = assertPlaybookEffectLedger(baselineValue, 'baseline effect ledger');
|
|
1973
|
+
current = assertPlaybookEffectLedger(currentValue, 'current effect ledger');
|
|
1974
|
+
} catch {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
if (
|
|
1978
|
+
current.revision < baseline.revision ||
|
|
1979
|
+
current.boundaries.length < baseline.boundaries.length ||
|
|
1980
|
+
current.logicalOperations.length < baseline.logicalOperations.length
|
|
1981
|
+
) {
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
if (
|
|
1985
|
+
current.revision === baseline.revision &&
|
|
1986
|
+
!jsonValuesEqual(
|
|
1987
|
+
baseline as unknown as JsonValue,
|
|
1988
|
+
current as unknown as JsonValue,
|
|
1989
|
+
)
|
|
1990
|
+
) {
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
const boundaryStableKeys = [
|
|
1994
|
+
'sequence',
|
|
1995
|
+
'boundaryId',
|
|
1996
|
+
'attemptId',
|
|
1997
|
+
'attemptNumber',
|
|
1998
|
+
'playbookId',
|
|
1999
|
+
'runtimeSessionId',
|
|
2000
|
+
'turnId',
|
|
2001
|
+
'callId',
|
|
2002
|
+
'roleId',
|
|
2003
|
+
'sourceStateId',
|
|
2004
|
+
'sourceOutcomeSchema',
|
|
2005
|
+
'dispositions',
|
|
2006
|
+
'canonicalWorktree',
|
|
2007
|
+
'baseline',
|
|
2008
|
+
'cohortId',
|
|
2009
|
+
] as const;
|
|
2010
|
+
const boundaryEvidenceKeys = [
|
|
2011
|
+
'after',
|
|
2012
|
+
'physicalReceipt',
|
|
2013
|
+
'finalText',
|
|
2014
|
+
'initialSemanticCandidate',
|
|
2015
|
+
] as const;
|
|
2016
|
+
for (const [index, prior] of baseline.boundaries.entries()) {
|
|
2017
|
+
const next = current.boundaries[index]!;
|
|
2018
|
+
if (
|
|
2019
|
+
!boundaryStableKeys.every((key) =>
|
|
2020
|
+
jsonValuesEqual(
|
|
2021
|
+
prior[key] as JsonValue,
|
|
2022
|
+
next[key] as JsonValue,
|
|
2023
|
+
),
|
|
2024
|
+
) ||
|
|
2025
|
+
!optionalEvidenceExtends(
|
|
2026
|
+
prior as unknown as Record<string, unknown>,
|
|
2027
|
+
next as unknown as Record<string, unknown>,
|
|
2028
|
+
boundaryEvidenceKeys,
|
|
2029
|
+
) ||
|
|
2030
|
+
!semanticCandidateExtends(prior, next) ||
|
|
2031
|
+
(prior.logicalOperationId !== undefined &&
|
|
2032
|
+
prior.logicalOperationId !== next.logicalOperationId) ||
|
|
2033
|
+
(prior.correctionBudget.spent && !next.correctionBudget.spent)
|
|
2034
|
+
) {
|
|
2035
|
+
return false;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const operationStableKeys = [
|
|
2039
|
+
'sequence',
|
|
2040
|
+
'operationId',
|
|
2041
|
+
'playbookId',
|
|
2042
|
+
'runtimeSessionId',
|
|
2043
|
+
'originalBaseline',
|
|
2044
|
+
] as const;
|
|
2045
|
+
// The current deferred binding is replaceable across authored repeated
|
|
2046
|
+
// questions; only a completed logical receipt becomes immutable evidence.
|
|
2047
|
+
const operationEvidenceKeys = ['logicalReceipt'] as const;
|
|
2048
|
+
for (const [index, prior] of baseline.logicalOperations.entries()) {
|
|
2049
|
+
const next = current.logicalOperations[index]!;
|
|
2050
|
+
if (
|
|
2051
|
+
!operationStableKeys.every((key) =>
|
|
2052
|
+
jsonValuesEqual(
|
|
2053
|
+
prior[key] as JsonValue,
|
|
2054
|
+
next[key] as JsonValue,
|
|
2055
|
+
),
|
|
2056
|
+
) ||
|
|
2057
|
+
next.boundaryIds.length < prior.boundaryIds.length ||
|
|
2058
|
+
!prior.boundaryIds.every(
|
|
2059
|
+
(boundaryId, boundaryIndex) =>
|
|
2060
|
+
boundaryId === next.boundaryIds[boundaryIndex],
|
|
2061
|
+
) ||
|
|
2062
|
+
!optionalEvidenceExtends(
|
|
2063
|
+
prior as unknown as Record<string, unknown>,
|
|
2064
|
+
next as unknown as Record<string, unknown>,
|
|
2065
|
+
operationEvidenceKeys,
|
|
2066
|
+
)
|
|
2067
|
+
) {
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return true;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function semanticCandidateExtends(
|
|
2075
|
+
prior: PlaybookEffectBoundary,
|
|
2076
|
+
next: PlaybookEffectBoundary,
|
|
2077
|
+
): boolean {
|
|
2078
|
+
if (prior.semanticCandidate === undefined) {
|
|
2079
|
+
return (
|
|
2080
|
+
!prior.correctionBudget.spent ||
|
|
2081
|
+
next.initialSemanticCandidate === undefined
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
if (
|
|
2085
|
+
next.semanticCandidate !== undefined &&
|
|
2086
|
+
jsonValuesEqual(prior.semanticCandidate, next.semanticCandidate)
|
|
2087
|
+
) {
|
|
2088
|
+
return (
|
|
2089
|
+
prior.initialSemanticCandidate !== undefined ||
|
|
2090
|
+
next.initialSemanticCandidate === undefined
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
return (
|
|
2094
|
+
prior.initialSemanticCandidate === undefined &&
|
|
2095
|
+
next.correctionBudget.spent &&
|
|
2096
|
+
next.initialSemanticCandidate !== undefined &&
|
|
2097
|
+
jsonValuesEqual(
|
|
2098
|
+
prior.semanticCandidate,
|
|
2099
|
+
next.initialSemanticCandidate,
|
|
2100
|
+
)
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
872
2104
|
export interface PlaybookRuntimeSnapshotValidationOptions {
|
|
873
2105
|
/**
|
|
874
2106
|
* Opt in only when the restore path will prepare and confirm the suspended
|
|
@@ -888,7 +2120,15 @@ function snapshotSuspendedCall(
|
|
|
888
2120
|
}
|
|
889
2121
|
rejectUnknownKeys(
|
|
890
2122
|
captured,
|
|
891
|
-
[
|
|
2123
|
+
[
|
|
2124
|
+
'callId',
|
|
2125
|
+
'stateId',
|
|
2126
|
+
'playbookId',
|
|
2127
|
+
'text',
|
|
2128
|
+
'childSessionId',
|
|
2129
|
+
'turnId',
|
|
2130
|
+
'effectBoundaryPrefixSequence',
|
|
2131
|
+
],
|
|
892
2132
|
path,
|
|
893
2133
|
);
|
|
894
2134
|
const call: PlaybookSuspendedCall = {
|
|
@@ -913,13 +2153,22 @@ function snapshotSuspendedCall(
|
|
|
913
2153
|
}
|
|
914
2154
|
call.turnId = captured.turnId as number;
|
|
915
2155
|
}
|
|
2156
|
+
if (own(captured, 'effectBoundaryPrefixSequence')) {
|
|
2157
|
+
call.effectBoundaryPrefixSequence =
|
|
2158
|
+
captured.effectBoundaryPrefixSequence === null
|
|
2159
|
+
? null
|
|
2160
|
+
: effectInteger(
|
|
2161
|
+
captured.effectBoundaryPrefixSequence,
|
|
2162
|
+
`${path}.effectBoundaryPrefixSequence`,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
916
2165
|
return Object.freeze(call);
|
|
917
2166
|
}
|
|
918
2167
|
|
|
919
|
-
// DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
|
|
920
|
-
// schema-
|
|
2168
|
+
// DR-014 §1 / DR-031 §5 / DR-032 / DR-040: validate and detach a host-supplied
|
|
2169
|
+
// schema-4 runtime snapshot before restore touches any state. A suspended
|
|
921
2170
|
// call is rejected unless the restore path explicitly promises to seed and
|
|
922
|
-
// claim it; older schemas are rejected
|
|
2171
|
+
// claim it; older schemas are rejected by this public restore boundary.
|
|
923
2172
|
export function assertPlaybookRuntimeSnapshot(
|
|
924
2173
|
value: unknown,
|
|
925
2174
|
expectedPlaybookId: string,
|
|
@@ -950,9 +2199,9 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
950
2199
|
);
|
|
951
2200
|
}
|
|
952
2201
|
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
953
|
-
if (snapshot.schemaVersion !==
|
|
2202
|
+
if (snapshot.schemaVersion !== 4) {
|
|
954
2203
|
throw new TypeError(
|
|
955
|
-
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
2204
|
+
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`,
|
|
956
2205
|
);
|
|
957
2206
|
}
|
|
958
2207
|
rejectUnknownKeys(
|
|
@@ -965,6 +2214,10 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
965
2214
|
'sequences',
|
|
966
2215
|
'state',
|
|
967
2216
|
'pendingBossQuestions',
|
|
2217
|
+
'effectLedger',
|
|
2218
|
+
'retainedEffectSourceSessionId',
|
|
2219
|
+
'retainedEffectReconciliation',
|
|
2220
|
+
'failedEffectAttempt',
|
|
968
2221
|
'suspendedCall',
|
|
969
2222
|
],
|
|
970
2223
|
'runtime snapshot',
|
|
@@ -1125,6 +2378,133 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
1125
2378
|
return Object.freeze(question);
|
|
1126
2379
|
},
|
|
1127
2380
|
);
|
|
2381
|
+
const effectLedger = assertPlaybookEffectLedger(
|
|
2382
|
+
snapshot.effectLedger,
|
|
2383
|
+
'runtime snapshot effectLedger',
|
|
2384
|
+
);
|
|
2385
|
+
const retainedEffectSourceSessionId = own(
|
|
2386
|
+
snapshot,
|
|
2387
|
+
'retainedEffectSourceSessionId',
|
|
2388
|
+
)
|
|
2389
|
+
? effectUuid(
|
|
2390
|
+
snapshot.retainedEffectSourceSessionId,
|
|
2391
|
+
'runtime snapshot retainedEffectSourceSessionId',
|
|
2392
|
+
)
|
|
2393
|
+
: undefined;
|
|
2394
|
+
let retainedEffectReconciliation:
|
|
2395
|
+
| PlaybookRuntimeSnapshot['retainedEffectReconciliation']
|
|
2396
|
+
| undefined;
|
|
2397
|
+
if (own(snapshot, 'retainedEffectReconciliation')) {
|
|
2398
|
+
if (!isRecord(snapshot.retainedEffectReconciliation)) {
|
|
2399
|
+
throw new TypeError(
|
|
2400
|
+
'runtime snapshot retainedEffectReconciliation must be an object',
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
rejectUnknownKeys(
|
|
2404
|
+
snapshot.retainedEffectReconciliation,
|
|
2405
|
+
['sourceSessionId', 'checkpoint'],
|
|
2406
|
+
'runtime snapshot retainedEffectReconciliation',
|
|
2407
|
+
);
|
|
2408
|
+
const sourceSessionId = effectUuid(
|
|
2409
|
+
snapshot.retainedEffectReconciliation.sourceSessionId,
|
|
2410
|
+
'runtime snapshot retainedEffectReconciliation.sourceSessionId',
|
|
2411
|
+
);
|
|
2412
|
+
const checkpoint = assertPlaybookEffectLedger(
|
|
2413
|
+
snapshot.retainedEffectReconciliation.checkpoint,
|
|
2414
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint',
|
|
2415
|
+
);
|
|
2416
|
+
if (
|
|
2417
|
+
checkpoint.boundaries.some(
|
|
2418
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
2419
|
+
)
|
|
2420
|
+
) {
|
|
2421
|
+
throw new TypeError(
|
|
2422
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint contains an incomplete physical boundary',
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)) {
|
|
2426
|
+
throw new TypeError(
|
|
2427
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint is not a monotonic prefix of effectLedger',
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
if (
|
|
2431
|
+
retainedEffectSourceSessionId === undefined ||
|
|
2432
|
+
retainedEffectSourceSessionId !== sourceSessionId
|
|
2433
|
+
) {
|
|
2434
|
+
throw new TypeError(
|
|
2435
|
+
'runtime snapshot retainedEffectReconciliation.sourceSessionId must equal retainedEffectSourceSessionId',
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
retainedEffectReconciliation = Object.freeze({
|
|
2439
|
+
sourceSessionId,
|
|
2440
|
+
checkpoint,
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
if (
|
|
2444
|
+
typeof suspendedCall?.effectBoundaryPrefixSequence === 'number' &&
|
|
2445
|
+
suspendedCall.effectBoundaryPrefixSequence >
|
|
2446
|
+
(effectLedger.boundaries.at(-1)?.sequence ?? 0)
|
|
2447
|
+
) {
|
|
2448
|
+
throw new TypeError(
|
|
2449
|
+
'runtime snapshot suspendedCall.effectBoundaryPrefixSequence exceeds the effect ledger',
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
let failedEffectAttempt: PlaybookRuntimeSnapshot['failedEffectAttempt'];
|
|
2453
|
+
if (own(snapshot, 'failedEffectAttempt')) {
|
|
2454
|
+
if (state.stateId !== 'failed') {
|
|
2455
|
+
throw new TypeError(
|
|
2456
|
+
'runtime snapshot failedEffectAttempt requires the failed state',
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
if (!isRecord(snapshot.failedEffectAttempt)) {
|
|
2460
|
+
throw new TypeError(
|
|
2461
|
+
'runtime snapshot failedEffectAttempt must be an object',
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
rejectUnknownKeys(
|
|
2465
|
+
snapshot.failedEffectAttempt,
|
|
2466
|
+
['boundaryPrefix', 'attemptId'],
|
|
2467
|
+
'runtime snapshot failedEffectAttempt',
|
|
2468
|
+
);
|
|
2469
|
+
const boundaryPrefix = effectInteger(
|
|
2470
|
+
snapshot.failedEffectAttempt.boundaryPrefix,
|
|
2471
|
+
'runtime snapshot failedEffectAttempt.boundaryPrefix',
|
|
2472
|
+
);
|
|
2473
|
+
const lastBoundarySequence = effectLedger.boundaries.at(-1)?.sequence ?? 0;
|
|
2474
|
+
if (boundaryPrefix > lastBoundarySequence) {
|
|
2475
|
+
throw new TypeError(
|
|
2476
|
+
'runtime snapshot failedEffectAttempt.boundaryPrefix exceeds the effect ledger',
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
const attemptId =
|
|
2480
|
+
snapshot.failedEffectAttempt.attemptId === null
|
|
2481
|
+
? null
|
|
2482
|
+
: effectUuid(
|
|
2483
|
+
snapshot.failedEffectAttempt.attemptId,
|
|
2484
|
+
'runtime snapshot failedEffectAttempt.attemptId',
|
|
2485
|
+
);
|
|
2486
|
+
const causalBoundaries = effectLedger.boundaries.filter(
|
|
2487
|
+
({ sequence }) => sequence > boundaryPrefix,
|
|
2488
|
+
);
|
|
2489
|
+
if (attemptId === null && causalBoundaries.length !== 0) {
|
|
2490
|
+
throw new TypeError(
|
|
2491
|
+
'runtime snapshot failedEffectAttempt null attemptId requires an empty causal suffix',
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
if (
|
|
2495
|
+
attemptId !== null &&
|
|
2496
|
+
(causalBoundaries.length === 0 ||
|
|
2497
|
+
causalBoundaries.some(
|
|
2498
|
+
({ attemptId: boundaryAttemptId }) =>
|
|
2499
|
+
boundaryAttemptId !== attemptId,
|
|
2500
|
+
))
|
|
2501
|
+
) {
|
|
2502
|
+
throw new TypeError(
|
|
2503
|
+
'runtime snapshot failedEffectAttempt does not match its causal ledger suffix',
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
failedEffectAttempt = Object.freeze({ boundaryPrefix, attemptId });
|
|
2507
|
+
}
|
|
1128
2508
|
const fields = {
|
|
1129
2509
|
playbookId,
|
|
1130
2510
|
machine,
|
|
@@ -1132,9 +2512,19 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
1132
2512
|
sequences: Object.freeze(sequences),
|
|
1133
2513
|
state,
|
|
1134
2514
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
2515
|
+
effectLedger,
|
|
2516
|
+
...(retainedEffectSourceSessionId === undefined
|
|
2517
|
+
? {}
|
|
2518
|
+
: { retainedEffectSourceSessionId }),
|
|
2519
|
+
...(retainedEffectReconciliation === undefined
|
|
2520
|
+
? {}
|
|
2521
|
+
: { retainedEffectReconciliation }),
|
|
2522
|
+
...(failedEffectAttempt === undefined
|
|
2523
|
+
? {}
|
|
2524
|
+
: { failedEffectAttempt }),
|
|
1135
2525
|
};
|
|
1136
2526
|
return Object.freeze({
|
|
1137
|
-
schemaVersion:
|
|
2527
|
+
schemaVersion: 4,
|
|
1138
2528
|
...fields,
|
|
1139
2529
|
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1140
2530
|
});
|