filegrc 0.12.0 → 0.12.2

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.
package/model/index.js CHANGED
@@ -55,9 +55,22 @@ export function loadModel(version = ACTIVE_MODEL_VERSION) {
55
55
  expandRegistryReference(model, field, `${objectType}.${name}`);
56
56
  }
57
57
  }
58
+ validateCollectionReviewPopulationRelations(model);
58
59
  return model;
59
60
  }
60
61
 
62
+ function validateCollectionReviewPopulationRelations(model) {
63
+ const field = model.resources["collection-review"]?.fields?.populationResourceIds;
64
+ if (!field) return;
65
+ const allowedTypes = new Set(field.relation || []);
66
+ const missingTypes = Object.keys(model.collectionReviews || {}).filter((type) => !allowedTypes.has(type));
67
+ if (missingTypes.length) {
68
+ throw new Error(
69
+ `Collection Review population relationships do not allow configured resource types: ${missingTypes.join(", ")}.`
70
+ );
71
+ }
72
+ }
73
+
61
74
  function expandRegistryReference(model, field, path) {
62
75
  if (!field.registry) return;
63
76
  const registry = model[field.registry];
package/model/v10.json CHANGED
@@ -7485,6 +7485,71 @@
7485
7485
  "coverage"
7486
7486
  ]
7487
7487
  },
7488
+ "reconciliation-dismissal": {
7489
+ "title": "Reconciliation dismissal",
7490
+ "pluralTitle": "Reconciliation dismissals",
7491
+ "group": "repository",
7492
+ "collection": "reconciliation-dismissals",
7493
+ "description": "A reviewed decision that one exact Git transition candidate is a false positive and does not represent a real policy event.",
7494
+ "guidance": {
7495
+ "policyBasis": "Direct file changes can resemble real operational events. This record preserves the reviewer, date, rationale, and exact candidate fingerprint when management confirms that no event occurred.",
7496
+ "cadence": "Create only from a current reconciliation candidate. A later change produces a different fingerprint and requires its own review."
7497
+ },
7498
+ "required": [
7499
+ "transitionFingerprint",
7500
+ "eventType",
7501
+ "subjectResourceId",
7502
+ "reviewedByIds",
7503
+ "reviewedOn",
7504
+ "rationale"
7505
+ ],
7506
+ "fields": {
7507
+ "transitionFingerprint": {
7508
+ "type": "string",
7509
+ "label": "Transition fingerprint"
7510
+ },
7511
+ "eventType": {
7512
+ "type": "string",
7513
+ "label": "Candidate event type",
7514
+ "filter": true
7515
+ },
7516
+ "subjectResourceId": {
7517
+ "type": "id",
7518
+ "relationGroup": "event-subject",
7519
+ "label": "Candidate subject"
7520
+ },
7521
+ "reviewedByIds": {
7522
+ "type": "array",
7523
+ "items": "id",
7524
+ "relation": [
7525
+ "person"
7526
+ ],
7527
+ "label": "Reviewed by"
7528
+ },
7529
+ "reviewedOn": {
7530
+ "type": "date",
7531
+ "label": "Reviewed on"
7532
+ },
7533
+ "rationale": {
7534
+ "type": "string",
7535
+ "search": true
7536
+ }
7537
+ },
7538
+ "listFields": [
7539
+ "title",
7540
+ "eventType",
7541
+ "subjectResourceId",
7542
+ "reviewedOn"
7543
+ ],
7544
+ "formFields": [
7545
+ "transitionFingerprint",
7546
+ "eventType",
7547
+ "subjectResourceId",
7548
+ "reviewedByIds",
7549
+ "reviewedOn",
7550
+ "rationale"
7551
+ ]
7552
+ },
7488
7553
  "collection-review": {
7489
7554
  "title": "Collection review",
7490
7555
  "pluralTitle": "Collection reviews",
@@ -7615,7 +7680,7 @@
7615
7680
  "populationResourceIds": {
7616
7681
  "type": "array",
7617
7682
  "items": "id",
7618
- "relationGroup": "obligation-scope",
7683
+ "relationGroup": "collection-review-population",
7619
7684
  "label": "Reviewed population"
7620
7685
  }
7621
7686
  },
@@ -9371,6 +9436,17 @@
9371
9436
  "team",
9372
9437
  "appointment"
9373
9438
  ],
9439
+ "collection-review-population": [
9440
+ "person",
9441
+ "framework",
9442
+ "vendor",
9443
+ "system",
9444
+ "complementary-control",
9445
+ "component",
9446
+ "classification",
9447
+ "information-type",
9448
+ "retention-schedule-item"
9449
+ ],
9374
9450
  "appointment-scope": [
9375
9451
  "workspace",
9376
9452
  "person",
@@ -9505,7 +9581,8 @@
9505
9581
  "vendor",
9506
9582
  "access-grant",
9507
9583
  "vulnerability",
9508
- "incident"
9584
+ "incident",
9585
+ "exception"
9509
9586
  ],
