@sublang/playbook 17.6.0 → 17.8.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.
@@ -796,7 +796,7 @@ function assertObservationIdentity(observation, identity, path) {
796
796
  function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpectedAfter = false, dispositions) {
797
797
  if (!isRecord(value))
798
798
  throw new TypeError(`${path} must be an object`);
799
- rejectUnknownKeys(value, ['classification', 'baseline', 'after', 'commitOid', 'preExisting'], path);
799
+ rejectUnknownKeys(value, ['classification', 'baseline', 'after', 'commitOid', 'commits', 'preExisting'], path);
800
800
  if (typeof value.classification !== 'string' ||
801
801
  !EFFECT_RECEIPT_CLASSIFICATIONS.has(value.classification)) {
802
802
  throw new TypeError(`${path}.classification is not supported`);
@@ -837,6 +837,21 @@ function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpect
837
837
  else if (commitOid !== undefined) {
838
838
  throw new TypeError(`${path}.commitOid is permitted only for one-descendant-commit`);
839
839
  }
840
+ // DR-088 §1: a receipt of more than one new commit lists every one, oldest
841
+ // first and ending at its commit OID; one new commit keeps today's shape.
842
+ if (own(value, 'commits')) {
843
+ const commits = value.commits;
844
+ if (classification !== 'one-descendant-commit' ||
845
+ !Array.isArray(commits) ||
846
+ commits.length < 2 ||
847
+ commits.some((oid) => typeof oid !== 'string' ||
848
+ !EFFECT_OID_PATTERN.test(oid) ||
849
+ oid === baseline.head) ||
850
+ new Set(commits).size !== commits.length ||
851
+ commits.at(-1) !== commitOid) {
852
+ throw new TypeError(`${path}.commits must list two or more new commits ending at the commit OID of a one-descendant-commit receipt`);
853
+ }
854
+ }
840
855
  if (classification === 'unchanged' &&
841
856
  after !== undefined &&
842
857
  !jsonValuesEqual(baseline, after)) {
@@ -902,6 +917,39 @@ function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpect
902
917
  }
903
918
  return value;
904
919
  }
920
+ const SEMANTIC_VALUE_SHAPE_WORDS = {
921
+ string: 'a JSON string',
922
+ 'string-array': 'a JSON array of strings',
923
+ 'number-array': 'a JSON array of numbers',
924
+ 'string-or-number-array': 'a JSON array of strings or numbers',
925
+ number: 'a JSON number',
926
+ boolean: 'a JSON boolean',
927
+ };
928
+ /** The words a judge prompt or validation error uses for one value shape. */
929
+ export function describePlaybookSemanticValueShape(shape) {
930
+ return SEMANTIC_VALUE_SHAPE_WORDS[shape];
931
+ }
932
+ /** Whether one detached JSON value has the given semantic value shape. */
933
+ function isPlaybookSemanticValueShape(value, shape) {
934
+ const isFiniteNumber = (entry) => typeof entry === 'number' && Number.isFinite(entry);
935
+ switch (shape) {
936
+ case 'string':
937
+ return typeof value === 'string';
938
+ case 'number':
939
+ return isFiniteNumber(value);
940
+ case 'boolean':
941
+ return typeof value === 'boolean';
942
+ case 'string-array':
943
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
944
+ case 'number-array':
945
+ return Array.isArray(value) && value.every(isFiniteNumber);
946
+ case 'string-or-number-array':
947
+ return (Array.isArray(value) &&
948
+ value.every((entry) => typeof entry === 'string' || isFiniteNumber(entry)));
949
+ default:
950
+ return false;
951
+ }
952
+ }
905
953
  /**
906
954
  * A judge candidate is structurally invalid, rather than merely awaiting or
907
955
  * conflicting with effect evidence. Callers use this distinction to spend at
@@ -916,7 +964,7 @@ export class PlaybookSemanticCandidateStructureError extends TypeError {
916
964
  function semanticCandidateStructureError(message) {
917
965
  throw new PlaybookSemanticCandidateStructureError(message);
918
966
  }
919
- function snapshotSemanticCandidate(value, outcomes) {
967
+ function snapshotSemanticCandidate(value, outcomes, shapes) {
920
968
  let detached;
921
969
  try {
922
970
  detached = snapshotJsonValue(value, 'semantic candidate');
@@ -948,9 +996,17 @@ function snapshotSemanticCandidate(value, outcomes) {
948
996
  (missing.length === 0 ? '' : `; missing ${missing.join(', ')}`) +
949
997
  (extra.length === 0 ? '' : `; extra or wrongly owned ${extra.join(', ')}`));
950
998
  }
999
+ // DR-089: each semantic-owned field has the shape the compiled machine
1000
+ // admits for it, a string unless the machine admits another.
1001
+ const outcomeShapes = shapes !== undefined && own(shapes, guard) ? shapes[guard] : undefined;
951
1002
  for (const field of semanticFields) {
952
- if (typeof detached[field] !== 'string') {
953
- semanticCandidateStructureError(`field ${JSON.stringify(field)} must be a string`);
1003
+ const shape = outcomeShapes !== undefined && own(outcomeShapes, field)
1004
+ ? outcomeShapes[field]
1005
+ : 'string';
1006
+ if (!isPlaybookSemanticValueShape(detached[field], shape)) {
1007
+ semanticCandidateStructureError(shape === 'string'
1008
+ ? `field ${JSON.stringify(field)} must be a string`
1009
+ : `field ${JSON.stringify(field)} must be ${describePlaybookSemanticValueShape(shape)}`);
954
1010
  }
955
1011
  }
956
1012
  return {
@@ -1005,7 +1061,9 @@ function divergentProjectionPaths(baseline, after) {
1005
1061
  * proved. The receipt states the failure; nothing here infers one.
1006
1062
  */
1007
1063
  function playbookRepositoryFailureCause(required, receipt) {
1008
- const observed = receipt.classification;
1064
+ // DR-088 §3: a receipt listing more than one new commit reads as
1065
+ // `multiple-commits`, so its statement names more than one commit.
1066
+ const observed = receipt.commits === undefined ? receipt.classification : 'multiple-commits';
1009
1067
  const baselineHead = receipt.baseline.head;
1010
1068
  const after = receipt.after;
1011
1069
  const common = { required, observed, baselineHead };
@@ -1096,11 +1154,25 @@ function unresolvedSemanticEvidence(reason, evidence, cause = assertPlaybookFail
1096
1154
  })) {
1097
1155
  return Object.freeze({ status: 'unresolved', reason, cause, evidence });
1098
1156
  }
