@smartmemory/compose 0.5.0 → 0.5.1

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.
@@ -21,6 +21,8 @@ import { execFileSync } from 'node:child_process';
21
21
  import { createHash, randomUUID } from 'node:crypto';
22
22
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
23
  import { tmpdir } from 'node:os';
24
+ import { normalizeOwnedPath, profilesDigest as digestJson } from './pipeline-profiles.js';
25
+ import { worktreeBaseFor, readCheckpointRef, prepareCheckpoint, publishCheckpoint, reconcileCheckpoint, WaveCheckpointError } from './wave-checkpoint.js';
24
26
 
25
27
  const JOURNAL_VERSION = 1;
26
28
  let tempIndexSequence = 0;
@@ -96,7 +98,7 @@ function canonicalPath(path) {
96
98
  return resolve(existing, ...missing);
97
99
  }
98
100
 
99
- function withTemporaryIndex(cwd, fn) {
101
+ export function withTemporaryIndex(cwd, fn) {
100
102
  const indexPath = join(
101
103
  tmpdir(),
102
104
  `compose-consumer-index-${process.pid}-${tempIndexSequence++}-${randomUUID()}`,
@@ -143,6 +145,56 @@ function cumulativeDiff(cwd, baseCommit) {
143
145
  });
144
146
  }
145
147
 
148
+ /** Enumerate the exact retained patch, never the worker's reported files_changed. */
149
+ function retainedPatchEvidence(cwd, baseCommit, diff) {
150
+ return withTemporaryIndex(cwd, env => {
151
+ git(cwd, ['read-tree', baseCommit], { env });
152
+ if (diff) git(cwd, ['apply', '--cached', '--binary', '-'], { env, input: diff });
153
+ const capturedTree = git(cwd, ['write-tree'], { env });
154
+ // --no-renames gives delete/add endpoints; -z preserves whitespace and newlines.
155
+ const changedPaths = git(cwd, ['diff', '--cached', '--name-only', '-z', '--no-renames', baseCommit, '--'],
156
+ { env, trim: false }).split('\0').filter(Boolean);
157
+ return { baseCommit, capturedTree, changedPaths };
158
+ });
159
+ }
160
+ function checkOwnership(cwd, entry, { capture = false } = {}) {
161
+ if (!entry.ownership && !Object.hasOwn(entry.itemBinding?.item ?? {}, 'files_owned')) return null;
162
+ let allowedFiles = [];
163
+ let evidence;
164
+ let code = 'FILES_OWNED_VIOLATION';
165
+ let files = [];
166
+ try {
167
+ if (!entry.itemBinding || entry.itemBinding.itemDigest !== digestJson(entry.itemBinding.item)) {
168
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH'; throw new Error('Item binding missing or digest differs');
169
+ }
170
+ const owned = entry.itemBinding?.item?.files_owned;
171
+ if (!Array.isArray(owned)) throw new Error('files_owned must be an array');
172
+ allowedFiles = owned.map(normalizeOwnedPath);
173
+ if (typeof entry.diff !== 'string' || entry.diffDigest !== sha256(entry.diff) || !entry.ownership?.baseCommit) {
174
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH';
175
+ throw new Error('Retained patch/base/digest missing or changed');
176
+ }
177
+ evidence = retainedPatchEvidence(cwd, entry.ownership.baseCommit, entry.diff);
178
+ if (!capture && (evidence.capturedTree !== entry.ownership.capturedTree
179
+ || digestJson(evidence.changedPaths) !== digestJson(entry.ownership.changedPaths))) {
180
+ code = 'OWNERSHIP_EVIDENCE_MISMATCH';
181
+ throw new Error('Retained patch tree/paths differ from capture');
182
+ }
183
+ files = evidence.changedPaths.filter(file => !allowedFiles.includes(file));
184
+ if (!files.length) { if (capture) entry.ownership = evidence; return null; }
185
+ } catch (error) {
186
+ if (code !== 'FILES_OWNED_VIOLATION' || evidence === undefined && allowedFiles.length) code = 'OWNERSHIP_EVIDENCE_MISMATCH';
187
+ files = evidence?.changedPaths ?? [];
188
+ return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
189
+ generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
190
+ diffDigest: entry.diffDigest, message: error.message };
191
+ }
192
+ if (capture) entry.ownership = evidence;
193
+ return { code, taskId: entry.itemBinding?.item?.id, stepId: entry.scopedId, itemIndex: entry.itemIndex,
194
+ generation: entry.generation, dispatchToken: entry.dispatchToken, severity: 'error', files, allowedFiles,
195
+ diffDigest: entry.diffDigest, message: `Task ${entry.itemBinding?.item?.id ?? entry.scopedId} changed unowned paths: ${files.join(', ')}` };
196
+ }
197
+
146
198
  /**
147
199
  * Apply one lane's cumulative diff to a temporary index.
148
200
  *
@@ -295,7 +347,7 @@ export class MergeAfterCancelError extends ConsumerArtifactError {
295
347
  }
296
348
 
297
349
  export class ConsumerFanoutArtifacts {
298
- constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest }) {
350
+ constructor({ runId, targetCwd, artifactRoot, hooks = {}, revisionDigest, specDigest, profilesDigest }) {
299
351
  this.runId = runId;
300
352
  this.targetCwd = canonicalPath(targetCwd);
301
353
  this.artifactRoot = canonicalPath(artifactRoot ?? defaultArtifactRoot(this.targetCwd));
@@ -311,6 +363,7 @@ export class ConsumerFanoutArtifacts {
311
363
  // a crash between journal creation and the first bind cannot leave an
312
364
  // unpinned journal that a drifted spec could later re-pin as the truth.
313
365
  this._initialRevisionDigest = typeof revisionDigest === 'string' && revisionDigest.length > 0 ? revisionDigest : null;
366
+ this._initialProfilesDigest = profilesDigest;
314
367
  this._initialSpecDigest = typeof specDigest === 'string' && specDigest.length > 0 ? specDigest : null;
315
368
  this.runRoot = join(this.artifactRoot, runDirectoryName(runId));
316
369
  this.journalPath = join(this.runRoot, 'journal.json');
@@ -419,6 +472,15 @@ export class ConsumerFanoutArtifacts {
419
472
  );
420
473
  }
421
474
  }
475
+ if (this._initialProfilesDigest !== undefined) {
476
+ if (journal.profilesDigest !== undefined && journal.profilesDigest !== this._initialProfilesDigest) {
477
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
478
+ }
479
+ if (journal.profilesDigest === undefined && (journal.issuances.length || journal.wave)) {
480
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Cannot add profiles to an active legacy run');
481
+ }
482
+ journal.profilesDigest = this._initialProfilesDigest;
483
+ }
422
484
  return journal;
423
485
  }
424
486
 
@@ -426,6 +488,7 @@ export class ConsumerFanoutArtifacts {
426
488
  if (!existsSync(this.journalPath)) {
427
489
  const journal = {
428
490
  version: JOURNAL_VERSION,
491
+ ...(this._initialProfilesDigest !== undefined ? { profilesDigest: this._initialProfilesDigest } : {}),
429
492
  runId: this.runId,
430
493
  targetCwd: this.targetCwd,
431
494
  createdAt: now(),
@@ -452,7 +515,8 @@ export class ConsumerFanoutArtifacts {
452
515
  const before = readFileSync(this.journalPath, 'utf8');
453
516
  const journal = this.#reload();
454
517
  if ((this._initialRevisionDigest && journal.revisionDigest !== JSON.parse(before).revisionDigest)
455
- || (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)) {
518
+ || (this._initialSpecDigest && journal.specDigest !== JSON.parse(before).specDigest)
519
+ || (this._initialProfilesDigest !== undefined && journal.profilesDigest !== JSON.parse(before).profilesDigest)) {
456
520
  this.#writeGuarded(journal);
457
521
  }
458
522
  return journal;
@@ -539,7 +603,10 @@ export class ConsumerFanoutArtifacts {
539
603
  itemIndex: descriptor.itemIndex,
540
604
  generation: descriptor.generation,
541
605
  path,
542
- baseCommit: git(this.targetCwd, ['rev-parse', 'HEAD']),
606
+ baseCommit: this.journal.wave
607
+ ? (this.journal.waveAdmissions?.find(a => a.fanoutStepId === descriptor.step && a.epoch === (this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding.epoch ?? descriptor.epoch))?.baseCommit
608
+ ?? worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) }))
609
+ : git(this.targetCwd, ['rev-parse', 'HEAD']),
543
610
  status: 'creating',
544
611
  superseded: false,
545
612
  createdAt: now(),
@@ -570,8 +637,15 @@ export class ConsumerFanoutArtifacts {
570
637
  * First-write-wins; a later issuance carrying a DIFFERENT engine revision digest
571
638
  * is a mid-run revision the journal must reject rather than silently absorb.
572
639
  */
