@brainervirus/workit-core 1.0.11 → 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
@@ -1346,7 +1259,7 @@ export class WorkitCore {
1346
1259
  if (
1347
1260
  !bindingCovers(assignment.scope, input.scope) ||
1348
1261
  !bindingCovers(task.data.intent.data.scope, input.scope) ||
1349
- (assignment.candidateId !== null && input.candidateId !== assignment.candidateId) ||
1262
+ !checkPin(assignment, null, input.candidateId ?? null) ||
1350
1263
  !refsWithinScope(input.refs, assignment.scope) ||
1351
1264
  !refsWithinScope(input.refs, task.data.intent.data.scope)
1352
1265
  )
@@ -1403,11 +1316,11 @@ export class WorkitCore {
1403
1316
  const evaluations = evaluateEvidence(task.data, current.data);
1404
1317
  const verified = evidence.some((entry) => {
1405
1318
  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
- );
1319
+ return findingVerificationPasses(finding.data.candidateId, {
1320
+ kind: entry.data.kind,
1321
+ candidateId: entry.data.candidateId,
1322
+ status: evaluation?.status ?? "missing",
1323
+ });
1411
1324
  });
1412
1325
  if (!verified)
1413
1326
  return failure("permission_denied", "fixed findings require passing verification evidence");
@@ -1423,7 +1336,7 @@ export class WorkitCore {
1423
1336
  : entry.data.candidateId === finding.data.candidateId ||
1424
1337
  entry.data.beforeCandidateId === finding.data.candidateId;
1425
1338
  const referenceMatches = finding.data.refs.some((left) =>
1426
- entry.data.refs.some((right) => JSON.stringify(left) === JSON.stringify(right)),
1339
+ entry.data.refs.some((right) => sameValue(left, right)),
1427
1340
  );
1428
1341
  return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
1429
1342
  });
