96 if (request.migrationId !== identity.migrationId) throw new MigrationReviewError('binding_mismatch', 'request belongs to a different migration');
97 if (request.destination && request.destination.siteId !== identity.destination.siteId) throw new MigrationReviewError('binding_mismatch', 'request belongs to a different destination');
116 if (['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)) throw new MigrationReviewError('unsupported_filesystem', 'review storage requires local atomic rename and directory fsync support');
198 if (inferred && canonical(bindingKeys(binding)) !== canonical(bindingKeys(inferred))) throw new MigrationReviewError('binding_mismatch', 'review binding differs from the accepted project identity');
206 if (existing && canonical(bindingKeys(existing)) !== canonical(bindingKeys(binding))) throw new MigrationReviewError('binding_mismatch', 'review source/destination identity differs from this project');
214 if (rows.length) throw new MigrationReviewError('missing_commit_descriptor', 'existing review artifacts need recovery; initialization cannot reset their history');
251 if (error.code === 'EEXIST') throw new MigrationReviewError('lock_held', 'review mutation is owned by another process', { owner: await lockOwner(lockPath) });
252 if (['EPERM', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)) throw new MigrationReviewError('unsupported_filesystem', 'review storage requires local exclusive hard-link claims');
253 throw error;
254 }
255 await syncDir(root);
256 return { lockPath, record };
257}
258
259async function releaseLock(lock) {
260 const current = await lockOwner(lock.lockPath);
261 if (!current || current.token !== lock.record.token) return;
294 throw new MigrationReviewError('lock_recovery_conflict', 'another live or unknown process owns recovery; retry after it finishes', { recoveryOwner: other });
313 if (!recoveryOwner || request.ownerToken !== recoveryOwner.token || ownerIsLive(recoveryOwner) !== false || request.expectedStateVersion !== (state ? state.stateVersion : 0)) throw new MigrationReviewError('lock_recovery_refused', 'recovery requires an unchanged descriptor and the original dead local owner', { owner, stateVersion: state && state.stateVersion });
314 if (!state && recoveryOwner.operation !== 'initialize') throw new MigrationReviewError('missing_commit_descriptor', 'committed descriptor must be recovered before mutation lock recovery');
315 if (!state) for (const folder of ['receipts', 'revisions', 'actions', 'executions', 'evidence']) {
317 if (files.length) throw new MigrationReviewError('missing_commit_descriptor', 'existing artifacts cannot be reset during initialization recovery');
318 }
319 // Keep the original claim in place until repair ends. Arbitration has no ownerless
320 // window or singleton recovery lock that another interruption could strand.
323 if (options.hooks && options.hooks.afterRecoveryClaim) await options.hooks.afterRecoveryClaim();
324 if (canonical(await lockOwner(lockPath)) !== canonical(owner)) throw new MigrationReviewError('lock_recovery_conflict', 'lock owner changed during recovery');
343 if (entry.requestHash !== requestHash) throw new MigrationReviewError('request_collision', `requestId ${requestId} was already committed with different content`);
357 if (request.expectedStateVersion !== initial.state.stateVersion) {
358 throw new MigrationReviewError('state_conflict', `expected state version ${request.expectedStateVersion}, current is ${initial.state.stateVersion}`, { stateVersion: initial.state.stateVersion });
359 }
360 const prepared = await build(initial);
361 if (options.hooks && options.hooks.afterStage) await options.hooks.afterStage(clone(prepared));
362 const now = (options.clock || (() => new Date()))().toISOString();
368 if (current.state.stateVersion !== request.expectedStateVersion) {
369 throw new MigrationReviewError('state_conflict', 'committed state changed while the mutation was staged', { stateVersion: current.state.stateVersion });
379 if (priorIntent && priorIntent.requestHash !== requestHash) throw new MigrationReviewError('request_collision', 'staged request identity was reused with different content');
380 if (priorIntent && priorIntent.expectedStateVersion !== current.state.stateVersion) throw new MigrationReviewError('state_conflict', 'staged request must be reconciled against changed committed state');
429 if (request.executionKind !== undefined && !['import', 'probe'].includes(request.executionKind)) throw new MigrationReviewError('invalid_request', 'executionKind must be import or probe');
430 if (!Array.isArray(request.assessedScope) || request.assessedScope.length === 0 || !request.assessedScope.every((scope) => typeof scope === 'string' && scope.length)) throw new MigrationReviewError('invalid_request', 'assessedScope must name record or capability scopes');
439 if (canonical(comparable) !== canonical(facts)) throw new MigrationReviewError('execution_collision', 'an execution identity cannot be rebound on resume');
452async function loadExecution(root, state, executionId) {
453 if (!state.executions || !state.executions[executionId]) throw new MigrationReviewError('unknown_execution', 'execution registration is not committed');
455 if (digest(execution) !== state.executions[executionId].objectHash) throw new MigrationReviewError('corrupt_store', 'execution bytes differ from committed hash');
456 if (execution.executionId !== executionId || state.executions[executionId].generation !== execution.generation) throw new MigrationReviewError('corrupt_store', 'committed execution identity differs');
457 return execution;
458}
459
460async function captureEvidence(projectDir, request, options = {}) {
462 if (!EVIDENCE_CATEGORIES.includes(request.category)) throw new MigrationReviewError('invalid_request', 'unknown evidence category');
463 assertObject(request.evidence, 'evidence');
464 if (!Object.keys(request.evidence).length) throw new MigrationReviewError('invalid_request', 'capture evidence must contain observed facts or explicit unknown areas');
465 if (request.category === 'source_scope' && request.evidence.acceptedScopes !== undefined && (!Array.isArray(request.evidence.acceptedScopes) || !request.evidence.acceptedScopes.every((scope) => typeof scope === 'string' && scope.length))) throw new MigrationReviewError('invalid_request', 'acceptedScopes must name accepted capability scopes, not record IDs');
466 if (request.category === 'source_scope') for (const scope of request.evidence.acceptedScopes || []) assertToken(scope, 'acceptedScopes entry');
470 if (!Array.isArray(scope) || !scope.length || scope.some((key) => !execution.assessedScope.includes(key))) throw new MigrationReviewError('scope_violation', 'capture scope is outside its committed execution');
476 if (canonical(comparable) !== canonical(capture)) throw new MigrationReviewError('capture_collision', 'a committed capture identity cannot be rebound');
492 if (!Number.isSafeInteger(lockWaitMs) || lockWaitMs < 0 || lockWaitMs > 5000) throw new MigrationReviewError('invalid_request', 'capture lockWaitMs must be between 0 and 5000');
585 if (Object.prototype.hasOwnProperty.call(item, 'affectedCount')) {
586 if (item.affectedCount !== null && (!Number.isSafeInteger(item.affectedCount) || item.affectedCount < 0)) throw new MigrationReviewError('invalid_request', 'task.affectedCount must be null or a nonnegative safe integer');
587 if (Array.isArray(item.affected) && item.affectedCount !== null && item.affectedCount < item.affected.length) throw new MigrationReviewError('invalid_request', 'task.affectedCount cannot be smaller than its affected ID preview');
590 if (!GROUPS.includes(item.group)) throw new MigrationReviewError('invalid_request', `task.group must be one of ${GROUPS.join(', ')}`);
591 // Applicability (not_applicable / superseded) closes a task; a publisher may only request it
592 // through applicabilityChanges, which require matching committed evidence. Never accepted inline.
593 if (producerId !== 'migration-review' && Object.prototype.hasOwnProperty.call(item, 'applicability')) throw new MigrationReviewError('invalid_disposition', 'task applicability is set only through evidence-backed applicabilityChanges');
595 // A task id is derived from its producer and finding key; a chosen id could occupy another
596 // owner's (including a service investigation's) slot and inherit its disposition.
597 if (item.taskId !== undefined && item.taskId !== derivedTaskId) throw new MigrationReviewError('identity_collision', 'task.taskId must be derived from its producer and finding key');
602 if (!['required', 'optional'].includes(normalized.requirement)) throw new MigrationReviewError('invalid_request', 'task requirement must be explicit');
603 if (normalized.requirement === 'required' && !normalized.dismissalAcknowledgment) normalized.dismissalAcknowledgment = normalized.consequence;
604 if (normalized.requirement === 'required' && !normalized.dismissalAcknowledgment) throw new MigrationReviewError('invalid_request', 'required task must describe its consequence');
620 if (`rev_${digest(content).slice(0, 24)}` !== revisionId) throw new MigrationReviewError('corrupt_store', 'revision content differs from its immutable identity');
621 return revision;
622}
623
624async function validateCaptures(root, state, execution, captureIds) {
625 const captures = [];
626 for (const captureId of captureIds || []) {
627 if (!state.captures || !state.captures[captureId] || state.captures[captureId].executionId !== execution.executionId) throw new MigrationReviewError('missing_evidence', 'capture is not committed for this execution');
630 throw new MigrationReviewError('missing_evidence', `capture ${captureId} does not belong to execution ${execution.executionId}`);
631 }
632 if (digest(row) !== state.captures[captureId].captureHash) throw new MigrationReviewError('evidence_integrity', 'capture metadata differs from its committed hash');
635 if (!object || digest(object) !== row.objectHash) throw new MigrationReviewError('evidence_integrity', `capture ${captureId} has missing or changed evidence bytes`);
636 captures.push({ ...row, evidence: object });
637 }
638 return captures;
639}
640
641async function readExecutionEvidence(projectDir, executionId, captureIds) {
642 const { root, state } = await loadIdentityAndState(projectDir);
675 siteParams.some((id) => id !== destination.siteId))) throw new MigrationReviewError('binding_mismatch', 'dashboard/editor link does not match the bound destination');
676 if ((!wixHost && (url.hostname.includes('wix.com') || siteParams.length && /editor|dashboard/.test(url.pathname)))) throw new MigrationReviewError('invalid_link', 'site management links must use a Wix host');
683 if (!check) throw new MigrationReviewError('verification_required', 'machine verification requires a committed check matching this finding and material scope');
684}
685
686// The committed captures named by a closing disposition still prove it for this task's current
687// material facts (same check as applicabilityChanges, against each capture's own execution).
688async function closingEvidenceHolds(root, state, task, applicability) {
702 if (producerId === 'migration-review' || producerId.startsWith('migration-review')) throw new MigrationReviewError('invalid_request', 'migration-review is reserved for service-owned scope investigations');
703 // Task ids are derived from `${producerId}:${findingKey}`; a ':' in the producer id would make
704 // that boundary ambiguous and let a crafted pair hash to another owner's task id.
705 if (producerId.includes(':')) throw new MigrationReviewError('invalid_request', 'producerId must not contain ":"');
707 if (request.completionReportDigest !== undefined) assertToken(request.completionReportDigest, 'completionReportDigest');
708 if (!Array.isArray(request.assessedScope) || request.assessedScope.length === 0) throw new MigrationReviewError('invalid_request', 'publish assessedScope must not be empty');
709 if (!request.coverage || !['complete', 'incomplete'].includes(request.coverage.status)) throw new MigrationReviewError('invalid_request', 'coverage.status must be complete or incomplete');
710 if (request.coverage.unknownAreas !== undefined && !Array.isArray(request.coverage.unknownAreas)) throw new MigrationReviewError('invalid_request', 'coverage unknownAreas must be a list');
713 if (execution.producerId && execution.producerId !== producerId) throw new MigrationReviewError('scope_violation', 'publication producer differs from the registered execution owner');
715 for (const scope of assessed) if (!execution.assessedScope.includes(scope)) throw new MigrationReviewError('scope_violation', `execution did not register assessed scope ${scope}`);
736 if (!Number.isFinite(previousAt) || !Number.isFinite(nextAt) || nextAt <= previousAt) throw new MigrationReviewError('source_provenance_conflict', 'source observations cannot advance this scope without newer or explicitly reconciled provenance');
740 if (normalizedTasks.some((row) => row.findingKey.startsWith('review-evidence:'))) throw new MigrationReviewError('invalid_request', 'coverage investigation keys are reserved for the service');
744 if (new Set(keys).size !== keys.length) throw new MigrationReviewError('identity_collision', 'publication contains duplicate finding/result keys');
745 }
746 for (const item of [...normalizedTasks, ...normalizedResults]) {
747 if (!assessed.includes(item.scopeKey)) throw new MigrationReviewError('scope_violation', `${item.scopeKey} is outside this publication's assessed scope`);
751 if (canonicalExisting && canonicalExisting.producerId !== producerId) {
752 throw new MigrationReviewError('shared_finding_requires_owner', `finding ${task.findingKey} is already owned by ${canonicalExisting.producerId}; reuse that canonical producer instead of duplicating it`, { taskId: canonicalExisting.taskId });
756 if (earlier && (earlier.taskId !== task.taskId || earlier.scopeKey !== task.scopeKey)) throw new MigrationReviewError('identity_collision', 'a finding cannot change task identity or owned scope on rerun');
775 if (earlier && (earlier.producerId !== producerId || earlier.resultId !== result.resultId || earlier.scopeKey !== result.scopeKey)) throw new MigrationReviewError('identity_collision', 'a result key already has a canonical owner/identity/scope');
776 const prior = results[result.resultId];
777 if (prior && (prior.producerId !== producerId || prior.resultKey !== result.resultKey)) throw new MigrationReviewError('identity_collision', `result identity collision for ${result.resultId}`);
778 results[result.resultId] = result;
779 resultByKey.set(result.resultKey, result);
780 }
781 for (const scope of assessed) frontier[`${producerId}\u0000${scope}`] = { generation: execution.generation, executionId: execution.executionId, sourceSnapshot: execution.sourceSnapshot };
787 if (sourceUnknown.some((item) => typeof item !== 'string')) throw new MigrationReviewError('invalid_request', 'unknown evidence areas must name scopes');
793 const investigation = normalizeFinding({ findingKey, scopeKey: scope, group: 'Data', title: `Review missing ${scope} evidence`, summary: 'Evidence coverage is incomplete; inspect the named missing areas before treating the report as complete.', affected: [], affectedCount: null, consequence: 'Unrecorded migration gaps may remain.', action: 'Investigate and capture the missing evidence.', requirement: 'required', verificationPolicy: 'machine_required', evidenceStatus: 'unassessed', unknownAreas }, producerId, 'task');
794 // The service opens or closes only its own investigation row under that id.
795 const held = tasks[investigation.taskId];
796 if (held && held.findingKey !== investigation.findingKey) throw new MigrationReviewError('identity_collision', `task ${investigation.taskId} is not the coverage investigation for ${scope}`);
797 if (coverage[key].status === 'incomplete') tasks[investigation.taskId] = investigation;
798 else if (held) tasks[investigation.taskId] = { ...held, applicability: { status: 'not_applicable', reason: 'Required coverage is now evidenced.', captureIds: request.captureIds } };
799 }
800 for (const change of request.applicabilityChanges || []) {
801 // Coverage investigations are service-owned: they close only when coverage is evidenced.
802 if (typeof change.findingKey === 'string' && change.findingKey.startsWith('review-evidence:')) throw new MigrationReviewError('invalid_disposition', 'coverage investigations close only when their coverage is evidenced');
804 if (!task || !assessed.includes(task.scopeKey) || !['not_applicable', 'superseded'].includes(change.status) || !change.reason) throw new MigrationReviewError('invalid_disposition', 'explicit disposition must identify an owned assessed finding and reason');
857 // Only the service's own row under this id may be opened or closed here.
858 if (tasks[taskId] && (tasks[taskId].producerId !== 'migration-review' || tasks[taskId].findingKey !== findingKey)) throw new MigrationReviewError('identity_collision', `task ${taskId} is not the service investigation for ${scope}`);
859 if (!owner && ownedScopes.has(scope)) {
860 if (tasks[taskId]) tasks[taskId] = { ...tasks[taskId], applicability: { status: 'not_applicable', reason: 'Coverage for this scope is now tracked by explicitly registered producers.' } };
864 if (tasks[taskId]) tasks[taskId] = { ...tasks[taskId], applicability: { status: 'not_applicable', reason: 'This scope now has published coverage; any missing categories remain separate investigations.' } };
947 if (!['resolve', 'dismiss', 'reopen'].includes(request.action)) throw new MigrationReviewError('invalid_request', 'action must be resolve, dismiss, reopen or later');
954 if (!task) throw new MigrationReviewError('unknown_task', `task ${request.taskId} does not exist in the committed revision`);
955 if (request.materialFingerprint !== task.materialFingerprint) throw new MigrationReviewError('stale_task', 'task material facts changed; re-read before recording an action', { materialFingerprint: task.materialFingerprint });
1002 if (`handoff_${digest(content).slice(0, 24)}` !== recap.handoffId) throw new MigrationReviewError('corrupt_store', 'recap content differs from its immutable identity');
1020 if (!value || typeof value !== 'object' || Array.isArray(value)) return ['migrationReview must declare the durable evidence capture contract'];
1021 if (value.contractVersion !== expected.contractVersion) problems.push(`migrationReview.contractVersion must be "${expected.contractVersion}"`);
1022 if (value.executionKind !== expected.executionKind) problems.push('migrationReview.executionKind must be "import"');
1023 for (const phase of expected.phases) if (!Array.isArray(value.phases) || !value.phases.includes(phase)) problems.push(`migrationReview missing phase: ${phase}`);
1024 for (const category of expected.requiredCategories) if (!Array.isArray(value.requiredCategories) || !value.requiredCategories.includes(category)) problems.push(`migrationReview missing evidence category: ${category}`);
1025 if (value.durableBeforeNextWrite !== true) problems.push('migrationReview.durableBeforeNextWrite must be true');
1026 if (value.probeFlag !== expected.probeFlag) problems.push(`migrationReview.probeFlag must be "${expected.probeFlag}"`);