filegrc 0.12.1 → 0.12.3

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.
@@ -1,12 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { modelSupports } from "../model/index.js";
5
- import { createObligationEvent } from "./obligations.js";
6
- import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
4
+ import { performance } from "node:perf_hooks";
5
+ import { loadModel, modelSupports, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
6
+ import { createObligationEvent, obligationRule } from "./obligations.js";
7
+ import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
8
+ import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js";
7
9
  import { markdownEntries } from "./resource-markdown.js";
8
10
  import { loadWorkspace } from "./workspace.js";
9
11
  import { serializeWorkspaceMutation } from "./mutation.js";
12
+ import { currentCalendarDate } from "./time.js";
13
+ import { assertHistoricalRecordValid } from "./validate.js";
10
14
 
11
15
  const TRANSITIONS = {
12
16
  person: [
@@ -93,8 +97,29 @@ const TRANSITIONS = {
93
97
  message: "Confirm whether this Asset disposal needs sanitization and evidence work."
94
98
  }]
95
99
  };
100
+ const MAX_RECONCILIATION_HISTORY_BYTES = 32 * 1024 * 1024;
101
+ const MAX_RECONCILIATION_HISTORY_REQUESTS = 20_000;
102
+ const MAX_MERGE_IDENTITY_CHECKS = 100_000;
103
+ const MAX_RECONCILIATION_MATCH_EVALUATIONS = 100_000;
104
+ const MAX_RECONCILIATION_SNAPSHOT_RECORDS = 100_000;
105
+ const RECORD_STATE_CHECKPOINT_INTERVAL = 64;
106
+ const RECONCILIATION_HISTORY_TYPES = new Set([
107
+ ...Object.keys(TRANSITIONS),
108
+ "action-item",
109
+ "obligation",
110
+ "obligation-event",
111
+ "obligation-rule",
112
+ "reconciliation-dismissal"
113
+ ]);
96
114
 
97
- export async function planReconciliation(input = process.cwd()) {
115
+ function allowsHistoricalTypeTransition(parentModel, currentModel, beforeType, afterType) {
116
+ return parentModel?.modelVersion === "3"
117
+ && currentModel?.modelVersion === "4"
118
+ && beforeType === "system"
119
+ && afterType === "component";
120
+ }
121
+
122
+ export async function planReconciliation(input = process.cwd(), options = {}) {
98
123
  const loaded = input?.entries && input?.resources && input?.model
99
124
  ? input
100
125
  : await loadWorkspace(input);
@@ -107,58 +132,107 @@ export async function planReconciliation(input = process.cwd()) {
107
132
  message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
108
133
  };
109
134
  }
110
- const baseRevision = gitRevision(loaded.root);
135
+ const historyDeadline = performance.now() + 10_000;
136
+ const baseRevision = gitRevision(loaded.root, historyDeadline);
111
137
  const currentByPath = new Map(loaded.entries.map((entry) => [
112
138
  `data/${entry.relativePath}`,
113
139
  entry
114
140
  ]));
115
- const workingChanges = pairWorkingRecordRenames(
116
- loaded.root,
117
- gitChangedEntries(loaded.root),
118
- currentByPath
119
- );
120
- const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
121
- [beforePath, afterPath].filter(Boolean)
122
- )))].sort();
141
+ const rawWorkingChanges = gitChangedEntries(loaded.root, historyDeadline, baseRevision);
123
142
  if (!baseRevision) {
124
143
  return {
125
144
  contractVersion: 1,
126
145
  gitRevision: null,
127
- changedPaths,
146
+ changedPaths: [...new Set(rawWorkingChanges.flatMap(({ beforePath, afterPath }) => (
147
+ [beforePath, afterPath].filter(Boolean)
148
+ )))].sort(),
128
149
  candidates: [],
129
150
  message: "Commit the initial workspace before reconciling later direct-file transitions."
130
151
  };
131
152
  }
