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 +1 -1
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +10 -5
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/validate.js +85 -1
- package/src/web.js +78 -5
package/package.json
CHANGED
package/src/program-amendment.js
CHANGED
|
@@ -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
|
|
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, ...
|
|
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) => (
|
package/src/program-path.js
CHANGED
|
@@ -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": "
|
|
15
|
-
"reporting-route-set": "
|
|
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.", "
|
|
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.", "
|
|
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"],
|
package/src/program-readiness.js
CHANGED
|
@@ -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
|
|
428
|
-
|
|
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 (
|
|
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
|
|
436
|
-
message = "
|
|
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
|
|
447
|
-
needsAction ? "
|
|
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",
|
package/src/reconciliation.js
CHANGED
|
@@ -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: "
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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"
|
|
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
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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);
|