@brainervirus/workit-core 1.0.11 → 1.1.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.
@@ -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,
@@ -50,6 +56,7 @@ import {
50
56
  type ReconcileActionInput,
51
57
  } from "./authority";
52
58
  import { diffPolicy, resolvePolicy } from "./policy-resolver";
59
+ import { verifyStandingApproval } from "./auto-approval";
53
60
  import { TaskStore, type MetadataLock, type ProcessEvidence } from "./task-store";
54
61
  import {
55
62
  exportDigest,
@@ -59,6 +66,7 @@ import {
59
66
  import {
60
67
  assertProductWriteAllowed,
61
68
  isUncertainWorker,
69
+ workerBlocksTransition,
62
70
  type CallerContext,
63
71
  type NativeWorkerObservation,
64
72
  type NativeWorkerVerification,
@@ -70,9 +78,12 @@ import {
70
78
  } from "./workers";
71
79
  import {
72
80
  captureCandidate,
81
+ checkPin,
73
82
  evaluateClosure,
74
83
  evaluateEvidence,
75
84
  evaluateRequirements,
85
+ findingVerificationPasses,
86
+ resolveBinding,
76
87
  type CandidateEnvironment,
77
88
  } from "./task-evaluation";
78
89
 
@@ -342,6 +353,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
342
353
  if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
343
354
  return failure("invalid_transition", "paused task cannot run a worker");
344
355
  const notStarted = input.dispatch === true;
356
+ if (notStarted && entry.data.state === "stopped" && input.session === null)
357
+ return success(task.data.revision, workspace.data.revision, entry);
345
358
  if (notStarted && (input.state !== "stopped" || input.session !== null))
346
359
  return failure("invalid_input", "a never-dispatched worker stops with no session");
347
360
  if (notStarted && entry.data.session !== null)
@@ -395,7 +408,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
395
408
  return success(task.data.revision, workspace.data.revision, entry);
396
409
  const changed = input.store.mutateTaskAndWorkspace({
397
410
  taskId: task.data.id,
398
- expectedTaskRevision: input.expectedRevision,
411
+ expectedRevision: input.expectedRevision,
399
412
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
400
413
  now: input.now,
401
414
  workspace: (current, mutation) =>
@@ -469,96 +482,28 @@ const importedProvenance = (context: OperationContext): Provenance => ({
469
482
  });
470
483
 
471
484
  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
- })),
485
+ // Schema-driven ref stripping: every Ref anywhere in the record is mapped
486
+ // by shape (arrays drop unportable refs, nullable fields null them), so a
487
+ // new ref-bearing field can never silently leak across checkouts.
488
+ const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
489
+ if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
490
+ throw new TypeError("portable task rewrite produced a non-record");
491
+ const clone = structuredClone(stripped) as TaskRecord;
492
+ const demote = (
493
+ facts: Assessment["facts"][number][],
494
+ signals: Assessment["signals"],
495
+ consequences: Assessment["consequences"],
496
+ ): void => {
497
+ for (const fact of facts) Object.assign(fact, portableFact(fact));
498
+ for (const name of Object.keys(signals) as (keyof Assessment["signals"])[]) {
499
+ const signal = signals[name];
500
+ Object.assign(signal, portableSignal(signal));
501
+ }
502
+ for (const consequence of consequences)
503
+ Object.assign(consequence.fact, portableFact(consequence.fact));
531
504
  };
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
- }));
505
+ for (const entry of clone.assessments)
506
+ demote(entry.data.facts, entry.data.signals, entry.data.consequences);
562
507
  // Candidate metadata can contain environment-derived paths and digests. The destination
563
508
  // must recapture its own candidate instead of receiving source checkout material.
564
509
  clone.candidates = [];