573
- bindRunRevision({ revisionDigest, specDigest }) {
640
+ bindRunRevision({ revisionDigest, specDigest, profilesDigest }) {
574
641
  return this.#mutate(() => {
642
+ if (profilesDigest !== undefined) {
643
+ if (this.journal.profilesDigest !== undefined && this.journal.profilesDigest !== profilesDigest
644
+ || this.journal.profilesDigest === undefined && (this.journal.issuances.length || this.journal.wave)) {
645
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles changed');
646
+ }
647
+ this.journal.profilesDigest = profilesDigest;
648
+ }
575
649
  if (typeof revisionDigest === 'string' && revisionDigest.length > 0) {
576
650
  if (this.journal.revisionDigest === null) {
577
651
  this.journal.revisionDigest = revisionDigest;
@@ -590,6 +664,236 @@ export class ConsumerFanoutArtifacts {
590
664
  });
591
665
  }
592
666
 
667
+ /** Additive v1 seam records: absent until explicitly enabled. */
668
+ recordWaveAdmission(admission) {
669
+ return this.#mutate(() => {
670
+ if (!Array.isArray(admission.items) || !admission.inputDigest || !admission.baseCommit) {
671
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Admission needs full items, digest and pinned base');
672
+ }
673
+ if (this.journal.wave && !this.journal.waveAdmissions?.some(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch)) {
674
+ const base = worktreeBaseFor({ journal: this.journal, ref: readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) });
675
+ if (base !== admission.baseCommit) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Admission base differs from published tip');
676
+ }
677
+ this.journal.waveAdmissions ??= [];
678
+ const previous = this.journal.waveAdmissions.find(a => a.fanoutStepId === admission.fanoutStepId && a.epoch === admission.epoch);
679
+ if (previous) {
680
+ if (digestJson({ ...previous, validatedAt: null }) !== digestJson({ ...admission, validatedAt: null })) {
681
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Recorded wave admission changed');
682
+ }
683
+ return previous;
684
+ }
685
+ const record = { ...structuredClone(admission), validatedAt: admission.validatedAt ?? now() };
686
+ this.journal.waveAdmissions.push(record);
687
+ return record;
688
+ });
689
+ }
690
+
691
+ recordDispatchBinding({ dispatchToken, itemBinding, resolvedProfile }) {
692
+ return this.#mutate(() => {
693
+ if (this.journal.profilesDigest !== undefined && resolvedProfile?.profilesDigest !== this.journal.profilesDigest) {
694
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch profile is not pinned to this run');
695
+ }
696
+ if (itemBinding.itemDigest !== digestJson(itemBinding.item)) {
697
+ throw new ConsumerArtifactError('WAVE_INPUT_INVALID', 'Dispatch item digest differs');
698
+ }
699
+ this.journal.dispatchBindings ??= {};
700
+ const binding = { itemBinding: structuredClone(itemBinding), resolvedProfile: structuredClone(resolvedProfile) };
701
+ const previous = this.journal.dispatchBindings[dispatchToken];
702
+ if (previous && digestJson(previous) !== digestJson(binding)) {
703
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Dispatch binding changed');
704
+ }
705
+ this.journal.dispatchBindings[dispatchToken] = binding;
706
+ return binding;
707
+ });
708
+ }
709
+
710
+ recordPendingUsageReceipt({ dispatchId, receipt }) {
711
+ return this.#mutate(() => {
712
+ this.journal.pendingUsageReceipts ??= [];
713
+ const existing = this.journal.pendingUsageReceipts.find(r => r.dispatchId === dispatchId);
714
+ if (existing) {
715
+ if (digestJson(existing.receipt) !== digestJson(receipt)) {
716
+ throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt payload changed on replay');
717
+ }
718
+ return existing;
719
+ }
720
+ const record = { dispatchId, receipt: structuredClone(receipt), state: 'pending' };
721
+ this.journal.pendingUsageReceipts.push(record);
722
+ return record;
723
+ });
724
+ }
725
+
726
+ acknowledgeUsageReceipt({ dispatchId, seq }) {
727
+ return this.#mutate(() => {
728
+ const receipt = this.journal.pendingUsageReceipts?.find(r => r.dispatchId === dispatchId);
729
+ if (!receipt) throw new ConsumerArtifactError('CONSUMER_EVIDENCE_MISMATCH', 'Receipt intent is missing');
730
+ receipt.state = 'acknowledged';
731
+ if (seq !== undefined) receipt.seq = seq;
732
+ return receipt;
733
+ });
734
+ }
735
+
736
+ initializeWave({ ref, profilesDigest }) {
737
+ return this.#mutate(() => {
738
+ if (this.journal.wave) {
739
+ if (this.journal.wave.ref !== ref || this.journal.wave.profilesDigest !== profilesDigest) {
740
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Wave configuration changed');
741
+ }
742
+ return this.journal.wave;
743
+ }
744
+ if (readCheckpointRef({ cwd: this.targetCwd, ref }) !== null) {
745
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Pre-existing unowned wave ref');
746
+ }
747
+ if (existsSync(resolve(this.targetCwd, git(this.targetCwd, ['rev-parse', '--git-path', 'MERGE_HEAD'])))
748
+ || git(this.targetCwd, ['ls-files', '-u'])) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unresolved index merge');
749
+ const baseCommit = git(this.targetCwd, ['rev-parse', 'HEAD']);
750
+ this.journal.wave = { ref, baseCommit, baseTree: git(this.targetCwd, ['rev-parse', 'HEAD^{tree}']),
751
+ initialWorkingTree: snapshotWorkingTree(this.targetCwd), profilesDigest, checkpoints: [] };
752
+ return this.journal.wave;
753
+ });
754
+ }
755
+
756
+ recordPreparedCheckpoint(checkpoint) {
757
+ return this.#mutate(() => {
758
+ const wave = this.journal.wave;
759
+ if (!wave) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Wave not initialized');
760
+ const existing = wave.checkpoints.find(c => c.gateToken === checkpoint.gateToken);
761
+ if (existing) {
762
+ if (existing.commit !== checkpoint.commit || existing.tree !== checkpoint.tree) {
763
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint token changed');
764
+ }
765
+ return existing;
766
+ }
767
+ const parent = wave.checkpoints.at(-1)?.commit ?? wave.baseCommit;
768
+ if (checkpoint.parentCommit !== parent || checkpoint.waveNumber !== wave.checkpoints.length + 1
769
+ || !checkpoint.gateToken || !Number.isInteger(checkpoint.gateOrdinal)) {
770
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint order or identity differs');
771
+ }
772
+ const record = { ...structuredClone(checkpoint), state: 'prepared', preparedAt: now() };
773
+ wave.checkpoints.push(record);
774
+ return record;
775
+ });
776
+ }
777
+
778
+ markCheckpointPublished({ gateToken, commit, evidenceReceiptId }) {
779
+ return this.#mutate(() => {
780
+ const checkpoint = this.journal.wave?.checkpoints.find(c => c.gateToken === gateToken);
781
+ if (!checkpoint || checkpoint.commit !== commit
782
+ || readCheckpointRef({ cwd: this.targetCwd, ref: this.journal.wave.ref }) !== commit) {
783
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Publication does not match prepared checkpoint');
784
+ }
785
+ this.#verifyCheckpointObject(checkpoint);
786
+ checkpoint.state = 'published';
787
+ checkpoint.publishedAt ??= now();
788
+ checkpoint.materializedTree ??= checkpoint.tree;
789
+ if (evidenceReceiptId) checkpoint.evidenceReceiptId = evidenceReceiptId;
790
+ return checkpoint;
791
+ });
792
+ }
793
+
794
+ /** Recover approved obligations only; never replay their patches into the live parent. */
795
+ recoverCheckpoint(transaction) {
796
+ const wave = this.journal.wave;
797
+ if (!wave) return;
798
+ let checkpoint = wave.checkpoints.find(c => c.gateToken === transaction.gateToken);
799
+ const tip = readCheckpointRef({ cwd: this.targetCwd, ref: wave.ref });
800
+ // A verified newer recorded tip must never be rewound to a historical checkpoint.
801
+ const index = checkpoint ? wave.checkpoints.indexOf(checkpoint) : -1;
802
+ if (checkpoint && (checkpoint.gateOrdinal !== transaction.gateOrdinal
803
+ || checkpoint.gateStepId !== transaction.gateStepId || checkpoint.tree !== transaction.witnessChain.at(-1)
804
+ || checkpoint.parentCommit !== transaction.checkpointParent)) {
805
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Checkpoint differs from its approved merge obligation');
806
+ }
807
+ for (const [i, recorded] of wave.checkpoints.entries()) {
808
+ if (recorded.parentCommit !== (wave.checkpoints[i - 1]?.commit ?? wave.baseCommit)) {
809
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Recorded checkpoint chain is broken');
810
+ }
811
+ }
812
+ if (index >= 0 && wave.checkpoints.slice(index + 1).some(c => c.state === 'published' && c.commit === tip)) {
813
+ for (const recorded of wave.checkpoints.slice(index)) this.#verifyCheckpointObject(recorded);
814
+ return;
815
+ }
816
+ if (!checkpoint) {
817
+ const previous = wave.checkpoints.at(-1)?.commit ?? null;
818
+ if (reconcileCheckpoint({ journalEntry: { gateOutcome: 'approve', previousCommit: previous }, refValue: tip }) !== 'PREPARE_AND_PUBLISH') {
819
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Unexpected ref before preparation');
820
+ }
821
+ if (!Number.isInteger(transaction.gateOrdinal)) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Missing gate ordinal');
822
+ const tree = transaction.witnessChain.at(-1);
823
+ this.#verifyCheckpointReplay(transaction, tree);
824
+ this.#materializeCheckpoint(transaction, tree);
825
+ const prepared = prepareCheckpoint({ cwd: this.targetCwd, ref: wave.ref,
826
+ parentCommit: transaction.checkpointParent ?? previous ?? wave.baseCommit, tree,
827
+ message: `Compose wave ${wave.checkpoints.length + 1} run ${this.runId} gate ${transaction.gateToken}` });
828
+ checkpoint = this.recordPreparedCheckpoint({ ...prepared, waveNumber: wave.checkpoints.length + 1,
829
+ fanoutStepId: transaction.fanoutStepId, epoch: transaction.epoch, gateStepId: transaction.gateStepId,
830
+ gateToken: transaction.gateToken, gateOrdinal: transaction.gateOrdinal, baselineTree: transaction.baselineTree,
831
+ orderedDispatchTokens: transaction.acceptedDispatchTokens ?? transaction.orderedDiffs.map(d => d.dispatchToken) });
832
+ }
833
+ this.#verifyCheckpointObject(checkpoint);
834
+ const action = reconcileCheckpoint({ journalEntry: checkpoint, refValue: tip });
835
+ if (action === 'REPLAY_AND_PUBLISH') {
836
+ if (checkpoint.payloadsDroppedAt) throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint payloads were already cleaned');
837
+ this.#materializeCheckpoint(transaction, checkpoint.tree);
838
+ this.#verifyCheckpointReplay(transaction, checkpoint.tree);
839
+ publishCheckpoint({ cwd: this.targetCwd, ref: wave.ref, expected: tip, commit: checkpoint.commit });
840
+ } else if (!['MARK_PUBLISHED', 'ALREADY_PUBLISHED'].includes(action)) {
841
+ throw new WaveCheckpointError(action, 'Cannot reconcile checkpoint ref');
842
+ } else if (transaction.witnessChain.slice(0, -1).includes(snapshotWorkingTree(this.targetCwd))) {
843
+ this.#materializeCheckpoint(transaction, checkpoint.tree);
844
+ }
845
+ this.markCheckpointPublished({ gateToken: checkpoint.gateToken, commit: checkpoint.commit });
846
+ }
847
+
848
+ #materializeCheckpoint(transaction, tree) {
849
+ const live = snapshotWorkingTree(this.targetCwd);
850
+ if (live === tree) return;
851
+ if (transaction.witnessChain.includes(live)) {
852
+ checkoutTreeDelta(this.targetCwd, live, tree);
853
+ return;
854
+ }
855
+ const changed = git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', tree, live, '--'], { trim: false }).split('\0').filter(Boolean);
856
+ const wavePaths = new Set(git(this.targetCwd, ['diff', '--name-only', '-z', '--no-renames', transaction.baselineTree, tree, '--'],
857
+ { trim: false }).split('\0').filter(Boolean));
858
+ if (changed.some(path => wavePaths.has(path))) {
859
+ throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Parent differs ambiguously from the integrated checkpoint');
860
+ }
861
+ // Independent post-wave edits are preserved; publication captures only the witness.
862
+ }
863
+
864
+ #verifyCheckpointReplay(transaction, expectedTree) {
865
+ try {
866
+ const tree = withTemporaryIndex(this.targetCwd, env => {
867
+ git(this.targetCwd, ['read-tree', transaction.baselineTree], { env });
868
+ for (const ordered of transaction.orderedDiffs) {
869
+ if (typeof ordered.diff !== 'string' || sha256(ordered.diff) !== ordered.digest) throw new Error('Missing or changed retained patch');
870
+ if (ordered.diff) applyDiffToIndex(this.targetCwd, ordered.diff, env);
871
+ }
872
+ return git(this.targetCwd, ['write-tree'], { env });
873
+ });
874
+ if (tree !== expectedTree) throw new Error('Replayed tree differs');
875
+ } catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
876
+ }
877
+
878
+ #verifyCheckpointObject(checkpoint) {
879
+ try {
880
+ const object = git(this.targetCwd, ['cat-file', 'commit', checkpoint.commit], { trim: false });
881
+ const boundary = object.indexOf('\n\n');
882
+ const headers = object.slice(0, boundary).split('\n');
883
+ const metadata = checkpoint.commitMetadata;
884
+ const epoch = Math.floor(Date.parse(metadata.date) / 1000);
885
+ const identity = `${metadata.authorName} <${metadata.authorEmail}> ${epoch} +0000`;
886
+ const expectedHeaders = [`tree ${checkpoint.tree}`, `parent ${checkpoint.parentCommit}`,
887
+ `author ${identity}`, `committer ${identity}`];
888
+ if (digestJson(headers) !== digestJson(expectedHeaders)
889
+ || object.slice(boundary + 2).replace(/\n$/, '') !== checkpoint.message.replace(/\n$/, '')) {
890
+ throw new Error('Checkpoint identity/tree/parent/message differs');
891
+ }
892
+ git(this.targetCwd, ['cat-file', '-e', `${checkpoint.parentCommit}^{commit}`]);
893
+ git(this.targetCwd, ['cat-file', '-e', `${checkpoint.tree}^{tree}`]);
894
+ } catch (error) { throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', error.message); }
895
+ }
896
+
593
897
  /** Record which fanout step a merge gate settles, so resume never re-derives it
594
898
  * from a mutable local spec that may have drifted. First-write-wins per gate. */
