@brainervirus/workit-core 8.13.0 → 8.14.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/src/ledger.ts CHANGED
@@ -38,8 +38,12 @@
38
38
  // the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
39
39
  // running chain. A row without it after the chain began, or whose hashes do
40
40
  // not match, is reported as unverified (`workit ledger verify-integrity`, a
41
- // warning in `ledger check`). Nothing is refused: this catches a hand edit
42
- // made by mistake, it is not a defense against a forger.
41
+ // warning in `ledger check`). Appends are never refused, but verdict gates
42
+ // (`checkVerdicts`) never accept an unverified or pre-chain verdict, nor a
43
+ // `--session` verdict whose id no SubagentStart hook issued, and ignore
44
+ // an unverified row's `supersedes`. The chain is keyless: it stops a row
45
+ // appended or edited by hand, not a forger who recomputes the hashes.
46
+ // The file is created 0600 (POSIX).
43
47
  // - Branch renames: rows keyed to a branch's earlier names (from its reflog)
44
48
  // count for it, up to the rename.
45
49
  //
@@ -49,9 +53,10 @@ import { spawnSync } from "node:child_process";
49
53
  import { createHash, randomBytes } from "node:crypto";
50
54
  import fs from "node:fs";
51
55
  import path from "node:path";
56
+ import { hostAwareLockTimeout } from "./core/store-lock";
52
57
  import { vcsConfig } from "./core/vcs-config";
53
58
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
54
- import { hostSessionFromEnv } from "./host-session";
59
+ import { hostSessionFromEnv, issuedBinding } from "./host-session";
55
60
  import { resolveStore } from "./store/paths";
56
61
 
57
62
  export const LEDGER_VERSION = 1;
@@ -146,6 +151,8 @@ export type VerdictRow = RowCommon & {
146
151
  self: boolean;
147
152
  selfReason: SelfReason | null;
148
153
  sessionSource: SessionSource;
154
+ /** The issued session's own subagent presented the token its hook bound the id to. */
155
+ sessionBound?: boolean;
149
156
  evidenceRefs: string[];
150
157
  };
151
158
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
@@ -171,8 +178,12 @@ export type LedgerRow =
171
178
  * the ledger had a chain).
172
179
  */
173
180
  export type RowIntegrity = "verified" | "unverified" | "legacy";
174
- /** unsigned: no chain fields (hand-appended) · hash_mismatch: edited · broken_link: a row before it was edited or removed. */
175
- export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link";
181
+ /**
182
+ * unsigned: no chain fields (hand-appended) · hash_mismatch: edited ·
183
+ * broken_link: a row before it was edited or removed · duplicate_id: a copy
184
+ * of an earlier row (a replayed line).
185
+ */
186
+ export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link" | "duplicate_id";
176
187
  /** A row as read back: any known or future type, plus its position. */
177
188
  export type ReadRow = RowCommon &
178
189
  Record<string, unknown> & {
@@ -182,6 +193,8 @@ export type ReadRow = RowCommon &
182
193
  supersedeIgnored: boolean;
183
194
  /** Set only when read with `{ integrity: true }`. */
184
195
  integrity?: RowIntegrity;
196
+ /** Why an `unverified` row is unverified. */
197
+ integrityReason?: UnverifiedReason;
185
198
  };
186
199
 
187
200
  export type LedgerError = {
@@ -497,11 +510,15 @@ export const ledgerLock = {
497
510
  },
498
511
  };
499
512
 