@@ -591,15 +536,13 @@ const importedTask = (
591
536
  allocate(entry.id);
592
537
  const fresh = (id: string) => allocate(id);
593
538
  const provenance = importedProvenance(context);
539
+ const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
594
540
  const intent = {
595
541
  ...source.intent,
596
542
  id: fresh(source.intent.id),
597
543
  recordedAt: timestamp,
598
544
  provenance,
599
- data: {
600
- ...source.intent.data,
601
- authorityRefs: source.intent.data.authorityRefs.map((ref) => mapRef(ref, ids)),
602
- },
545
+ data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
603
546
  };
604
547
  const assessments = source.assessments.map((entry) => {
605
548
  const data = entry.data;
@@ -608,30 +551,7 @@ const importedTask = (
608
551
  id: fresh(entry.id),
609
552
  recordedAt: timestamp,
610
553
  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
- },
554
+ data: rewriteRecordRefs(data, assessmentSchema, remap) as typeof data,
635
555
  };
636
556
  });
637
557
  const evidence = source.evidence.map((entry) => ({
@@ -639,11 +559,7 @@ const importedTask = (
639
559
  id: fresh(entry.id),
640
560
  recordedAt: timestamp,
641
561
  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
- },
562
+ data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
647
563
  }));
648
564
  const decisions = source.decisions.map((entry) => ({
649
565
  ...entry,
@@ -651,23 +567,25 @@ const importedTask = (
651
567
  recordedAt: timestamp,
652
568
  provenance,
653
569
  }));
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
- }));
570
+ const findings = source.findings.map((entry) => {
571
+ const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
572
+ return {
573
+ ...entry,
574
+ id: fresh(entry.id),
575
+ recordedAt: timestamp,
576
+ provenance,
577
+ data: {
578
+ ...data,
579
+ resolution: data.resolution
580
+ ? {
581
+ ...data.resolution,
582
+ evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
583
+ decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
584
+ }
585
+ : null,
586
+ },
587
+ };
588
+ });
671
589
  const workers = source.workers.map((entry) => ({
672
590
  ...entry,
673
591
  id: fresh(entry.id),
@@ -712,19 +630,21 @@ const importedTask = (
712
630
  closure: null,
713
631
  assessments,
714
632
  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)),
633
+ decisions: decisions.map((entry) => {
634
+ const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
635
+ return {
636
+ ...entry,
637
+ data: {
638
+ ...data,
639
+ binding: {
640
+ ...data.binding,
641
+ taskId: "",
642
+ workspaceId: destinationWorkspaceId,
643
+ },
644
+ consumption: null,
724
645
  },
725
- consumption: null,
726
- },
727
- })),
646
+ };
647
+ }),
728
648
  ...(actionProgress ? { actionProgress } : {}),
729
649
  findings,
730
650
  workers,
@@ -829,7 +749,7 @@ export class WorkitCore {
829
749
  ) {
830
750
  if (workspace.writer)
831
751
  return failure("recovery_required", "writer ownership must be released first");
832
- if (task.workers.some((entry) => isUncertainWorker(entry.data.state)))
752
+ if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
833
753
  return failure("recovery_required", "worker state requires reconciliation");
834
754
  return null;
835
755
  }
@@ -1080,30 +1000,23 @@ export class WorkitCore {
1080
1000
  environment(),
1081
1001
  );
1082
1002
  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
- }
1003
+ // Resolve the binding first (explicit IDs, else the worker pin, else the
1004
+ // current candidate), then validate and pin-check the resolved values —
1005
+ // never the caller's possibly-empty fields. The input itself is untouched.
1006
+ const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
1007
+ const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
1093
1008
  if (helper?.ok) {
1094
1009
  const assignment = helper.data.data.assignment;
1095
1010
  if (
1096
1011
  evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
1097
- (assignment.candidateId !== null &&
1098
- evidence.candidateId !== assignment.candidateId &&
1099
- evidence.beforeCandidateId !== assignment.candidateId) ||
1012
+ !checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
1100
1013
  !refsWithinScope(evidence.refs, assignment.scope) ||
1101
1014
  !refsWithinScope(evidence.refs, task.data.intent.data.scope)
1102
1015
  )
1103
1016
  return failure("permission_denied", "evidence is outside the worker assignment");
1104
1017
  }