132
- const reconciliationHistory = reconciliationCommits(loaded.root);
133
- const historicalRecords = reconciliationHistory.length
134
- ? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
135
- : new Map();
136
- const historyIndex = getDataRecordHistoryIndex(loaded.root);
153
+ const historyIndex = getDataRecordHistoryIndex(loaded.root, { deadline: historyDeadline, head: baseRevision });
154
+ if (!historyIndex.available) {
155
+ throw new Error(`Git history is unavailable for reconciliation${historyIndex.error?.message ? `: ${historyIndex.error.message}` : "."}`);
156
+ }
157
+ const workingChanges = pairWorkingRecordRenames(loaded.root, rawWorkingChanges, currentByPath, baseRevision);
158
+ const changedPaths = [...new Set(workingChanges.flatMap(({ beforePath, afterPath }) => (
159
+ [beforePath, afterPath].filter(Boolean)
160
+ )))].sort();
161
+ const historyContext = { ...historyIndex, recordStatesByCommit: new Map() };
162
+ for (const commit of historyIndex.commits) recordStateAtCommit(historyContext, loaded.model, commit);
163
+ validateMergeIdentities(historyContext, loaded.model);
164
+ const { commits: reconciliationHistory } = reconciliationCommits(historyContext, loaded.model);
165
+ const committedRecordIds = new Set(historyIndex.historiesById.keys());
166
+ const currentTransitionIndex = indexTransitionResources(loaded.resources);
167
+ const transitionIndexes = new WeakMap([[loaded.resources, currentTransitionIndex]]);
168
+ let transitionMatchEvaluations = 0;
169
+ const consumeMatchEvaluation = () => {
170
+ transitionMatchEvaluations += 1;
171
+ if (transitionMatchEvaluations > MAX_RECONCILIATION_MATCH_EVALUATIONS) {
172
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_MATCH_EVALUATIONS}-match safety limit.`);
173
+ }
174
+ };
175
+ const eventRecordSnapshots = new Map();
176
+ let snapshotRecordsProcessed = 0;
177
+ const recordsForEvent = (event) => {
178
+ if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
179
+ const history = historyIndex.historiesById.get(event.id) || [];
180
+ const firstCommit = history.at(-1)?.commit;
181
+ const records = firstCommit
182
+ ? materializeRecordState(recordStateAtCommit(historyContext, loaded.model, firstCommit)).map(({ record }) => record)
183
+ : loaded.resources;
184
+ snapshotRecordsProcessed += records.length;
185
+ if (snapshotRecordsProcessed > MAX_RECONCILIATION_SNAPSHOT_RECORDS) {
186
+ throw new Error(`Git reconciliation history exceeds the ${MAX_RECONCILIATION_SNAPSHOT_RECORDS}-record snapshot safety limit.`);
187
+ }
188
+ eventRecordSnapshots.set(event.id, records);
189
+ while (eventRecordSnapshots.size > 4) eventRecordSnapshots.delete(eventRecordSnapshots.keys().next().value);
190
+ return records;
191
+ };
137
192
  const currentMarkdownOwners = new Map();
138
193
  for (const entry of loaded.entries) {
139
194
  for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
140
195
  }
141
- const candidates = [];
196
+ const workingMarkdownPathsByRecordId = new Map();
197
+ for (const changed of changedPaths) {
198
+ const ownerId = currentMarkdownOwners.get(changed)?.record.id;
199
+ if (!ownerId) continue;
200
+ if (!workingMarkdownPathsByRecordId.has(ownerId)) workingMarkdownPathsByRecordId.set(ownerId, []);
201
+ workingMarkdownPathsByRecordId.get(ownerId).push(changed);
202
+ }
203
+ const rawCandidates = [];
204
+ const filteredCandidates = [];
142
205
  const examined = new Set();
143
-
206
+ const committedContexts = [];
207
+ const historicalRequests = new Map();
144
208
  for (const commit of reconciliationHistory) {
145
- const markdownOwnersBefore = historicalMarkdownOwners(loaded.model, historicalRecords);
146
- for (const change of committedChangedEntries(loaded.root, commit)) {
147
- if (change.beforePath?.endsWith(".json") && !change.afterPath) {
148
- const removed = readRevisionRecord(loaded.root, `${commit}^`, change.beforePath);
149
- if (removed?.id) historicalRecords.delete(removed.id);
150
- }
151
- }
152
- for (const [id, item] of historyIndex.recordsByCommit.get(commit) || []) historicalRecords.set(id, item);
153
- const markdownOwners = historicalMarkdownOwners(loaded.model, historicalRecords);
154
- const commitChanges = pairCommittedRecordRenames(loaded.root, commit, committedChangedEntries(loaded.root, commit));
209
+ const parents = historyContext.parentsByCommit.get(commit) || [];
210
+ const parent = parents[0] || null;
211
+ const recordsBefore = parent ? recordStateAtCommit(historyContext, loaded.model, parent) : null;
212
+ const recordsAfter = recordStateAtCommit(historyContext, loaded.model, commit);
213
+ const changes = historyContext.changesByCommit.get(commit) || [];
214
+ const commitChanges = pairCommittedRecordRenames(changes, recordsBefore, recordsAfter);
155
215
  const commitPaths = [...new Set(commitChanges.flatMap(({ beforePath, afterPath }) => (
156
216
  [beforePath, afterPath].filter(Boolean)
157
217
  )))];
218
+ const changedMarkdownPathsByRecordId = new Map();
219
+ for (const changed of commitPaths.filter((path) => path.endsWith(".md"))) {
220
+ const owners = new Set([
221
+ markdownOwnerAtPath(recordsAfter, changed)?.record.id,
222
+ markdownOwnerAtPath(recordsBefore, changed)?.record.id
223
+ ].filter(Boolean));
224
+ for (const ownerId of owners) {
225
+ if (!changedMarkdownPathsByRecordId.has(ownerId)) changedMarkdownPathsByRecordId.set(ownerId, []);
226
+ changedMarkdownPathsByRecordId.get(ownerId).push(changed);
227
+ }
228
+ }
229
+ const changeContexts = [];
158
230
  const commitExamined = new Set();
159
231
  for (const change of commitChanges) {
160
232
  const path = change.afterPath || change.beforePath;
161
- const currentEntry = currentByPath.get(path) || markdownOwners.get(path) || markdownOwnersBefore.get(path);
233
+ const currentEntry = recordAtPath(recordsAfter, path)
234
+ || markdownOwnerAtPath(recordsAfter, path)
235
+ || markdownOwnerAtPath(recordsBefore, path);
162
236
  const previousRecordPath = change.beforePath?.endsWith(".json")
163
237
  ? change.beforePath
164
238
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
@@ -166,42 +240,115 @@ export async function planReconciliation(input = process.cwd()) {
166
240
  ? change.afterPath
167
241
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
168
242
  const recordPath = currentRecordPath || previousRecordPath;
169
- const previous = readRevisionRecord(loaded.root, `${commit}^`, previousRecordPath);
170
- const current = readRevisionRecord(loaded.root, commit, currentRecordPath);
243
+ const previous = recordAtPath(recordsBefore, previousRecordPath)?.record || null;
244
+ const current = recordAtPath(recordsAfter, currentRecordPath)?.record || null;
171
245
  const record = current || previous;
172
246
  if (!record || commitExamined.has(`${record.type}:${record.id}`)) continue;
173
247
  commitExamined.add(`${record.type}:${record.id}`);
174
- const changedMarkdownPaths = commitPaths.filter((changed) => (
175
- markdownOwners.get(changed)?.record.id === record.id
176
- || markdownOwnersBefore.get(changed)?.record.id === record.id
177
- ));
248
+ const changedMarkdownPaths = changedMarkdownPathsByRecordId.get(record.id) || [];
178
249
  const markdownChanged = changedMarkdownPaths.length > 0;
250
+ const alternateParents = parents.slice(1).map((alternateRevision) => {
251
+ const alternateState = recordStateAtCommit(historyContext, loaded.model, alternateRevision);
252
+ const alternateEntry = recordAtId(alternateState, record.id);
253
+ return {
254
+ revision: alternateRevision,
255
+ recordPath: alternateEntry?.path || null,
256
+ markdownPaths: changedMarkdownPaths
257
+ };
258
+ });
259
+ for (const [revision, sourcePath] of [
260
+ [commit, currentRecordPath],
261
+ [parent, previousRecordPath],
262
+ ...changedMarkdownPaths.flatMap((changed) => [[commit, changed], [parent, changed]]),
263
+ ...alternateParents.flatMap((alternate) => [
264
+ [alternate.revision, alternate.recordPath],
265
+ ...alternate.markdownPaths.map((markdownPath) => [alternate.revision, markdownPath])
266
+ ])
267
+ ]) {
268
+ if (revision && sourcePath) historicalRequests.set(`${revision}\0${sourcePath}`, { revision, relativePath: sourcePath });
269
+ }
270
+ changeContexts.push({
271
+ path,
272
+ record,
273
+ recordPath: currentRecordPath || previousRecordPath,
274
+ previous,
275
+ current,
276
+ currentRecordPath,
277
+ previousRecordPath,
278
+ changedMarkdownPaths,
279
+ markdownChanged,
280
+ alternateParents
281
+ });
282
+ }
283
+ committedContexts.push({ commit, parent, changeContexts });
284
+ }
285
+ const historicalRequestList = [...historicalRequests.values()];
286
+ const historicalSources = getFilesAtRevisions(loaded.root, historicalRequestList, {
287
+ batchSize: 512,
288
+ maxRequests: MAX_RECONCILIATION_HISTORY_REQUESTS,
289
+ maxTotalBytes: MAX_RECONCILIATION_HISTORY_BYTES,
290
+ deadline: historyDeadline
291
+ });
292
+ const sourceByRevisionAndPath = new Map(historicalRequestList.map((request, index) => [
293
+ `${request.revision}\0${request.relativePath}`,
294
+ historicalSources[index] || ""
295
+ ]));
296
+
297
+ for (const { commit, parent, changeContexts } of committedContexts) {
298
+ for (const change of changeContexts) {
299
+ const {
300
+ path,
301
+ record,
302
+ recordPath,
303
+ previous,
304
+ current,
305
+ currentRecordPath,
306
+ previousRecordPath,
307
+ changedMarkdownPaths,
308
+ markdownChanged,
309
+ alternateParents
310
+ } = change;
179
311
  const currentSource = [
180
- readRevisionSource(loaded.root, commit, currentRecordPath),
181
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, commit, changed))
312
+ historicalSource(sourceByRevisionAndPath, commit, currentRecordPath),
313
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, commit, changed))
182
314
  ].join("\n");
183
315
  const beforeSource = [
184
- readRevisionSource(loaded.root, `${commit}^`, previousRecordPath),
185
- ...changedMarkdownPaths.map((changed) => readRevisionSource(loaded.root, `${commit}^`, changed))
316
+ historicalSource(sourceByRevisionAndPath, parent, previousRecordPath),
317
+ ...changedMarkdownPaths.map((changed) => historicalSource(sourceByRevisionAndPath, parent, changed))
186
318
  ].join("\n");
319
+ const repeatsParentState = alternateParents.some((alternate) => {
320
+ const alternateSource = [
321
+ historicalSource(sourceByRevisionAndPath, alternate.revision, alternate.recordPath),
322
+ ...alternate.markdownPaths.map((markdownPath) => (
323
+ historicalSource(sourceByRevisionAndPath, alternate.revision, markdownPath)
324
+ ))
325
+ ].join("\n");
326
+ return alternateSource === currentSource;
327
+ });
328
+ if (repeatsParentState) continue;
187
329
  for (const transition of TRANSITIONS[record.type] || []) {
188
330
  if (!transition.applies(previous, current, { markdownChanged })) continue;
189
- if (committedEventHandled(loaded.root, commit, commitPaths, transition.eventType, record.id, historicalRecords)) continue;
190
- const fingerprint = transitionFingerprint({
191
- baseRevision: commit,
331
+ const fingerprintInput = {
332
+ baseRevision: parent || commit,
192
333
  eventType: transition.eventType,
193
334
  subjectId: record.id,
194
335
  path: recordPath,
195
336
  beforeSource,
196
337
  currentSource,
197
338
  markdownChanged
339
+ };
340
+ const fingerprint = transitionFingerprint(fingerprintInput);
341
+ const legacyCommittedFingerprint = transitionFingerprint({ ...fingerprintInput, baseRevision: commit });
342
+ const legacyWorkingFingerprint = transitionFingerprint({
343
+ ...fingerprintInput,
344
+ beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
345
+ readRevisionSource(loaded.root, `${commit}^`, changed, historyIndex)
346
+ ))].join("\n")
198
347
  });
199
348
  const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
200
- if (loaded.resources.some((item) => (
201
- item.type === "obligation-event"
202
- && (item.id === eventId || item.transitionFingerprint === fingerprint)
203
- ))) continue;
204
- candidates.push(reconciliationCandidate(
349
+ const handledFingerprints = [fingerprint, legacyCommittedFingerprint, legacyWorkingFingerprint];
350
+ const handledEventIds = handledFingerprints.map((value) => `obligation-event-git-${value.slice(0, 16)}`);
351
+ const candidate = reconciliationCandidate(
205
352
  loaded,
206
353
  transition,
207
354
  record,
@@ -209,7 +356,22 @@ export async function planReconciliation(input = process.cwd()) {
209
356
  fingerprint,
210
357
  eventId,
211
358
  { committedRevision: commit }
212
- ));
359
+ );
360
+ rawCandidates.push(candidate);
361
+ if (!transitionHandled(loaded.resources, handledEventIds, handledFingerprints, {
362
+ eventType: transition.eventType,
363
+ subjectId: record.id,
364
+ includeDismissed: options.includeDismissed,
365
+ committedRecordIds,
366
+ timezone: loaded.workspace.timezone,
367
+ recordsForEvent,
368
+ currentIndex: currentTransitionIndex,
369
+ consumeMatchEvaluation,
370
+ indexFor: (records) => {
371
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
372
+ return transitionIndexes.get(records);
373
+ }
374
+ })) filteredCandidates.push(candidate);
213
375
  }
214
376
  }
215
377
  }
@@ -223,14 +385,12 @@ export async function planReconciliation(input = process.cwd()) {
223
385
  const currentRecordPath = change.afterPath?.endsWith(".json")
224
386
  ? change.afterPath
225
387
  : currentEntry ? `data/${currentEntry.relativePath}` : null;
226
- const previous = readHeadRecord(loaded.root, previousRecordPath);
388
+ const previous = readHeadRecord(loaded.root, previousRecordPath, baseRevision);
227
389
  const current = currentRecordPath ? currentByPath.get(currentRecordPath)?.record || currentEntry?.record || null : null;
228
390
  const record = current || previous;
229
391
  if (!record || examined.has(`${record.type}:${record.id}`)) continue;
230
392
  examined.add(`${record.type}:${record.id}`);
231
- const changedMarkdownPaths = changedPaths.filter((changed) => (
232
- currentMarkdownOwners.get(changed)?.record.id === record.id
233
- ));
393
+ const changedMarkdownPaths = workingMarkdownPathsByRecordId.get(record.id) || [];
234
394
  const markdownChanged = changedMarkdownPaths.length > 0;
235
395
  const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
236
396
  try {
@@ -239,12 +399,12 @@ export async function planReconciliation(input = process.cwd()) {
239
399
  return "";
240
400
  }
241
401
  }));
242
- const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
402
+ const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed, baseRevision));
243
403
  const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
244
- const beforeSource = [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n");
404
+ const beforeSource = [readHeadSource(loaded.root, previousRecordPath, baseRevision), ...previousMarkdown].join("\n");
245
405
  for (const transition of TRANSITIONS[record.type] || []) {
246
406
  if (!transition.applies(previous, current, { markdownChanged })) continue;
247
- const fingerprint = transitionFingerprint({
407
+ const fingerprintInput = {
248
408
  baseRevision,
249
409
  eventType: transition.eventType,
250
410
  subjectId: record.id,
@@ -252,24 +412,46 @@ export async function planReconciliation(input = process.cwd()) {
252
412
  beforeSource,
253
413
  currentSource,
254
414
  markdownChanged
415
+ };
416
+ const fingerprint = transitionFingerprint(fingerprintInput);
417
+ const legacyFingerprint = transitionFingerprint({
418
+ ...fingerprintInput,
419
+ beforeSource: [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n")
255
420
  });
256
421
  const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
257
- if (loaded.resources.some((item) => (
258
- item.type === "obligation-event"
259
- && (
260
- item.id === eventId
261
- || item.transitionFingerprint === fingerprint
262
- )
263
- ))) continue;
264
- candidates.push(reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId));
422
+ const legacyEventId = `obligation-event-git-${legacyFingerprint.slice(0, 16)}`;
423
+ const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId);
424
+ rawCandidates.push(candidate);
425
+ if (!transitionHandled(loaded.resources, [eventId, legacyEventId], [fingerprint, legacyFingerprint], {
426
+ eventType: transition.eventType,
427
+ subjectId: record.id,
428
+ includeDismissed: options.includeDismissed,
429
+ committedRecordIds,
430
+ timezone: loaded.workspace.timezone,
431
+ recordsForEvent,
432
+ currentIndex: currentTransitionIndex,
433
+ consumeMatchEvaluation,
434
+ indexFor: (records) => {
435
+ if (!transitionIndexes.has(records)) transitionIndexes.set(records, indexTransitionResources(records));
436
+ return transitionIndexes.get(records);
437
+ }
438
+ })) filteredCandidates.push(candidate);
265
439
  }
266
440
  }
267
- return {
441
+ const result = {
268
442
  contractVersion: 1,
269
- gitRevision: gitRevision(loaded.root),
443
+ gitRevision: baseRevision,
270
444
  changedPaths,
271
- candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
445
+ candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
446
+ .sort((a, b) => a.id.localeCompare(b.id))
272
447
  };
448
+ if (options.includeHandled) {
449
+ Object.defineProperty(result, "filteredPlan", {
450
+ value: { ...result, candidates: filteredCandidates.sort((a, b) => a.id.localeCompare(b.id)) },
451
+ enumerable: false
452
+ });
453
+ }
454
+ return result;
273
455
  }
274
456
 
275
457
  function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
@@ -295,42 +477,27 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
295
477
  };
296
478
  }
297
479
 
298
- function reconciliationCommits(root) {
299
- const commits = lines(runGit(root, ["log", "--reverse", "--format=%H", "--", "data"]));
300
- const baseline = commits.findIndex((commit) => {
301
- const workspace = readRevisionRecord(root, commit, "data/workspace.json");
480
+ function reconciliationCommits(index, model) {
481
+ const commits = index.commits.filter((commit) => {
482
+ const parent = index.parentsByCommit.get(commit)?.[0] || null;
483
+ if (!parent) return false;
484
+ const workspace = recordAtId(recordStateAtCommit(index, model, parent), "workspace")?.record;
302
485
  return Number(workspace?.dataModelVersion) >= 3;
303
486
  });
304
- return baseline < 0 ? [] : commits.slice(baseline + 1);
305
- }
306
-
307
- function committedChangedEntries(root, commit) {
308
- return parseNameStatus(runGit(root, [
309
- "diff-tree",
310
- "--root",
311
- "--no-commit-id",
312
- "--name-status",
313
- "-M",
314
- "-r",
315
- commit,
316
- "--",
317
- "data"
318
- ])).filter(({ beforePath, afterPath }) => (
319
- beforePath?.startsWith("data/") || afterPath?.startsWith("data/")
320
- ));
487
+ return { commits };
321
488
  }
322
489
 
323
- function pairCommittedRecordRenames(root, commit, changes) {
490
+ function pairCommittedRecordRenames(changes, recordsBefore, recordsAfter) {
324
491
  const deleted = changes.filter(({ beforePath, afterPath }) => beforePath?.endsWith(".json") && !afterPath);
325
492
  const added = changes.filter(({ beforePath, afterPath }) => !beforePath && afterPath?.endsWith(".json"));
326
493
  const paired = new Set();
327
494
  const replacements = [];
328
495
  for (const removed of deleted) {
329
- const previous = readRevisionRecord(root, `${commit}^`, removed.beforePath);
496
+ const previous = recordAtPath(recordsBefore, removed.beforePath)?.record;
330
497
  if (!previous?.type || !previous?.id) continue;
331
498
  const addition = added.find((candidate) => {
332
499
  if (paired.has(candidate)) return false;
333
- const current = readRevisionRecord(root, commit, candidate.afterPath);
500
+ const current = recordAtPath(recordsAfter, candidate.afterPath)?.record;
334
501
  return current?.type === previous.type && current?.id === previous.id;
335
502
  });
336
503
  if (!addition) continue;
@@ -341,30 +508,310 @@ function pairCommittedRecordRenames(root, commit, changes) {
341
508
  return [...changes.filter((change) => !paired.has(change)), ...replacements];
342
509
  }
343
510
 
344
- function committedEventHandled(root, commit, commitPaths, eventType, subjectId, historicalRecords) {
345
- const requiredObligationIds = applicableEventObligationIds(
346
- [...historicalRecords.values()].map(({ record }) => record),
347
- eventType
511
+ function recordStateAtCommit(index, model, commit) {
512
+ if (index.recordStatesByCommit.has(commit)) return index.recordStatesByCommit.get(commit);
513
+ const lineage = [];
514
+ let cursor = commit;
515
+ while (cursor && !index.recordStatesByCommit.has(cursor)) {
516
+ lineage.push(cursor);
517
+ cursor = index.parentsByCommit.get(cursor)?.[0] || null;
518
+ }
519
+ let state = cursor ? index.recordStatesByCommit.get(cursor) : null;
520
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
521
+ const changedAt = lineage[position];
522
+ state = extendRecordState(index, model, changedAt, state);
523
+ index.recordStatesByCommit.set(changedAt, state);
524
+ }
525
+ return index.recordStatesByCommit.get(commit);
526
+ }
527
+
528
+ function extendRecordState(index, model, commit, parentState) {
529
+ const changes = index.changesByCommit.get(commit) || [];
530
+ if (!changes.length) return parentState;
531
+ const state = {
532
+ parent: parentState,
533
+ depth: (parentState?.depth || 0) + 1,
534
+ recordsById: new Map(),
535
+ recordsByPath: new Map(),
536
+ markdownOwnersByPath: new Map(),
537
+ identitiesById: new Map(),
538
+ snapshot: null
539
+ };
540
+ const changedRecordsByPath = new Map(
541
+ [...(index.recordsByCommit.get(commit) || new Map()).values()].map((item) => [item.path, item])
348
542
  );
349
- return commitPaths.some((path) => {
350
- if (!path.endsWith(".json")) return false;
351
- const record = readRevisionRecord(root, commit, path);
352
- const previous = readRevisionRecord(root, `${commit}^`, path);
353
- return !previous
354
- && record?.type === "obligation-event"
355
- && record.eventType === eventType
356
- && (record.subjectResourceIds || []).includes(subjectId)
357
- && requiredObligationIds.every((id) => (record.obligationIds || []).includes(id));
358
- });
543
+ const workspaceChanges = [...changedRecordsByPath.values()].filter(({ path, record }) => (
544
+ path === "data/workspace.json" || record.id === "workspace" || record.type === "workspace"
545
+ ));
546
+ if (workspaceChanges.length > 1) {
547
+ throw new Error(`Git history contains multiple Workspace records at ${commit.slice(0, 12)}.`);
548
+ }
549
+ const workspaceChange = workspaceChanges[0] || null;
550
+ let historicalModel = parentState ? parentState.model : model;
551
+ if (workspaceChange) {
552
+ if (
553
+ workspaceChange.path !== "data/workspace.json"
554
+ || workspaceChange.record.id !== "workspace"
555
+ || workspaceChange.record.type !== "workspace"
556
+ ) {
557
+ throw new Error(`Git history contains an invalid Workspace identity or location at ${commit.slice(0, 12)}.`);
558
+ }
559
+ if (!Object.hasOwn(workspaceChange.record, "dataModelVersion")) {
560
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
561
+ }
562
+ const version = workspaceChange.record.dataModelVersion;
563
+ if (typeof version !== "string" || (version !== "1" && !SUPPORTED_MODEL_VERSIONS.includes(version))) {
564
+ throw new Error(`Git history declares an unsupported data model at ${commit.slice(0, 12)}.`);
565
+ }
566
+ if (version === "1") {
567
+ historicalModel = null;
568
+ } else {
569
+ historicalModel = loadModel(version);
570
+ assertHistoricalRecordValid(
571
+ workspaceChange.record,
572
+ historicalModel,
573
+ workspaceChange.path.replace(/^data\//, "")
574
+ );
575
+ }
576
+ }
577
+ state.model = historicalModel;
578
+ const modelTransition = Boolean(
579
+ historicalModel
580
+ && parentState?.model?.modelVersion !== historicalModel.modelVersion
581
+ );
582
+ const removedPaths = new Set(changes.filter(({ beforePath, afterPath }) => (
583
+ beforePath?.endsWith(".json") && beforePath !== afterPath
584
+ )).map(({ beforePath }) => beforePath));
585
+ for (const change of changes) {
586
+ if (!change.beforePath?.endsWith(".json") && !change.afterPath?.endsWith(".json")) continue;
587
+ const previous = recordAtPath(parentState, change.beforePath);
588
+ if (previous) {
589
+ for (const markdown of parentState?.model ? markdownEntries(parentState.model, previous.record) : []) {
590
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, null);
591
+ }
592
+ }
593
+ if (change.beforePath?.endsWith(".json") && change.beforePath !== change.afterPath) {
594
+ state.recordsByPath.set(change.beforePath, null);
595
+ if (previous?.record.id) {
596
+ const replacement = [...changedRecordsByPath.values()].find(({ record }) => record.id === previous.record.id);
597
+ if (!replacement) {
598
+ state.recordsById.set(previous.record.id, null);
599
+ const identity = identityAtId(parentState, previous.record.id);
600
+ if (identity) state.identitiesById.set(previous.record.id, { ...identity, active: false });
601
+ }
602
+ }
603
+ }
604
+ if (change.afterPath?.endsWith(".json")) {
605
+ const current = changedRecordsByPath.get(change.afterPath) || null;
606
+ if (!current || (historicalModel && !historicalModel.resources[current.record.type])) {
607
+ throw new Error(`Git history contains an unsupported data record at ${commit.slice(0, 12)}:${change.afterPath}.`);
608
+ }
609
+ if (historicalModel && RECONCILIATION_HISTORY_TYPES.has(current.record.type)) {
610
+ assertHistoricalRecordValid(current.record, historicalModel, change.afterPath.replace(/^data\//, ""));
611
+ }
612
+ const priorAtIdentity = previous || recordAtId(parentState, current.record.id);
613
+ const allowedTypeTransition = priorAtIdentity && allowsHistoricalTypeTransition(
614
+ parentState?.model,
615
+ historicalModel,
616
+ priorAtIdentity.record.type,
617
+ current.record.type
618
+ );
619
+ if (priorAtIdentity && priorAtIdentity.record.type !== current.record.type) {
620
+ if (!allowedTypeTransition) {
621
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
622
+ }
623
+ assertHistoricalRecordValid(
624
+ priorAtIdentity.record,
625
+ parentState.model,
626
+ priorAtIdentity.path.replace(/^data\//, "")
627
+ );
628
+ assertHistoricalRecordValid(
629
+ current.record,
630
+ historicalModel,
631
+ change.afterPath.replace(/^data\//, "")
632
+ );
633
+ }
634
+ if (previous && (
635
+ previous.record.id !== current.record.id
636
+ || (previous.record.type !== current.record.type && !allowedTypeTransition)
637
+ )) {
638
+ throw new Error(`Git history changes immutable record identity at ${commit.slice(0, 12)}:${change.afterPath}.`);
639
+ }
640
+ const existing = recordAtId(parentState, current.record.id);
641
+ const priorIdentity = identityAtId(parentState, current.record.id);
642
+ if (priorIdentity && !priorIdentity.active) {
643
+ const sameIdentityRestore = priorIdentity.path === change.afterPath
644
+ && priorIdentity.type === current.record.type;
645
+ if (!sameIdentityRestore) {
646
+ throw new Error(`Git history reuses deleted record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
647
+ }
648
+ }
649
+ if (existing && existing.path !== change.afterPath && !removedPaths.has(existing.path)) {
650
+ throw new Error(`Git history reuses record ID "${current.record.id}" at ${commit.slice(0, 12)}:${change.afterPath}.`);
651
+ }
652
+ if (existing && existing.record.type !== current.record.type && !allowedTypeTransition) {
653
+ throw new Error(`Git history changes immutable record type for "${current.record.id}" at ${commit.slice(0, 12)}.`);
654
+ }
655
+ state.recordsByPath.set(change.afterPath, current);
656
+ if (current?.record.id) {
657
+ state.recordsById.set(current.record.id, current);
658
+ state.identitiesById.set(current.record.id, priorIdentity
659
+ ? { ...priorIdentity, type: current.record.type, record: current.record, path: change.afterPath, active: true }
660
+ : { type: current.record.type, introducedAt: commit, record: current.record, path: change.afterPath, active: true });
661
+ const entry = { ...current, relativePath: current.path.replace(/^data\//, "") };
662
+ for (const markdown of historicalModel ? markdownEntries(historicalModel, current.record) : []) {
663
+ state.markdownOwnersByPath.set(`data/${markdown.path}`, entry);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ const activeWorkspace = recordAtPath(state, "data/workspace.json");
669
+ if (
670
+ !activeWorkspace
671
+ || activeWorkspace.record.id !== "workspace"
672
+ || activeWorkspace.record.type !== "workspace"
673
+ ) {
674
+ throw new Error(`Git history has no active canonical Workspace at ${commit.slice(0, 12)}.`);
675
+ }
676
+ if (modelTransition) {
677
+ for (const entry of materializeRecordState(state)) {
678
+ if (!RECONCILIATION_HISTORY_TYPES.has(entry.record.type)) continue;
679
+ assertHistoricalRecordValid(entry.record, historicalModel, entry.path.replace(/^data\//, ""));
680
+ }
681
+ }
682
+ if (state.depth % RECORD_STATE_CHECKPOINT_INTERVAL === 0) state.snapshot = snapshotRecordState(state);
683
+ return state;
684
+ }
685
+
686
+ function recordAtPath(state, path) {
687
+ if (!path) return null;
688
+ for (let current = state; current; current = current.parent) {
689
+ if (current.snapshot) {
690
+ const item = current.snapshot.recordsByPath.get(path);
691
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
692
+ }
693
+ if (!current.recordsByPath.has(path)) continue;
694
+ const item = current.recordsByPath.get(path);
695
+ return item ? { ...item, relativePath: path.replace(/^data\//, "") } : null;
696
+ }
697
+ return null;
698
+ }
699
+
700
+ function recordAtId(state, id) {
701
+ if (!id) return null;
702
+ for (let current = state; current; current = current.parent) {
703
+ if (current.snapshot) return current.snapshot.recordsById.get(id) || null;
704
+ if (!current.recordsById.has(id)) continue;
705
+ return current.recordsById.get(id);
706
+ }
707
+ return null;
708
+ }
709
+
710
+ function markdownOwnerAtPath(state, path) {
711
+ if (!path) return null;
712
+ for (let current = state; current; current = current.parent) {
713
+ if (current.snapshot) return current.snapshot.markdownOwnersByPath.get(path) || null;
714
+ if (current.markdownOwnersByPath.has(path)) return current.markdownOwnersByPath.get(path);
715
+ }
716
+ return null;
717
+ }
718
+
719
+ function identityAtId(state, id) {
720
+ if (!id) return null;
721
+ for (let current = state; current; current = current.parent) {
722
+ if (current.snapshot) return current.snapshot.identitiesById.get(id) || null;
723
+ if (current.identitiesById.has(id)) return current.identitiesById.get(id);
724
+ }
725
+ return null;
726
+ }
727
+
728
+ function snapshotRecordState(state) {
729
+ const snapshot = {
730
+ recordsById: new Map(),
731
+ recordsByPath: new Map(),
732
+ markdownOwnersByPath: new Map(),
733
+ identitiesById: new Map()
734
+ };
735
+ const seen = Object.fromEntries(Object.keys(snapshot).map((key) => [key, new Set()]));
736
+ for (let current = state; current; current = current.parent) {
737
+ for (const key of Object.keys(snapshot)) {
738
+ const source = current[key];
739
+ if (source) {
740
+ for (const [itemKey, value] of source) {
741
+ if (seen[key].has(itemKey)) continue;
742
+ seen[key].add(itemKey);
743
+ if (value) snapshot[key].set(itemKey, value);
744
+ }
745
+ }
746
+ if (current !== state && current.snapshot) {
747
+ for (const [itemKey, value] of current.snapshot[key]) {
748
+ if (seen[key].has(itemKey)) continue;
749
+ seen[key].add(itemKey);
750
+ if (value) snapshot[key].set(itemKey, value);
751
+ }
752
+ }
753
+ }
754
+ if (current !== state && current.snapshot) break;
755
+ }
756
+ return snapshot;
359
757
  }
360
758
 
361
- function applicableEventObligationIds(records, eventType) {
759
+ function validateMergeIdentities(index, model) {
760
+ const snapshots = new Map();
761
+ let checks = 0;
762
+ for (const [commit, parents] of index.parentsByCommit) {
763
+ if (parents.length < 2) continue;
764
+ const identities = new Map();
765
+ for (const parent of parents) {
766
+ const state = recordStateAtCommit(index, model, parent);
767
+ let snapshot = snapshots.get(state);
768
+ if (!snapshot) {
769
+ snapshot = state.snapshot || snapshotRecordState(state);
770
+ snapshots.set(state, snapshot);
771
+ }
772
+ checks += snapshot.identitiesById.size;
773
+ if (checks > MAX_MERGE_IDENTITY_CHECKS) {
774
+ throw new Error("Git merge identity history is too large to reconcile safely.");
775
+ }
776
+ for (const [id, identity] of snapshot.identitiesById) {
777
+ const previous = identities.get(id);
778
+ if (previous && (
779
+ previous.type !== identity.type
780
+ || previous.introducedAt !== identity.introducedAt
781
+ )) {
782
+ throw new Error(`Git history reuses record ID "${id}" across merge branches at ${commit.slice(0, 12)}.`);
783
+ }
784
+ identities.set(id, identity);
785
+ }
786
+ }
787
+ }
788
+ }
789
+
790
+ function materializeRecordState(state) {
791
+ const records = new Map();
792
+ const seen = new Set();
793
+ for (let current = state; current; current = current.parent) {
794
+ for (const [id, item] of current.recordsById) {
795
+ if (seen.has(id)) continue;
796
+ seen.add(id);
797
+ if (item) records.set(id, item);
798
+ }
799
+ }
800
+ return [...records.values()];
801
+ }
802
+
803
+ function historicalSource(sources, revision, path) {
804
+ return revision && path ? sources.get(`${revision}\0${path}`) || "" : "";
805
+ }
806
+
807
+ function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
362
808
  const byId = new Map(records.map((record) => [record.id, record]));
363
809
  return records.filter((record) => {
364
810
  if (record.type !== "obligation" || record.status !== "active") return false;
365
- const rule = byId.get(record.activeRuleId);
366
- const schedule = rule?.type === "obligation-rule" && rule.status === "active" ? rule : record;
367
- return schedule.recurrence?.mode === "event" && schedule.recurrence.eventType === eventType;
811
+ const schedule = obligationRule(record, byId, { now: occurredAt }) || record;
812
+ return schedule.recurrence?.mode === "event"
813
+ && schedule.recurrence.eventType === eventType
814
+ && (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
368
815
  }).map(({ id }) => id);
369
816
  }
370
817
 
@@ -377,10 +824,13 @@ function readRevisionRecord(root, revision, path) {
377
824
  }
378
825
  }
379
826
 
380
- function readRevisionSource(root, revision, path) {
827
+ function readRevisionSource(root, revision, path, historyIndex = null) {
381
828
  if (!path) return "";
382
829
  try {
383
- const commit = runGit(root, ["rev-parse", `${revision}^{commit}`]);
830
+ const parentMatch = String(revision).match(/^([a-f0-9]{40})\^$/i);
831
+ const commit = parentMatch && historyIndex
832
+ ? historyIndex.parentsByCommit.get(parentMatch[1])?.[0] || null
833
+ : /^[a-f0-9]{40}$/i.test(String(revision)) ? String(revision) : runGit(root, ["rev-parse", `${revision}^{commit}`]);
384
834
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
385
835
  } catch {
386
836
  return "";
@@ -393,9 +843,7 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
393
843
  }
394
844
  return serializeWorkspaceMutation(input, async (root) => {
395
845
  const plan = await planReconciliation(root);
396
- const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
397
- id === options.candidateId || transitionFingerprint === options.transitionFingerprint
398
- ));
846
+ const candidate = findCandidate(plan, options);
399
847
  if (!candidate) {
400
848
  throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
401
849
  }
@@ -417,15 +865,229 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
417
865
  });
418
866
  }
419
867
 
420
- function gitChangedEntries(root) {
421
- const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
422
- const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
868
+ export async function dismissReconciliation(input = process.cwd(), options = {}) {
869
+ if (options.confirmed !== true) {
870
+ throw new Error("Dismissing a reconciliation candidate records a review decision. Preview the candidate and confirm the write.");
871
+ }
872
+ return serializeWorkspaceMutation(input, async (root) => {
873
+ const plan = await planReconciliation(root);
874
+ const candidate = findCandidate(plan, options);
875
+ if (!candidate) {
876
+ throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
877
+ }
878
+ const loaded = await loadWorkspace(root);
879
+ if (!loaded.model.resources["reconciliation-dismissal"]) {
880
+ throw new Error("Reconciliation dismissals require data model v10 or newer.");
881
+ }
882
+ const reviewedById = String(options.reviewedById || "").trim();
883
+ const reviewedOn = String(options.reviewedOn || "").trim();
884
+ const rationale = String(options.rationale || "").trim();
885
+ if (!loaded.resources.some((record) => (
886
+ record.type === "person" && record.id === reviewedById && record.status === "active"
887
+ ))) {
888
+ throw new Error("An active Person ID is required as the dismissal reviewer.");
889
+ }
890
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewedOn)) {
891
+ throw new Error("The dismissal review date must use YYYY-MM-DD.");
892
+ }
893
+ if (reviewedOn !== currentCalendarDate(loaded.workspace.timezone)) {
894
+ throw new Error("The dismissal review date must be the current date in the workspace timezone.");
895
+ }
896
+ if (!rationale) throw new Error("A rationale is required to dismiss a reconciliation candidate.");
897
+ const record = {
898
+ id: `reconciliation-dismissal-${candidate.transitionFingerprint}`,
899
+ type: "reconciliation-dismissal",
900
+ title: `Dismiss ${candidate.eventType} for ${candidate.subject.title}`,
901
+ transitionFingerprint: candidate.transitionFingerprint,
902
+ eventType: candidate.eventType,
903
+ subjectResourceId: candidate.subject.id,
904
+ reviewedByIds: [reviewedById],
905
+ reviewedOn,
906
+ rationale
907
+ };
908
+ const result = await createResource(root, record, {
909
+ workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
910
+ });
911
+ return { candidate, dismissal: result.record, path: result.path };
912
+ });
913
+ }
914
+
915
+ function transitionHandled(resources, eventIds, fingerprints, transition) {
916
+ const currentIndex = transition.currentIndex || indexTransitionResources(resources);
917
+ const matchingEvents = new Set();
918
+ for (const eventId of eventIds) {
919
+ const event = currentIndex.eventsById.get(eventId);
920
+ if (event) matchingEvents.add(event);
921
+ }
922
+ for (const fingerprint of fingerprints) {
923
+ for (const event of currentIndex.eventsByFingerprint.get(fingerprint) || []) {
924
+ transition.consumeMatchEvaluation?.();
925
+ matchingEvents.add(event);
926
+ }
927
+ }
928
+ for (const item of matchingEvents) {
929
+ const records = transition.recordsForEvent?.(item) || resources;
930
+ const historicalIndex = transition.indexFor?.(records) || indexTransitionResources(records);
931
+ const event = historicalIndex.byId.get(item.id) || item;
932
+ if ((eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
933
+ && obligationEventHandlesTransition(
934
+ event,
935
+ records,
936
+ transition.eventType,
937
+ transition.subjectId,
938
+ resources,
939
+ historicalIndex,
940
+ currentIndex
941
+ )) return true;
942
+ }
943
+ if (transition.includeDismissed) return false;
944
+ const dismissal = currentIndex.byId.get(`reconciliation-dismissal-${fingerprints[0]}`);
945
+ return Boolean(dismissal && reconciliationDismissalMatches(dismissal, {
946
+ transitionFingerprint: fingerprints[0],
947
+ eventType: transition.eventType,
948
+ subject: { id: transition.subjectId }
949
+ }, resources, {
950
+ requireActiveReviewer: !transition.committedRecordIds.has(dismissal.id),
951
+ timezone: transition.timezone,
952
+ peopleById: currentIndex.peopleById
953
+ })
954
+ );
955
+ }
956
+
957
+ function indexTransitionResources(resources) {
958
+ const index = {
959
+ byId: new Map(),
960
+ peopleById: new Map(),
961
+ eventsById: new Map(),
962
+ eventsByFingerprint: new Map(),
963
+ actionsBySourceAndObligation: new Map()
964
+ };
965
+ for (const record of resources) {
966
+ index.byId.set(record.id, record);
967
+ if (record.type === "person") index.peopleById.set(record.id, record);
968
+ if (record.type === "obligation-event") {
969
+ index.eventsById.set(record.id, record);
970
+ if (record.transitionFingerprint) {
971
+ if (!index.eventsByFingerprint.has(record.transitionFingerprint)) index.eventsByFingerprint.set(record.transitionFingerprint, []);
972
+ index.eventsByFingerprint.get(record.transitionFingerprint).push(record);
973
+ }
974
+ }
975
+ if (record.type === "action-item" && record.sourceResourceId && record.obligationId) {
976
+ index.actionsBySourceAndObligation.set(`${record.sourceResourceId}\0${record.obligationId}`, record);
977
+ }
978
+ }
979
+ return index;
980
+ }
981
+
982
+ function obligationEventHandlesTransition(
983
+ event,
984
+ records,
985
+ eventType,
986
+ subjectId,
987
+ currentRecords = records,
988
+ historicalIndex = indexTransitionResources(records),
989
+ currentIndex = indexTransitionResources(currentRecords)
990
+ ) {
991
+ if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
992
+ if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
993
+ const currentEvent = currentIndex.byId.get(event.id);
994
+ if (
995
+ currentEvent?.type !== "obligation-event"
996
+ || currentEvent.eventType !== eventType
997
+ || currentEvent.status === "canceled"
998
+ || !(currentEvent.subjectResourceIds || []).includes(subjectId)
999
+ || currentEvent.riskLevel !== event.riskLevel
1000
+ || currentEvent.occurredOn !== event.occurredOn
1001
+ || currentEvent.occurredAt !== event.occurredAt
1002
+ ) return false;
1003
+ const occurredAt = event.occurredAt || (event.occurredOn ? `${event.occurredOn}T23:59:59Z` : undefined);
1004
+ const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
1005
+ if (!requiredObligationIds.length) return false;
1006
+ return requiredObligationIds.every((obligationId) => {
1007
+ const historicalAction = historicalIndex.actionsBySourceAndObligation.get(`${event.id}\0${obligationId}`);
1008
+ const currentAction = historicalAction ? currentIndex.byId.get(historicalAction.id) : null;
1009
+ return (event.obligationIds || []).includes(obligationId)
1010
+ && (currentEvent.obligationIds || []).includes(obligationId)
1011
+ && historicalAction
1012
+ && historicalAction.status !== "canceled"
1013
+ && currentAction?.type === "action-item"
1014
+ && currentAction.sourceResourceId === event.id
1015
+ && currentAction.obligationId === obligationId
1016
+ && currentAction.status !== "canceled";
1017
+ });
1018
+ }
1019
+
1020
+ export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
1021
+ if (record?.type !== "reconciliation-dismissal") return false;
1022
+ const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
1023
+ const reviewer = reviewers.length === 1
1024
+ ? options.peopleById?.get(reviewers[0]) || resources.find((item) => (
1025
+ item.type === "person" && item.id === reviewers[0]
1026
+ ))
1027
+ : null;
1028
+ return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
1029
+ && record.transitionFingerprint === candidate.transitionFingerprint
1030
+ && record.eventType === candidate.eventType
1031
+ && record.subjectResourceId === candidate.subject.id
1032
+ && reviewers.length === 1
1033
+ && reviewer
1034
+ && (!options.requireActiveReviewer || reviewer.status === "active")
1035
+ && /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
1036
+ && (!options.requireActiveReviewer
1037
+ || record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
1038
+ && typeof record.rationale === "string"
1039
+ && Boolean(record.rationale.trim());
1040
+ }
1041
+
1042
+ export async function validateReconciliationDismissals(loaded, diagnostics) {
1043
+ const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
1044
+ if (!dismissals.length) return;
1045
+ const raw = await planReconciliation(loaded, { includeHandled: true });
1046
+ const candidateByFingerprint = new Map(raw.candidates.map((candidate) => [
1047
+ candidate.transitionFingerprint,
1048
+ candidate
1049
+ ]));
1050
+ const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
1051
+ const seen = new Set();
1052
+ for (const entry of dismissals) {
1053
+ const candidate = candidateByFingerprint.get(entry.record.transitionFingerprint);
1054
+ if (
1055
+ !candidate
1056
+ || seen.has(entry.record.transitionFingerprint)
1057
+ || !reconciliationDismissalMatches(entry.record, candidate, loaded.resources, {
1058
+ requireActiveReviewer: !committedRecordIds.has(entry.record.id),
1059
+ timezone: loaded.workspace.timezone
1060
+ })
1061
+ ) {
1062
+ diagnostics.push({
1063
+ severity: "error",
1064
+ code: "invalid-reconciliation-dismissal",
1065
+ path: `data/${entry.relativePath}`,
1066
+ message: `Reconciliation dismissal "${entry.record.id}" must bind one current raw transition candidate to its exact fingerprint, event type, subject, and one active Person reviewer.`
1067
+ });
1068
+ }
1069
+ seen.add(entry.record.transitionFingerprint);
1070
+ }
1071
+ return raw;
1072
+ }
1073
+
1074
+ function findCandidate(plan, options) {
1075
+ return plan.candidates.find(({ id, transitionFingerprint }) => (
1076
+ id === options.candidateId
1077
+ || transitionFingerprint === options.candidateId
1078
+ || transitionFingerprint === options.transitionFingerprint
1079
+ ));
1080
+ }
1081
+
1082
+ function gitChangedEntries(root, deadline, baseRevision = "HEAD") {
1083
+ const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", baseRevision, "--", "data"], deadline));
1084
+ const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"], deadline))
423
1085
  .map((path) => ({ beforePath: null, afterPath: path }));
424
1086
  return [...tracked, ...untracked]
425
1087
  .filter(({ beforePath, afterPath }) => beforePath?.startsWith("data/") || afterPath?.startsWith("data/"));
426
1088
  }
427
1089
 
428
- function pairWorkingRecordRenames(root, changes, currentByPath) {
1090
+ function pairWorkingRecordRenames(root, changes, currentByPath, baseRevision = null) {
429
1091
  const additions = new Map();
430
1092
  for (const change of changes) {
431
1093
  if (change.beforePath || !change.afterPath?.endsWith(".json")) continue;
@@ -436,7 +1098,7 @@ function pairWorkingRecordRenames(root, changes, currentByPath) {
436
1098
  const result = [];
437
1099
  for (const change of changes) {
438
1100
  if (!change.afterPath && change.beforePath?.endsWith(".json")) {
439
- const record = readHeadRecord(root, change.beforePath);
1101
+ const record = readHeadRecord(root, change.beforePath, baseRevision);
440
1102
  const addition = record?.type && record?.id ? additions.get(`${record.type}:${record.id}`) : null;
441
1103
  if (addition) {
442
1104
  paired.add(addition);
@@ -462,32 +1124,39 @@ function parseNameStatus(value) {
462
1124
  }).filter(Boolean);
463
1125
  }
464
1126
 
465
- function readHeadRecord(root, path) {
1127
+ function readHeadRecord(root, path, revision = null) {
466
1128
  if (!path) return null;
467
1129
  try {
468
- return JSON.parse(readHeadSource(root, path));
1130
+ return JSON.parse(readHeadSource(root, path, revision));
469
1131
  } catch {
470
1132
  return null;
471
1133
  }
472
1134
  }
473
1135
 
474
- function readHeadSource(root, path) {
1136
+ function readHeadSource(root, path, revision = null) {
475
1137
  try {
476
- const commit = gitRevision(root);
1138
+ const commit = revision || gitRevision(root);
477
1139
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
478
1140
  } catch {
479
1141
  return "";
480
1142
  }
481
1143
  }
482
1144
 
483
- function gitRevision(root) {
484
- return runGit(root, ["rev-parse", "HEAD"]) || null;
1145
+ function gitRevision(root, deadline) {
1146
+ return runGit(root, ["rev-parse", "HEAD"], deadline) || null;
485
1147
  }
486
1148
 
487
- function runGit(root, args) {
1149
+ function runGit(root, args, deadline) {
488
1150
  try {
489
- return runGitCommandSync(root, args);
490
- } catch {
1151
+ const timeoutMs = deadline ? Math.floor(deadline - performance.now()) : undefined;
1152
+ if (deadline && timeoutMs <= 0) throw new Error("Git reconciliation history exceeded its cumulative deadline.");
1153
+ return runGitCommandSync(root, args, timeoutMs ? { timeoutMs } : {});
1154
+ } catch (error) {
1155
+ if (deadline && performance.now() >= deadline) {
1156
+ const timeoutError = new Error("Git reconciliation history exceeded its cumulative deadline.", { cause: error });
1157
+ timeoutError.code = "FILEGRC_HISTORY_DEADLINE";
1158
+ throw timeoutError;
1159
+ }
491
1160
  return "";
492
1161
  }
493
1162
  }
@@ -522,22 +1191,3 @@ function reconciliationCommand(eventType, subjectId, fingerprint, needsTimestamp
522
1191
  const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
523
1192
  return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
524
1193
  }
525
-
526
- function historicalMarkdownOwners(model, recordsById) {
527
- const owners = new Map();
528
- for (const { record, path } of recordsById.values()) {
529
- const entry = { record, relativePath: path.replace(/^data\//, "") };
530
- for (const markdown of markdownEntries(model, record)) owners.set(`data/${markdown.path}`, entry);
531
- }
532
- return owners;
533
- }
534
-
535
- function recordsAtRevision(root, revision) {
536
- const records = new Map();
537
- for (const path of lines(runGit(root, ["ls-tree", "-r", "--name-only", revision, "--", "data"]))) {
538
- if (!path.endsWith(".json")) continue;
539
- const record = readRevisionRecord(root, revision, path);
540
- if (record?.id) records.set(record.id, { record, path });
541
- }
542
- return records;
543
- }