500
- /** Run `fn` holding the ledger's advisory lock; null when it stays held for `waitMs`. */
513
+ /**
514
+ * Run `fn` holding the ledger's advisory lock; null when it stays held for
515
+ * `waitMs`. The wait blocks the thread, so in-process hosts (OpenCode, Pi,
516
+ * MCP) wait at most 250 ms by default; the CLI keeps LOCK_WAIT_MS.
517
+ */
501
518
  export function withLedgerLock<T>(
502
519
  file: string,
503
520
  fn: () => T,
504
- waitMs: number = LOCK_WAIT_MS,
521
+ waitMs: number = hostAwareLockTimeout(LOCK_WAIT_MS),
505
522
  ): T | null {
506
523
  const lock = `${file}.lock`;
507
524
  const deadline = Date.now() + waitMs;
@@ -569,6 +586,15 @@ const lastLine = (fd: number, size: number): string | null => {
569
586
  return null;
570
587
  };
571
588
 
589
+ /** A ledger created before 0600 loses its group/other bits on the next append. */
590
+ const tighten = (fd: number): void => {
591
+ try {
592
+ if ((fs.fstatSync(fd).mode & 0o077) !== 0) fs.fchmodSync(fd, 0o600);
593
+ } catch {
594
+ // Not the owner (a shared store): the append still goes through.
595
+ }
596
+ };
597
+
572
598
  /** The line to write, given the file's current last line (null: empty file). */
573
599
  type LineBuilder = (previous: string | null) => LedgerResult<string>;
574
600
 
@@ -576,8 +602,10 @@ function appendLine(file: string, build: LineBuilder, options: AppendOptions): L
576
602
  const write = (): LedgerResult<void> => {
577
603
  // a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
578
604
  // peek at the tail to chain onto the last row and fence off a torn one.
579
- const fd = fs.openSync(file, "a+");
605
+ // 0600: rows name sessions, branches and verdict notes (audit N14).
606
+ const fd = fs.openSync(file, "a+", 0o600);
580
607
  try {
608
+ if (process.platform !== "win32") tighten(fd);
581
609
  const size = fs.fstatSync(fd).size;
582
610
  let fence = "";
583
611
  if (size > 0) {
@@ -774,7 +802,9 @@ export function readLedger(
774
802
  const file = ledgerPath(cwd);
775
803
  if (!file.ok) return file;
776
804
  let text = "";
805
+ let stat: fs.Stats | null = null;
777
806
  try {
807
+ stat = fs.statSync(file.value);
778
808
  text = fs.readFileSync(file.value, "utf8");
779
809
  } catch (error) {
780
810
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
@@ -798,6 +828,7 @@ export function readLedger(
798
828
  // link. An edited row fails its own hash and breaks every link to it.
799
829
  const earlier = new Set<string | null>([null]);
800
830
  let chainStarted = false;
831
+ const ids = new Set<string>();
801
832
  for (const raw of text.split("\n")) {
802
833
  const trimmed = raw.trim();
803
834
  if (!trimmed) continue;
@@ -827,6 +858,9 @@ export function readLedger(
827
858
  else if (!linked(prevHash)) reason = "broken_link";
828
859
  } else if (chainStarted) reason = "unsigned";
829
860
  else rowIntegrity = "legacy";
861
+ // A replayed copy of an earlier line hashes and links like the original.
862
+ if (options.integrity && !reason && ids.has(parsed.id as string)) reason = "duplicate_id";
863
+ if (options.integrity) ids.add(parsed.id as string);
830
864
  if (reason) rowIntegrity = "unverified";
831
865
  const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
832
866
  const row: ReadRow = {
@@ -850,6 +884,7 @@ export function readLedger(
850
884
  superseded: false,
851
885
  supersedeIgnored: false,
852
886
  ...(rowIntegrity ? { integrity: rowIntegrity } : {}),
887
+ ...(reason ? { integrityReason: reason } : {}),
853
888
  };
854
889
  rows.push(row);
855
890
  if (!options.integrity) continue;
@@ -866,7 +901,8 @@ export function readLedger(
866
901
  }
867
902
  // Only a well-formed supersede link counts; anything else is ignored. A
868
903
  // link across branch names is checked against the renames (reflog read
869
- // only for such links, once per branch).
904
+ // only for such links, once per branch). An unverified row's link never
905
+ // counts: a hand-appended row cannot retire another one (N13).
870
906
  const scopes = new Map<string, BranchScope>();
871
907
  const scopeOf = (branch: string): BranchScope => {
872
908
  let scope = scopes.get(branch);
@@ -876,17 +912,71 @@ export function readLedger(
876
912
  const byId = new Map<string, ReadRow>();
877
913
  for (const row of rows) {
878
914
  const target = row.supersedes ? byId.get(row.supersedes) : undefined;
879
- if (target && supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null)
915
+ if (
916
+ target &&
917
+ row.integrity !== "unverified" &&
918
+ supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null
919
+ )
880
920
  target.superseded = true;
881
921
  else if (row.supersedes) row.supersedeIgnored = true;
882
922
  if (!byId.has(row.id)) byId.set(row.id, row);
883
923
  }
924
+ // A read that saw exactly the stat'ed file remembers its chain breaks.
925
+ if (options.integrity && stat && Buffer.byteLength(text) === stat.size)
926
+ tamperCache.set(file.value, {
927
+ size: stat.size,
928
+ stamp: fileStamp(stat),
929
+ breaks: integrity.unverified.filter((row) => TAMPER_REASONS.has(row.reason)),
930
+ });
884
931
  return {
885
932
  ok: true,
886
933
  value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
887
934
  };
888
935
  }
889
936
 
937
+ /** Chain breaks honest appends never produce: a row edited, removed or moved. */
938
+ const TAMPER_REASONS: ReadonlySet<UnverifiedReason> = new Set(["hash_mismatch", "broken_link"]);
939
+ const tamperCache = new Map<string, { size: number; stamp: string; breaks: UnverifiedRow[] }>();
940
+ /** Inode, mtime and ctime: `touch -r` can restore an mtime, never a ctime. */
941
+ const fileStamp = (stat: fs.Stats): string => `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
942
+
943
+ /**
944
+ * Rows anywhere in the ledger whose chain shows an edit, a removal or a move
945
+ * (`hash_mismatch`, `broken_link`). Cached per file size and mtime; empty
946
+ * when the ledger cannot be read.
947
+ */
948
+ export function ledgerTampering(cwd: string): UnverifiedRow[] {
949
+ const file = ledgerPath(cwd);
950
+ if (!file.ok) return [];
951
+ try {
952
+ const stat = fs.statSync(file.value);
953
+ const cached = tamperCache.get(file.value);
954
+ if (cached && cached.size === stat.size && cached.stamp === fileStamp(stat))
955
+ return cached.breaks;
956
+ } catch {
957
+ return [];
958
+ }
959
+ const read = readLedger(cwd, { integrity: true });
960
+ return read.ok
961
+ ? (read.value.integrity?.unverified ?? []).filter((row) => TAMPER_REASONS.has(row.reason))
962
+ : [];
963
+ }
964
+
965
+ /** How to repair a tampered ledger: see what broke, then start a new one and re-record. */
966
+ export function tamperedUnblock(cwd: string): string {
967
+ const file = ledgerPath(cwd);
968
+ const where = file.ok ? file.value : "the ledger file";
969
+ return `workit ledger verify-integrity # lists the edited or missing rows; if the edit is understood, move ${where} aside (mv ${where} ${where}.tampered-<date>) and record the verdicts again`;
970
+ }
971
+
972
+ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string =>
973
+ `the ledger was edited, or a row was removed or moved (${breaks
974
+ .slice(0, 3)
975
+ .map((row) => `${row.reason} at row ${row.seq}`)
976
+ .join(
977
+ ", ",
978
+ )}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
979
+
890
980
  export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
891
981
 
892
982
  export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
@@ -1002,7 +1092,21 @@ export type RejectReason =
1002
1092
  | "failing_verdict"
1003
1093
  /** The only passing current verdict is `type-check-only`, which proves no behavior. */
1004
1094
  | "type_check_only"
1005
- | "no_verdict";
1095
+ | "no_verdict"
1096
+ /** The verdict row is outside the CLI's hash chain: appended by hand, or pre-chain. */
1097
+ | "unsigned"
1098
+ /** The verdict row was edited after the CLI wrote it. */
1099
+ | "hash_mismatch"
1100
+ /** A row before the verdict was edited or removed. */
1101
+ | "broken_link"
1102
+ /** The verdict row repeats an earlier row's id (a replayed line). */
1103
+ | "duplicate_id"
1104
+ /** A `--session` verdict whose id no SubagentStart hook issued, recorded on a host. */
1105
+ | "unissued_session"
1106
+ /** An issued id recorded without the token its hook bound it to (another session reusing it). */
1107
+ | "unbound_session"
1108
+ /** A row somewhere in the ledger was edited, removed or moved: no verdict is accepted until it is repaired. */
1109
+ | "ledger_tampered";
1006
1110
 
1007
1111
  export type VerdictEntry = {
1008
1112
  kind: string;
@@ -1042,8 +1146,8 @@ export type VerdictCheck = {
1042
1146
  warnings: string[];
1043
1147
  };
1044
1148
 
1045
- const isLiveVerdict = (row: ReadRow): boolean =>
1046
- row.type === "verdict" && typeof row.result === "string" && !row.superseded;
1149
+ const isLiveVerdict = (row: ReadRow, retired: ReadonlySet<string>): boolean =>
1150
+ row.type === "verdict" && typeof row.result === "string" && !retired.has(row.id);
1047
1151
 
1048
1152
  /** Sessions recorded in `Workit-Session:` trailers of `base...head` commits. */
1049
1153
  function trailerSessions(cwd: string, base: string | null, head: string | null): string[] {
@@ -1161,6 +1265,83 @@ const authoredBy = (authors: Set<string>, session: string): boolean => {
1161
1265
  return false;
1162
1266
  };
1163
1267
 
1268
+ /** Reasons a gate cannot trust a verdict row at all, whoever it names. */
1269
+ const TRUST_REASONS: ReadonlySet<RejectReason> = new Set([
1270
+ "unsigned",
1271
+ "hash_mismatch",
1272
+ "broken_link",
1273
+ "duplicate_id",
1274
+ "unissued_session",
1275
+ "unbound_session",
1276
+ ]);
1277
+
1278
+ /**
1279
+ * Hosts whose shell itself names the session (OpenCode, Pi, Codex), and no
1280
+ * host at all (`cli`: CI or a terminal, where any id is a declaration): an
1281
+ * environment session from them was checked against that shell when recorded.
1282
+ */
1283
+ const SHELL_VOUCHED_HOSTS: ReadonlySet<string> = new Set([
1284
+ "cli",
1285
+ "opencode",
1286
+ "pi",
1287
+ "codex_cli",
1288
+ "codex_desktop",
1289
+ ]);
1290
+
1291
+ /**
1292
+ * A verdict acting as a session nothing vouches for (`ledger verdict` refuses
1293
+ * these when recording; rows by an older CLI or by hand are caught here):
1294
+ * - `unissued_session`: a `--session` id on a host, or a `<lead>:<x>`
1295
+ * environment id on a host that does not name it in its shell (Claude
1296
+ * Code, Cursor, any declared WORKIT_HOST), that no SubagentStart hook issued;
1297
+ * - `unbound_session`: such an id the hook bound to a token, recorded
1298
+ * without it (another session reusing an id it read in the ledger).
1299
+ */
1300
+ function sessionTrust(cwd: string, row: ReadRow): RejectReason | null {
1301
+ const session = row.actor.session;
1302
+ if (!session || row.self === true) return null;
1303
+ const source = typeof row.sessionSource === "string" ? row.sessionSource : "env";
1304
+ const host = row.actor.host;
1305
+ const issuedOnly =
1306
+ source === "flag"
1307
+ ? host !== "cli"
1308
+ : source === "env" && session.includes(":") && !SHELL_VOUCHED_HOSTS.has(host);
1309
+ if (!issuedOnly) return null;
1310
+ const binding = issuedBinding(cwd, session);
1311
+ if (binding === null) return "unissued_session";
1312
+ return binding === "token" && row.sessionBound !== true ? "unbound_session" : null;
1313
+ }
1314
+
1315
+ /**
1316
+ * Why a gate cannot trust `row` (D18, audit N1): outside the hash chain, or
1317
+ * acting as a session nothing vouches for. Rows read without integrity are unsigned.
1318
+ */
1319
+ function trustReasons(cwd: string, row: ReadRow): RejectReason[] {
1320
+ if (row.integrity === "unverified") return [row.integrityReason ?? "unsigned"];
1321
+ if (row.integrity !== "verified") return ["unsigned"];
1322
+ const session = sessionTrust(cwd, row);
1323
+ return session ? [session] : [];
1324
+ }
1325
+
1326
+ /**
1327
+ * `rows` with their place in the hash chain. Rows read without integrity are
1328
+ * re-read with it (and with integrity-aware supersede links): every caller of
1329
+ * `checkVerdicts` is a gate. A row the re-read cannot match stays unsigned.
1330
+ */
1331
+ function withIntegrity(cwd: string, rows: readonly ReadRow[]): readonly ReadRow[] {
1332
+ if (rows.every((row) => row.integrity !== undefined)) return rows;
1333
+ const read = readLedger(cwd, { integrity: true });
1334
+ const bySeq = new Map(read.ok ? read.value.rows.map((row) => [row.seq, row]) : []);
1335
+ return rows.map((row) => {
1336
+ if (row.integrity !== undefined) return row;
1337
+ const fresh = bySeq.get(row.seq);
1338
+ return fresh && fresh.id === row.id && fresh.at === row.at ? fresh : row;
1339
+ });
1340
+ }
1341
+
1342
+ const untrusted = (reasons: readonly RejectReason[]): boolean =>
1343
+ reasons.some((reason) => TRUST_REASONS.has(reason));
1344
+
1164
1345
  const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
1165
1346
  const reasons: RejectReason[] = [];
1166
1347
  if (!row.actor.session) reasons.push("no_session");
@@ -1181,7 +1362,7 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
1181
1362
  */
1182
1363
  function effectiveOf(
1183
1364
  rows: readonly ReadRow[],
1184
- authors: Set<string>,
1365
+ reasonsOf: (row: ReadRow) => RejectReason[],
1185
1366
  sameCode: (failed: ReadRow, row: ReadRow) => boolean,
1186
1367
  isCurrent: (row: ReadRow) => boolean,
1187
1368
  ): ReadRow | null {
@@ -1191,8 +1372,10 @@ function effectiveOf(
1191
1372
  effective = row;
1192
1373
  continue;
1193
1374
  }
1194
- const rowIndependent = independenceReasons(row, authors).length === 0;
1195
- const effIndependent = independenceReasons(effective, authors).length === 0;
1375
+ // A row the gate cannot trust never displaces one it can.
1376
+ if (untrusted(reasonsOf(row)) && !untrusted(reasonsOf(effective))) continue;
1377
+ const rowIndependent = reasonsOf(row).length === 0;
1378
+ const effIndependent = reasonsOf(effective).length === 0;
1196
1379
  if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
1197
1380
  if (
1198
1381
  effIndependent &&
@@ -1227,6 +1410,7 @@ function restackCarries(
1227
1410
  if (
1228
1411
  row.type === "stack.restacked" &&
1229
1412
  row.observer === "workit_cli" &&
1413
+ row.integrity === "verified" &&
1230
1414
  row.branch === branch &&
1231
1415
  row.patchEqual === true &&
1232
1416
  typeof row.fromHead === "string" &&
@@ -1248,7 +1432,12 @@ function restackCarries(
1248
1432
  }
1249
1433
 
1250
1434
  /** Current and accepted verdicts for `branch`, after carry-over (D18). */
1251
- export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
1435
+ export function checkVerdicts(
1436
+ cwd: string,
1437
+ branch: string,
1438
+ ledgerRows: readonly ReadRow[],
1439
+ ): VerdictCheck {
1440
+ const rows = withIntegrity(cwd, ledgerRows);
1252
1441
  const head = branchHead(cwd, branch);
1253
1442
  const fallbackBase = defaultBase(cwd);
1254
1443
  const scope = branchScope(cwd, branch);
@@ -1284,20 +1473,69 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1284
1473
  );
1285
1474
  };
1286
1475
  const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
1476
+ // A failure is an author's only when chained ledger rows written before it
1477
+ // made its session an author. `ledger verdict` refuses an author's
1478
+ // independent verdict, so authorship gained afterwards (a forged row, a
1479
+ // commit or an empty amend under the reviewer's id, a trailer) never
1480
+ // discounts a failure (audit N13, mv83zyg6 P2b).
1481
+ const failureAuthorCache = new Map<number, Set<string>>();
1482
+ const failureAuthors = (row: ReadRow): Set<string> => {
1483
+ let set = failureAuthorCache.get(row.seq);
1484
+ if (!set) {
1485
+ set = authorSessions(
1486
+ cwd,
1487
+ rows.filter((other) => other.integrity === "verified" && other.seq < row.seq),
1488
+ branch,
1489
+ { base: null, head: null },
1490
+ scope,
1491
+ );
1492
+ failureAuthorCache.set(row.seq, set);
1493
+ }
1494
+ return set;
1495
+ };
1496
+ const reasonCache = new Map<ReadRow, RejectReason[]>();
1497
+ const reasonsOf = (row: ReadRow): RejectReason[] => {
1498
+ let reasons = reasonCache.get(row);
1499
+ if (!reasons) {
1500
+ const failing = FAILING.has(String(row.result));
1501
+ reasons = [
1502
+ ...trustReasons(cwd, row),
1503
+ ...independenceReasons(row, failing ? failureAuthors(row) : authors),
1504
+ ];
1505
+ reasonCache.set(row, reasons);
1506
+ }
1507
+ return reasons;
1508
+ };
1509
+ // Only a trusted verdict retires another: an unverified row or one acting
1510
+ // as an unissued or unbound session never supersedes (N13, mv83zyg6 P1).
1511
+ const retired = new Set<string>();
1512
+ for (const row of rows)
1513
+ if (
1514
+ row.type === "verdict" &&
1515
+ row.supersedes &&
1516
+ !row.supersedeIgnored &&
1517
+ trustReasons(cwd, row).length === 0
1518
+ )
1519
+ retired.add(row.supersedes);
1287
1520
  const byKind = new Map<string, ReadRow[]>();
1288
1521
  for (const row of rows)
1289
- if (isLiveVerdict(row) && scope(row)) {
1522
+ if (isLiveVerdict(row, retired) && scope(row)) {
1290
1523
  const kind = typeof row.kind === "string" ? row.kind : "review";
1291
1524
  byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
1292
1525
  }
1293
1526
  const verdicts: VerdictEntry[] = [];
1294
1527
  const warnings: string[] = [];
1295
1528
  for (const [kind, list] of byKind) {
1296
- const row = effectiveOf(list, authors, sameCode, (candidate) => basisOf(candidate) !== "stale");
1529
+ const row = effectiveOf(
1530
+ list,
1531
+ reasonsOf,
1532
+ sameCode,
1533
+ (candidate) => basisOf(candidate) !== "stale",
1534
+ );
1297
1535
  if (!row) continue;
1298
1536
  const basis = basisOf(row);
1299
1537
  const current = basis !== "stale";
1300
- const independence = independenceReasons(row, authors);
1538
+ const independence = reasonsOf(row);
1301
1539
  const reasons: RejectReason[] = [
1302
1540
  ...(current ? [] : (["stale"] as const)),
1303
1541
  ...(PASSING.has(String(row.result)) ? [] : (["not_passing"] as const)),
@@ -1318,7 +1556,8 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1318
1556
  for (const later of list)
1319
1557
  if (
1320
1558
  later.seq > row.seq &&
1321
- independenceReasons(later, authors).length > 0 &&
1559
+ reasonsOf(later).length > 0 &&
1560
+ !untrusted(reasonsOf(later)) &&
1322
1561
  FAILING.has(String(later.result)) &&
1323
1562
  basisOf(later) !== "stale"
1324
1563
  )
@@ -1331,6 +1570,16 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1331
1570
  warnings.push(
1332
1571
  `ledger row ${row.id} (${row.type}) on ${row.branch ?? branch} is unverified: the workit CLI did not write it or it changed afterwards (workit ledger verify-integrity)`,
1333
1572
  );
1573
+ // An edited, removed or moved row anywhere breaks the chain; honest appends
1574
+ // never do. Until it is repaired no verdict is accepted (mv83zyg6 P3/P4).
1575
+ const tampered = ledgerTampering(cwd);
1576
+ if (tampered.length) {
1577
+ warnings.push(tamperedWarning(cwd, tampered));
1578
+ for (const entry of verdicts) {
1579
+ entry.accepted = false;
1580
+ entry.reasons = [...entry.reasons, "ledger_tampered"];
1581
+ }
1582
+ }
1334
1583
  verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
1335
1584
  const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
1336
1585
  const failing = verdicts.find(
@@ -1340,8 +1589,9 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1340
1589
  verdicts.findLast(
1341
1590
  (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
1342
1591
  ) ?? null;
1343
- const accepted =
1344
- newestAccepted && !failing
1592
+ const accepted = tampered.length
1593
+ ? { accepted: false, verdict: null, reasons: ["ledger_tampered"] as RejectReason[] }
1594
+ : newestAccepted && !failing
1345
1595
  ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
1346
1596
  : {
1347
1597
  accepted: false,
@@ -1358,7 +1608,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1358
1608
  ? undefined
1359
1609
  : verdicts.findLast(
1360
1610
  (entry) =>
1361
- entry.current && !entry.independent && STRONG_RESULTS.has(String(entry.verdict.result)),
1611
+ entry.current &&
1612
+ !entry.independent &&
1613
+ !untrusted(entry.reasons) &&
1614
+ STRONG_RESULTS.has(String(entry.verdict.result)),
1362
1615
  );
1363
1616
  return {
1364
1617
  branch,
@@ -1387,6 +1640,8 @@ export type RecordContext = {
1387
1640
  supersedes?: string;
1388
1641
  /** Where `actor.session` came from (verdicts record it). Default: the environment. */
1389
1642
  sessionSource?: SessionSource;
1643
+ /** The caller presented the token bound to its issued session (verdicts record it). */
1644
+ sessionBound?: boolean;
1390
1645
  now?: Date;
1391
1646
  };
1392
1647
 
@@ -1734,6 +1989,7 @@ export function recordVerdict(
1734
1989
  self,
1735
1990
  selfReason,
1736
1991
  sessionSource: context.sessionSource ?? "env",
1992
+ ...(context.sessionBound === true ? { sessionBound: true } : {}),
1737
1993
  evidenceRefs: input.evidenceRefs ?? [],
1738
1994
  },
1739
1995
  { now: context.now },
package/src/stack.ts CHANGED
@@ -1180,7 +1180,7 @@ export function qualify(
1180
1180
  export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
1181
1181
  const mismatch = repoMismatch(ctx, stack);
1182
1182
  if (mismatch) return mismatch;
1183
- const ledger = readLedger(ctx.cwd);
1183
+ const ledger = readLedger(ctx.cwd, { integrity: true });
1184
1184
  if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
1185
1185
  const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
1186
1186
  const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
@@ -1823,7 +1823,7 @@ export async function landStack(
1823
1823
  absorb(synced.data);
1824
1824
  }
1825
1825
  const ledgerRows = () => {
1826
- const ledger = readLedger(ctx.cwd);
1826
+ const ledger = readLedger(ctx.cwd, { integrity: true });
1827
1827
  return ledger.ok ? ledger.value.rows : [];
1828
1828
  };
1829
1829
  const simulated = new Set<string>();