1105
1018
  const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
1106
- for (const candidateId of [evidence.beforeCandidateId, evidence.candidateId]) {
1019
+ for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
1107
1020
  if (
1108
1021
  candidateId &&
1109
1022
  candidateId !== currentCandidate.data.id &&
@@ -1111,6 +1024,7 @@ export class WorkitCore {
1111
1024
  )
1112
1025
  return failure("invalid_input", "evidence candidate is not a captured candidate");
1113
1026
  }
1027
+ const boundEvidence = { ...evidence, ...bound };
1114
1028
  const changed = this.store.mutateTask(
1115
1029
  task.data.id,
1116
1030
  input.expectedRevision,
@@ -1122,14 +1036,14 @@ export class WorkitCore {
1122
1036
  id: newId(),
1123
1037
  recordedAt: mutation.now,
1124
1038
  provenance: provenance(this.context, "agent_reported"),
1125
- data: evidence,
1039
+ data: boundEvidence,
1126
1040
  };
1127
1041
  return success(mutation.revision, null, {
1128
1042
  ...current,
1129
1043
  candidates,
1130
1044
  evidence: [...current.evidence, entry],
1131
1045
  findings: current.findings.map((finding) => {
1132
- if (finding.data.disposition === "open" || evidence.result === "skipped")
1046
+ if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
1133
1047
  return finding;
1134
1048
  if (finding.data.disposition !== "fixed") return finding;
1135
1049
  // A fix stands while its verification still passes on the current
@@ -1142,17 +1056,17 @@ export class WorkitCore {
1142
1056
  item.status,
1143
1057
  ]),
1144
1058
  );
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),
1059
+ const verified = withEntry.evidence.some((item) =>
1060
+ findingVerificationPasses(finding.data.candidateId, {
1061
+ kind: item.data.kind,
1062
+ candidateId: item.data.candidateId,
1063
+ status: statuses.get(item.id) ?? "missing",
1064
+ }),
1151
1065
  );
1152
1066
  const contradicted =
1153
- evidence.result === "failed" &&
1067
+ boundEvidence.result === "failed" &&
1154
1068
  finding.data.refs.some((left: Ref) =>
1155
- evidence.refs.some((right: Ref) => JSON.stringify(left) === JSON.stringify(right)),
1069
+ boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
1156
1070
  );
1157
1071
  return verified && !contradicted
1158
1072
  ? finding
@@ -1174,10 +1088,21 @@ export class WorkitCore {
1174
1088
  return this.recordDecision(request, observation, true);
1175
1089
  }
1176
1090
 
1091
+ /**
1092
+ * Record a standing auto-approval: no live question, authorized by the
1093
+ * workspace rule named in binding.standing while it covers the operation.
1094
+ * Lead-only, task-scoped, verified live here — revocation is config
1095
+ * removal, which fails the next reserve closed.
1096
+ */
1097
+ observeStandingDecision(request: unknown): Result<Entry<Decision>> {
1098
+ return this.recordDecision(request, undefined, true, true);
1099
+ }
1100
+
1177
1101
  private recordDecision(
1178
1102
  request: unknown,
1179
1103
  nativeObservation?: unknown,
1180
1104
  nativeRequired = false,
1105
+ standing = false,
1181
1106
  ): Result<Entry<Decision>> {
1182
1107
  const root = this.contextRootError();
1183
1108
  if (!root.ok) return root as Result<never>;
@@ -1197,8 +1122,16 @@ export class WorkitCore {
1197
1122
  return failure("permission_denied", "stated choices cannot authorize mutating actions");
1198
1123
  if (stated && !input.binding?.statedChoice)
1199
1124
  return failure("invalid_input", "stated choices require binding.statedChoice");
1200
- if (nativeRequired && nativeObservation === undefined && !stated)
1125
+ if (nativeRequired && nativeObservation === undefined && !stated && !standing)
1201
1126
  return failure("permission_denied", "native decision observation is required");
1127
+ if (
1128
+ standing &&
1129
+ (!input.binding?.standing || input.purpose !== "action" || input.response !== "approved")
1130
+ )
1131
+ return failure(
1132
+ "invalid_input",
1133
+ "standing approvals need an approved action binding with a standing rule",
1134
+ );
1202
1135
  const task = this.store.readTask(input.taskId);
1203
1136
  if (!task.ok) return task as Result<never>;
1204
1137
  if ((this.context.workerId ?? null) !== null)
@@ -1228,8 +1161,13 @@ export class WorkitCore {
1228
1161
  consumption: null,
1229
1162
  } satisfies Omit<Decision, "digest">;
1230
1163
  const data: Decision = { ...base, digest: decisionDigest(base) };
1164
+ const standingApproval =
1165
+ standing && !stated
1166
+ ? verifyStandingApproval(this.store.root, task.data, this.context.caller, input.binding)
1167
+ : null;
1168
+ if (standingApproval && !standingApproval.ok) return standingApproval as Result<never>;
1231
1169
  const native =
1232
- nativeRequired && !stated
1170
+ nativeRequired && !stated && !standingApproval
1233
1171
  ? verifyNativeDecision(
1234
1172
  this.context.nativeAuthority,
1235
1173
  {
@@ -1276,7 +1214,10 @@ export class WorkitCore {
1276
1214
  const entry: Entry<Decision> = {
1277
1215
  id: newId(),
1278
1216
  recordedAt: mutation.now,
1279
- provenance: nativeProvenance ?? provenance(this.context, "agent_reported"),
1217
+ provenance:
1218
+ standingApproval?.ok === true
1219
+ ? standingApproval.data
1220
+ : (nativeProvenance ?? provenance(this.context, "agent_reported")),
1280
1221
  data,
1281
1222
  };
1282
1223
  return success(mutation.revision, null, {
@@ -1346,7 +1287,7 @@ export class WorkitCore {
1346
1287
  if (
1347
1288
  !bindingCovers(assignment.scope, input.scope) ||
1348
1289
  !bindingCovers(task.data.intent.data.scope, input.scope) ||
1349
- (assignment.candidateId !== null && input.candidateId !== assignment.candidateId) ||
1290
+ !checkPin(assignment, null, input.candidateId ?? null) ||
1350
1291
  !refsWithinScope(input.refs, assignment.scope) ||
1351
1292
  !refsWithinScope(input.refs, task.data.intent.data.scope)
1352
1293
  )
@@ -1403,11 +1344,11 @@ export class WorkitCore {
1403
1344
  const evaluations = evaluateEvidence(task.data, current.data);
1404
1345
  const verified = evidence.some((entry) => {
1405
1346
  const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1406
- return (
1407
- evaluation?.status === "passed" &&
1408
- (entry.data.kind === "check" || entry.data.kind === "review") &&
1409
- (finding.data.candidateId === null || entry.data.candidateId === finding.data.candidateId)
1410
- );
1347
+ return findingVerificationPasses(finding.data.candidateId, {
1348
+ kind: entry.data.kind,
1349
+ candidateId: entry.data.candidateId,
1350
+ status: evaluation?.status ?? "missing",
1351
+ });
1411
1352
  });
1412
1353
  if (!verified)
1413
1354
  return failure("permission_denied", "fixed findings require passing verification evidence");
@@ -1423,7 +1364,7 @@ export class WorkitCore {
1423
1364
  : entry.data.candidateId === finding.data.candidateId ||
1424
1365
  entry.data.beforeCandidateId === finding.data.candidateId;
1425
1366
  const referenceMatches = finding.data.refs.some((left) =>
1426
- entry.data.refs.some((right) => JSON.stringify(left) === JSON.stringify(right)),
1367
+ entry.data.refs.some((right) => sameValue(left, right)),
1427
1368
  );
1428
1369
  return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
1429
1370
  });
@@ -1546,7 +1487,7 @@ export class WorkitCore {
1546
1487
  const workerId = newId();
1547
1488
  const changed = this.store.mutateTaskAndWorkspace({
1548
1489
  taskId: task.data.id,
1549
- expectedTaskRevision: input.expectedRevision,
1490
+ expectedRevision: input.expectedRevision,
1550
1491
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1551
1492
  now: trustedNow(this.context),
1552
1493
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1606,7 +1547,7 @@ export class WorkitCore {
1606
1547
  return failure("permission_denied", "worker report is outside the worker assignment");
1607
1548
  const changed = this.store.mutateTaskAndWorkspace({
1608
1549
  taskId: task.data.id,
1609
- expectedTaskRevision: input.expectedRevision,
1550
+ expectedRevision: input.expectedRevision,
1610
1551
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1611
1552
  now: trustedNow(this.context),
1612
1553
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1639,20 +1580,28 @@ export class WorkitCore {
1639
1580
  return failure("invalid_transition", "closed task cannot cancel workers");
1640
1581
  if (entry.data.state === "unknown")
1641
1582
  return failure("recovery_required", "worker state requires recovery");
1642
- if (entry.data.state === "stopped")
1643
- return failure("invalid_transition", "stopped worker cannot be cancelled");
1644
- // A reported worker already made its terminal session-authenticated
1645
- // statement, and a repeat cancel is the lead's explicit confirmation that
1646
- // an unconfirmed worker ended. Both settle instead of stranding the worker
1647
- // in cancelling when the session end was never observed. A first cancel of
1648
- // a live unreported worker still waits for an observed stop.
1649
- const settles = entry.data.report !== null || entry.data.state === "cancelling";
1583
+ if (entry.data.state === "stopped") {
1584
+ // Idempotent cancel: repeating a settled stop succeeds with the
1585
+ // current entry instead of failing the retry.
1586
+ const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
1587
+ return success(task.data.revision, workspace.data.revision, stopped);
1588
+ }
1589
+ // Lead-attested cancel is terminal for every live state: the lead saw
1590
+ // the run end, so no host observation is awaited. A later live-child
1591
+ // sighting against a stopped worker is a new anomaly for the host to
1592
+ // flag, not a reason to strand the worker short of stopped. A writer
1593
+ // held by the cancelled worker dies with it; anything else would brick
1594
+ // the checkout behind a stopped owner.
1650
1595
  const changed = this.store.mutateTaskAndWorkspace({
1651
1596
  taskId: task.data.id,
1652
- expectedTaskRevision: input.expectedRevision,
1597
+ expectedRevision: input.expectedRevision,
1653
1598
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1654
1599
  now: trustedNow(this.context),
1655
- workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
1600
+ workspace: (current, mutation) =>
1601
+ success(mutation.revision, mutation.revision, {
1602
+ ...current,
1603
+ writer: current.writer?.owner.workerId === input.workerId ? null : current.writer,
1604
+ }),
1656
1605
  task: (current, mutation) =>
1657
1606
  success(mutation.revision, null, {
1658
1607
  ...current,
@@ -1661,10 +1610,7 @@ export class WorkitCore {
1661
1610
  ? {
1662
1611
  ...candidate,
1663
1612
  recordedAt: mutation.now,
1664
- data: {
1665
- ...candidate.data,
1666
- state: settles ? ("stopped" as const) : ("cancelling" as const),
1667
- },
1613
+ data: { ...candidate.data, state: "stopped" as const },
1668
1614
  }
1669
1615
  : candidate,
1670
1616
  ),
@@ -1776,7 +1722,7 @@ export class WorkitCore {
1776
1722
  if (!attested.ok) return attested as Result<never>;
1777
1723
  const changed = this.store.mutateTaskAndWorkspace({
1778
1724
  taskId: task.data.id,
1779
- expectedTaskRevision: input.expectedRevision,
1725
+ expectedRevision: input.expectedRevision,
1780
1726
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1781
1727
  now: trustedNow(this.context),
1782
1728
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1958,6 +1904,26 @@ export class WorkitCore {
1958
1904
  if (!sameSession(helper.data.data.session, this.context))
1959
1905
  return failure("permission_denied", "worker session does not match the caller");
1960
1906
  }
1907
+ // Write-timed requirements gate acquisition, not just close: a spec
1908
+ // gate is enforced where writes begin, never as advisory prose.
1909
+ const gate = this.view(task.data);
1910
+ if (gate.ok) {
1911
+ const writeGates = (task.data.policy?.requirements ?? []).filter(
1912
+ (requirement) =>
1913
+ requirement.before === "write" &&
1914
+ gate.data.requirements.some(
1915
+ (evaluation) =>
1916
+ evaluation.requirementId === requirement.id &&
1917
+ (evaluation.status === "unsatisfied" || evaluation.status === "unavailable"),
1918
+ ),
1919
+ );
1920
+ if (writeGates.length)
1921
+ return failure(
1922
+ "requirements_unsatisfied",
1923
+ "writer ownership is gated by unsatisfied requirements; record their evidence or an approved limitation waiver first",
1924
+ { requirementIds: writeGates.map((requirement) => requirement.id) },
1925
+ );
1926
+ }
1961
1927
  const owner = {
1962
1928
  taskId: task.data.id,
1963
1929
  workerId: helperId,
@@ -1969,7 +1935,7 @@ export class WorkitCore {
1969
1935
  };
1970
1936
  const changed = this.store.mutateTaskAndWorkspace({
1971
1937
  taskId: task.data.id,
1972
- expectedTaskRevision: input.expectedRevision,
1938
+ expectedRevision: input.expectedRevision,
1973
1939
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1974
1940
  now: trustedNow(this.context),
1975
1941
  workspace: (current, mutation) =>
@@ -2006,7 +1972,7 @@ export class WorkitCore {
2006
1972
  }
2007
1973
  const changed = this.store.mutateTaskAndWorkspace({
2008
1974
  taskId: task.data.id,
2009
- expectedTaskRevision: input.expectedRevision,
1975
+ expectedRevision: input.expectedRevision,
2010
1976
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2011
1977
  now: trustedNow(this.context),
2012
1978
  workspace: (currentWorkspace, mutation) =>
@@ -2317,10 +2283,18 @@ export class WorkitCore {
2317
2283
  if (seen.has(observation.workerId))
2318
2284
  return failure("invalid_input", "worker observations must be unique");
2319
2285
  seen.add(observation.workerId);
2286
+ // Anchor on fresh reads, not the passed view: a view captured before
2287
+ // recent mutations must not stale-fail observations that match the
2288
+ // current state. Genuinely stale observations still conflict below.
2289
+ const freshTask = this.store.readTask(view.task.id);
2290
+ const freshWorkspace = this.store.readWorkspace();
2291
+ if (!freshTask.ok) return freshTask as Result<never>;
2292
+ if (!freshWorkspace.ok) return freshWorkspace as Result<never>;
2293
+ if (!freshWorkspace.data) return failure("not_found", "workspace not found");
2320
2294
  if (
2321
2295
  observation.taskId !== view.task.id ||
2322
- observation.expectedRevision !== view.task.revision ||
2323
- observation.expectedWorkspaceRevision !== view.workspace.revision
2296
+ observation.expectedRevision !== freshTask.data.revision ||
2297
+ observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
2324
2298
  )
2325
2299
  return failure("revision_conflict", "worker observation is stale");
2326
2300
  if (!view.task.workers.some((entry) => entry.id === observation.workerId))
@@ -2433,11 +2407,27 @@ export class WorkitCore {
2433
2407
  return failure("permission_denied", "imported resume requires native destination approval");
2434
2408
  resumeCandidate = current.data.candidate;
2435
2409
  }
2436
- const blocker = this.activeWorkerBlocker(task.data, workspace.data);
2410
+ const blocker =
2411
+ status === "paused"
2412
+ ? workspace.data.writer
2413
+ ? failure("recovery_required", "writer ownership must be released first")
2414
+ : null
2415
+ : this.activeWorkerBlocker(task.data, workspace.data);
2437
2416
  if (blocker) return blocker as Result<never>;
2417
+ let pauseCandidate: import("./task-contract").Candidate | null = null;
2418
+ if (status === "paused") {
2419
+ // Freezing records the tree state the task resumes from.
2420
+ const captured = captureCandidate(
2421
+ this.store.root,
2422
+ task.data.intent.data.scope,
2423
+ environment(),
2424
+ );
2425
+ if (!captured.ok) return captured as Result<never>;
2426
+ pauseCandidate = captured.data;
2427
+ }
2438
2428
  const changed = this.store.mutateTaskAndWorkspace({
2439
2429
  taskId: task.data.id,
2440
- expectedTaskRevision: input.expectedRevision,
2430
+ expectedRevision: input.expectedRevision,
2441
2431
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2442
2432
  now: trustedNow(this.context),
2443
2433
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2446,9 +2436,11 @@ export class WorkitCore {
2446
2436
  ...current,
2447
2437
  status,
2448
2438
  candidates:
2449
- resumeCandidate &&
2450
- !current.candidates.some((candidate) => candidate.id === resumeCandidate!.id)
2451
- ? [...current.candidates, resumeCandidate]
2439
+ (resumeCandidate ?? pauseCandidate) &&
2440
+ !current.candidates.some(
2441
+ (candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
2442
+ )
2443
+ ? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
2452
2444
  : current.candidates,
2453
2445
  progress: current.progress,
2454
2446
  pauseReason: status === "paused" ? (input.reason ?? null) : null,
@@ -2487,18 +2479,12 @@ export class WorkitCore {
2487
2479
  const workspace = this.store.readWorkspace();
2488
2480
  if (!workspace.ok) return workspace as Result<never>;
2489
2481
  if (!workspace.data) return failure("not_found", "workspace not found");
2490
- if (
2491
- workspace.data.writer ||
2492
- task.data.workers.some((entry) => isUncertainWorker(entry.data.state))
2493
- )
2494
- return failure(
2495
- "recovery_required",
2496
- "scope revision requires worker ownership reconciliation",
2497
- );
2482
+ const revisionBlocker = this.activeWorkerBlocker(task.data, workspace.data);
2483
+ if (revisionBlocker) return revisionBlocker as Result<never>;
2498
2484
  this.fillRevisions(input, task.data, workspace.data);
2499
2485
  const changed = this.store.mutateTaskAndWorkspace({
2500
2486
  taskId: task.data.id,
2501
- expectedTaskRevision: input.expectedRevision,
2487
+ expectedRevision: input.expectedRevision,
2502
2488
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2503
2489
  now: trustedNow(this.context),
2504
2490
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2558,7 +2544,7 @@ export class WorkitCore {
2558
2544
  if (!closure.ok) return closure as Result<never>;
2559
2545
  const changed = this.store.mutateTaskAndWorkspace({
2560
2546
  taskId: task.data.id,
2561
- expectedTaskRevision: input.expectedRevision,
2547
+ expectedRevision: input.expectedRevision,
2562
2548
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2563
2549
  now: trustedNow(this.context),
2564
2550
  workspace: (value, context) => success(context.revision, context.revision, value),