@@ -1546,7 +1459,7 @@ export class WorkitCore {
1546
1459
  const workerId = newId();
1547
1460
  const changed = this.store.mutateTaskAndWorkspace({
1548
1461
  taskId: task.data.id,
1549
- expectedTaskRevision: input.expectedRevision,
1462
+ expectedRevision: input.expectedRevision,
1550
1463
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1551
1464
  now: trustedNow(this.context),
1552
1465
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1606,7 +1519,7 @@ export class WorkitCore {
1606
1519
  return failure("permission_denied", "worker report is outside the worker assignment");
1607
1520
  const changed = this.store.mutateTaskAndWorkspace({
1608
1521
  taskId: task.data.id,
1609
- expectedTaskRevision: input.expectedRevision,
1522
+ expectedRevision: input.expectedRevision,
1610
1523
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1611
1524
  now: trustedNow(this.context),
1612
1525
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1639,20 +1552,28 @@ export class WorkitCore {
1639
1552
  return failure("invalid_transition", "closed task cannot cancel workers");
1640
1553
  if (entry.data.state === "unknown")
1641
1554
  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";
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.
1650
1567
  const changed = this.store.mutateTaskAndWorkspace({
1651
1568
  taskId: task.data.id,
1652
- expectedTaskRevision: input.expectedRevision,
1569
+ expectedRevision: input.expectedRevision,
1653
1570
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1654
1571
  now: trustedNow(this.context),
1655
- 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
+ }),
1656
1577
  task: (current, mutation) =>
1657
1578
  success(mutation.revision, null, {
1658
1579
  ...current,
@@ -1661,10 +1582,7 @@ export class WorkitCore {
1661
1582
  ? {
1662
1583
  ...candidate,
1663
1584
  recordedAt: mutation.now,
1664
- data: {
1665
- ...candidate.data,
1666
- state: settles ? ("stopped" as const) : ("cancelling" as const),
1667
- },
1585
+ data: { ...candidate.data, state: "stopped" as const },
1668
1586
  }
1669
1587
  : candidate,
1670
1588
  ),
@@ -1776,7 +1694,7 @@ export class WorkitCore {
1776
1694
  if (!attested.ok) return attested as Result<never>;
1777
1695
  const changed = this.store.mutateTaskAndWorkspace({
1778
1696
  taskId: task.data.id,
1779
- expectedTaskRevision: input.expectedRevision,
1697
+ expectedRevision: input.expectedRevision,
1780
1698
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1781
1699
  now: trustedNow(this.context),
1782
1700
  workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
@@ -1958,6 +1876,26 @@ export class WorkitCore {
1958
1876
  if (!sameSession(helper.data.data.session, this.context))
1959
1877
  return failure("permission_denied", "worker session does not match the caller");
1960
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
+ }
1961
1899
  const owner = {
1962
1900
  taskId: task.data.id,
1963
1901
  workerId: helperId,
@@ -1969,7 +1907,7 @@ export class WorkitCore {
1969
1907
  };
1970
1908
  const changed = this.store.mutateTaskAndWorkspace({
1971
1909
  taskId: task.data.id,
1972
- expectedTaskRevision: input.expectedRevision,
1910
+ expectedRevision: input.expectedRevision,
1973
1911
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1974
1912
  now: trustedNow(this.context),
1975
1913
  workspace: (current, mutation) =>
@@ -2006,7 +1944,7 @@ export class WorkitCore {
2006
1944
  }
2007
1945
  const changed = this.store.mutateTaskAndWorkspace({
2008
1946
  taskId: task.data.id,
2009
- expectedTaskRevision: input.expectedRevision,
1947
+ expectedRevision: input.expectedRevision,
2010
1948
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2011
1949
  now: trustedNow(this.context),
2012
1950
  workspace: (currentWorkspace, mutation) =>
@@ -2317,10 +2255,18 @@ export class WorkitCore {
2317
2255
  if (seen.has(observation.workerId))
2318
2256
  return failure("invalid_input", "worker observations must be unique");
2319
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");
2320
2266
  if (
2321
2267
  observation.taskId !== view.task.id ||
2322
- observation.expectedRevision !== view.task.revision ||
2323
- observation.expectedWorkspaceRevision !== view.workspace.revision
2268
+ observation.expectedRevision !== freshTask.data.revision ||
2269
+ observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
2324
2270
  )
2325
2271
  return failure("revision_conflict", "worker observation is stale");
2326
2272
  if (!view.task.workers.some((entry) => entry.id === observation.workerId))
@@ -2433,11 +2379,27 @@ export class WorkitCore {
2433
2379
  return failure("permission_denied", "imported resume requires native destination approval");
2434
2380
  resumeCandidate = current.data.candidate;
2435
2381
  }
2436
- 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);
2437
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
+ }
2438
2400
  const changed = this.store.mutateTaskAndWorkspace({
2439
2401
  taskId: task.data.id,
2440
- expectedTaskRevision: input.expectedRevision,
2402
+ expectedRevision: input.expectedRevision,
2441
2403
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2442
2404
  now: trustedNow(this.context),
2443
2405
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2446,9 +2408,11 @@ export class WorkitCore {
2446
2408
  ...current,
2447
2409
  status,
2448
2410
  candidates:
2449
- resumeCandidate &&
2450
- !current.candidates.some((candidate) => candidate.id === resumeCandidate!.id)
2451
- ? [...current.candidates, resumeCandidate]
2411
+ (resumeCandidate ?? pauseCandidate) &&
2412
+ !current.candidates.some(
2413
+ (candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
2414
+ )
2415
+ ? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
2452
2416
  : current.candidates,
2453
2417
  progress: current.progress,
2454
2418
  pauseReason: status === "paused" ? (input.reason ?? null) : null,
@@ -2487,18 +2451,12 @@ export class WorkitCore {
2487
2451
  const workspace = this.store.readWorkspace();
2488
2452
  if (!workspace.ok) return workspace as Result<never>;
2489
2453
  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
- );
2454
+ const revisionBlocker = this.activeWorkerBlocker(task.data, workspace.data);
2455
+ if (revisionBlocker) return revisionBlocker as Result<never>;
2498
2456
  this.fillRevisions(input, task.data, workspace.data);
2499
2457
  const changed = this.store.mutateTaskAndWorkspace({
2500
2458
  taskId: task.data.id,
2501
- expectedTaskRevision: input.expectedRevision,
2459
+ expectedRevision: input.expectedRevision,
2502
2460
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2503
2461
  now: trustedNow(this.context),
2504
2462
  workspace: (workspace, context) => success(context.revision, context.revision, workspace),
@@ -2558,7 +2516,7 @@ export class WorkitCore {
2558
2516
  if (!closure.ok) return closure as Result<never>;
2559
2517
  const changed = this.store.mutateTaskAndWorkspace({
2560
2518
  taskId: task.data.id,
2561
- expectedTaskRevision: input.expectedRevision,
2519
+ expectedRevision: input.expectedRevision,
2562
2520
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
2563
2521
  now: trustedNow(this.context),
2564
2522
  workspace: (value, context) => success(context.revision, context.revision, value),