@brainervirus/workit-core 1.0.10 → 1.0.12

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.
@@ -5,8 +5,14 @@ import {
5
5
  failure,
6
6
  decisionDigest,
7
7
  exportBundleSchema,
8
+ assessmentSchema,
9
+ decisionSchema,
10
+ evidenceSchema,
11
+ findingSchema,
12
+ intentSchema,
8
13
  newId,
9
14
  parseOperation,
15
+ rewriteRecordRefs,
10
16
  success,
11
17
  type Assessment,
12
18
  type Capability,
@@ -59,6 +65,7 @@ import {
59
65
  import {
60
66
  assertProductWriteAllowed,
61
67
  isUncertainWorker,
68
+ workerBlocksTransition,
62
69
  type CallerContext,
63
70
  type NativeWorkerObservation,
64
71
  type NativeWorkerVerification,
@@ -70,9 +77,12 @@ import {
70
77
  } from "./workers";
71
78
  import {
72
79
  captureCandidate,
80
+ checkPin,
73
81
  evaluateClosure,
74
82
  evaluateEvidence,
75
83
  evaluateRequirements,
84
+ findingVerificationPasses,
85
+ resolveBinding,
76
86
  type CandidateEnvironment,
77
87
  } from "./task-evaluation";
78
88
 
@@ -342,6 +352,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
342
352
  if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
343
353
  return failure("invalid_transition", "paused task cannot run a worker");
344
354
  const notStarted = input.dispatch === true;
355
+ if (notStarted && entry.data.state === "stopped" && input.session === null)
356
+ return success(task.data.revision, workspace.data.revision, entry);
345
357
  if (notStarted && (input.state !== "stopped" || input.session !== null))
346
358
  return failure("invalid_input", "a never-dispatched worker stops with no session");
347
359
  if (notStarted && entry.data.session !== null)
@@ -395,7 +407,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
395
407
  return success(task.data.revision, workspace.data.revision, entry);
396
408
  const changed = input.store.mutateTaskAndWorkspace({
397
409
  taskId: task.data.id,
398
- expectedTaskRevision: input.expectedRevision,
410
+ expectedRevision: input.expectedRevision,
399
411
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
400
412
  now: input.now,
401
413
  workspace: (current, mutation) =>
@@ -469,96 +481,28 @@ const importedProvenance = (context: OperationContext): Provenance => ({
469
481
  });
470
482
 
471
483
  const portableTask = (task: TaskRecord): TaskRecord => {
472
- const clone = structuredClone(task);
473
- const portableProvenance = (value: Provenance): Provenance => ({
474
- ...value,
475
- session: null,
476
- receipts: [],
477
- });
478
- const entries = [
479
- clone.intent,
480
- ...clone.assessments,
481
- ...clone.evidence,
482
- ...clone.decisions,
483
- ...clone.findings,
484
- ...clone.workers,
485
- ];
486
- for (const entry of entries) entry.provenance = portableProvenance(entry.provenance);
487
- for (const worker of clone.workers) worker.data.session = null;
488
- clone.intent = {
489
- ...clone.intent,
490
- data: { ...clone.intent.data, authorityRefs: portableRefs(clone.intent.data.authorityRefs) },
491
- };
492
- clone.constraints = clone.constraints.flatMap((constraint) => {
493
- const source = portableRef(constraint.source);
494
- return source
495
- ? [
496
- {
497
- ...constraint,
498
- source,
499
- requires: constraint.requires.map((requirement) => ({
500
- ...requirement,
501
- refs: portableRefs(requirement.refs),
502
- })),
503
- },
504
- ]
505
- : [];
506
- });
507
- clone.assessments = clone.assessments.map((entry) => ({
508
- ...entry,
509
- data: {
510
- ...entry.data,
511
- facts: entry.data.facts.map(portableFact),
512
- signals: Object.fromEntries(
513
- Object.entries(entry.data.signals).map(([name, signal]) => [name, portableSignal(signal)]),
514
- ) as typeof entry.data.signals,
515
- consequences: entry.data.consequences.map((consequence) => ({
516
- ...consequence,
517
- fact: portableFact(consequence.fact),
518
- })),
519
- verification: entry.data.verification.map((verification) => ({
520
- ...verification,
521
- availableChecks: portableRefs(verification.availableChecks),
522
- })),
523
- },
524
- }));
525
- clone.progress = {
526
- ...clone.progress,
527
- blockers: clone.progress.blockers.map((blocker) => ({
528
- ...blocker,
529
- refs: portableRefs(blocker.refs),
530
- })),
484
+ // Schema-driven ref stripping: every Ref anywhere in the record is mapped
485
+ // by shape (arrays drop unportable refs, nullable fields null them), so a
486
+ // new ref-bearing field can never silently leak across checkouts.
487
+ const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
488
+ if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
489
+ throw new TypeError("portable task rewrite produced a non-record");
490
+ const clone = structuredClone(stripped) as TaskRecord;
491
+ const demote = (
492
+ facts: Assessment["facts"][number][],
493
+ signals: Assessment["signals"],
494
+ consequences: Assessment["consequences"],
495
+ ): void => {
496
+ for (const fact of facts) Object.assign(fact, portableFact(fact));
497
+ for (const name of Object.keys(signals) as (keyof Assessment["signals"])[]) {
498
+ const signal = signals[name];
499
+ Object.assign(signal, portableSignal(signal));
500
+ }
501
+ for (const consequence of consequences)
502
+ Object.assign(consequence.fact, portableFact(consequence.fact));
531
503
  };
532
- clone.evidence = clone.evidence.map((entry) => ({
533
- ...entry,
534
- data: {
535
- ...entry.data,
536
- refs: portableRefs(entry.data.refs),
537
- reviewContext: portableRef(entry.data.reviewContext),
538
- },
539
- }));
540
- clone.decisions = clone.decisions.map((entry) => ({
541
- ...entry,
542
- data: {
543
- ...entry.data,
544
- consumption: entry.data.consumption
545
- ? portableRef(entry.data.consumption.actionRef)
546
- ? {
547
- ...entry.data.consumption,
548
- actionRef: portableRef(entry.data.consumption.actionRef)!,
549
- }
550
- : null
551
- : null,
552
- binding: {
553
- ...entry.data.binding,
554
- contentRefs: portableRefs(entry.data.binding.contentRefs),
555
- },
556
- },
557
- }));
558
- clone.findings = clone.findings.map((entry) => ({
559
- ...entry,
560
- data: { ...entry.data, refs: portableRefs(entry.data.refs) },
561
- }));
504
+ for (const entry of clone.assessments)
505
+ demote(entry.data.facts, entry.data.signals, entry.data.consequences);
562
506
  // Candidate metadata can contain environment-derived paths and digests. The destination
563
507
  // must recapture its own candidate instead of receiving source checkout material.
564
508
  clone.candidates = [];
@@ -591,15 +535,13 @@ const importedTask = (
591
535
  allocate(entry.id);
592
536
  const fresh = (id: string) => allocate(id);
593
537
  const provenance = importedProvenance(context);
538
+ const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
594
539
  const intent = {
595
540
  ...source.intent,
596
541
  id: fresh(source.intent.id),
597
542
  recordedAt: timestamp,
598
543
  provenance,
599
- data: {
600
- ...source.intent.data,
601
- authorityRefs: source.intent.data.authorityRefs.map((ref) => mapRef(ref, ids)),
602
- },
544
+ data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
603
545
  };
604
546
  const assessments = source.assessments.map((entry) => {
605
547
  const data = entry.data;
@@ -608,30 +550,7 @@ const importedTask = (
608
550
  id: fresh(entry.id),
609
551
  recordedAt: timestamp,
610
552
  provenance,
611
- data: {
612
- ...data,
613
- facts: data.facts.map((fact) => ({
614
- ...fact,
615
- refs: fact.refs.map((ref) => mapRef(ref, ids)),
616
- })),
617
- signals: Object.fromEntries(
618
- Object.entries(data.signals).map(([name, signal]) => [
619
- name,
620
- { ...signal, refs: signal.refs.map((ref) => mapRef(ref, ids)) },
621
- ]),
622
- ) as typeof data.signals,
623
- consequences: data.consequences.map((consequence) => ({
624
- ...consequence,
625
- fact: {
626
- ...consequence.fact,
627
- refs: consequence.fact.refs.map((ref) => mapRef(ref, ids)),
628
- },
629
- })),
630
- verification: data.verification.map((verification) => ({
631
- ...verification,
632
- availableChecks: verification.availableChecks.map((ref) => mapRef(ref, ids)),
633
- })),
634
- },
553
+ data: rewriteRecordRefs(data, assessmentSchema, remap) as typeof data,
635
554
  };
636
555
  });
637
556
  const evidence = source.evidence.map((entry) => ({
@@ -639,11 +558,7 @@ const importedTask = (
639
558
  id: fresh(entry.id),
640
559
  recordedAt: timestamp,
641
560
  provenance,
642
- data: {
643
- ...entry.data,
644
- refs: entry.data.refs.map((ref) => mapRef(ref, ids)),
645
- reviewContext: entry.data.reviewContext ? mapRef(entry.data.reviewContext, ids) : null,
646
- },
561
+ data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
647
562
  }));
648
563
  const decisions = source.decisions.map((entry) => ({
649
564
  ...entry,
@@ -651,23 +566,25 @@ const importedTask = (
651
566
  recordedAt: timestamp,
652
567
  provenance,
653
568
  }));
654
- const findings = source.findings.map((entry) => ({
655
- ...entry,
656
- id: fresh(entry.id),
657
- recordedAt: timestamp,
658
- provenance,
659
- data: {
660
- ...entry.data,
661
- refs: entry.data.refs.map((ref) => mapRef(ref, ids)),
662
- resolution: entry.data.resolution
663
- ? {
664
- ...entry.data.resolution,
665
- evidenceIds: entry.data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
666
- decisionIds: entry.data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
667
- }
668
- : null,
669
- },
670
- }));
569
+ const findings = source.findings.map((entry) => {
570
+ const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
571
+ return {
572
+ ...entry,
573
+ id: fresh(entry.id),
574
+ recordedAt: timestamp,
575
+ provenance,
576
+ data: {
577
+ ...data,
578
+ resolution: data.resolution
579
+ ? {
580
+ ...data.resolution,
581
+ evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
582
+ decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
583
+ }
584
+ : null,
585
+ },
586
+ };
587
+ });
671
588
  const workers = source.workers.map((entry) => ({
672
589
  ...entry,
673
590
  id: fresh(entry.id),
@@ -712,19 +629,21 @@ const importedTask = (
712
629
  closure: null,
713
630
  assessments,
714
631
  evidence,
715
- decisions: decisions.map((entry) => ({
716
- ...entry,
717
- data: {
718
- ...entry.data,
719
- binding: {
720
- ...entry.data.binding,
721
- taskId: "",
722
- workspaceId: destinationWorkspaceId,
723
- contentRefs: entry.data.binding.contentRefs.map((ref) => mapRef(ref, ids)),
632
+ decisions: decisions.map((entry) => {
633
+ const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
634
+ return {
635
+ ...entry,
636
+ data: {
637
+ ...data,
638
+ binding: {
639
+ ...data.binding,
640
+ taskId: "",
641
+ workspaceId: destinationWorkspaceId,
642
+ },
643
+ consumption: null,
724
644
  },
725
- consumption: null,
726
- },
727
- })),
645
+ };
646
+ }),
728
647
  ...(actionProgress ? { actionProgress } : {}),
729
648
  findings,
730
649
  workers,
@@ -829,7 +748,7 @@ export class WorkitCore {
829
748
  ) {
830
749
  if (workspace.writer)
831
750
  return failure("recovery_required", "writer ownership must be released first");
832
- if (task.workers.some((entry) => isUncertainWorker(entry.data.state)))
751
+ if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
833
752
  return failure("recovery_required", "worker state requires reconciliation");
834
753
  return null;
835
754
  }
@@ -1080,30 +999,23 @@ export class WorkitCore {
1080
999
  environment(),
1081
1000
  );
1082
1001
  if (!currentCandidate.ok) return currentCandidate as Result<never>;
1083
- if (evidence.kind === "check" || evidence.kind === "review") {
1084
- // Checks run against the tree as observed now; bind them to the captured
1085
- // candidate so callers never hand-compute digests. Later tree changes
1086
- // still mark this evidence stale through the normal evaluation.
1087
- evidence.beforeCandidateId ??= currentCandidate.data.id;
1088
- evidence.candidateId ??= currentCandidate.data.id;
1089
- } else {
1090
- evidence.beforeCandidateId ??= null;
1091
- evidence.candidateId ??= null;
1092
- }
1002
+ // Resolve the binding first (explicit IDs, else the worker pin, else the
1003
+ // current candidate), then validate and pin-check the resolved values —
1004
+ // never the caller's possibly-empty fields. The input itself is untouched.
1005
+ const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
1006
+ const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
1093
1007
  if (helper?.ok) {
1094
1008
  const assignment = helper.data.data.assignment;
1095
1009
  if (
1096
1010
  evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
1097
- (assignment.candidateId !== null &&
1098
- evidence.candidateId !== assignment.candidateId &&
1099
- evidence.beforeCandidateId !== assignment.candidateId) ||
1011
+ !checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
1100
1012
  !refsWithinScope(evidence.refs, assignment.scope) ||
1101
1013
  !refsWithinScope(evidence.refs, task.data.intent.data.scope)
1102
1014
  )
1103
1015
  return failure("permission_denied", "evidence is outside the worker assignment");
1104
1016
  }
1105
1017
  const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
1106
- for (const candidateId of [evidence.beforeCandidateId, evidence.candidateId]) {
1018
+ for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
1107
1019
  if (
1108
1020
  candidateId &&
1109
1021
  candidateId !== currentCandidate.data.id &&
@@ -1111,6 +1023,7 @@ export class WorkitCore {
1111
1023
  )
1112
1024
  return failure("invalid_input", "evidence candidate is not a captured candidate");
1113
1025
  }
1026
+ const boundEvidence = { ...evidence, ...bound };
1114
1027
  const changed = this.store.mutateTask(
1115
1028
  task.data.id,
1116
1029
  input.expectedRevision,
@@ -1122,14 +1035,14 @@ export class WorkitCore {
1122
1035
  id: newId(),
1123
1036
  recordedAt: mutation.now,
1124
1037
  provenance: provenance(this.context, "agent_reported"),
1125
- data: evidence,
1038
+ data: boundEvidence,
1126
1039
  };
1127
1040
  return success(mutation.revision, null, {
1128
1041
  ...current,
1129
1042
  candidates,
1130
1043
  evidence: [...current.evidence, entry],
1131
1044
  findings: current.findings.map((finding) => {
1132
- if (finding.data.disposition === "open" || evidence.result === "skipped")
1045
+ if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
1133
1046
  return finding;
1134
1047
  if (finding.data.disposition !== "fixed") return finding;
1135
1048
  // A fix stands while its verification still passes on the current
@@ -1142,17 +1055,17 @@ export class WorkitCore {
1142
1055
  item.status,
1143
1056
  ]),
1144
1057
  );
1145
- const verified = withEntry.evidence.some(
1146
- (item) =>
1147
- statuses.get(item.id) === "passed" &&
1148
- (item.data.kind === "check" || item.data.kind === "review") &&
1149
- (finding.data.candidateId === null ||
1150
- item.data.candidateId === finding.data.candidateId),
1058
+ const verified = withEntry.evidence.some((item) =>
1059
+ findingVerificationPasses(finding.data.candidateId, {
1060
+ kind: item.data.kind,
1061
+ candidateId: item.data.candidateId,
1062
+ status: statuses.get(item.id) ?? "missing",
1063
+ }),
1151
1064
  );
1152
1065
  const contradicted =
1153
- evidence.result === "failed" &&
1066
+ boundEvidence.result === "failed" &&
1154
1067
  finding.data.refs.some((left: Ref) =>
1155
- evidence.refs.some((right: Ref) => JSON.stringify(left) === JSON.stringify(right)),
1068
+ boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
1156
1069
  );
1157
1070
  return verified && !contradicted
1158
1071
  ? finding
@@ -1189,7 +1102,15 @@ export class WorkitCore {
1189
1102
  "permission_denied",
1190
1103
  "native observation is only valid for recording decisions",
1191
1104
  );
1192
- if (nativeRequired && nativeObservation === undefined)
1105
+ // Stated choices carry user-settled decisions without a native receipt.
1106
+ // They never authorize mutating actions; native verification is skipped
1107
+ // and provenance stays agent-reported.
1108
+ const stated = input.action === "record" && input.response === "stated";
1109
+ if (stated && input.purpose === "action")
1110
+ return failure("permission_denied", "stated choices cannot authorize mutating actions");
1111
+ if (stated && !input.binding?.statedChoice)
1112
+ return failure("invalid_input", "stated choices require binding.statedChoice");
1113
+ if (nativeRequired && nativeObservation === undefined && !stated)
1193
1114
  return failure("permission_denied", "native decision observation is required");
1194
1115
  const task = this.store.readTask(input.taskId);
1195
1116
  if (!task.ok) return task as Result<never>;
@@ -1220,26 +1141,27 @@ export class WorkitCore {
1220
1141
  consumption: null,
1221
1142
  } satisfies Omit<Decision, "digest">;
1222
1143
  const data: Decision = { ...base, digest: decisionDigest(base) };
1223
- const native = nativeRequired
1224
- ? verifyNativeDecision(
1225
- this.context.nativeAuthority,
1226
- {
1227
- observation: nativeObservation,
1228
- expected: {
1229
- taskId: task.data.id,
1230
- workspaceId: workspace.data.id,
1231
- purpose: input.purpose,
1232
- response: input.response,
1233
- binding: input.binding,
1234
- bindingBytes: canonicalJson(input.binding),
1235
- digest: data.digest,
1236
- requirementIds: [...input.requirementIds],
1144
+ const native =
1145
+ nativeRequired && !stated
1146
+ ? verifyNativeDecision(
1147
+ this.context.nativeAuthority,
1148
+ {
1149
+ observation: nativeObservation,
1150
+ expected: {
1151
+ taskId: task.data.id,
1152
+ workspaceId: workspace.data.id,
1153
+ purpose: input.purpose,
1154
+ response: input.response,
1155
+ binding: input.binding,
1156
+ bindingBytes: canonicalJson(input.binding),
1157
+ digest: data.digest,
1158
+ requirementIds: [...input.requirementIds],
1159
+ },
1160
+ caller: this.context.caller,
1237
1161
  },
1238
- caller: this.context.caller,
1239
- },
1240
- { owner: this.authorityOwner, store: this.store, root: this.store.root },
1241
- )
1242
- : null;
1162
+ { owner: this.authorityOwner, store: this.store, root: this.store.root },
1163
+ )
1164
+ : null;
1243
1165
  if (native && !native.ok) return native as Result<never>;
1244
1166
  const nativeProvenance = native?.ok ? retireNativeAuthority(native.data) : null;
1245
1167
  if (native?.ok && !nativeProvenance)
@@ -1337,7 +1259,7 @@ export class WorkitCore {
1337
1259
  if (
1338
1260
  !bindingCovers(assignment.scope, input.scope) ||
1339
1261
  !bindingCovers(task.data.intent.data.scope, input.scope) ||
1340
- (assignment.candidateId !== null && input.candidateId !== assignment.candidateId) ||
1262
+ !checkPin(assignment, null, input.candidateId ?? null) ||
1341
1263
  !refsWithinScope(input.refs, assignment.scope) ||
1342
1264
  !refsWithinScope(input.refs, task.data.intent.data.scope)
1343
1265
  )
@@ -1394,11 +1316,11 @@ export class WorkitCore {
1394
1316
  const evaluations = evaluateEvidence(task.data, current.data);
1395
1317
  const verified = evidence.some((entry) => {
1396
1318
  const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1397
- return (
1398
- evaluation?.status === "passed" &&
1399
- (entry.data.kind === "check" || entry.data.kind === "review") &&
1400
- (finding.data.candidateId === null || entry.data.candidateId === finding.data.candidateId)
1401
- );
1319
+ return findingVerificationPasses(finding.data.candidateId, {
1320
+ kind: entry.data.kind,
1321
+ candidateId: entry.data.candidateId,
1322
+ status: evaluation?.status ?? "missing",
1323
+ });
1402
1324
  });
1403
1325
  if (!verified)
1404
1326
  return failure("permission_denied", "fixed findings require passing verification evidence");
@@ -1414,7 +1336,7 @@ export class WorkitCore {
1414
1336
  : entry.data.candidateId === finding.data.candidateId ||
1415
1337
  entry.data.beforeCandidateId === finding.data.candidateId;
1416
1338
  const referenceMatches = finding.data.refs.some((left) =>
1417
- entry.data.refs.some((right) => JSON.stringify(left) === JSON.stringify(right)),
1339
+ entry.data.refs.some((right) => sameValue(left, right)),
1418
1340
  );
1419
1341
  return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
1420
1342
  });
@@ -1537,7 +1459,7 @@ export class WorkitCore {
1537
1459
  const workerId = newId();
1538
1460
  const changed = this.store.mutateTaskAndWorkspace({
1539
1461
  taskId: task.data.id,
1540
- expectedTaskRevision: input.expectedRevision,
1462
+ expectedRevision: input.expectedRevision,
1541
1463
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1542
1464
  now: trustedNow(this.context),
1543
1465
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1597,7 +1519,7 @@ export class WorkitCore {
1597
1519
  return failure("permission_denied", "worker report is outside the worker assignment");
1598
1520
  const changed = this.store.mutateTaskAndWorkspace({
1599
1521
  taskId: task.data.id,
1600
- expectedTaskRevision: input.expectedRevision,
1522
+ expectedRevision: input.expectedRevision,
1601
1523
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1602
1524
  now: trustedNow(this.context),
1603
1525
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1630,20 +1552,28 @@ export class WorkitCore {
1630
1552
  return failure("invalid_transition", "closed task cannot cancel workers");
1631
1553
  if (entry.data.state === "unknown")
1632
1554
  return failure("recovery_required", "worker state requires recovery");
1633
- if (entry.data.state === "stopped")
1634
- return failure("invalid_transition", "stopped worker cannot be cancelled");
1635
- // A reported worker already made its terminal session-authenticated
1636
- // statement, and a repeat cancel is the lead's explicit confirmation that
1637
- // an unconfirmed worker ended. Both settle instead of stranding the worker
1638
- // in cancelling when the session end was never observed. A first cancel of
1639
- // a live unreported worker still waits for an observed stop.
1640
- const settles = entry.data.report !== null || entry.data.state === "cancelling";
1555
+ if (entry.data.state === "stopped") {
1556
+ // Idempotent cancel: repeating a settled stop succeeds with the
1557
+ // current entry instead of failing the retry.
1558
+ const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
1559
+ return success(task.data.revision, workspace.data.revision, stopped);
1560
+ }
1561
+ // Lead-attested cancel is terminal for every live state: the lead saw
1562
+ // the run end, so no host observation is awaited. A later live-child
1563
+ // sighting against a stopped worker is a new anomaly for the host to
1564
+ // flag, not a reason to strand the worker short of stopped. A writer
1565
+ // held by the cancelled worker dies with it; anything else would brick
1566
+ // the checkout behind a stopped owner.
1641
1567
  const changed = this.store.mutateTaskAndWorkspace({
1642
1568
  taskId: task.data.id,
1643
- expectedTaskRevision: input.expectedRevision,
1569
+ expectedRevision: input.expectedRevision,
1644
1570
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1645
1571
  now: trustedNow(this.context),
1646
- workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
1572
+ workspace: (current, mutation) =>
1573
+ success(mutation.revision, mutation.revision, {
1574
+ ...current,
1575
+ writer: current.writer?.owner.workerId === input.workerId ? null : current.writer,
1576
+ }),
1647
1577
  task: (current, mutation) =>
1648
1578
  success(mutation.revision, null, {
1649
1579
  ...current,
@@ -1652,10 +1582,7 @@ export class WorkitCore {
1652
1582
  ? {
1653
1583
  ...candidate,
1654
1584
  recordedAt: mutation.now,
1655
- data: {
1656
- ...candidate.data,
1657
- state: settles ? ("stopped" as const) : ("cancelling" as const),
1658
- },
1585
+ data: { ...candidate.data, state: "stopped" as const },
1659
1586
  }
1660
1587
  : candidate,
1661
1588
  ),
@@ -1767,7 +1694,7 @@ export class WorkitCore {
1767
1694
  if (!attested.ok) return attested as Result<never>;
1768
1695
  const changed = this.store.mutateTaskAndWorkspace({
1769
1696
  taskId: task.data.id,
1770
- expectedTaskRevision: input.expectedRevision,
1697
+ expectedRevision: input.expectedRevision,
1771
1698
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1772
1699
  now: trustedNow(this.context),
1773
1700
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1949,6 +1876,26 @@ export class WorkitCore {
1949
1876
  if (!sameSession(helper.data.data.session, this.context))
1950
1877
  return failure("permission_denied", "worker session does not match the caller");
1951
1878
  }
1879
+ // Write-timed requirements gate acquisition, not just close: a spec
1880
+ // gate is enforced where writes begin, never as advisory prose.
1881
+ const gate = this.view(task.data);
1882
+ if (gate.ok) {
1883
+ const writeGates = (task.data.policy?.requirements ?? []).filter(
1884
+ (requirement) =>
1885
+ requirement.before === "write" &&
1886
+ gate.data.requirements.some(
1887
+ (evaluation) =>
1888
+ evaluation.requirementId === requirement.id &&
1889
+ (evaluation.status === "unsatisfied" || evaluation.status === "unavailable"),
1890
+ ),
1891
+ );
1892
+ if (writeGates.length)
1893
+ return failure(
1894
+ "requirements_unsatisfied",
1895
+ "writer ownership is gated by unsatisfied requirements; record their evidence or an approved limitation waiver first",
1896
+ { requirementIds: writeGates.map((requirement) => requirement.id) },
1897
+ );
1898
+ }
1952
1899
  const owner = {
1953
1900
  taskId: task.data.id,
1954
1901
  workerId: helperId,
@@ -1960,7 +1907,7 @@ export class WorkitCore {
1960
1907
  };
1961
1908
  const changed = this.store.mutateTaskAndWorkspace({
1962
1909
  taskId: task.data.id,
1963
- expectedTaskRevision: input.expectedRevision,
1910
+ expectedRevision: input.expectedRevision,
1964
1911
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1965
1912
  now: trustedNow(this.context),
1966
1913
  workspace: (current, mutation) =>
@@ -1997,7 +1944,7 @@ export class WorkitCore {
1997
1944
  }
1998
1945
  const changed = this.store.mutateTaskAndWorkspace({
1999
1946
  taskId: task.data.id,
2000
- expectedTaskRevision: input.expectedRevision,
1947
+ expectedRevision: input.expectedRevision,
2001
1948
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2002
1949
  now: trustedNow(this.context),
2003
1950
  workspace: (currentWorkspace, mutation) =>
@@ -2308,10 +2255,18 @@ export class WorkitCore {
2308
2255
  if (seen.has(observation.workerId))
2309
2256
  return failure("invalid_input", "worker observations must be unique");
2310
2257
  seen.add(observation.workerId);
2258
+ // Anchor on fresh reads, not the passed view: a view captured before
2259
+ // recent mutations must not stale-fail observations that match the
2260
+ // current state. Genuinely stale observations still conflict below.
2261
+ const freshTask = this.store.readTask(view.task.id);
2262
+ const freshWorkspace = this.store.readWorkspace();
2263
+ if (!freshTask.ok) return freshTask as Result<never>;
2264
+ if (!freshWorkspace.ok) return freshWorkspace as Result<never>;
2265
+ if (!freshWorkspace.data) return failure("not_found", "workspace not found");
2311
2266
  if (
2312
2267
  observation.taskId !== view.task.id ||
2313
- observation.expectedRevision !== view.task.revision ||
2314
- observation.expectedWorkspaceRevision !== view.workspace.revision
2268
+ observation.expectedRevision !== freshTask.data.revision ||
2269
+ observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
2315
2270
  )
2316
2271
  return failure("revision_conflict", "worker observation is stale");
2317
2272
  if (!view.task.workers.some((entry) => entry.id === observation.workerId))
@@ -2424,11 +2379,27 @@ export class WorkitCore {
2424
2379
  return failure("permission_denied", "imported resume requires native destination approval");
2425
2380
  resumeCandidate = current.data.candidate;
2426
2381
  }
2427
- const blocker = this.activeWorkerBlocker(task.data, workspace.data);
2382
+ const blocker =
2383
+ status === "paused"
2384
+ ? workspace.data.writer
2385
+ ? failure("recovery_required", "writer ownership must be released first")
2386
+ : null
2387
+ : this.activeWorkerBlocker(task.data, workspace.data);
2428
2388
  if (blocker) return blocker as Result<never>;
2389
+ let pauseCandidate: import("./task-contract").Candidate | null = null;
2390
+ if (status === "paused") {
2391
+ // Freezing records the tree state the task resumes from.
2392
+ const captured = captureCandidate(
2393
+ this.store.root,
2394
+ task.data.intent.data.scope,
2395
+ environment(),
2396
+ );
2397
+ if (!captured.ok) return captured as Result<never>;
2398
+ pauseCandidate = captured.data;
2399
+ }
2429
2400
  const changed = this.store.mutateTaskAndWorkspace({
2430
2401
  taskId: task.data.id,
2431
- expectedTaskRevision: input.expectedRevision,
2402
+ expectedRevision: input.expectedRevision,
2432
2403
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2433
2404
  now: trustedNow(this.context),
2434
2405
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2437,9 +2408,11 @@ export class WorkitCore {
2437
2408
  ...current,
2438
2409
  status,
2439
2410
  candidates:
2440
- resumeCandidate &&
2441
- !current.candidates.some((candidate) => candidate.id === resumeCandidate!.id)
2442
- ? [...current.candidates, resumeCandidate]
2411
+ (resumeCandidate ?? pauseCandidate) &&
2412
+ !current.candidates.some(
2413
+ (candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
2414
+ )
2415
+ ? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
2443
2416
  : current.candidates,
2444
2417
  progress: current.progress,
2445
2418
  pauseReason: status === "paused" ? (input.reason ?? null) : null,
@@ -2478,18 +2451,12 @@ export class WorkitCore {
2478
2451
  const workspace = this.store.readWorkspace();
2479
2452
  if (!workspace.ok) return workspace as Result<never>;
2480
2453
  if (!workspace.data) return failure("not_found", "workspace not found");
2481
- if (
2482
- workspace.data.writer ||
2483
- task.data.workers.some((entry) => isUncertainWorker(entry.data.state))
2484
- )
2485
- return failure(
2486
- "recovery_required",
2487
- "scope revision requires worker ownership reconciliation",
2488
- );
2454
+ const revisionBlocker = this.activeWorkerBlocker(task.data, workspace.data);
2455
+ if (revisionBlocker) return revisionBlocker as Result<never>;
2489
2456
  this.fillRevisions(input, task.data, workspace.data);
2490
2457
  const changed = this.store.mutateTaskAndWorkspace({
2491
2458
  taskId: task.data.id,
2492
- expectedTaskRevision: input.expectedRevision,
2459
+ expectedRevision: input.expectedRevision,
2493
2460
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2494
2461
  now: trustedNow(this.context),
2495
2462
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2549,7 +2516,7 @@ export class WorkitCore {
2549
2516
  if (!closure.ok) return closure as Result<never>;
2550
2517
  const changed = this.store.mutateTaskAndWorkspace({
2551
2518
  taskId: task.data.id,
2552
- expectedTaskRevision: input.expectedRevision,
2519
+ expectedRevision: input.expectedRevision,
2553
2520
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2554
2521
  now: trustedNow(this.context),
2555
2522
  workspace: (value, context) => success(context.revision, context.revision, value),