1157
+ /**
1158
+ * DR-088 §2: whether the player's final text names one commit as a word, by
1159
+ * its full OID or by an abbreviation of at least seven leading hex digits.
1160
+ * It designates which observed commit is the work and proves nothing itself.
1161
+ */
1162
+ function finalTextNamesCommit(finalText, oid) {
1163
+ for (const [word] of finalText.matchAll(/(?<![0-9A-Za-z_])[0-9A-Fa-f]{7,64}(?![0-9A-Za-z_])/g)) {
1164
+ if (oid.startsWith(word.toLowerCase()))
1165
+ return true;
1166
+ }
1167
+ return false;
1168
+ }
1099
1169
  /**
1100
1170
  * Reconcile one exact semantic candidate with host-owned effect evidence.
1101
1171
  * Presentation prose remains opaque: it is retained verbatim and only its
1102
1172
  * trimmed value is copied into linker-declared presentation fields. No
1103
- * repository fact is inferred from that prose or from the semantic candidate.
1173
+ * repository fact is inferred from that prose or from the semantic candidate;
1174
+ * the prose may only name, as the work, the commit an `unchanged` receipt
1175
+ * observed at HEAD (DR-088 §2).
1104
1176
  */
1105
1177
  export function reconcilePlaybookSemanticEvidence(input) {
1106
1178
  let report;
@@ -1117,7 +1189,7 @@ export function reconcilePlaybookSemanticEvidence(input) {
1117
1189
  evidence: { reason: `No declared outcome matches: ${report.blocked}` },
1118
1190
  }));