9510
9587
  "evidence-source-record": [
9511
9588
  "appointment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  import { planProgramAmendment } from "./program-amendment.js";
69
69
  import { resolveProgram } from "./program.js";
70
70
  import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
71
- import { applyReconciliation, planReconciliation } from "./reconciliation.js";
71
+ import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
72
72
  import { markdownEntries } from "./resource-markdown.js";
73
73
  import { effectiveResourceStatus } from "./resource-status.js";
74
74
  import { searchResources } from "./search.js";
@@ -617,7 +617,17 @@ export async function runCli(argv = process.argv.slice(2)) {
617
617
  return result;
618
618
  }
619
619
  if (command === "reconcile") {
620
- const result = flags.apply
620
+ if (flags.apply && flags.dismiss) throw new Error("Choose either --apply or --dismiss for one reconciliation candidate.");
621
+ const result = flags.dismiss
622
+ ? await withWorkflowDelta(root, () => dismissReconciliation(root, {
623
+ candidateId: flags.candidate,
624
+ transitionFingerprint: flags.candidate,
625
+ reviewedById: flags.reviewer,
626
+ reviewedOn: flags["reviewed-on"],
627
+ rationale: flags.rationale,
628
+ confirmed: flags.yes === true
629
+ }))
630
+ : flags.apply
621
631
  ? await withWorkflowDelta(root, () => applyReconciliation(root, {
622
632
  candidateId: flags.candidate,
623
633
  transitionFingerprint: flags.candidate,
@@ -630,7 +640,9 @@ export async function runCli(argv = process.argv.slice(2)) {
630
640
  }))
631
641
  : await planReconciliation(root);
632
642
  if (flags.json) console.log(JSON.stringify(result, null, 2));
633
- else if (flags.apply) {
643
+ else if (flags.dismiss) {
644
+ console.log(`Dismissed ${result.candidate.eventType}: recorded ${result.dismissal.id}.`);
645
+ } else if (flags.apply) {
634
646
  console.log(`Reconciled ${result.candidate.eventType}: created ${result.event.id} and ${result.actions.length} linked tasks.`);
635
647
  } else if (!result.candidates.length) {
636
648
  console.log("No direct-file transitions need confirmation.");
@@ -1328,7 +1340,7 @@ Usage:
1328
1340
  filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
1329
1341
  filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
1330
1342
  filegrc prepare-audit <audit-id> [--json]
1331
- filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--program program-id] [--risk-level normal|high] [--json]
1343
+ filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes|--dismiss --candidate fingerprint --reviewer person-id --reviewed-on YYYY-MM-DD --rationale text --yes] [--program program-id] [--risk-level normal|high] [--json]
1332
1344
  filegrc external-reviewer-setup --scaffold
1333
1345
  filegrc external-reviewer-setup <reviewer.json|-> [--preview|--yes] [--json]
1334
1346
  filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
package/src/files.js CHANGED
@@ -22,7 +22,8 @@ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
22
22
  collectionReviewReassessment: Symbol("collection-review-reassessment"),
23
23
  reportingRouteSetProposal: Symbol("reporting-route-set-proposal"),
24
24
  reportingRouteSetApproval: Symbol("reporting-route-set-approval"),
25
- reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation")
25
+ reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation"),
26
+ reconciliationDismissal: Symbol("reconciliation-dismissal")
26
27
  });
27
28
 
28
29
  export async function createResource(input, record, options = {}) {
@@ -671,6 +672,9 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu
671
672
  const preservesExisting = (allowed = []) => [...new Set([...Object.keys(existing), ...Object.keys(next)])].every((key) => (
672
673
  allowed.includes(key) || JSON.stringify(next[key]) === JSON.stringify(existing[key])
673
674
  ));
675
+ if (existing.type === "reconciliation-dismissal" && !preservesExisting()) {
676
+ throw new Error(`Reconciliation dismissal "${existing.id}" is immutable because it records a fingerprint-bound review decision.`);
677
+ }
674
678
  if (existing.type === "collection-review" && ["active", "retired"].includes(existing.status)) {
675
679
  const retirement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
676
680
  && existing.status === "active"
@@ -908,6 +912,13 @@ function finalizedOccurrenceUsingProof(loaded, targetId) {
908
912
  }
909
913
 
910
914
  function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) {
915
+ if (
916
+ record?.type === "reconciliation-dismissal"
917
+ && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
918
+ && options.lifecycleOperation !== "model-migration"
919
+ ) {
920
+ throw new Error(`Reconciliation dismissal "${record.id || ""}" is workflow-managed. Dismiss the current transition candidate instead of creating it directly.`);
921
+ }
911
922
  if (
912
923
  record?.type === "collection-review"
913
924
  && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
@@ -984,7 +995,8 @@ async function deleteResourceUnlocked(input, type, id, options) {
984
995
  assertRevision(source, options.expectedRevision, "The record");
985
996
  const record = JSON.parse(source);
986
997
  if (
987
- (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
998
+ record.type === "reconciliation-dismissal"
999
+ || (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
988
1000
  || (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
989
1001
  || (record.type === "obligation-event" && ["complete", "canceled"].includes(record.status))
990
1002
  || (record.type === "action-item" && record.obligationId && ["done", "canceled"].includes(record.status))
package/src/index.js CHANGED
@@ -115,7 +115,7 @@ export {
115
115
  } from "./recurrence.js";
116
116
  export { searchResources, searchableValues } from "./search.js";
117
117
  export { effectiveResourceStatus } from "./resource-status.js";
118
- export { applyReconciliation, planReconciliation } from "./reconciliation.js";
118
+ export { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
119
119
  export { assessRequirementMappingReadiness } from "./requirement-mapping.js";
120
120
  export { assessRetentionReadiness, nearDuplicateInformationTypes, resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent, retentionUses } from "./retention.js";
121
121
  export { createFilegrcServer, serveWorkspace } from "./server.js";
@@ -1856,7 +1856,7 @@ function obligationActivity(model, obligation) {
1856
1856
  };
1857
1857
  }
1858
1858
 
1859
- function obligationRule(obligation, byId, options = {}) {
1859
+ export function obligationRule(obligation, byId, options = {}) {
1860
1860
  const proposedId = options.includeProposed
1861
1861
  ? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
1862
1862
  : null;
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { modelSupports } from "../model/index.js";
5
- import { createObligationEvent } from "./obligations.js";
5
+ import { createObligationEvent, obligationRule } from "./obligations.js";
6
+ import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
6
7
  import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
7
8
  import { markdownEntries } from "./resource-markdown.js";
8
9
  import { loadWorkspace } from "./workspace.js";
9
10
  import { serializeWorkspaceMutation } from "./mutation.js";
11
+ import { currentCalendarDate } from "./time.js";
10
12
 
11
13
  const TRANSITIONS = {
12
14
  person: [
@@ -94,7 +96,7 @@ const TRANSITIONS = {
94
96
  }]
95
97
  };
96
98
 
97
- export async function planReconciliation(input = process.cwd()) {
99
+ export async function planReconciliation(input = process.cwd(), options = {}) {
98
100
  const loaded = input?.entries && input?.resources && input?.model
99
101
  ? input
100
102
  : await loadWorkspace(input);
@@ -134,11 +136,24 @@ export async function planReconciliation(input = process.cwd()) {
134
136
  ? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
135
137
  : new Map();
136
138
  const historyIndex = getDataRecordHistoryIndex(loaded.root);
139
+ const committedRecordIds = new Set(historyIndex.historiesById.keys());
140
+ const eventRecordSnapshots = new Map();
141
+ const recordsForEvent = (event) => {
142
+ if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
143
+ const history = historyIndex.historiesById.get(event.id) || [];
144
+ const firstCommit = history.at(-1)?.commit;
145
+ const records = firstCommit
146
+ ? [...recordsAtRevision(loaded.root, firstCommit).values()].map(({ record }) => record)
147
+ : loaded.resources;
148
+ eventRecordSnapshots.set(event.id, records);
149
+ return records;
150
+ };
137
151
  const currentMarkdownOwners = new Map();
138
152
  for (const entry of loaded.entries) {
139
153
  for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
140
154
  }
141
- const candidates = [];
155
+ const rawCandidates = [];
156
+ const filteredCandidates = [];
142
157
  const examined = new Set();
143
158
 
144
159
  for (const commit of reconciliationHistory) {
@@ -186,22 +201,27 @@ export async function planReconciliation(input = process.cwd()) {
186
201
  ].join("\n");
187
202
  for (const transition of TRANSITIONS[record.type] || []) {
188
203
  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,
204
+ const fingerprintInput = {
205
+ baseRevision: parentRevision(loaded.root, commit),
192
206
  eventType: transition.eventType,
193
207
  subjectId: record.id,
194
208
  path: recordPath,
195
209
  beforeSource,
196
210
  currentSource,
197
211
  markdownChanged
212
+ };
213
+ const fingerprint = transitionFingerprint(fingerprintInput);
214
+ const legacyCommittedFingerprint = transitionFingerprint({ ...fingerprintInput, baseRevision: commit });
215
+ const legacyWorkingFingerprint = transitionFingerprint({
216
+ ...fingerprintInput,
217
+ beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
218
+ readRevisionSource(loaded.root, `${commit}^`, changed)
219
+ ))].join("\n")
198
220
  });
199
221
  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(
222
+ const handledFingerprints = [fingerprint, legacyCommittedFingerprint, legacyWorkingFingerprint];
223
+ const handledEventIds = handledFingerprints.map((value) => `obligation-event-git-${value.slice(0, 16)}`);
224
+ const candidate = reconciliationCandidate(
205
225
  loaded,
206
226
  transition,
207
227
  record,
@@ -209,7 +229,16 @@ export async function planReconciliation(input = process.cwd()) {
209
229
  fingerprint,
210
230
  eventId,
211
231
  { committedRevision: commit }
212
- ));
232
+ );
233
+ rawCandidates.push(candidate);
234
+ if (!transitionHandled(loaded.resources, handledEventIds, handledFingerprints, {
235
+ eventType: transition.eventType,
236
+ subjectId: record.id,
237
+ includeDismissed: options.includeDismissed,
238
+ committedRecordIds,
239
+ timezone: loaded.workspace.timezone,
240
+ recordsForEvent
241
+ })) filteredCandidates.push(candidate);
213
242
  }
214
243
  }
215
244
  }
@@ -241,10 +270,10 @@ export async function planReconciliation(input = process.cwd()) {
241
270
  }));
242
271
  const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
243
272
  const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
244
- const beforeSource = [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n");
273
+ const beforeSource = [readHeadSource(loaded.root, previousRecordPath), ...previousMarkdown].join("\n");
245
274
  for (const transition of TRANSITIONS[record.type] || []) {
246
275
  if (!transition.applies(previous, current, { markdownChanged })) continue;
247
- const fingerprint = transitionFingerprint({
276
+ const fingerprintInput = {
248
277
  baseRevision,
249
278
  eventType: transition.eventType,
250
279
  subjectId: record.id,
@@ -252,24 +281,40 @@ export async function planReconciliation(input = process.cwd()) {
252
281
  beforeSource,
253
282
  currentSource,
254
283
  markdownChanged
284
+ };
285
+ const fingerprint = transitionFingerprint(fingerprintInput);
286
+ const legacyFingerprint = transitionFingerprint({
287
+ ...fingerprintInput,
288
+ beforeSource: [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n")
255
289
  });
256
290
  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));
291
+ const legacyEventId = `obligation-event-git-${legacyFingerprint.slice(0, 16)}`;
292
+ const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId);
293
+ rawCandidates.push(candidate);
294
+ if (!transitionHandled(loaded.resources, [eventId, legacyEventId], [fingerprint, legacyFingerprint], {
295
+ eventType: transition.eventType,
296
+ subjectId: record.id,
297
+ includeDismissed: options.includeDismissed,
298
+ committedRecordIds,
299
+ timezone: loaded.workspace.timezone,
300
+ recordsForEvent
301
+ })) filteredCandidates.push(candidate);
265
302
  }
266
303
  }
267
- return {
304
+ const result = {
268
305
  contractVersion: 1,
269
306
  gitRevision: gitRevision(loaded.root),
270
307
  changedPaths,
271
- candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
308
+ candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
309
+ .sort((a, b) => a.id.localeCompare(b.id))
272
310
  };
311
+ if (options.includeHandled) {
312
+ Object.defineProperty(result, "filteredPlan", {
313
+ value: { ...result, candidates: filteredCandidates.sort((a, b) => a.id.localeCompare(b.id)) },
314
+ enumerable: false
315
+ });
316
+ }
317
+ return result;
273
318
  }
274
319
 
275
320
  function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
@@ -341,30 +386,14 @@ function pairCommittedRecordRenames(root, commit, changes) {
341
386
  return [...changes.filter((change) => !paired.has(change)), ...replacements];
342
387
  }
343
388
 
344
- function committedEventHandled(root, commit, commitPaths, eventType, subjectId, historicalRecords) {
345
- const requiredObligationIds = applicableEventObligationIds(
346
- [...historicalRecords.values()].map(({ record }) => record),
347
- eventType
348
- );
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
- });
359
- }
360
-
361
- function applicableEventObligationIds(records, eventType) {
389
+ function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
362
390
  const byId = new Map(records.map((record) => [record.id, record]));
363
391
  return records.filter((record) => {
364
392
  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;
393
+ const schedule = obligationRule(record, byId, { now: occurredAt }) || record;
394
+ return schedule.recurrence?.mode === "event"
395
+ && schedule.recurrence.eventType === eventType
396
+ && (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
368
397
  }).map(({ id }) => id);
369
398
  }
370
399
 
@@ -393,9 +422,7 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
393
422
  }
394
423
  return serializeWorkspaceMutation(input, async (root) => {
395
424
  const plan = await planReconciliation(root);
396
- const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
397
- id === options.candidateId || transitionFingerprint === options.transitionFingerprint
398
- ));
425
+ const candidate = findCandidate(plan, options);
399
426
  if (!candidate) {
400
427
  throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
401
428
  }
@@ -417,6 +444,172 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
417
444
  });
418
445
  }
419
446
 
447
+ export async function dismissReconciliation(input = process.cwd(), options = {}) {
448
+ if (options.confirmed !== true) {
449
+ throw new Error("Dismissing a reconciliation candidate records a review decision. Preview the candidate and confirm the write.");
450
+ }
451
+ return serializeWorkspaceMutation(input, async (root) => {
452
+ const plan = await planReconciliation(root);
453
+ const candidate = findCandidate(plan, options);
454
+ if (!candidate) {
455
+ throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
456
+ }
457
+ const loaded = await loadWorkspace(root);
458
+ if (!loaded.model.resources["reconciliation-dismissal"]) {
459
+ throw new Error("Reconciliation dismissals require data model v10 or newer.");
460
+ }
461
+ const reviewedById = String(options.reviewedById || "").trim();
462
+ const reviewedOn = String(options.reviewedOn || "").trim();
463
+ const rationale = String(options.rationale || "").trim();
464
+ if (!loaded.resources.some((record) => (
465
+ record.type === "person" && record.id === reviewedById && record.status === "active"
466
+ ))) {
467
+ throw new Error("An active Person ID is required as the dismissal reviewer.");
468
+ }
469
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewedOn)) {
470
+ throw new Error("The dismissal review date must use YYYY-MM-DD.");
471
+ }
472
+ if (reviewedOn !== currentCalendarDate(loaded.workspace.timezone)) {
473
+ throw new Error("The dismissal review date must be the current date in the workspace timezone.");
474
+ }
475
+ if (!rationale) throw new Error("A rationale is required to dismiss a reconciliation candidate.");
476
+ const record = {
477
+ id: `reconciliation-dismissal-${candidate.transitionFingerprint}`,
478
+ type: "reconciliation-dismissal",
479
+ title: `Dismiss ${candidate.eventType} for ${candidate.subject.title}`,
480
+ transitionFingerprint: candidate.transitionFingerprint,
481
+ eventType: candidate.eventType,
482
+ subjectResourceId: candidate.subject.id,
483
+ reviewedByIds: [reviewedById],
484
+ reviewedOn,
485
+ rationale
486
+ };
487
+ const result = await createResource(root, record, {
488
+ workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
489
+ });
490
+ return { candidate, dismissal: result.record, path: result.path };
491
+ });
492
+ }
493
+
494
+ function transitionHandled(resources, eventIds, fingerprints, transition) {
495
+ return resources.some((item) => (
496
+ item.type === "obligation-event"
497
+ ? (() => {
498
+ if (!eventIds.includes(item.id) && !fingerprints.includes(item.transitionFingerprint)) return false;
499
+ const records = transition.recordsForEvent?.(item) || resources;
500
+ const event = records.find((record) => record.id === item.id) || item;
501
+ return (eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
502
+ && obligationEventHandlesTransition(event, records, transition.eventType, transition.subjectId, resources);
503
+ })()
504
+ : !transition.includeDismissed && reconciliationDismissalMatches(item, {
505
+ transitionFingerprint: fingerprints[0],
506
+ eventType: transition.eventType,
507
+ subject: { id: transition.subjectId }
508
+ }, resources, {
509
+ requireActiveReviewer: !transition.committedRecordIds.has(item.id),
510
+ timezone: transition.timezone
511
+ })
512
+ ));
513
+ }
514
+
515
+ function obligationEventHandlesTransition(event, records, eventType, subjectId, currentRecords = records) {
516
+ if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
517
+ if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
518
+ const currentEvent = currentRecords.find((record) => record.id === event.id);
519
+ if (
520
+ currentEvent?.type !== "obligation-event"
521
+ || currentEvent.eventType !== eventType
522
+ || currentEvent.status === "canceled"
523
+ || !(currentEvent.subjectResourceIds || []).includes(subjectId)
524
+ || currentEvent.riskLevel !== event.riskLevel
525
+ || currentEvent.occurredOn !== event.occurredOn
526
+ || currentEvent.occurredAt !== event.occurredAt
527
+ ) return false;
528
+ const occurredAt = event.occurredAt || (event.occurredOn ? `${event.occurredOn}T23:59:59Z` : undefined);
529
+ const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
530
+ if (!requiredObligationIds.length) return false;
531
+ return requiredObligationIds.every((obligationId) => {
532
+ const historicalAction = records.find((record) => (
533
+ record.type === "action-item"
534
+ && record.sourceResourceId === event.id
535
+ && record.obligationId === obligationId
536
+ && record.status !== "canceled"
537
+ ));
538
+ return (event.obligationIds || []).includes(obligationId)
539
+ && (currentEvent.obligationIds || []).includes(obligationId)
540
+ && historicalAction
541
+ && currentRecords.some((record) => (
542
+ record.type === "action-item"
543
+ && record.id === historicalAction.id
544
+ && record.sourceResourceId === event.id
545
+ && record.obligationId === obligationId
546
+ && record.status !== "canceled"
547
+ ));
548
+ });
549
+ }
550
+
551
+ export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
552
+ if (record?.type !== "reconciliation-dismissal") return false;
553
+ const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
554
+ return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
555
+ && record.transitionFingerprint === candidate.transitionFingerprint
556
+ && record.eventType === candidate.eventType
557
+ && record.subjectResourceId === candidate.subject.id
558
+ && reviewers.length === 1
559
+ && resources.some((item) => (
560
+ item.type === "person"
561
+ && item.id === reviewers[0]
562
+ && (!options.requireActiveReviewer || item.status === "active")
563
+ ))
564
+ && /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
565
+ && (!options.requireActiveReviewer
566
+ || record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
567
+ && typeof record.rationale === "string"
568
+ && Boolean(record.rationale.trim());
569
+ }
570
+
571
+ export async function validateReconciliationDismissals(loaded, diagnostics) {
572
+ const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
573
+ if (!dismissals.length) return;
574
+ const raw = await planReconciliation(loaded, { includeHandled: true });
575
+ const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
576
+ const seen = new Set();
577
+ for (const entry of dismissals) {
578
+ const candidate = raw.candidates.find(({ transitionFingerprint }) => (
579
+ transitionFingerprint === entry.record.transitionFingerprint
580
+ ));
581
+ if (
582
+ !candidate
583
+ || seen.has(entry.record.transitionFingerprint)
584
+ || !reconciliationDismissalMatches(entry.record, candidate, loaded.resources, {
585
+ requireActiveReviewer: !committedRecordIds.has(entry.record.id),
586
+ timezone: loaded.workspace.timezone
587
+ })
588
+ ) {
589
+ diagnostics.push({
590
+ severity: "error",
591
+ code: "invalid-reconciliation-dismissal",
592
+ path: `data/${entry.relativePath}`,
593
+ 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.`
594
+ });
595
+ }
596
+ seen.add(entry.record.transitionFingerprint);
597
+ }
598
+ return raw;
599
+ }
600
+
601
+ function parentRevision(root, commit) {
602
+ return runGit(root, ["rev-parse", `${commit}^`]) || commit;
603
+ }
604
+
605
+ function findCandidate(plan, options) {
606
+ return plan.candidates.find(({ id, transitionFingerprint }) => (
607
+ id === options.candidateId
608
+ || transitionFingerprint === options.candidateId
609
+ || transitionFingerprint === options.transitionFingerprint
610
+ ));
611
+ }
612
+
420
613
  function gitChangedEntries(root) {
421
614
  const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
422
615
  const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
package/src/server.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  } from "./external-reviewer.js";
58
58
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
59
59
  import { activatePolicies } from "./policy-activation.js";
60
- import { applyReconciliation, planReconciliation } from "./reconciliation.js";
60
+ import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
61
61
  import { resourceReviewRevisions } from "./retention.js";
62
62
  import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
63
63
  import { setupWorkspace } from "./setup.js";
@@ -190,6 +190,15 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
190
190
  confirmed: payload.confirmed === true
191
191
  })));
192
192
  }
193
+ if (request.method === "POST" && url.pathname === "/api/reconciliation/dismissal") {
194
+ const payload = await readJson(request);
195
+ return json(response, 201, await browserMutation(input, requestOptions, {
196
+ message: (result) => `Dismiss Git transition candidate: ${result.candidate?.eventType || payload.candidateId}`
197
+ }, () => dismissReconciliation(input, {
198
+ ...payload,
199
+ confirmed: payload.confirmed === true
200
+ })));
201
+ }
193
202
  if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance/preview") {
194
203
  return json(response, 200, await planExternalReviewerGovernance(input, await readJson(request)));
195
204
  }
package/src/state.js CHANGED
@@ -5,6 +5,7 @@ import { assessCollectionReviews } from "./collection-review.js";
5
5
  import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js";
6
6
  import { renderMarkdown } from "./markdown.js";
7
7
  import { planObligations } from "./obligations.js";
8
+ import { planReconciliation } from "./reconciliation.js";
8
9
  import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
9
10
  import { assessProgramReadiness } from "./program-readiness.js";
10
11
  import { markdownEntries } from "./resource-markdown.js";
@@ -82,6 +83,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
82
83
  counts: { errors: 0, warnings: 0 },
83
84
  diagnostics: []
84
85
  },
86
+ reconciliation: { contractVersion: 1, gitRevision: null, changedPaths: [], candidates: [] },
85
87
  obligations: { loading: true, items: [], triggers: [], counts: {} },
86
88
  collectionReviews: {},
87
89
  applicabilityConstraints: {},
@@ -116,6 +118,8 @@ export async function createAppStateSection(input, section, options = {}) {
116
118
  validateWorkspace(loaded),
117
119
  measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root))
118
120
  ]);
121
+ const reconciliation = validation.reconciliation
122
+ ?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
119
123
  const git = { ...snapshot };
120
124
  delete git.root;
121
125
  const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
@@ -128,6 +132,7 @@ export async function createAppStateSection(input, section, options = {}) {
128
132
  readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
129
133
  repository,
130
134
  git,
135
+ reconciliation,
131
136
  validation: {
132
137
  ok: validation.ok,
133
138
  counts: validation.counts,
@@ -294,6 +299,8 @@ async function createAppStateUnlocked(input, options) {
294
299
  now: options.now ?? generatedAt,
295
300
  model: loaded.model
296
301
  });
302
+ const reconciliation = validation.reconciliation
303
+ ?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
297
304
  const workflow = await measureTiming("state-workflow", () => assessWorkflow(loaded, {
298
305
  programId: activeProgram.id,
299
306
  asOf,
@@ -303,6 +310,7 @@ async function createAppStateUnlocked(input, options) {
303
310
  Object.entries(auditPreparations).filter(([id]) => id !== "none")
304
311
  ),
305
312
  obligations,
313
+ reconciliation,
306
314
  git,
307
315
  validation
308
316
  }));
@@ -337,6 +345,7 @@ async function createAppStateUnlocked(input, options) {
337
345
  diagnostics: validation.diagnostics
338
346
  },
339
347
  obligations,
348
+ reconciliation,
340
349
  collectionReviews,
341
350
  applicabilityConstraints,
342
351
  programReadiness,
package/src/validate.js CHANGED
@@ -62,6 +62,7 @@ export async function validateWorkspace(input = process.cwd()) {
62
62
  async function validateWorkspaceUnmeasured(input) {
63
63
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
64
64
  const diagnostics = [...loaded.diagnostics];
65
+ let reconciliation = null;
65
66
  const { byId } = indexResources(loaded.resources);
66
67
  const seen = new Map();
67
68
  const pathById = new Map(loaded.entries.map((entry) => [
@@ -216,8 +217,9 @@ async function validateWorkspaceUnmeasured(input) {
216
217
  validateCollectionReviewSet(loaded.resources, pathById, diagnostics);
217
218
  }
218
219
  if (modelSupports(loaded.model, "guided-workflow")) {
219
- const { planReconciliation } = await import("./reconciliation.js");
220
- const reconciliation = await planReconciliation(loaded);
220
+ const { planReconciliation, validateReconciliationDismissals } = await import("./reconciliation.js");
221
+ const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
222
+ reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
221
223
  for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
222
224
  diagnostics.push(warning(
223
225
  "unreconciled-committed-transition",
@@ -241,6 +243,10 @@ async function validateWorkspaceUnmeasured(input) {
241
243
  value: loaded,
242
244
  enumerable: false
243
245
  });
246
+ Object.defineProperty(result, "reconciliation", {
247
+ value: reconciliation,
248
+ enumerable: false
249
+ });
244
250
  return result;
245
251
  }
246
252
 
package/src/web.js CHANGED
@@ -3121,10 +3121,11 @@ function renderRepository(main) {
3121
3121
  const validationBody = state.validation.diagnostics.length
3122
3122
  ? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
3123
3123
  : empty("No validation problems.");
3124
- main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
3124
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
3125
3125
  main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
3126
3126
  main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
3127
3127
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
3128
+ bindReconciliationDismissals(main);
3128
3129
  }
3129
3130
 
3130
3131
  function renderTrunkRepository(main) {
@@ -3148,9 +3149,63 @@ function renderTrunkRepository(main) {
3148
3149
  const validationBody = state.validation.diagnostics.length
3149
3150
  ? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
3150
3151
  : empty("No validation problems.");
3151
- main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
3152
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
3152
3153
  main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
3153
3154
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
3155
+ bindReconciliationDismissals(main);
3156
+ }
3157
+
3158
+ function reconciliationPanel() {
3159
+ const candidates = state.reconciliation?.candidates || [];
3160
+ if (!candidates.length) return "";
3161
+ const items = candidates.map((candidate) => '<article class="reconciliation-candidate"><div><span class="badge warn">Review</span><strong>' + esc(titleCase(candidate.eventType)) + '</strong><p>' + esc(candidate.message) + '</p><small><code>' + esc(candidate.sourcePath) + '</code> · fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code></small></div><div class="reconciliation-actions"><button class="button" type="button" data-dismiss-reconciliation="' + esc(candidate.transitionFingerprint) + '" ' + (state.readOnly ? 'disabled title="Writes are not available in this repository state"' : "") + '>Dismiss false positive</button><code>' + esc(candidate.action.command) + '</code></div></article>').join("");
3162
+ return '<section class="panel span-2 reconciliation-panel"><div class="panel-head"><div><p class="kicker">Git transition review</p><h3>Transitions Need Confirmation</h3></div><span class="badge warn">' + candidates.length + '</span></div><p>Confirm real events with the shown command. Dismiss only when the exact Git change does not represent a real event.</p><div class="reconciliation-candidates">' + items + '</div></section>';
3163
+ }
3164
+
3165
+ function bindReconciliationDismissals(main) {
3166
+ main.querySelectorAll("[data-dismiss-reconciliation]").forEach((button) => button.addEventListener("click", () => {
3167
+ const candidate = state.reconciliation?.candidates?.find((item) => item.transitionFingerprint === button.dataset.dismissReconciliation);
3168
+ if (candidate) openReconciliationDismissal(candidate);
3169
+ }));
3170
+ }
3171
+
3172
+ function openReconciliationDismissal(candidate) {
3173
+ const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
3174
+ const dialog = document.createElement("dialog");
3175
+ dialog.className = "commit-dialog event-dialog";
3176
+ dialog.setAttribute("aria-labelledby", "reconciliation-dismissal-title");
3177
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">False-positive review</p><h2 id="reconciliation-dismissal-title">Dismiss ' + esc(titleCase(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This decision applies only to fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code>. A later change creates a new candidate.</p><label><span>Reviewer</span><select name="reviewedById" required><option value="">Select a Person</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label><span>Rationale</span><textarea name="rationale" rows="4" required placeholder="Explain why this Git change does not represent a real event."></textarea></label><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary" id="save-reconciliation-dismissal">Record dismissal</button></div></form>';
3178
+ document.body.append(dialog);
3179
+ dialog.showModal();
3180
+ dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
3181
+ dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
3182
+ dialog.addEventListener("close", () => dialog.remove());
3183
+ dialog.querySelector("form").addEventListener("submit", async (event) => {
3184
+ event.preventDefault();
3185
+ const form = event.currentTarget;
3186
+ if (!form.reportValidity()) return;
3187
+ try {
3188
+ setMutationBusy(dialog, true, "Recording…", "Record dismissal");
3189
+ const response = await localFetch("/api/reconciliation/dismissal", {
3190
+ method: "POST",
3191
+ headers: { "content-type": "application/json" },
3192
+ body: JSON.stringify({
3193
+ candidateId: candidate.transitionFingerprint,
3194
+ reviewedById: form.elements.reviewedById.value,
3195
+ reviewedOn: form.elements.reviewedOn.value,
3196
+ rationale: form.elements.rationale.value,
3197
+ confirmed: true
3198
+ })
3199
+ });
3200
+ if (!response.ok) throw new Error(await responseMessage(response));
3201
+ applyMutationState(await response.json());
3202
+ dialog.close();
3203
+ render();
3204
+ } catch (error) {
3205
+ setMutationBusy(dialog, false, "", "Record dismissal");
3206
+ dialog.querySelector(".dialog-error").textContent = error.message;
3207
+ }
3208
+ });
3154
3209
  }
3155
3210
 
3156
3211
  async function runRepositoryGitAction(action) {
@@ -5576,6 +5631,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
5576
5631
  .page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.page-guide>.guide-review{grid-column:1/-1;border-top:1px solid var(--line);border-left:0}.guide-review ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:0;padding-left:18px;color:var(--muted);font-size:11px;line-height:1.45}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
5577
5632
  .operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
5578
5633
  .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
5634
+ .reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
5579
5635
  .onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
5580
5636
  .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
5581
5637
  .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
@@ -5588,6 +5644,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
5588
5644
  @media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid,.evidence-map-expectation,.evidence-map-links,.external-evidence-list,.policy-lifecycle-note{grid-template-columns:1fr}.context-workflow,.evidence-map-head{align-items:stretch;flex-direction:column}.evidence-map-actions{flex-wrap:wrap}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
5589
5645
  .detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
5590
5646
  @media(max-width:760px){.detail-grid aside{order:-1}}
5647
+ @media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
5591
5648
  @media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}.detail-grid-structured .detail-history-panel{order:1}}
5592
5649
  @media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
5593
5650
  @media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
@@ -15,6 +15,7 @@ import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } f
15
15
  import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js";
16
16
 
17
17
  const FINAL_STATUSES = new Map([
18
+ ["reconciliation-dismissal", new Set([undefined])],
18
19
  ["collection-review", new Set(["active", "retired"])],
19
20
  ["obligation-rule", new Set(["active", "retired"])],
20
21
  ["obligation-occurrence", new Set(["reconciled", "superseded"])],
@@ -96,6 +97,21 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
96
97
  ));
97
98
  continue;
98
99
  }
100
+ if (
101
+ entry.record.type === "reconciliation-dismissal"
102
+ && !validInitialReconciliationDismissal(
103
+ historical,
104
+ loaded,
105
+ historiesById,
106
+ workspaceTimezoneAtRevision(loaded, historical.commit)
107
+ )
108
+ ) {
109
+ diagnostics.push(integrityError(
110
+ entry,
111
+ `Reconciliation dismissal "${entry.record.id}" must first be committed with exactly one active Person reviewer.`
112
+ ));
113
+ continue;
114
+ }
99
115
  if (
100
116
  entry.record.type === "exception"
101
117
  && historical.record.reportingRouteSetId
@@ -354,6 +370,21 @@ function validInitialCollectionReview(historical, timezone) {
354
370
  );
355
371
  }
356
372
 
373
+ function validInitialReconciliationDismissal(historical, loaded, historiesById, timezone) {
374
+ const reviewerIds = historical.record.reviewedByIds || [];
375
+ if (reviewerIds.length !== 1) return false;
376
+ const reviewer = recordAtRevision(
377
+ loaded,
378
+ historical.commit,
379
+ reviewerIds[0],
380
+ historiesById.get(reviewerIds[0]) || []
381
+ )?.record;
382
+ return reviewer?.type === "person"
383
+ && reviewer.status === "active"
384
+ && isRfc3339Timestamp(historical.timestamp)
385
+ && historical.record.reviewedOn === currentCalendarDate(timezone, new Date(historical.timestamp));
386
+ }
387
+
357
388
  function legacyCollectionReview(loaded, historical) {
358
389
  if (historical.legacyBaseline && historical.record.status === "active" && !historical.record.knowledgeCutoffAt) {
359
390
  return true;