filegrc 0.12.4 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -172,13 +172,14 @@ export async function assessProgramAmendmentReadiness(loaded) {
172
172
  const commitments = loaded.resources.filter((record) => (
173
173
  record.type === "commitment" && !["superseded", "retired"].includes(record.status)
174
174
  ));
175
- const sourceIds = new Set(commitments.flatMap((record) => record.sourceResourceIds || []));
175
+ const supplementalCommitments = commitments.filter((record) => (record.sourceResourceIds || []).length > 0);
176
+ const sourceIds = new Set(supplementalCommitments.flatMap((record) => record.sourceResourceIds || []));
176
177
  for (const record of loaded.resources) {
177
178
  if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
178
179
  sourceIds.add(record.id);
179
180
  }
180
181
  }
181
- const sourceRecords = [...new Set([...sourceIds, ...commitments.map(({ id }) => id)])]
182
+ const sourceRecords = [...new Set([...sourceIds, ...supplementalCommitments.map(({ id }) => id)])]
182
183
  .map((id) => byId.get(id))
183
184
  .filter((record) => record && SOURCE_TYPES.has(record.type));
184
185
  const plans = await Promise.all(sourceRecords.map((record) => (
@@ -11,8 +11,8 @@ export const RESOURCE_INSTRUCTIONS = {
11
11
  framework: "Confirm the criteria framework and version used for the program.",
12
12
  requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
13
13
  commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
14
- "requirement-mapping": "Record a reviewed relationship among Requirements, Commitments, and Controls. Choose the comparison method and relationship explicitly, explain the rationale, and bind the review to every mapped source revision.",
15
- "reporting-route-set": "Record the normal and fallback ways people send one kind of report, including where each channel goes and the role that keeps it usable. Commit the proposal before approval, use separate approval and ongoing authority Appointments, and create a successor when either channel changes.",
14
+ "requirement-mapping": "Use a Requirement Mapping when supplemental policies, contracts, privacy promises, frameworks, or other sources need explicit coverage semantics. Choose the comparison method and relationship, explain the rationale, and bind the review to every mapped source revision.",
15
+ "reporting-route-set": "Prepare the normal and fallback ways people will send each report required by proposed program content, including where each channel goes and the role that keeps it usable. Commit the proposal in Step 1, approve it before cutover, use separate approval and ongoing authority Appointments, and create a successor when either channel changes.",
16
16
  policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
17
17
  document: "Complete required program Documents in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare Audit Documents in Step 5.",
18
18
  control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
@@ -82,8 +82,8 @@ export const PROGRAM_PATH = [
82
82
  description: "Ownership, criteria, and service boundary",
83
83
  summary: "Name the owners, criteria, service, Systems, and providers in scope.",
84
84
  sections: [
85
- { id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Set a normal security reporting channel and a fallback, then commit that proposal before approval.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
86
- { id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and reviewed mappings. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
85
+ { id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Replace the starter reporting-channel placeholders, propose the set, and commit that proposal before Step 1 is complete. Approve it before the later implementation cutover.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
86
+ { id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Replace each planned service-commitment prompt with the actual promise or requirement. Use Requirement Mappings only when a supplemental source needs an explicit coverage comparison. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
87
87
  { id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Create the complete bounded System and select it on the Program.", "Add only relevant Components, with a role and rationale for each System use.", "Create Vendors for material external provider relationships and link supplied Components when factual.", "Normalize Information Types and Classifications used by the System, Components, Vendors, Risks, and Evidence Artifacts."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
88
88
  ],
89
89
  resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
@@ -424,16 +424,32 @@ function reportingRouteSetItem(assessment) {
424
424
  const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
425
425
  const required = assessment.requirements.length > 0;
426
426
  const proposed = assessment.proposedRequirements.length > 0;
427
- const ready = assessment.issues.length === 0 && (!required || Boolean(current));
428
- const needsAction = assessment.issues.length > 0;
427
+ const proposedPurposeKeys = [...new Set(assessment.proposedRequirements
428
+ .map(({ purposeKey }) => purposeKey)
429
+ .filter(Boolean))];
430
+ const preparedPurposeKeys = new Set(assessment.routeSets
431
+ .filter(({ record, committed, canceled, proposedRequirementIssues }) => (
432
+ ["proposed", "approved"].includes(record.status)
433
+ && committed
434
+ && !canceled
435
+ && proposedRequirementIssues.length === 0
436
+ ))
437
+ .map(({ record }) => record.purposeKey));
438
+ const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
439
+ const ready = assessment.issues.length === 0
440
+ && (!required || Boolean(current))
441
+ && unpreparedPurposeKeys.length === 0;
442
+ const needsAction = assessment.issues.length > 0 || unpreparedPurposeKeys.length > 0;
429
443
  const target = current?.record || draft?.record || { type: "reporting-route-set" };
430
444
  let message;
431
- if (needsAction) {
445
+ if (assessment.issues.length) {
432
446
  message = assessment.issues[0].message;
447
+ } else if (unpreparedPurposeKeys.length) {
448
+ message = `Complete and commit a Reporting Channel Set proposal for ${unpreparedPurposeKeys.join(", ")} before Step 1 is complete. Approval and effectiveness remain part of the later implementation cutover.`;
433
449
  } else if (required && ready && current) {
434
450
  message = `${current.record.title} is committed, effective, and has a current responsible Appointment. Assignments bind its Git commit.`;
435
- } else if (!required && proposed && draft) {
436
- message = "A draft or approved-but-inactive rule will require normal and fallback reporting channels later. Review them before that rule becomes active; this is not a current readiness gate.";
451
+ } else if (!required && proposed) {
452
+ message = "Every reporting-channel requirement in proposed program content has a committed Reporting Channel Set proposal. Approve the exact proposal before its governing content becomes active.";
437
453
  } else if (!required && draft) {
438
454
  message = "No approved rule currently requires these reporting channels. Keep the draft for review or remove it; it is not a readiness gate.";
439
455
  } else if (!required) {
@@ -443,12 +459,15 @@ function reportingRouteSetItem(assessment) {
443
459
  }
444
460
  return item(
445
461
  "security-reporting-route-set",
446
- needsAction ? "action" : required ? ready ? "complete" : "action" : proposed && draft ? "later" : "info",
447
- needsAction ? "Fix reporting channel requirements" : required ? "Approve required reporting channels" : proposed ? "Review proposed reporting channels" : "Reporting channels are not currently required",
462
+ needsAction ? "action" : required ? ready ? "complete" : "action" : proposed ? "complete" : "info",
463
+ needsAction ? "Prepare required reporting channels" : required ? "Approve required reporting channels" : proposed ? "Reporting channel proposals are ready" : "Reporting channels are not currently required",
448
464
  message,
449
465
  target,
450
466
  {
451
467
  requirements: assessment.requirements,
468
+ proposedRequirements: assessment.proposedRequirements,
469
+ proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
470
+ unpreparedPurposeKeys,
452
471
  issues: assessment.issues,
453
472
  commands: [
454
473
  "npx filegrc reporting-route-sets --json",
@@ -456,6 +456,10 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
456
456
 
457
457
  function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
458
458
  const needsTimestamp = eventNeedsTimestamp(loaded, transition.eventType);
459
+ const requiredFacts = [
460
+ transition.eventType === "person-ended" ? "riskLevel" : null,
461
+ needsTimestamp ? "occurredAt" : "occurredOn"
462
+ ].filter(Boolean);
459
463
  return {
460
464
  id: `reconcile-${fingerprint.slice(0, 16)}`,
461
465
  eventId,
@@ -465,12 +469,13 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
465
469
  sourcePath: path,
466
470
  state: "needs-confirmation",
467
471
  message: transition.message,
468
- requiredFacts: [
469
- transition.eventType === "person-ended" ? "riskLevel" : null,
470
- needsTimestamp ? "occurredAt" : "occurredOn"
471
- ].filter(Boolean),
472
+ requiredFacts,
472
473
  action: {
473
- kind: "command",
474
+ kind: "reconcile-transition",
475
+ candidateId: fingerprint,
476
+ eventType: transition.eventType,
477
+ subject: { type: record.type, id: record.id },
478
+ requiredFacts,
474
479
  command: reconciliationCommand(transition.eventType, record.id, fingerprint, needsTimestamp)
475
480
  },
476
481
  ...extra
@@ -14,6 +14,7 @@ import { appointmentWasAuthorizedOn } from "./soc2.js";
14
14
  import { isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
15
15
 
16
16
  const CONTEMPORANEOUS_COMMIT_WINDOW_MS = 86_400_000;
17
+ const REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
17
18
 
18
19
  export function reportingRouteRevision(record) {
19
20
  const effectiveFacts = {
@@ -103,10 +104,14 @@ export function reportingRouteAssertionTiming(loaded, routeSet, eventName) {
103
104
 
104
105
  export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at, timezone = "UTC", options = {}) {
105
106
  let date;
107
+ let availableDate;
106
108
  try {
107
109
  date = /^\d{4}-\d{2}-\d{2}$/.test(String(at || ""))
108
110
  ? String(at)
109
111
  : localDateTimeValue(instant(at, "Supported event time"), timezone).slice(0, 10);
112
+ availableDate = options.availableAt
113
+ ? localDateTimeValue(instant(options.availableAt, "Evidence availability time"), timezone).slice(0, 10)
114
+ : null;
110
115
  } catch {
111
116
  return [];
112
117
  }
@@ -119,6 +124,7 @@ export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at,
119
124
  || evidence.status !== "verified"
120
125
  || !arrayValue(evidence.sourceResourceIds).includes(subjectId)
121
126
  || !verifiedEvidenceComplete(evidence, personIds)
127
+ || (availableDate && (evidence.collectedOn > availableDate || evidence.verifiedOn > availableDate))
122
128
  ) return false;
123
129
  const coversDate = coverageContains(evidence.coverage, date)
124
130
  || [evidence.businessEventAt, evidence.sourceGeneratedAt].filter(Boolean).some((value) => {
@@ -274,6 +280,150 @@ export function reportingRouteRequirementAppliesToProgram(requirement, programId
274
280
  && arrayValue(requirement.programIds).includes(programId);
275
281
  }
276
282
 
283
+ export function reportingRouteProposalIssues(records, routeSet, options = {}) {
284
+ return reportingRouteProposalIssuesForRequirements(
285
+ reportingRouteRequirementsForProposal(records, routeSet),
286
+ routeSet,
287
+ records,
288
+ options
289
+ );
290
+ }
291
+
292
+ export function reportingRouteRequirementsForProposal(records, routeSet, options = {}) {
293
+ return records.flatMap((source) => (
294
+ REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source.type)
295
+ && (
296
+ reportingRouteSourceMayBecomeEffective(source)
297
+ || reportingRouteSourceEffectiveAt(source, options.at, options.timezone)
298
+ )
299
+ ? (Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : [])
300
+ .filter((requirement) => (
301
+ requirement
302
+ && typeof requirement === "object"
303
+ && !Array.isArray(requirement)
304
+ && requirement.purposeKey === routeSet.purposeKey
305
+ && reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
306
+ ))
307
+ : []
308
+ ));
309
+ }
310
+
311
+ function reportingRouteSourceEffectiveAt(source, at, timezone = "UTC") {
312
+ if (!at) return false;
313
+ try { return reportingRouteSourceEffective(source, at, timezone); } catch { return false; }
314
+ }
315
+
316
+ export function reportingRouteSupportIssues(requirements, routeSet, proposalRecords, currentRecords, options = {}) {
317
+ const proposalIssues = reportingRouteProposalIssuesForRequirements(
318
+ requirements,
319
+ routeSet,
320
+ proposalRecords,
321
+ { ...options, commit: options.proposalCommit }
322
+ );
323
+ const currentIssues = reportingRouteProposalIssuesForRequirements(
324
+ requirements,
325
+ routeSet,
326
+ currentRecords,
327
+ { ...options, commit: options.currentCommit }
328
+ );
329
+ return [...new Map([...proposalIssues, ...currentIssues].map((item) => [
330
+ `${item.code}\0${item.resourceId}\0${item.message}`,
331
+ item
332
+ ])).values()];
333
+ }
334
+
335
+ export function reportingRouteProposalIssuesForRequirements(requirements, routeSet, records, options = {}) {
336
+ const applicableRequirements = requirements.filter((requirement) => (
337
+ requirement
338
+ && typeof requirement === "object"
339
+ && !Array.isArray(requirement)
340
+ && requirement.purposeKey === routeSet.purposeKey
341
+ && reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
342
+ ));
343
+ const issues = [];
344
+ const placeholder = (value) => /^(?:\[|tbd\b|todo\b|unknown\b|replace\b|complete before\b)/i.test(String(value || "").trim());
345
+ if (placeholder(routeSet.primaryLane?.destination)) {
346
+ issues.push({
347
+ code: "incomplete-reporting-route-proposal",
348
+ resourceId: routeSet.id,
349
+ message: `${routeSet.title} needs the real normal reporting destination before it can be proposed.`
350
+ });
351
+ }
352
+ if (applicableRequirements.some(({ requiredLanes }) => requiredLanes?.includes("alternate"))) {
353
+ if (!routeSet.alternateLane || placeholder(routeSet.alternateLane.destination)) {
354
+ issues.push({
355
+ code: "incomplete-reporting-route-proposal",
356
+ resourceId: routeSet.id,
357
+ message: `${routeSet.title} needs the real fallback reporting destination before it can be proposed.`
358
+ });
359
+ }
360
+ }
361
+ if (applicableRequirements.some(({ distinctChannels }) => distinctChannels)
362
+ && routeSet.alternateLane?.channelKind === routeSet.primaryLane?.channelKind) {
363
+ issues.push({
364
+ code: "reporting-route-channel-not-distinct",
365
+ resourceId: routeSet.id,
366
+ message: `${routeSet.title} needs different normal and fallback channel types before it can be proposed.`
367
+ });
368
+ }
369
+ if (applicableRequirements.some(({ independentDependencies }) => independentDependencies)
370
+ && !reportingRouteLanesIndependent(
371
+ routeSet,
372
+ records,
373
+ options.at || new Date(),
374
+ options
375
+ )) {
376
+ issues.push({
377
+ code: "reporting-route-dependencies-not-independent",
378
+ resourceId: routeSet.id,
379
+ message: `${routeSet.title} needs independent normal and fallback channel dependencies or an applicable Exception before it can be proposed.`
380
+ });
381
+ }
382
+ return issues;
383
+ }
384
+
385
+ export function reportingRouteLanesIndependent(record, records, at, options = {}) {
386
+ if (!record.alternateLane) return false;
387
+ for (const lane of [record.primaryLane, record.alternateLane]) {
388
+ if (!lane?.dependencyBasis) return false;
389
+ if (lane.dependencyBasis === "cataloged" && !lane.dependencySystemIds?.length) return false;
390
+ if (lane.dependencyBasis === "none" && !String(lane.dependencyRationale || "").trim()) return false;
391
+ }
392
+ const primary = new Set(record.primaryLane?.dependencySystemIds || []);
393
+ const overlap = (record.alternateLane.dependencySystemIds || []).filter((id) => primary.has(id));
394
+ if (!overlap.length) return true;
395
+ const timezone = options.timezone || record.approval?.timezone || "UTC";
396
+ let date;
397
+ let availableDate;
398
+ try {
399
+ date = localDateTimeValue(instant(at, "Reporting Route assessment time"), timezone).slice(0, 10);
400
+ availableDate = localDateTimeValue(
401
+ instant(options.availableAt || at, "Reporting Route support availability time"),
402
+ timezone
403
+ ).slice(0, 10);
404
+ } catch {
405
+ return false;
406
+ }
407
+ return records.some((candidate) => (
408
+ candidate.type === "exception"
409
+ && candidate.status === "approved"
410
+ && candidate.reportingRouteSetId === record.id
411
+ && candidate.reportingRouteLanePair === "primary-alternate"
412
+ && overlap.every((id) => candidate.dependencySystemIds?.includes(id))
413
+ && candidate.approval?.approvedOn <= date
414
+ && candidate.approval?.approvedOn <= availableDate
415
+ && candidate.approval?.expiresOn >= date
416
+ && reportingRouteFixedEvidence(
417
+ records,
418
+ candidate.id,
419
+ candidate.evidenceIds,
420
+ candidate.approval?.approvedOn,
421
+ timezone,
422
+ { root: options.root, commit: options.commit, availableAt: options.availableAt || at }
423
+ ).length > 0
424
+ ));
425
+ }
426
+
277
427
  export function reportingRouteSourceEffective(source, at, timezone = "UTC") {
278
428
  const when = instant(at, "Source assessment time");
279
429
  if (!reportingRouteSourceMayApply(source)) return false;
@@ -299,6 +449,11 @@ export function reportingRouteSourceMayApply(source) {
299
449
  return false;
300
450
  }
301
451
 
452
+ export function reportingRouteSourceMayBecomeEffective(source) {
453
+ return REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source?.type)
454
+ && !["superseded", "retired", "closed", "archived"].includes(source.status);
455
+ }
456
+
302
457
  export function reportingRouteSetInterval(routeSet) {
303
458
  if (!["approved", "canceled"].includes(routeSet?.status) || !routeSet.approval?.effectiveAt) return null;
304
459
  return {
@@ -524,6 +679,32 @@ export function reportingRouteHistory(loaded, routeSetId) {
524
679
  return getDataRecordHistoryIndex(loaded.root).historiesById.get(routeSetId) || [];
525
680
  }
526
681
 
682
+ export function reportingRouteExactHistoryEntry(loaded, routeSet, head) {
683
+ return reportingRouteHistory(loaded, routeSet.id).find((summary) => {
684
+ if (head && !isDataHistoryAncestor(loaded, summary.commit, head)) return false;
685
+ const source = getFileAtRevision(loaded.root, summary.commit, summary.path);
686
+ try { return source && JSON.stringify(JSON.parse(source)) === JSON.stringify(routeSet); } catch { return false; }
687
+ }) || null;
688
+ }
689
+
690
+ export function reportingRouteCommitTimestamp(loaded, routeSetId, commit) {
691
+ return reportingRouteHistory(loaded, routeSetId)
692
+ .find(({ commit: changedAt }) => changedAt === commit)?.timestamp || null;
693
+ }
694
+
695
+ export function reportingRouteProposalAssessmentTime(timestamp, assessmentAt = new Date()) {
696
+ let commitAt;
697
+ let assessedAt;
698
+ try {
699
+ commitAt = instant(timestamp, "Proposal commit time");
700
+ assessedAt = instant(assessmentAt, "Proposal assessment time");
701
+ } catch {
702
+ return null;
703
+ }
704
+ if (commitAt.getTime() > assessedAt.getTime() + CONTEMPORANEOUS_COMMIT_WINDOW_MS) return null;
705
+ return commitAt > assessedAt ? assessedAt : commitAt;
706
+ }
707
+
527
708
  export function recordsAtRevision(loaded, commit) {
528
709
  const index = getDataRecordHistoryIndex(loaded.root);
529
710
  const records = [];
@@ -3,14 +3,25 @@ import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES, updateResource } fr
3
3
  import {
4
4
  effectiveReportingRouteRequirements as deriveEffectiveReportingRouteRequirements,
5
5
  reportingRouteAssertionTiming,
6
+ reportingRouteCommitTimestamp,
6
7
  reportingRouteEventCommit,
7
8
  reportingRouteEventAuthorityIssue,
8
9
  reportingRouteEventAuthorityIssueAtCommit,
10
+ reportingRouteExactHistoryEntry,
9
11
  reportingRouteFixedEvidence,
12
+ reportingRouteLanesIndependent,
10
13
  reportingRouteOngoingAuthorities,
14
+ reportingRouteProposalIssues,
15
+ reportingRouteProposalAssessmentTime,
16
+ reportingRouteProposalIssuesForRequirements,
11
17
  reportingRouteRecordAtRevision,
18
+ reportingRouteRequirementAppliesToProgram,
19
+ reportingRouteRequirementsForProposal,
20
+ reportingRouteSupportIssues,
21
+ recordsAtRevision,
12
22
  reportingRouteSourceEffective,
13
23
  reportingRouteSourceMayApply,
24
+ reportingRouteSourceMayBecomeEffective,
14
25
  reportingRouteSourceAppliesToProgram
15
26
  } from "./reporting-route-integrity.js";
16
27
  import { localDateTimeValue } from "./time.js";
@@ -23,6 +34,8 @@ const SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
23
34
  const MAX_PERIOD_BOUNDARIES = 512;
24
35
  const PERIOD_REPOSITORY_SNAPSHOT = Symbol("filegrc.reportingRoutePeriodRepositorySnapshot");
25
36
 
37
+ export { reportingRouteLanesIndependent };
38
+
26
39
  export async function assessReportingRouteSets(input = process.cwd(), options = {}) {
27
40
  const loaded = typeof input === "object" && input?.resources ? input : await loadWorkspace(input);
28
41
  if (!loaded.model.resources?.["reporting-route-set"]) {
@@ -46,13 +59,19 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
46
59
  const requirements = deriveEffectiveReportingRouteRequirements(loaded.resources, at, programId, timezone);
47
60
  const proposedRequirements = loaded.resources.flatMap((source) => (
48
61
  SOURCE_TYPES.has(source.type)
62
+ && reportingRouteSourceMayBecomeEffective(source)
49
63
  && (!programId || reportingRouteSourceAppliesToProgram(
50
64
  source,
51
65
  loaded.resources.find(({ type, id }) => type === "program" && id === programId),
52
66
  loaded.resources
53
67
  ))
54
68
  ? (Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : [])
55
- .filter((requirement) => requirement && typeof requirement === "object" && !Array.isArray(requirement))
69
+ .filter((requirement) => (
70
+ requirement
71
+ && typeof requirement === "object"
72
+ && !Array.isArray(requirement)
73
+ && (!programId || reportingRouteRequirementAppliesToProgram(requirement, programId))
74
+ ))
56
75
  .map((requirement) => ({
57
76
  ...requirement,
58
77
  sourceId: source.id,
@@ -66,7 +85,10 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
66
85
  ));
67
86
  const repository = options[PERIOD_REPOSITORY_SNAPSHOT]
68
87
  || await getRepositorySnapshot(loaded.root, { fresh: true });
69
- const assessments = routeSets.map((record) => assessRouteSet(record, loaded, at, repository));
88
+ const assessments = routeSets.map((record) => ({
89
+ ...assessRouteSet(record, loaded, at, repository),
90
+ ...proposedRequirementAssessment(record, loaded, repository, proposedRequirements, at, timezone)
91
+ }));
70
92
  const issues = loaded.resources.flatMap((source) => {
71
93
  if (!SOURCE_TYPES.has(source.type) || !Object.hasOwn(source, "reportingRouteRequirements")) return [];
72
94
  if (!reportingRouteSourceMayApply(source)) return [];
@@ -118,7 +140,10 @@ export async function assessReportingRouteSets(input = process.cwd(), options =
118
140
  if (requirement.distinctChannels && route.alternateLane?.channelKind === route.primaryLane?.channelKind) {
119
141
  issues.push(issue("reporting-route-channel-not-distinct", route.id, `${requirement.sourceId} requires different normal and fallback channel types.`));
120
142
  }
121
- if (requirement.independentDependencies && !reportingRouteLanesIndependent(route, loaded.resources, at)) {
143
+ if (requirement.independentDependencies && !reportingRouteLanesIndependent(route, loaded.resources, at, {
144
+ timezone,
145
+ root: loaded.root
146
+ })) {
122
147
  issues.push(issue("reporting-route-dependencies-not-independent", route.id, `${requirement.sourceId} requires independent channel dependencies or an applicable Exception.`));
123
148
  }
124
149
  }
@@ -331,6 +356,12 @@ export async function proposeReportingRouteSet(input, options = {}) {
331
356
  const loaded = await loadWorkspace(input);
332
357
  const entry = routeEntry(loaded, options.routeSetId);
333
358
  if (entry.record.status !== "draft") throw new Error(`Reporting Channel Set "${entry.record.id}" must be draft before it is proposed.`);
359
+ const proposalIssues = reportingRouteProposalIssues(loaded.resources, entry.record, {
360
+ at: new Date(),
361
+ timezone: loaded.workspace?.timezone || "UTC",
362
+ root: loaded.root
363
+ });
364
+ if (proposalIssues.length) throw new Error(proposalIssues[0].message);
334
365
  const result = await updateResource(loaded.root, entry.record.type, entry.record.id, {
335
366
  ...entry.record,
336
367
  status: "proposed"
@@ -347,12 +378,38 @@ export async function approveReportingRouteSet(input, options = {}) {
347
378
  if (entry.record.status !== "proposed") throw new Error(`Reporting Channel Set "${entry.record.id}" must be proposed before approval.`);
348
379
  const proposalCommit = fullCommit(options.proposalCommit, "Proposal commit");
349
380
  assertProposalMatches(loaded, entry, proposalCommit);
381
+ const proposalRecords = recordsAtRevision(loaded, proposalCommit);
382
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, entry.record.id, proposalCommit);
383
+ if (!proposalTimestamp) throw new Error(`Proposal commit ${proposalCommit} is not present in Reporting Channel Set history.`);
384
+ const proposalAssessmentAt = reportingRouteProposalAssessmentTime(proposalTimestamp, new Date());
385
+ if (!proposalAssessmentAt) throw new Error("Proposal commit time is too far in the future to establish a reliable proposal.");
386
+ const proposalIssues = reportingRouteProposalIssues(proposalRecords, entry.record, {
387
+ at: proposalAssessmentAt,
388
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
389
+ root: loaded.root,
390
+ commit: proposalCommit
391
+ });
392
+ if (proposalIssues.length) throw new Error(proposalIssues[0].message);
350
393
  const approvedAt = pastOrPresentTimestamp(options.approvedAt, "Approval time");
351
394
  const effectiveAt = instant(options.effectiveAt, "Effective time");
352
395
  const timezone = timezoneName(options.timezone);
353
396
  assertTimestampZone(options.approvedAt, timezone, "Approval time");
354
397
  assertTimestampZone(options.effectiveAt, timezone, "Effective time");
355
398
  if (effectiveAt < approvedAt) throw new Error("A Reporting Channel Set cannot become effective before it is approved.");
399
+ const cutoverIssues = reportingRouteSupportIssues(
400
+ reportingRouteRequirementsForProposal(loaded.resources, entry.record, { at: effectiveAt, timezone }),
401
+ entry.record,
402
+ proposalRecords,
403
+ loaded.resources,
404
+ {
405
+ at: effectiveAt,
406
+ availableAt: approvedAt,
407
+ timezone,
408
+ root: loaded.root,
409
+ proposalCommit
410
+ }
411
+ );
412
+ if (cutoverIssues.length) throw new Error(cutoverIssues[0].message);
356
413
  const authority = approvalAuthority(loaded, entry.record, options, approvedAt, timezone);
357
414
  const approvalEvidenceIds = [...new Set(options.evidenceIds || [])];
358
415
  if (!reportingRouteFixedEvidence(
@@ -527,6 +584,83 @@ function assessRouteSet(record, loaded, at, repository) {
527
584
  if (COMMIT_REQUIRED_STATUSES.has(record.status) && !committed) {
528
585
  issues.push(issue("reporting-route-commit-required", record.id, `${record.title} must be committed before this lifecycle state can be relied on.`));
529
586
  }
587
+ const proposalEntry = loaded.entries.find(({ record: candidate }) => candidate.id === record.id);
588
+ const historicalProposal = ["approved", "canceled"].includes(record.status)
589
+ && proposalEntry
590
+ && /^[a-f0-9]{40}$/i.test(String(record.proposalCommit || ""))
591
+ ? reportingRouteRecordAtRevision(loaded, proposalEntry, record.proposalCommit)
592
+ : null;
593
+ if (record.status === "proposed") {
594
+ const proposalHistory = committed && repository.commit
595
+ ? reportingRouteExactHistoryEntry(loaded, record, repository.commit)
596
+ : null;
597
+ const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources;
598
+ const proposalRecord = proposalHistory
599
+ ? proposalRecords.find(({ id }) => id === record.id) || record
600
+ : record;
601
+ const proposalAssessmentAt = proposalHistory
602
+ ? reportingRouteProposalAssessmentTime(proposalHistory.timestamp, at)
603
+ : at;
604
+ if (proposalHistory && !proposalAssessmentAt) {
605
+ issues.push(issue("invalid-reporting-route-proposal-time", record.id, "The proposal commit time is too far in the future to establish a reliable proposal."));
606
+ }
607
+ issues.push(...reportingRouteProposalIssues(proposalRecords, proposalRecord, {
608
+ at: proposalAssessmentAt || at,
609
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
610
+ root: loaded.root,
611
+ commit: proposalHistory?.commit
612
+ }));
613
+ } else if (historicalProposal) {
614
+ const proposalRecords = recordsAtRevision(loaded, record.proposalCommit);
615
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, record.id, record.proposalCommit);
616
+ const proposalAssessmentAt = proposalTimestamp
617
+ ? reportingRouteProposalAssessmentTime(proposalTimestamp, at)
618
+ : null;
619
+ if (!proposalTimestamp) {
620
+ issues.push(issue("invalid-reporting-route-proposal", record.id, "The proposal commit must be an exact Reporting Channel Set history entry."));
621
+ } else if (!proposalAssessmentAt) {
622
+ issues.push(issue("invalid-reporting-route-proposal-time", record.id, "The proposal commit time is too far in the future to establish a reliable proposal."));
623
+ } else {
624
+ const proposalIssues = reportingRouteProposalIssues(
625
+ proposalRecords,
626
+ historicalProposal,
627
+ {
628
+ at: proposalAssessmentAt,
629
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
630
+ root: loaded.root,
631
+ commit: record.proposalCommit
632
+ }
633
+ );
634
+ issues.push(...proposalIssues);
635
+ if (!proposalIssues.length) {
636
+ const approvalCommit = reportingRouteEventCommit(loaded, record, "approval");
637
+ const approvalRecords = approvalCommit ? recordsAtRevision(loaded, approvalCommit) : loaded.resources;
638
+ const liveCommit = record.status === "canceled"
639
+ ? reportingRouteEventCommit(loaded, record, "cancellation")
640
+ : null;
641
+ const liveRecords = liveCommit ? recordsAtRevision(loaded, liveCommit) : loaded.resources;
642
+ const cutoverAt = record.approval?.effectiveAt || record.approval?.approvedAt || at;
643
+ const cutoverTimezone = record.approval?.timezone || loaded.workspace?.timezone || "UTC";
644
+ issues.push(...reportingRouteSupportIssues(
645
+ reportingRouteRequirementsForProposal(approvalRecords, record, {
646
+ at: cutoverAt,
647
+ timezone: cutoverTimezone
648
+ }),
649
+ record,
650
+ proposalRecords,
651
+ liveRecords,
652
+ {
653
+ at: cutoverAt,
654
+ availableAt: record.approval?.approvedAt || at,
655
+ timezone: cutoverTimezone,
656
+ root: loaded.root,
657
+ proposalCommit: record.proposalCommit,
658
+ currentCommit: liveCommit
659
+ }
660
+ ));
661
+ }
662
+ }
663
+ }
530
664
  if (record.status === "approved" && record.approval && repository.commit && !isDataHistoryAncestor(loaded, record.proposalCommit, repository.commit)) {
531
665
  issues.push(issue("invalid-reporting-route-proposal-lineage", record.id, "The approved revision must descend from the exact committed proposal."));
532
666
  }
@@ -576,6 +710,26 @@ function assessRouteSet(record, loaded, at, repository) {
576
710
  };
577
711
  }
578
712
 
713
+ function proposedRequirementAssessment(record, loaded, repository, requirements, at, timezone) {
714
+ const history = repository.commit
715
+ ? reportingRouteExactHistoryEntry(loaded, record, repository.commit)
716
+ : null;
717
+ const records = history ? recordsAtRevision(loaded, history.commit) : loaded.resources;
718
+ return {
719
+ proposedRequirementIssues: reportingRouteProposalIssuesForRequirements(
720
+ requirements,
721
+ record,
722
+ records,
723
+ {
724
+ at: history ? reportingRouteProposalAssessmentTime(history.timestamp, at) || at : at,
725
+ timezone,
726
+ root: loaded.root,
727
+ commit: history?.commit
728
+ }
729
+ )
730
+ };
731
+ }
732
+
579
733
  function committedRecordMatches(loaded, record, commit) {
580
734
  const entry = loaded.entries.find(({ record: candidate }) => candidate.id === record.id);
581
735
  const historical = entry ? reportingRouteRecordAtRevision(loaded, entry, commit) : null;
@@ -677,28 +831,6 @@ function approvalAuthority(loaded, routeSet, options, at, timezone) {
677
831
  return appointment;
678
832
  }
679
833
 
680
- export function reportingRouteLanesIndependent(record, records, at) {
681
- if (!record.alternateLane) return false;
682
- for (const lane of [record.primaryLane, record.alternateLane]) {
683
- if (!lane?.dependencyBasis) return false;
684
- if (lane.dependencyBasis === "cataloged" && !lane.dependencySystemIds?.length) return false;
685
- if (lane.dependencyBasis === "none" && !String(lane.dependencyRationale || "").trim()) return false;
686
- }
687
- const primary = new Set(record.primaryLane?.dependencySystemIds || []);
688
- const overlap = (record.alternateLane.dependencySystemIds || []).filter((id) => primary.has(id));
689
- if (!overlap.length) return true;
690
- const date = localDateTimeValue(at, record.approval?.timezone || "UTC").slice(0, 10);
691
- return records.some((candidate) => (
692
- candidate.type === "exception"
693
- && candidate.status === "approved"
694
- && candidate.reportingRouteSetId === record.id
695
- && candidate.reportingRouteLanePair === "primary-alternate"
696
- && overlap.every((id) => candidate.dependencySystemIds?.includes(id))
697
- && candidate.approval?.approvedOn <= date
698
- && candidate.approval?.expiresOn >= date
699
- ));
700
- }
701
-
702
834
  function routeEntry(loaded, id) {
703
835
  const entry = loaded.entries.find(({ record }) => record.type === "reporting-route-set" && record.id === id);
704
836
  if (!entry) throw new Error(`Reporting Channel Set "${id || ""}" was not found.`);
package/src/validate.js CHANGED
@@ -30,10 +30,17 @@ import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./
30
30
  import {
31
31
  reportingRouteRevision,
32
32
  reportingRouteBindingExpectationForValidation,
33
+ reportingRouteCommitTimestamp,
33
34
  reportingRouteEventCommit,
34
35
  reportingRouteEventAuthorityIssueAtCommit,
36
+ reportingRouteExactHistoryEntry,
35
37
  reportingRouteFixedEvidence,
36
- reportingRouteRecordAtRevision
38
+ reportingRouteProposalIssues,
39
+ reportingRouteProposalAssessmentTime,
40
+ reportingRouteRecordAtRevision,
41
+ reportingRouteRequirementsForProposal,
42
+ reportingRouteSupportIssues,
43
+ recordsAtRevision
37
44
  } from "./reporting-route-integrity.js";
38
45
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
39
46
 
@@ -902,6 +909,29 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
902
909
  }
903
910
  for (const route of routeSets) {
904
911
  const path = pathById.get(route.id);
912
+ if (route.status === "proposed") {
913
+ const proposalHistory = repository.commit
914
+ ? reportingRouteExactHistoryEntry(loaded, route, repository.commit)
915
+ : null;
916
+ const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources;
917
+ const proposalRecord = proposalHistory
918
+ ? proposalRecords.find(({ id }) => id === route.id) || route
919
+ : route;
920
+ const proposalAssessmentAt = proposalHistory
921
+ ? reportingRouteProposalAssessmentTime(proposalHistory.timestamp, new Date())
922
+ : new Date();
923
+ if (proposalHistory && !proposalAssessmentAt) {
924
+ diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
925
+ }
926
+ for (const issue of reportingRouteProposalIssues(proposalRecords, proposalRecord, {
927
+ at: proposalAssessmentAt || new Date(),
928
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
929
+ root: loaded.root,
930
+ commit: proposalHistory?.commit
931
+ })) {
932
+ diagnostics.push(error(issue.code, path, issue.message));
933
+ }
934
+ }
905
935
  if (["draft", "proposed", "approved"].includes(route.status)) {
906
936
  const key = `${route.programId}\0${route.purposeKey}`;
907
937
  const current = currentByPurpose.get(key) || { approved: [], pending: [] };
@@ -937,6 +967,60 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
937
967
  const proposal = entry ? reportingRouteRecordAtRevision(loaded, entry, route.proposalCommit) : null;
938
968
  if (!proposal || proposal.status !== "proposed" || !sameRouteProposal(proposal, route)) {
939
969
  diagnostics.push(error("changed-reporting-route-proposal", path, "The approved Route Set facts must exactly match the committed proposal; only managed approval fields may differ."));
970
+ } else {
971
+ const proposalRecords = recordsAtRevision(loaded, route.proposalCommit);
972
+ const proposalTimestamp = reportingRouteCommitTimestamp(loaded, route.id, route.proposalCommit);
973
+ const proposalAssessmentAt = proposalTimestamp
974
+ ? reportingRouteProposalAssessmentTime(proposalTimestamp, new Date())
975
+ : null;
976
+ if (!proposalTimestamp) {
977
+ diagnostics.push(error("invalid-reporting-route-proposal", path, "The proposal commit must be an exact Reporting Channel Set history entry."));
978
+ } else if (!proposalAssessmentAt) {
979
+ diagnostics.push(error("invalid-reporting-route-proposal-time", path, "The proposal commit time is too far in the future to establish a reliable proposal."));
980
+ } else {
981
+ const proposalIssues = reportingRouteProposalIssues(
982
+ proposalRecords,
983
+ proposal,
984
+ {
985
+ at: proposalAssessmentAt,
986
+ timezone: proposalRecords.find(({ type }) => type === "workspace")?.timezone || loaded.workspace?.timezone || "UTC",
987
+ root: loaded.root,
988
+ commit: route.proposalCommit
989
+ }
990
+ );
991
+ for (const issue of proposalIssues) {
992
+ diagnostics.push(error(issue.code, path, issue.message));
993
+ }
994
+ if (!proposalIssues.length) {
995
+ const approvalCommit = reportingRouteEventCommit(loaded, route, "approval");
996
+ const approvalRecords = approvalCommit ? recordsAtRevision(loaded, approvalCommit) : loaded.resources;
997
+ const liveCommit = route.status === "canceled"
998
+ ? reportingRouteEventCommit(loaded, route, "cancellation")
999
+ : null;
1000
+ const liveRecords = liveCommit ? recordsAtRevision(loaded, liveCommit) : loaded.resources;
1001
+ const cutoverAt = route.approval?.effectiveAt || route.approval?.approvedAt || new Date();
1002
+ const cutoverTimezone = route.approval?.timezone || loaded.workspace?.timezone || "UTC";
1003
+ for (const issue of reportingRouteSupportIssues(
1004
+ reportingRouteRequirementsForProposal(approvalRecords, route, {
1005
+ at: cutoverAt,
1006
+ timezone: cutoverTimezone
1007
+ }),
1008
+ route,
1009
+ proposalRecords,
1010
+ liveRecords,
1011
+ {
1012
+ at: cutoverAt,
1013
+ availableAt: route.approval?.approvedAt || new Date(),
1014
+ timezone: cutoverTimezone,
1015
+ root: loaded.root,
1016
+ proposalCommit: route.proposalCommit,
1017
+ currentCommit: liveCommit
1018
+ }
1019
+ )) {
1020
+ diagnostics.push(error(issue.code, path, issue.message));
1021
+ }
1022
+ }
1023
+ }
940
1024
  }
941
1025
  for (const markdown of markdownEntries(loaded.model, route)) {
942
1026
  const currentPath = `data/${markdown.path}`;
package/src/web.js CHANGED
@@ -174,7 +174,7 @@ function desiredStateSections(route) {
174
174
  const sections = new Set(["repository", ...blockingStateSections(route)]);
175
175
  if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
176
176
  if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
177
- if (route.name === "detail" && ["policy", "document", "framework", "requirement", "commitment", "system", "component", "vendor", "information-type", "source-coverage", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("workflow");
177
+ if (route.name === "detail") sections.add("workflow");
178
178
  if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
179
179
  if (route.name === "detail" && route.type === "audit") sections.add("audits");
180
180
  return [...sections];
@@ -1001,10 +1001,12 @@ function workflowItemHref(item) {
1001
1001
  if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
1002
1002
  return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
1003
1003
  }
1004
- const commands = [
1005
- ...(item.actions || []).map((action) => action.command),
1006
- item.nextAction?.command
1007
- ].filter(Boolean);
1004
+ const actions = [...(item.actions || []), item.nextAction].filter(Boolean);
1005
+ const reconciliationAction = actions.find((action) => action.kind === "reconcile-transition");
1006
+ if (reconciliationAction?.candidateId) {
1007
+ return "#/stage/run?reconcile=" + encodeURIComponent(reconciliationAction.candidateId);
1008
+ }
1009
+ const commands = actions.map((action) => action.command).filter(Boolean);
1008
1010
  if (item.createResourceType && state.model.resources[item.createResourceType]) {
1009
1011
  const params = new URLSearchParams({ new: "1" });
1010
1012
  if (item.title) params.set("title", item.createResourceType === "commitment"
@@ -1612,6 +1614,16 @@ function renderObligations(main, params = new URLSearchParams()) {
1612
1614
  if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
1613
1615
  });
1614
1616
  }
1617
+ const requestedReconciliation = params.get("reconcile");
1618
+ if (requestedReconciliation) {
1619
+ queueMicrotask(() => {
1620
+ const candidate = state.reconciliation?.candidates?.find((item) => (
1621
+ item.transitionFingerprint === requestedReconciliation
1622
+ || item.id === requestedReconciliation
1623
+ ));
1624
+ if (candidate) openReconciliationConfirmation(candidate);
1625
+ });
1626
+ }
1615
1627
  const requestedWork = params.get("work");
1616
1628
  if (requestedWork) {
1617
1629
  queueMicrotask(() => {
@@ -3274,6 +3286,67 @@ function openReconciliationDismissal(candidate) {
3274
3286
  });
3275
3287
  }
3276
3288
 
3289
+ function openReconciliationConfirmation(candidate) {
3290
+ if (document.querySelector('[data-reconciliation-confirmation="' + CSS.escape(candidate.transitionFingerprint) + '"]')) return;
3291
+ const writeDisabled = state.readOnly
3292
+ ? ' disabled title="Writes are not available in this repository state"'
3293
+ : "";
3294
+ const needsTimestamp = (candidate.requiredFacts || []).includes("occurredAt");
3295
+ const eventField = needsTimestamp
3296
+ ? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
3297
+ : '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
3298
+ const riskField = (candidate.requiredFacts || []).includes("riskLevel")
3299
+ ? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
3300
+ : "";
3301
+ const dialog = document.createElement("dialog");
3302
+ dialog.className = "commit-dialog event-dialog";
3303
+ dialog.dataset.reconciliationConfirmation = candidate.transitionFingerprint;
3304
+ dialog.setAttribute("aria-labelledby", "reconciliation-confirmation-title");
3305
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git transition review</p><h2 id="reconciliation-confirmation-title">' + esc(policyEventName(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(candidate.message) + '</p><section class="event-dialog-steps"><div><strong>' + esc(candidate.subject.title || candidate.subject.id) + '</strong><small>' + esc(candidate.sourcePath) + '</small></div></section>' + eventField + riskField + '<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(candidate.eventType)) + '"' + writeDisabled + '></label>' + (state.readOnly ? '<p class="dialog-note">Open this workspace in the local writable renderer or use the CLI to confirm or dismiss this transition.</p>' : "") + '<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-candidate' + writeDisabled + '>Dismiss false positive</button><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary"' + writeDisabled + '>Confirm and add work</button></div></form>';
3306
+ document.body.append(dialog);
3307
+ dialog.showModal();
3308
+ dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
3309
+ dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
3310
+ dialog.querySelector("[data-dismiss-candidate]").addEventListener("click", () => {
3311
+ dialog.close();
3312
+ openReconciliationDismissal(candidate);
3313
+ });
3314
+ dialog.addEventListener("close", () => dialog.remove());
3315
+ dialog.querySelector("form").addEventListener("submit", async (event) => {
3316
+ event.preventDefault();
3317
+ const form = event.currentTarget;
3318
+ if (!form.reportValidity()) return;
3319
+ try {
3320
+ setMutationBusy(dialog, true, "Confirming…", "Confirm and add work");
3321
+ const response = await localFetch("/api/reconciliation", {
3322
+ method: "POST",
3323
+ headers: { "content-type": "application/json" },
3324
+ body: JSON.stringify({
3325
+ candidateId: candidate.transitionFingerprint,
3326
+ occurredOn: form.elements.occurredOn?.value || undefined,
3327
+ occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
3328
+ riskLevel: form.elements.riskLevel?.value || undefined,
3329
+ title: form.elements.title.value,
3330
+ confirmed: true
3331
+ })
3332
+ });
3333
+ if (!response.ok) throw new Error(await responseMessage(response));
3334
+ const created = await response.json();
3335
+ policyEventFeedback = {
3336
+ name: policyEventName(candidate.eventType),
3337
+ taskCount: created.actions?.length || 0
3338
+ };
3339
+ applyMutationState(created);
3340
+ dialog.close();
3341
+ history.replaceState(null, "", "#/stage/run");
3342
+ render();
3343
+ } catch (error) {
3344
+ setMutationBusy(dialog, false, "", "Confirm and add work");
3345
+ dialog.querySelector(".dialog-error").textContent = error.message;
3346
+ }
3347
+ });
3348
+ }
3349
+
3277
3350
  async function runRepositoryGitAction(action) {
3278
3351
  const buttons = [...document.querySelectorAll("[data-git-action]")];
3279
3352
  const disabled = buttons.map((button) => button.disabled);