1119
1191
  }
1120
- const { candidate, outcome } = snapshotSemanticCandidate(input.semanticCandidate, input.outcomes);
1192
+ const { candidate, outcome } = snapshotSemanticCandidate(input.semanticCandidate, input.outcomes, input.semanticValueShapes);
1121
1193
  const evidence = retainedSemanticEvidence(candidate, input.finalText);
1122
1194
  const finalText = typeof input.finalText === 'string' ? input.finalText.trim() : undefined;
1123
1195
  if (finalText === undefined || finalText.length === 0) {
@@ -1135,9 +1207,18 @@ export function reconcilePlaybookSemanticEvidence(input) {
1135
1207
  return unresolvedSemanticEvidence('invalid-repository-receipt', evidence);
1136
1208
  }
1137
1209
  const disposition = outcome.repositoryDisposition;
1210
+ // DR-088 §2: a commit outcome over an unchanged receipt takes the commit
1211
+ // already at HEAD when the final text names it and the call declares no
1212
+ // outcome of its own for leaving the repository unchanged.
1213
+ const alreadyAtHead = disposition === 'one-descendant-commit' &&
1214
+ receipt.classification === 'unchanged' &&
1215
+ receipt.after !== undefined &&
1216
+ !/^0+$/.test(receipt.after.head) &&
1217
+ Object.values(input.outcomes).every(({ repositoryDisposition }) => repositoryDisposition !== 'unchanged') &&
1218
+ finalTextNamesCommit(finalText, receipt.after.head);
1138
1219
  const dispositionMatches = (disposition === 'unchanged' && receipt.classification === 'unchanged') ||
1139
1220
  (disposition === 'one-descendant-commit' &&
1140
- receipt.classification === 'one-descendant-commit') ||
1221
+ (receipt.classification === 'one-descendant-commit' || alreadyAtHead)) ||
1141
1222
  (disposition === 'deferred' &&
1142
1223
  candidate.guard === 'needsBossReply' &&
1143
1224
  Object.entries(input.outcomes).some(([guard, declared]) => guard !== candidate.guard &&
@@ -1182,13 +1263,16 @@ export function reconcilePlaybookSemanticEvidence(input) {
1182
1263
  // DR-045: effect injection selects by the accepted arm's declared
1183
1264
  // repository disposition, never by the field's name. A
1184
1265
  // one-descendant-commit arm's effect fields carry the qualifying
1185
- // receipt's exact new-descendant commit OID; an unchanged arm's effect
1186
- // fields carry the matching unchanged receipt's observed HEAD OID. A
1187
- // deferred arm declares no effect field, and any shape the validated
1188
- // receipt cannot prove fails closed as unresolved.
1266
+ // receipt's exact new-descendant commit OID, or the HEAD OID its final
1267
+ // text named over an unchanged receipt (DR-088 §2); an unchanged arm's
1268
+ // effect fields carry the matching unchanged receipt's observed HEAD
1269
+ // OID. A deferred arm declares no effect field, and any shape the
1270
+ // validated receipt cannot prove fails closed as unresolved.
1189
1271
  value =
1190
1272
  disposition === 'one-descendant-commit'
1191
- ? receipt.commitOid
1273
+ ? alreadyAtHead
1274
+ ? receipt.after?.head
1275
+ : receipt.commitOid
1192
1276
  : disposition === 'unchanged'
1193
1277
  ? receipt.after?.head
1194
1278
  : undefined;
@@ -1167,7 +1167,7 @@ function effectReceipt(
1167
1167
  if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
1168
1168
  rejectUnknownKeys(
1169
1169
  value,
1170
- ['classification', 'baseline', 'after', 'commitOid', 'preExisting'],
1170
+ ['classification', 'baseline', 'after', 'commitOid', 'commits', 'preExisting'],
1171
1171
  path,
1172
1172
  );
1173
1173
  if (
@@ -1227,6 +1227,28 @@ function effectReceipt(
1227
1227
  `${path}.commitOid is permitted only for one-descendant-commit`,
1228
1228
  );
1229
1229
  }
1230
+ // DR-088 §1: a receipt of more than one new commit lists every one, oldest
1231
+ // first and ending at its commit OID; one new commit keeps today's shape.
1232
+ if (own(value, 'commits')) {
1233
+ const commits = value.commits;
1234
+ if (
1235
+ classification !== 'one-descendant-commit' ||
1236
+ !Array.isArray(commits) ||
1237
+ commits.length < 2 ||
1238
+ commits.some(
1239
+ (oid) =>
1240
+ typeof oid !== 'string' ||
1241
+ !EFFECT_OID_PATTERN.test(oid) ||
1242
+ oid === baseline.head,
1243
+ ) ||
1244
+ new Set(commits).size !== commits.length ||
1245
+ commits.at(-1) !== commitOid
1246
+ ) {
1247
+ throw new TypeError(
1248
+ `${path}.commits must list two or more new commits ending at the commit OID of a one-descendant-commit receipt`,
1249
+ );
1250
+ }
1251
+ }
1230
1252
  if (
1231
1253
  classification === 'unchanged' &&
1232
1254
  after !== undefined &&
@@ -1346,6 +1368,73 @@ export interface PlaybookSemanticOutcomeSpec {
1346
1368
  readonly repositoryDisposition: PlaybookRepositoryDisposition;
1347
1369
  }
1348
1370
 
1371
+ /**
1372
+ * The JSON value shape a compiled machine admits for one semantic-owned field
1373
+ * (DR-089). A field the machine admits as a string, or whose shape the
1374
+ * machine does not reveal, is `string`.
1375
+ */
1376
+ export type PlaybookSemanticValueShape =
1377
+ | 'string'
1378
+ | 'string-array'
1379
+ | 'number-array'
1380
+ | 'string-or-number-array'
1381
+ | 'number'
1382
+ | 'boolean';
1383
+
1384
+ /**
1385
+ * Per outcome, the non-string shapes of its semantic-owned fields; a field
1386
+ * left out is a string.
1387
+ */
1388
+ export type PlaybookSemanticValueShapes = Readonly<
1389
+ Record<string, Readonly<Record<string, PlaybookSemanticValueShape>>>
1390
+ >;
1391
+
1392
+ const SEMANTIC_VALUE_SHAPE_WORDS: Readonly<
1393
+ Record<PlaybookSemanticValueShape, string>
1394
+ > = {
1395
+ string: 'a JSON string',
1396
+ 'string-array': 'a JSON array of strings',
1397
+ 'number-array': 'a JSON array of numbers',
1398
+ 'string-or-number-array': 'a JSON array of strings or numbers',
1399
+ number: 'a JSON number',
1400
+ boolean: 'a JSON boolean',
1401
+ };
1402
+
1403
+ /** The words a judge prompt or validation error uses for one value shape. */
1404
+ export function describePlaybookSemanticValueShape(
1405
+ shape: PlaybookSemanticValueShape,
1406
+ ): string {
1407
+ return SEMANTIC_VALUE_SHAPE_WORDS[shape];
1408
+ }
1409
+
1410
+ /** Whether one detached JSON value has the given semantic value shape. */
1411
+ function isPlaybookSemanticValueShape(
1412
+ value: unknown,
1413
+ shape: PlaybookSemanticValueShape,
1414
+ ): boolean {
1415
+ const isFiniteNumber = (entry: unknown): boolean =>
1416
+ typeof entry === 'number' && Number.isFinite(entry);
1417
+ switch (shape) {
1418
+ case 'string':
1419
+ return typeof value === 'string';
1420
+ case 'number':
1421
+ return isFiniteNumber(value);
1422
+ case 'boolean':
1423
+ return typeof value === 'boolean';
1424
+ case 'string-array':
1425
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
1426
+ case 'number-array':
1427
+ return Array.isArray(value) && value.every(isFiniteNumber);
1428
+ case 'string-or-number-array':
1429
+ return (
1430
+ Array.isArray(value) &&
1431
+ value.every((entry) => typeof entry === 'string' || isFiniteNumber(entry))
1432
+ );
1433
+ default:
1434
+ return false;
1435
+ }
1436
+ }
1437
+
1349
1438
  /** Evidence available when one governed semantic candidate is reconciled. */
1350
1439
  export interface PlaybookSemanticEvidenceInput {
1351
1440
  readonly outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>;
@@ -1353,11 +1442,16 @@ export interface PlaybookSemanticEvidenceInput {
1353
1442
  readonly finalText?: unknown;
1354
1443
  readonly receipt?: unknown;
1355
1444
  readonly runtimeFields?: unknown;
1445
+ /**
1446
+ * DR-089: the shape the compiled machine admits for each semantic-owned
1447
+ * field, keyed by outcome; every field it leaves out is a string.
1448
+ */
1449
+ readonly semanticValueShapes?: PlaybookSemanticValueShapes;
1356
1450
  }
1357
1451
 
1358
1452
  /** The exact detached actor output admitted to FSM delivery. */
1359
1453
  export type PlaybookReconciledSemanticOutput = Readonly<
1360
- Record<string, string>
1454
+ Record<string, JsonValue>
1361
1455
  > & {
1362
1456
  readonly guard: string;
1363
1457
  };
@@ -1365,7 +1459,7 @@ export type PlaybookReconciledSemanticOutput = Readonly<
1365
1459
  /** Retained presentation and semantic evidence, kept separate from output. */
1366
1460
  export interface PlaybookRetainedSemanticEvidence {
1367
1461
  readonly finalText?: string;
1368
- readonly semanticCandidate: Readonly<Record<string, string>>;
1462
+ readonly semanticCandidate: Readonly<Record<string, JsonValue>>;
1369
1463
  }
1370
1464
 
1371
1465
  export type PlaybookSemanticReconciliationReason =
@@ -1412,8 +1506,9 @@ function semanticCandidateStructureError(message: string): never {
1412
1506
  function snapshotSemanticCandidate(
1413
1507
  value: unknown,
1414
1508
  outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>,
1509
+ shapes: PlaybookSemanticValueShapes | undefined,
1415
1510
  ): {
1416
- readonly candidate: Readonly<Record<string, string>> & {
1511
+ readonly candidate: Readonly<Record<string, JsonValue>> & {
1417
1512
  readonly guard: string;
1418
1513
  };
1419
1514
  readonly outcome: PlaybookSemanticOutcomeSpec;
@@ -1452,15 +1547,25 @@ function snapshotSemanticCandidate(
1452
1547
  (extra.length === 0 ? '' : `; extra or wrongly owned ${extra.join(', ')}`),
1453
1548
  );
1454
1549
  }
1550
+ // DR-089: each semantic-owned field has the shape the compiled machine
1551
+ // admits for it, a string unless the machine admits another.
1552
+ const outcomeShapes =
1553
+ shapes !== undefined && own(shapes, guard) ? shapes[guard] : undefined;
1455
1554
  for (const field of semanticFields) {
1456
- if (typeof detached[field] !== 'string') {
1555
+ const shape =
1556
+ outcomeShapes !== undefined && own(outcomeShapes, field)
1557
+ ? outcomeShapes[field]!
1558
+ : 'string';
1559
+ if (!isPlaybookSemanticValueShape(detached[field], shape)) {
1457
1560
  semanticCandidateStructureError(
1458
- `field ${JSON.stringify(field)} must be a string`,
1561
+ shape === 'string'
1562
+ ? `field ${JSON.stringify(field)} must be a string`
1563
+ : `field ${JSON.stringify(field)} must be ${describePlaybookSemanticValueShape(shape)}`,
1459
1564
  );
1460
1565
  }
1461
1566
  }
1462
1567
  return {
1463
- candidate: detached as Readonly<Record<string, string>> & {
1568
+ candidate: detached as Readonly<Record<string, JsonValue>> & {
1464
1569
  readonly guard: string;
1465
1570
  },
1466
1571
  outcome,
@@ -1468,7 +1573,7 @@ function snapshotSemanticCandidate(
1468
1573
  }
1469
1574
 
1470
1575
  function retainedSemanticEvidence(
1471
- candidate: Readonly<Record<string, string>>,
1576
+ candidate: Readonly<Record<string, JsonValue>>,
1472
1577
  finalText: unknown,
1473
1578
  ): PlaybookRetainedSemanticEvidence {
1474
1579
  return Object.freeze({
@@ -1544,7 +1649,10 @@ function playbookRepositoryFailureCause(
1544
1649
  required: PlaybookRepositoryDisposition,
1545
1650
  receipt: PlaybookRepositoryReceipt,
1546
1651
  ): PlaybookFailureCause {
1547
- const observed = receipt.classification;
1652
+ // DR-088 §3: a receipt listing more than one new commit reads as
1653
+ // `multiple-commits`, so its statement names more than one commit.
1654
+ const observed =
1655
+ receipt.commits === undefined ? receipt.classification : 'multiple-commits';
1548
1656
  const baselineHead = receipt.baseline.head;
1549
1657
  const after = receipt.after;
1550
1658
  const common = { required, observed, baselineHead };
@@ -1645,11 +1753,27 @@ function unresolvedSemanticEvidence(
1645
1753
  return Object.freeze({ status: 'unresolved', reason, cause, evidence });
1646
1754
  }
1647
1755
 
1756
+ /**
1757
+ * DR-088 §2: whether the player's final text names one commit as a word, by
1758
+ * its full OID or by an abbreviation of at least seven leading hex digits.
1759
+ * It designates which observed commit is the work and proves nothing itself.
1760
+ */
1761
+ function finalTextNamesCommit(finalText: string, oid: string): boolean {
1762
+ for (const [word] of finalText.matchAll(
1763
+ /(?<![0-9A-Za-z_])[0-9A-Fa-f]{7,64}(?![0-9A-Za-z_])/g,
1764
+ )) {
1765
+ if (oid.startsWith(word.toLowerCase())) return true;
1766
+ }
1767
+ return false;
1768
+ }
1769
+
1648
1770
  /**
1649
1771
  * Reconcile one exact semantic candidate with host-owned effect evidence.
1650
1772
  * Presentation prose remains opaque: it is retained verbatim and only its
1651
1773
  * trimmed value is copied into linker-declared presentation fields. No
1652
- * repository fact is inferred from that prose or from the semantic candidate.
1774
+ * repository fact is inferred from that prose or from the semantic candidate;
1775
+ * the prose may only name, as the work, the commit an `unchanged` receipt
1776
+ * observed at HEAD (DR-088 §2).
1653
1777
  */
1654
1778
  export function reconcilePlaybookSemanticEvidence(
1655
1779
  input: PlaybookSemanticEvidenceInput,
@@ -1664,7 +1788,7 @@ export function reconcilePlaybookSemanticEvidence(
1664
1788
  typeof report.blocked === 'string' && report.blocked.trim().length > 0) {
1665
1789
  return unresolvedSemanticEvidence(
1666
1790
  'no-matching-outcome',
1667
- retainedSemanticEvidence(report as Readonly<Record<string, string>>, input.finalText),
1791
+ retainedSemanticEvidence(report as Readonly<Record<string, JsonValue>>, input.finalText),
1668
1792
  assertPlaybookFailureCause({
1669
1793
  code: 'runtime-defect',
1670
1794
  evidence: { reason: `No declared outcome matches: ${report.blocked}` },
@@ -1674,6 +1798,7 @@ export function reconcilePlaybookSemanticEvidence(
1674
1798
  const { candidate, outcome } = snapshotSemanticCandidate(
1675
1799
  input.semanticCandidate,
1676
1800
  input.outcomes,
1801
+ input.semanticValueShapes,
1677
1802
  );
1678
1803
  const evidence = retainedSemanticEvidence(candidate, input.finalText);
1679
1804
  const finalText =
@@ -1706,10 +1831,22 @@ export function reconcilePlaybookSemanticEvidence(
1706
1831
  }
1707
1832
 
1708
1833
  const disposition = outcome.repositoryDisposition;
1834
+ // DR-088 §2: a commit outcome over an unchanged receipt takes the commit
1835
+ // already at HEAD when the final text names it and the call declares no
1836
+ // outcome of its own for leaving the repository unchanged.
1837
+ const alreadyAtHead =
1838
+ disposition === 'one-descendant-commit' &&
1839
+ receipt.classification === 'unchanged' &&
1840
+ receipt.after !== undefined &&
1841
+ !/^0+$/.test(receipt.after.head) &&
1842
+ Object.values(input.outcomes).every(
1843
+ ({ repositoryDisposition }) => repositoryDisposition !== 'unchanged',
1844
+ ) &&
1845
+ finalTextNamesCommit(finalText, receipt.after.head);
1709
1846
  const dispositionMatches =
1710
1847
  (disposition === 'unchanged' && receipt.classification === 'unchanged') ||
1711
1848
  (disposition === 'one-descendant-commit' &&
1712
- receipt.classification === 'one-descendant-commit') ||
1849
+ (receipt.classification === 'one-descendant-commit' || alreadyAtHead)) ||
1713
1850
  (disposition === 'deferred' &&
1714
1851
  candidate.guard === 'needsBossReply' &&
1715
1852
  Object.entries(input.outcomes).some(
@@ -1761,10 +1898,10 @@ export function reconcilePlaybookSemanticEvidence(
1761
1898
  return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
1762
1899
  }
1763
1900
 
1764
- const output: Record<string, string> = {};
1901
+ const output: Record<string, JsonValue> = {};
1765
1902
  defineEnumerableDataProperty(output, 'guard', candidate.guard);
1766
1903
  for (const [field, authority] of Object.entries(outcome.fields)) {
1767
- let value: string | undefined;
1904
+ let value: JsonValue | undefined;
1768
1905
  if (authority === 'semantic') {
1769
1906
  value = candidate[field];
1770
1907
  } else if (authority === 'presentation') {
@@ -1773,13 +1910,16 @@ export function reconcilePlaybookSemanticEvidence(
1773
1910
  // DR-045: effect injection selects by the accepted arm's declared
1774
1911
  // repository disposition, never by the field's name. A
1775
1912
  // one-descendant-commit arm's effect fields carry the qualifying
1776
- // receipt's exact new-descendant commit OID; an unchanged arm's effect
1777
- // fields carry the matching unchanged receipt's observed HEAD OID. A
1778
- // deferred arm declares no effect field, and any shape the validated
1779
- // receipt cannot prove fails closed as unresolved.
1913
+ // receipt's exact new-descendant commit OID, or the HEAD OID its final
1914
+ // text named over an unchanged receipt (DR-088 §2); an unchanged arm's
1915
+ // effect fields carry the matching unchanged receipt's observed HEAD
1916
+ // OID. A deferred arm declares no effect field, and any shape the
1917
+ // validated receipt cannot prove fails closed as unresolved.
1780
1918
  value =
1781
1919
  disposition === 'one-descendant-commit'
1782
- ? receipt.commitOid
1920
+ ? alreadyAtHead
1921
+ ? receipt.after?.head
1922
+ : receipt.commitOid
1783
1923
  : disposition === 'unchanged'
1784
1924
  ? receipt.after?.head
1785
1925
  : undefined;