595
899
  recordGateBinding({ gateStepId, fanoutStepId }) {
@@ -626,7 +930,7 @@ export class ConsumerFanoutArtifacts {
626
930
  || (scope.fanoutStepId === fanoutStepId && scope.itemIndex === itemIndex);
627
931
  let changed = false;
628
932
  for (const issuance of this.journal.issuances) {
629
- if (issuance.state === 'merged' || issuance.state === 'superseded') continue;
933
+ if (issuance.state === 'merged' || issuance.state === 'superseded' || issuance.state === 'failed') continue;
630
934
  if (!inScope(issuance.fanoutStepId, issuance.itemIndex)) continue;
631
935
  const fanoutItems = audit?.steps?.[issuance.fanoutStepId]?.fanout?.items;
632
936
  const item = Array.isArray(fanoutItems) ? fanoutItems[issuance.itemIndex] : undefined;
@@ -678,13 +982,17 @@ export class ConsumerFanoutArtifacts {
678
982
  */
679
983
  reconcileDescriptor(descriptor, audit) {
680
984
  return this.#mutate(() => {
985
+ const binding = this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding;
986
+ if (isNoneIsolation(descriptor) && Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
987
+ throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
988
+ }
681
989
  // Per-item settlement: the descriptor's audit is a concurrent snapshot that
682
990
  // may be stale for OTHER items, so reconcile only this item's own records.
683
991
  this.#reconcileAuditInto(audit, { fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
684
992
  const prepared = this.journal.issuances.find(
685
993
  (entry) => entry.dispatchToken === descriptor.dispatchToken,
686
994
  );
687
- if (prepared?.state === 'prepared') {
995
+ if (prepared?.state === 'prepared' || prepared?.state === 'failed') {
688
996
  return { action: 'report', envelope: structuredClone(prepared.envelope), issuance: prepared };
689
997
  }
690
998
  if (prepared?.state === 'accepted') {
@@ -747,14 +1055,26 @@ export class ConsumerFanoutArtifacts {
747
1055
  });
748
1056
  }
749
1057
 
750
- prepareIssuance(descriptor, envelope, { finalStage }) {
1058
+ prepareIssuance(descriptor, envelope, { finalStage, itemBinding, resolvedProfile, ownership } = {}) {
751
1059
  return this.#mutate(() => {
752
1060
  const existing = this.journal.issuances.find(
753
1061
  (entry) => entry.dispatchToken === descriptor.dispatchToken,
754
1062
  );
755
1063
  if (existing) return existing;
756
1064
 
1065
+ const binding = itemBinding ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.itemBinding
1066
+ ?? (ownership ? {
1067
+ item: structuredClone(descriptor.item ?? {}), itemDigest: digestJson(descriptor.item ?? {}),
1068
+ epoch: descriptor.epoch, sourceProvenance: 'descriptor.item',
1069
+ } : undefined);
1070
+ const ownershipEnabled = ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned');
1071
+ const profile = resolvedProfile ?? this.journal.dispatchBindings?.[descriptor.dispatchToken]?.resolvedProfile;
1072
+ const fields = { ...(binding ? { itemBinding: structuredClone(binding) } : {}),
1073
+ ...(profile ? { resolvedProfile: structuredClone(profile) } : {}) };
757
1074
  if (isNoneIsolation(descriptor)) {
1075
+ if (ownership || Object.hasOwn(binding?.item ?? {}, 'files_owned')) {
1076
+ throw new ConsumerArtifactError('WAVE_OWNERSHIP_INVALID', 'Ownership requires worktree isolation');
1077
+ }
758
1078
  // Envelope-only journal entry: no worktree, no witness, no diff. It never
759
1079
  // participates in the merge (prepareMerge filters isolation:none out), so
760
1080
  // its files persist directly in the target cwd.
@@ -768,6 +1088,7 @@ export class ConsumerFanoutArtifacts {
768
1088
  attempt: descriptor.attempt,
769
1089
  revisionDigest: descriptor.revisionDigest,
770
1090
  contractDigest: descriptor.contractDigest,
1091
+ ...fields,
771
1092
  isolation: 'none',
772
1093
  worktreeKey: null,
773
1094
  witnessTree: null,
@@ -804,6 +1125,8 @@ export class ConsumerFanoutArtifacts {
804
1125
  attempt: descriptor.attempt,
805
1126
  revisionDigest: descriptor.revisionDigest,
806
1127
  contractDigest: descriptor.contractDigest,
1128
+ ...fields,
1129
+ ...(ownershipEnabled && finalStage ? { ownership: { baseCommit: worktree.baseCommit } } : {}),
807
1130
  isolation: 'worktree',
808
1131
  worktreeKey: worktree.key,
809
1132
  witnessTree: witness.witnessTree,
@@ -814,11 +1137,39 @@ export class ConsumerFanoutArtifacts {
814
1137
  diffDigest: diff === null ? null : sha256(diff),
815
1138
  preparedAt: now(),
816
1139
  };
1140
+ if (finalStage && ownershipEnabled) {
1141
+ const finding = checkOwnership(this.targetCwd, entry, { capture: true });
1142
+ if (finding) this.#failOwnership(entry, finding);
1143
+ }
817
1144
  this.journal.issuances.push(entry);
818
1145
  return entry;
819
1146
  });
820
1147
  }
821
1148
 
1149
+ #failOwnership(entry, finding) {
1150
+ entry.state = 'failed';
1151
+ entry.findings ??= [];
1152
+ if (!entry.findings.some(f => f.code === finding.code && f.message === finding.message)) entry.findings.push(finding);
1153
+ entry.ownership ??= {};
1154
+ entry.ownership.finding = finding;
1155
+ entry.envelope = { ...entry.envelope, failure: `${finding.code}: ${finding.message}` };
1156
+ delete entry.envelope.output;
1157
+ }
1158
+
1159
+ #verifyOwnership(entry, ordered) {
1160
+ let finding = checkOwnership(this.targetCwd, entry);
1161
+ if (entry.itemBinding && ordered && (ordered.diff !== entry.diff || ordered.digest !== entry.diffDigest
1162
+ || ordered.bindingDigest !== digestJson(entry.itemBinding))) {
1163
+ finding = { code: 'OWNERSHIP_EVIDENCE_MISMATCH', message: 'Ordered patch/binding differs from captured issuance',
1164
+ dispatchToken: entry.dispatchToken, severity: 'error', files: [], allowedFiles: entry.itemBinding.item?.files_owned ?? [] };
1165
+ }
1166
+ if (finding) {
1167
+ this.#failOwnership(entry, finding);
1168
+ this.#save();
1169
+ throw new ConsumerMergeDecisionError(finding.code, finding.message, { finding });
1170
+ }
1171
+ }
1172
+
822
1173
  prepareArtifactFailure(descriptor, envelope, error) {
823
1174
  return this.#mutate(() => {
824
1175
  const existing = this.journal.issuances.find(
@@ -869,13 +1220,20 @@ export class ConsumerFanoutArtifacts {
869
1220
  { gateToken },
870
1221
  );
871
1222
  }
872
- if (existing) return existing;
1223
+ if (existing) {
1224
+ for (const ordered of existing.orderedDiffs ?? []) {
1225
+ const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
1226
+ if (issuance) this.#verifyOwnership(issuance, ordered);
1227
+ }
1228
+ return existing;
1229
+ }
873
1230
 
874
1231
  const accepted = this.acceptedEntriesFor(fanoutStepId);
875
1232
  // Only isolation:worktree items own a diff and participate in the merge.
876
1233
  // isolation:none items already wrote in the target cwd; they are accepted
877
1234
  // evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
878
1235
  const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
1236
+ for (const entry of worktreeAccepted) this.#verifyOwnership(entry);
879
1237
  const baselineTree = snapshotWorkingTree(this.targetCwd);
880
1238
  // A worker that changed NOTHING owes an empty diff, not a missing one, and it
881
1239
  // must not join the merge: applying nothing leaves the tree identical, so its
@@ -896,6 +1254,7 @@ export class ConsumerFanoutArtifacts {
896
1254
  itemIndex: entry.itemIndex,
897
1255
  generation: entry.generation,
898
1256
  digest: entry.diffDigest,
1257
+ ...(entry.itemBinding ? { bindingDigest: digestJson(entry.itemBinding) } : {}),
899
1258
  diff: entry.diff,
900
1259
  }));
901
1260
  const recordBlocked = (code, message, witnessChain = [baselineTree]) => {
@@ -973,6 +1332,12 @@ export class ConsumerFanoutArtifacts {
973
1332
  gateToken,
974
1333
  fanoutStepId,
975
1334
  state: 'prepared',
1335
+ ...(this.journal.wave ? {
1336
+ epoch: audit?.steps?.[fanoutStepId]?.epoch,
1337
+ checkpointParent: this.journal.wave.checkpoints.at(-1)?.commit ?? this.journal.wave.baseCommit,
1338
+ acceptedDispatchTokens: worktreeAccepted.map(e => e.dispatchToken),
1339
+ gateOrdinal: (audit?.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === gateStepId).length,
1340
+ } : {}),
976
1341
  baselineTree,
977
1342
  witnessChain,
978
1343
  orderedDiffs,
@@ -1087,6 +1452,10 @@ export class ConsumerFanoutArtifacts {
1087
1452
  // not a lockfile in this slice.
1088
1453
  const live = this.#currentTransaction(gateToken);
1089
1454
  if (live) this.#assertMergeNotDecided(live, gateToken);
1455
+ this.#mutate(() => {
1456
+ const issuance = this.journal.issuances.find(e => e.dispatchToken === ordered.dispatchToken);
1457
+ if (issuance) this.#verifyOwnership(issuance, ordered);
1458
+ });
1090
1459
  // Same merge algorithm as the witness precompute (temporary index,
1091
1460
  // three-way), then the merged tree is checked out. Applying straight to
1092
1461
  // the working tree cannot use --3way (it requires the real index to match).
@@ -1261,10 +1630,18 @@ export class ConsumerFanoutArtifacts {
1261
1630
  });
1262
1631
  }
1263
1632
 
1264
- cleanupWorktrees(reason) {
1633
+ cleanupWorktrees(reason, { dispatchTokens } = {}) {
1265
1634
  this.#mutate(() => {
1635
+ const selected = dispatchTokens ? new Set(dispatchTokens) : null;
1636
+ const eligible = token => {
1637
+ if (selected && !selected.has(token)) return false;
1638
+ if (!this.journal.wave) return true;
1639
+ return this.journal.wave.checkpoints.some(c => c.state === 'published' && c.evidenceReceiptId
1640
+ && c.orderedDispatchTokens.includes(token));
1641
+ };
1642
+ const worktreeKeys = new Set(this.journal.issuances.filter(e => eligible(e.dispatchToken)).map(e => e.worktreeKey));
1266
1643
  for (const record of this.journal.worktrees) {
1267
- if (record.cleanedAt) continue;
1644
+ if (record.cleanedAt || (selected || this.journal.wave) && !worktreeKeys.has(record.key)) continue;
1268
1645
  if (existsSync(record.path)) {
1269
1646
  try {
1270
1647
  git(this.targetCwd, ['worktree', 'remove', '--force', record.path], { timeout: 60_000 });
@@ -1277,16 +1654,20 @@ export class ConsumerFanoutArtifacts {
1277
1654
  record.cleanupReason = reason;
1278
1655
  }
1279
1656
  for (const issuance of this.journal.issuances) {
1280
- if (typeof issuance.diff === 'string') {
1657
+ if (eligible(issuance.dispatchToken) && typeof issuance.diff === 'string') {
1281
1658
  issuance.diff = null;
1282
1659
  issuance.diffDroppedAt = now();
1283
1660
  }
1284
1661
  }
1662
+ for (const checkpoint of this.journal.wave?.checkpoints ?? []) {
1663
+ if (checkpoint.state === 'published' && checkpoint.evidenceReceiptId
1664
+ && checkpoint.orderedDispatchTokens.every(eligible)) checkpoint.payloadsDroppedAt ??= now();
1665
+ }
1285
1666
  for (const transaction of this.journal.mergeTransactions) {
1286
1667
  for (const ordered of transaction.orderedDiffs ?? []) {
1287
- if (typeof ordered.diff === 'string') ordered.diff = null;
1668
+ if (eligible(ordered.dispatchToken) && typeof ordered.diff === 'string') ordered.diff = null;
1288
1669
  }
1289
- if (transaction.orderedDiffs?.length > 0 && !transaction.diffPayloadsDroppedAt) {
1670
+ if (transaction.orderedDiffs?.length > 0 && transaction.orderedDiffs.every(d => d.diff === null) && !transaction.diffPayloadsDroppedAt) {
1290
1671
  transaction.diffPayloadsDroppedAt = now();
1291
1672
  }
1292
1673
  }
@@ -1320,10 +1701,15 @@ export function verifyConsumerRunRevision({
1320
1701
  artifactRoot,
1321
1702
  specDigest,
1322
1703
  resumeRevisionDigest,
1704
+ profilesDigest,
1323
1705
  }) {
1324
1706
  if (!runId || !existsSync(journalLocation({ runId, targetCwd, artifactRoot }))) return null;
1325
1707
  const { journal } = new ConsumerFanoutArtifacts({ runId, targetCwd, artifactRoot });
1326
1708
 
1709
+ if ((journal.profilesDigest !== undefined || profilesDigest !== undefined) && journal.profilesDigest !== profilesDigest) {
1710
+ throw new ConsumerArtifactError('CONSUMER_PROFILE_REVISION_MISMATCH', 'Consumer profiles differ from the pinned run',
1711
+ { recorded: journal.profilesDigest, current: profilesDigest });
1712
+ }
1327
1713
  const fullyPinned = Boolean(journal.revisionDigest) && Boolean(journal.specDigest);
1328
1714
  if (!fullyPinned) {
1329
1715
  const hasWork = journal.issuances.length > 0
@@ -1405,13 +1791,14 @@ export function recoverAdvancedConsumerArtifacts({
1405
1791
  const matchingGateEvents = gateEvents.filter(
1406
1792
  (event) => event.stepId === transaction.gateStepId,
1407
1793
  );
1408
- const eventOffset = gateEventOffsets.get(transaction.gateStepId) ?? 0;
1794
+ const eventOffset = transaction.gateOrdinal ?? gateEventOffsets.get(transaction.gateStepId) ?? 0;
1409
1795
  const resolvedEvent = matchingGateEvents[eventOffset];
1410
1796
  gateEventOffsets.set(transaction.gateStepId, eventOffset + 1);
1411
1797
  const resolvedOutcome = transaction.gateOutcome ?? resolvedEvent?.detail?.decision;
1412
1798
  const approvedComplete = transaction.state === 'complete' && resolvedOutcome === 'approve';
1413
1799
 
1414
- if (approvedComplete && gateState?.status !== 'waiting_gate') {
1800
+ if (approvedComplete && (artifacts.journal.wave || gateState?.status !== 'waiting_gate')) {
1801
+ if (artifacts.journal.wave) artifacts.recoverCheckpoint(transaction);
1415
1802
  artifacts.markGateAdvanced({ gateToken: transaction.gateToken, outcome: resolvedOutcome, at: resolvedEvent?.at });
1416
1803
  recoveredDecision = true;
1417
1804
  approvedAdvanced = true;
@@ -6,13 +6,17 @@
6
6
  * model IDs degrade to usd:null rather than crashing — a crashed / future
7
7
  * model still yields a record with partial metrics.
8
8
  *
9
- * Price source: Anthropic public pricing page + OpenAI pricing (as of 2026-07).
9
+ * Claude 5 rates: COMP-FABLE-ASTRA slice 1 (2026-09). Earlier rates retained
10
+ * for historical receipts (Anthropic / OpenAI, 2026-07).
10
11
  * Keys are prefix-matched so dated variants (e.g. claude-sonnet-4-6-20250514)
11
12
  * resolve against the base key.
12
13
  */
13
14
 
14
15
  /** @type {Record<string, { inputPerMTok: number, outputPerMTok: number }>} */
15
16
  const EXPERIMENT_PRICING = {
17
+ 'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
18
+ 'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
19
+ 'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
16
20
  // Claude 4.x
17
21
  'claude-opus-4-8': { inputPerMTok: 5, outputPerMTok: 25 },
18
22
  'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },