filegrc 0.7.1 → 0.9.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/README.md +7 -7
- package/model/index.js +22 -4
- package/model/v5.json +10233 -0
- package/model/v6.json +10358 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +143 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +181 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +173 -35
- package/src/git.js +71 -7
- package/src/index.js +11 -1
- package/src/model-migration.js +363 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +7 -3
- package/src/program-lifecycle.js +131 -3
- package/src/program-path.js +19 -13
- package/src/program-readiness.js +338 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +36 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +178 -16
- package/src/web.js +264 -17
- package/src/workflow.js +38 -7
- package/src/workspace.js +5 -0
package/src/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
|
+
import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
|
|
2
3
|
import {
|
|
3
4
|
calendarOccurrence,
|
|
4
5
|
calendarOccurrenceIndex,
|
|
@@ -52,6 +53,8 @@ export function renderIndex(state = null) {
|
|
|
52
53
|
export const APP_SCRIPT = String.raw`
|
|
53
54
|
const root = document.querySelector("#app");
|
|
54
55
|
let state;
|
|
56
|
+
const MODEL_CAPABILITY_VERSIONS = ${JSON.stringify(MODEL_CAPABILITY_VERSIONS)};
|
|
57
|
+
const modelSupports = (capability) => Number(state?.model?.modelVersion || 0) >= MODEL_CAPABILITY_VERSIONS[capability];
|
|
55
58
|
const LIST_PAGE_SIZE = 25;
|
|
56
59
|
const SEARCH_PAGE_SIZE = 25;
|
|
57
60
|
const NAV_GROUP_STORAGE_KEY = "filegrc.sidebar.groups.v3";
|
|
@@ -173,7 +176,8 @@ function buildNavigation(route) {
|
|
|
173
176
|
const current = route.type === type && (!contextualStageId || contextualStageId === stage.id);
|
|
174
177
|
return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="#/resources/' + encodeURIComponent(type) + '"><span>' + esc(titleCase(definition.pluralTitle)) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
175
178
|
}).join("") + (section.relatedLinks || []).map((link) => {
|
|
176
|
-
const current = route.type === link.type && contextualStageId === stage.id
|
|
179
|
+
const current = route.type === link.type && contextualStageId === stage.id
|
|
180
|
+
|| link.href === "#/stage/" + route.stageId;
|
|
177
181
|
return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="' + esc(link.href) + '"><span>' + esc(link.label) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
178
182
|
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
179
183
|
if (direct) return links;
|
|
@@ -242,7 +246,9 @@ function topbar(route) {
|
|
|
242
246
|
? "Work Queue"
|
|
243
247
|
: route.name === "audit-packet"
|
|
244
248
|
? "Audit Readiness"
|
|
245
|
-
:
|
|
249
|
+
: route.name === "list" && route.type === "document"
|
|
250
|
+
? documentListTitle(route.params)
|
|
251
|
+
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
246
252
|
const repositoryLabel = state.repository?.mode === "trunk"
|
|
247
253
|
? state.repository.label
|
|
248
254
|
: state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
|
|
@@ -353,12 +359,16 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
353
359
|
const progress = stageProgress(stage);
|
|
354
360
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
355
361
|
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 5</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
|
|
356
|
-
(stage.id === "policies" ? renderPolicyApprovalGuidance()
|
|
362
|
+
(stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
357
363
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
358
364
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
359
365
|
event.currentTarget.remove();
|
|
360
366
|
});
|
|
361
367
|
main.querySelector("[data-review-policy-activation]")?.addEventListener("click", openPolicyActivationDialog);
|
|
368
|
+
main.querySelector("[data-review-document-activation]")?.addEventListener("click", () => openDocumentActivationDialog());
|
|
369
|
+
main.querySelector("[data-review-audit-document-activation]")?.addEventListener("click", (event) => {
|
|
370
|
+
openDocumentActivationDialog(event.currentTarget.dataset.reviewAuditDocumentActivation);
|
|
371
|
+
});
|
|
362
372
|
}
|
|
363
373
|
|
|
364
374
|
function renderPolicyApprovalGuidance() {
|
|
@@ -370,7 +380,197 @@ function renderPolicyApprovalGuidance() {
|
|
|
370
380
|
: "";
|
|
371
381
|
return '<article><strong>' + esc(proposal.title) + '</strong><p>' + esc(proposal.message) + '</p>' + review + '<div class="evidence-map-references">' + proposal.policyIds.map((id) => formatReference(id)).join("") + '</div></article>';
|
|
372
382
|
}).join("");
|
|
373
|
-
return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">
|
|
383
|
+
return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">Step 2 approval</p><h3>Approve the governed content</h3><p>Approval means your company reviewed and accepted the requirements and intended values in each Policy, program Document, and Training record. It does not mean the linked Controls are implemented yet.</p><p>Bind each approval to the exact Markdown revision here. Implement Controls and configure Obligations in Step 3, then activate each unchanged approved revision at implementation cutover.</p></div>' + (proposalRows ? '<div class="policy-library-proposals">' + proposalRows + '</div>' : "") + '</section>';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderPoliciesTable() {
|
|
387
|
+
const entries = state.resources.filter(({ record }) => (
|
|
388
|
+
record.type === "policy"
|
|
389
|
+
|| record.type === "training"
|
|
390
|
+
|| record.type === "document" && !auditSpecificDocument(record)
|
|
391
|
+
)).sort((left, right) => (
|
|
392
|
+
["policy", "document", "training"].indexOf(left.record.type) - ["policy", "document", "training"].indexOf(right.record.type)
|
|
393
|
+
|| left.record.title.localeCompare(right.record.title)
|
|
394
|
+
));
|
|
395
|
+
const ownerNames = (record) => (record.ownerIds || []).map((id) => (
|
|
396
|
+
state.resources.find(({ record: candidate }) => candidate.id === id)?.record.title || id
|
|
397
|
+
));
|
|
398
|
+
const approverNames = (record) => (record.approverIds || record.approvedByIds || []).map((id) => (
|
|
399
|
+
state.resources.find(({ record: candidate }) => candidate.id === id)?.record.title || id
|
|
400
|
+
));
|
|
401
|
+
const typeLabel = (record) => record.type === "document" && record.documentKind
|
|
402
|
+
? '<span>' + esc(state.model.resources.document.title) + '<small>' + esc(properCase(record.documentKind)) + '</small></span>'
|
|
403
|
+
: esc(state.model.resources[record.type].title);
|
|
404
|
+
const approval = (record) => {
|
|
405
|
+
const approvers = approverNames(record);
|
|
406
|
+
const bound = Boolean(record.approvedContentRevisions || record.effectiveContentRevisions);
|
|
407
|
+
if (!record.approvedOn || !approvers.length) return '<span class="muted">Not approved</span>';
|
|
408
|
+
return '<span>' + esc(record.approvedOn) + '<small>' + esc(approvers.join(", ")) + (bound ? ' · Revision bound' : '') + '</small></span>';
|
|
409
|
+
};
|
|
410
|
+
const row = (entry) => {
|
|
411
|
+
const record = entry.record;
|
|
412
|
+
const detail = '#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '?stage=policies' + (record.type === "document" ? '&documentScope=program' : '');
|
|
413
|
+
const owners = ownerNames(record);
|
|
414
|
+
return '<tr data-policy-content-row data-history="' + (["superseded", "retired"].includes(record.status) ? 'true' : 'false') + '"><td data-label="Name" data-primary-field><a class="record-title" href="' + detail + '">' + esc(record.title) + '</a><small><code>' + esc(record.id) + '</code></small></td><td data-label="Type" class="policy-content-type">' + typeLabel(record) + '</td><td data-label="Owner">' + (owners.length ? esc(owners.join(", ")) : '<span class="muted">Missing</span>') + '</td><td data-label="Status">' + formatValue(displayStatus(record), "status", record.type, true) + '</td><td data-label="Approval" class="policy-content-approval">' + approval(record) + '</td><td data-label="Next action">' + policyContentWorkflowCell(entry) + '</td></tr>';
|
|
415
|
+
};
|
|
416
|
+
const add = !state.readOnly
|
|
417
|
+
? '<details class="policy-content-add"><summary class="button primary">Add</summary><div><button type="button" data-add-policy-content="policy">Policy</button><button type="button" data-add-policy-content="document">Document</button><button type="button" data-add-policy-content="training">Training</button></div></details>'
|
|
418
|
+
: '';
|
|
419
|
+
const emptyRow = '<tr><td colspan="6">' + empty('No governed content has been created.') + '</td></tr>';
|
|
420
|
+
const html = '<section class="policy-content-table"><div class="section-head"><div><p class="kicker">Governed content</p><h2>Policies</h2><p>Review every program artifact in one place. Each row keeps its own type-specific fields and Git file.</p></div><div class="policy-content-tools"><label><span class="sr-only">Filter policies</span><input type="search" data-policy-content-search placeholder="Filter policies"></label><label class="history-toggle"><input type="checkbox" data-policy-content-history> Show retired</label>' + add + '</div></div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Name</th><th>Type</th><th>Owner</th><th>Status</th><th>Approval</th><th>Next action</th></tr></thead><tbody data-policy-content-body>' + (entries.length ? entries.map(row).join('') : emptyRow) + '</tbody></table></div><p class="policy-content-count" data-policy-content-count></p></section>';
|
|
421
|
+
queueMicrotask(() => wirePoliciesTable(entries.length));
|
|
422
|
+
return html;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function wirePoliciesTable(total) {
|
|
426
|
+
const table = root.querySelector('.policy-content-table');
|
|
427
|
+
if (!table) return;
|
|
428
|
+
const search = table.querySelector('[data-policy-content-search]');
|
|
429
|
+
const history = table.querySelector('[data-policy-content-history]');
|
|
430
|
+
const count = table.querySelector('[data-policy-content-count]');
|
|
431
|
+
const filter = () => {
|
|
432
|
+
const query = search.value.trim().toLowerCase();
|
|
433
|
+
let visible = 0;
|
|
434
|
+
table.querySelectorAll('[data-policy-content-row]').forEach((row) => {
|
|
435
|
+
const show = (history.checked || row.dataset.history !== 'true')
|
|
436
|
+
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
437
|
+
row.hidden = !show;
|
|
438
|
+
if (show) visible += 1;
|
|
439
|
+
});
|
|
440
|
+
count.textContent = visible + ' of ' + total + ' ' + pluralize('artifact', total);
|
|
441
|
+
};
|
|
442
|
+
search.addEventListener('input', filter);
|
|
443
|
+
history.addEventListener('change', filter);
|
|
444
|
+
table.querySelectorAll('[data-add-policy-content]').forEach((button) => {
|
|
445
|
+
button.addEventListener('click', () => openEditor(button.dataset.addPolicyContent));
|
|
446
|
+
});
|
|
447
|
+
filter();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function policyContentWorkflowCell(entry) {
|
|
451
|
+
const record = entry.record;
|
|
452
|
+
const items = recordWorkflowItems(record.type, record.id);
|
|
453
|
+
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
454
|
+
const item = items[0];
|
|
455
|
+
const href = workflowItemHref(item) || '#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '?stage=policies';
|
|
456
|
+
const title = ["draft", "in-review"].includes(record.status)
|
|
457
|
+
? "Complete and approve"
|
|
458
|
+
: record.status === "approved"
|
|
459
|
+
? "Implement and activate"
|
|
460
|
+
: "Resolve readiness gaps";
|
|
461
|
+
return '<a class="record-workflow-action policy-content-next-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(title) + '</strong><small>' + items.length + ' ' + pluralize('check', items.length) + '</small></span></a>';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderDocumentActivationAssessments() {
|
|
465
|
+
const assessments = [
|
|
466
|
+
...(state.programReadiness?.documentActivations || []).map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })),
|
|
467
|
+
...(state.programReadiness?.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId }))
|
|
468
|
+
];
|
|
469
|
+
if (!assessments.length) return "";
|
|
470
|
+
const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
|
|
471
|
+
const cards = assessments.map((assessment) => {
|
|
472
|
+
const operating = assessment.state === "active-and-operating";
|
|
473
|
+
const controls = assessment.missingImplementationControlIds.length
|
|
474
|
+
? '<div class="policy-activation-gaps"><div><small>Controls still being implemented</small><div class="evidence-map-references">' + assessment.missingImplementationControlIds.map((id) => formatReference(id, "control")).join("") + '</div></div></div>'
|
|
475
|
+
: "";
|
|
476
|
+
const schedule = assessment.resourceType === "training" && !assessment.assignmentScheduled
|
|
477
|
+
? '<p class="policy-activation-warning">Enable at least one assignment Obligation before activating this Training.</p>'
|
|
478
|
+
: '';
|
|
479
|
+
const detail = '#/resource/' + assessment.resourceType + '/' + encodeURIComponent(assessment.resourceId) + '?stage=controls' + (assessment.resourceType === "document" ? '&documentScope=program' : '');
|
|
480
|
+
return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="' + detail + '">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + controls + schedule + '<div class="policy-activation-actions"><a class="button" href="' + detail + '">View ' + esc(properCase(assessment.resourceType)) + '</a></div></article>';
|
|
481
|
+
}).join("");
|
|
482
|
+
const action = candidates.length && !state.readOnly
|
|
483
|
+
? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-document-activation>Review content activation</button></div>'
|
|
484
|
+
: "";
|
|
485
|
+
return '<section class="policy-activation"><div class="evidence-map-head"><div><p class="kicker">Program content cutover</p><h2>Activate approved program content</h2><p>After linked requirements are implemented and Training assignment Obligations are ready, activate each unchanged approved Document and Training record. FileGRC records a separate activation date and binds the exact activated revision.</p></div>' + action + '</div><div class="policy-activation-grid">' + cards + '</div></section>';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderAuditDocumentActivationAssessments() {
|
|
489
|
+
const audits = resourcesOfType("audit").map(({ record }) => record);
|
|
490
|
+
const audit = audits.find(({ status }) => !["complete", "closed", "canceled"].includes(status)) || audits[0];
|
|
491
|
+
const preparation = audit ? state.auditPreparations?.[audit.id] : null;
|
|
492
|
+
const assessments = preparation?.documentActivations || [];
|
|
493
|
+
if (!audit || !assessments.length) return "";
|
|
494
|
+
const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
|
|
495
|
+
const cards = assessments.map((assessment) => {
|
|
496
|
+
const operating = assessment.state === "active-and-operating";
|
|
497
|
+
const issues = assessment.issues.length
|
|
498
|
+
? '<ul class="policy-activation-warning">' + assessment.issues.map((issue) => '<li>' + esc(issue) + '</li>').join("") + '</ul>'
|
|
499
|
+
: "";
|
|
500
|
+
return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=audit&documentScope=audit">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + issues + '<div class="policy-activation-actions"><a class="button" href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=audit&documentScope=audit">View Document</a></div></article>';
|
|
501
|
+
}).join("");
|
|
502
|
+
const action = candidates.length && !state.readOnly
|
|
503
|
+
? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-audit-document-activation="' + esc(audit.id) + '">Review Step 5 activation</button></div>'
|
|
504
|
+
: "";
|
|
505
|
+
return '<section class="policy-activation audit-document-activation"><div class="evidence-map-head"><div><p class="kicker">Step 5 Document lifecycle</p><h2>Activate the engagement Documents</h2><p>After each engagement Document is complete and independently approved, record the Person who activates the unchanged approved revision for this Audit.</p></div>' + action + '</div><div class="policy-activation-grid">' + cards + '</div></section>';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function openDocumentActivationDialog(auditId = null) {
|
|
509
|
+
const candidates = (auditId
|
|
510
|
+
? state.auditPreparations?.[auditId]?.documentActivations || []
|
|
511
|
+
: [
|
|
512
|
+
...(state.programReadiness?.documentActivations || []).map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })),
|
|
513
|
+
...(state.programReadiness?.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId }))
|
|
514
|
+
]).filter(({ state }) => state === "ready-to-activate");
|
|
515
|
+
const entryById = new Map(state.resources
|
|
516
|
+
.filter(({ record }) => (auditId ? record.type === "document" : ["document", "training"].includes(record.type)) && record.status === "approved")
|
|
517
|
+
.map((entry) => [entry.record.id, entry]));
|
|
518
|
+
const ready = candidates.map((item) => auditId
|
|
519
|
+
? { ...item, resourceType: "document", resourceId: item.documentId }
|
|
520
|
+
: item).filter(({ resourceId }) => entryById.has(resourceId));
|
|
521
|
+
if (!ready.length) return;
|
|
522
|
+
const activators = state.resources
|
|
523
|
+
.filter(({ record }) => record.type === "person" && record.status === "active")
|
|
524
|
+
.map(({ record }) => record);
|
|
525
|
+
const today = currentDate();
|
|
526
|
+
const dialog = document.createElement("dialog");
|
|
527
|
+
dialog.className = "commit-dialog event-dialog policy-activation-dialog";
|
|
528
|
+
dialog.setAttribute("aria-labelledby", "document-activation-dialog-title");
|
|
529
|
+
const step = auditId ? "Step 5" : "Step 3";
|
|
530
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + step + ' activation</p><h2 id="document-activation-dialog-title">Review governed content activation</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' +
|
|
531
|
+
'<p>' + (auditId ? 'These Audit Documents are complete and independently approved for the selected Audit.' : 'These program Documents and Training records already have independent Step 2 approvals, implemented linked requirements, and any required schedules.') + ' Activation records the Person who puts each unchanged approved revision into use.</p>' +
|
|
532
|
+
'<div class="policy-activation-selections">' + ready.map((assessment) => '<label class="policy-activation-selection"><input type="checkbox" name="resourceId" value="' + esc(assessment.resourceId) + '" checked><span><strong>' + esc(assessment.title) + '</strong><small>' + esc(properCase(assessment.resourceType)) + ' · Approved ' + esc(assessment.approvedOn) + (auditId ? ' · Engagement facts complete' : ' · Implementation ready') + '</small></span></label>').join("") + '</div>' +
|
|
533
|
+
'<label><span>Activated by</span><select name="activatedById" required><option value="">Select the Person who performs this activation</option>' + activators.map((person) => '<option value="' + esc(person.id) + '">' + esc(person.title) + '</option>').join("") + '</select></label>' +
|
|
534
|
+
'<label><span>Activation date</span><input name="activatedOn" type="date" value="' + esc(today) + '" readonly required></label>' +
|
|
535
|
+
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
536
|
+
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Activate selected content</button></div></form>';
|
|
537
|
+
document.body.append(dialog);
|
|
538
|
+
const close = () => dialog.close();
|
|
539
|
+
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
540
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
541
|
+
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
|
542
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
if (!event.currentTarget.reportValidity() || dialog.dataset.mutationBusy === "true") return;
|
|
545
|
+
const resourceIds = [...event.currentTarget.querySelectorAll('[name="resourceId"]:checked')].map(({ value }) => value);
|
|
546
|
+
if (!resourceIds.length) {
|
|
547
|
+
dialog.querySelector(".dialog-error").textContent = "Select at least one approved artifact to activate.";
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
setMutationBusy(dialog, true, "Activating…", "Activate selected content");
|
|
551
|
+
try {
|
|
552
|
+
const response = await localFetch(auditId ? "/api/document-activations" : "/api/governed-content-activations", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: { "content-type": "application/json" },
|
|
555
|
+
body: JSON.stringify({
|
|
556
|
+
...(auditId ? { documentIds: resourceIds } : { resourceIds }),
|
|
557
|
+
...(auditId ? { auditId } : {}),
|
|
558
|
+
activatedByIds: [event.currentTarget.elements.activatedById.value],
|
|
559
|
+
activatedOn: event.currentTarget.elements.activatedOn.value,
|
|
560
|
+
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
561
|
+
expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, entryById.get(resourceId).revision]))
|
|
562
|
+
})
|
|
563
|
+
});
|
|
564
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
565
|
+
applyMutationState(await response.json());
|
|
566
|
+
dialog.close();
|
|
567
|
+
render();
|
|
568
|
+
} catch (error) {
|
|
569
|
+
setMutationBusy(dialog, false, "", "Activate selected content");
|
|
570
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
dialog.showModal();
|
|
374
574
|
}
|
|
375
575
|
|
|
376
576
|
function renderPolicyActivationAssessments() {
|
|
@@ -390,7 +590,8 @@ function renderPolicyActivationAssessments() {
|
|
|
390
590
|
gapGroup("Planned or partial Controls", assessment.plannedOrPartialControlIds, "control") +
|
|
391
591
|
gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
|
|
392
592
|
gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
|
|
393
|
-
gapGroup("Missing enabled
|
|
593
|
+
gapGroup("Missing enabled Obligations", assessment.missingScheduleControlIds, "control") +
|
|
594
|
+
gapGroup("Inactive governed Documents", assessment.missingGovernedDocumentIds || [], "document") +
|
|
394
595
|
gapGroup("Unresolved Exceptions", assessment.unresolvedExceptionIds, "exception") +
|
|
395
596
|
gapGroup("Documented Exceptions", assessment.documentedExceptionIds, "exception") + '</div>' +
|
|
396
597
|
(assessment.activationWarning ? '<p class="policy-activation-warning">' + esc(assessment.activationWarning) + '</p>' : "") +
|
|
@@ -418,6 +619,7 @@ function openPolicyActivationDialog() {
|
|
|
418
619
|
[assessment.missingComponentControlIds.length, "missing Components"],
|
|
419
620
|
[assessment.missingEvidenceSourceControlIds.length, "missing evidence sources"],
|
|
420
621
|
[assessment.missingScheduleControlIds.length, "missing schedules"],
|
|
622
|
+
[(assessment.missingGovernedDocumentIds || []).length, "inactive governed Documents"],
|
|
421
623
|
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
422
624
|
].filter(([count]) => count).map(([count, label]) => count + " " + label).join(" · ");
|
|
423
625
|
return '<label class="policy-activation-selection"><input type="checkbox" name="policyId" value="' + esc(assessment.policyId) + '" checked><span><strong>' + esc(assessment.title) + '</strong><small>' + esc(assessment.label) + (details ? " · " + details : " · No implementation gaps") + '</small></span></label>';
|
|
@@ -426,7 +628,7 @@ function openPolicyActivationDialog() {
|
|
|
426
628
|
dialog.className = "commit-dialog event-dialog policy-activation-dialog";
|
|
427
629
|
dialog.setAttribute("aria-labelledby", "policy-activation-dialog-title");
|
|
428
630
|
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Step 3 cutover</p><h2 id="policy-activation-dialog-title">Review policy activation</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' +
|
|
429
|
-
'<p>Choose which approved Policies should take effect. Enabled schedules stay dormant until the effective date
|
|
631
|
+
'<p>Choose which approved Policies should take effect. Enabled schedules stay dormant until the effective date, their linked Controls are implemented, and required governed Documents are active and effective.</p>' +
|
|
430
632
|
'<div class="policy-activation-selections">' + candidateRows + '</div>' +
|
|
431
633
|
'<section class="policy-activation-review-warning"><strong>Activation with gaps</strong><p>You can activate a Policy with documented gaps or Exceptions. The gaps stay open, Controls keep their current status, and Evidence Readiness stays incomplete.</p></section>' +
|
|
432
634
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
@@ -593,7 +795,7 @@ function openCollectionReviewDialog(type) {
|
|
|
593
795
|
if (!assessment) return;
|
|
594
796
|
const configuration = assessment.configuration;
|
|
595
797
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
596
|
-
const v4 =
|
|
798
|
+
const v4 = modelSupports("program-scope");
|
|
597
799
|
const sourceType = v4 ? "component" : "system";
|
|
598
800
|
const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
|
|
599
801
|
const allowedDecisions = configuration.decisions || ["complete"];
|
|
@@ -830,6 +1032,9 @@ function stagePageDestinations(stage) {
|
|
|
830
1032
|
}
|
|
831
1033
|
|
|
832
1034
|
function stagePageSummary(destination) {
|
|
1035
|
+
if (destination.section && !destination.section.types.includes(destination.type)) {
|
|
1036
|
+
return destination.description || destination.section.description || "";
|
|
1037
|
+
}
|
|
833
1038
|
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
834
1039
|
const section = destination.section || (destination.type
|
|
835
1040
|
? readinessStageForType(destination.type)?.sections.find((candidate) => candidate.types.includes(destination.type))
|
|
@@ -917,6 +1122,13 @@ function stagePageItems(stage, destination) {
|
|
|
917
1122
|
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
918
1123
|
)
|
|
919
1124
|
));
|
|
1125
|
+
if (stage.id === "policies" && destination.href === "#/stage/policies") {
|
|
1126
|
+
return items.sort((left, right) => (
|
|
1127
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1128
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1129
|
+
|| left.key.localeCompare(right.key)
|
|
1130
|
+
));
|
|
1131
|
+
}
|
|
920
1132
|
return items.filter((item) => {
|
|
921
1133
|
if (
|
|
922
1134
|
destination.type === "requirement"
|
|
@@ -1032,6 +1244,9 @@ function sectionDestinations(section) {
|
|
|
1032
1244
|
if (!definition) continue;
|
|
1033
1245
|
destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
|
|
1034
1246
|
}
|
|
1247
|
+
for (const link of section.relatedLinks || []) {
|
|
1248
|
+
destinations.push({ type: link.type, kind: "Record page", label: link.label, href: link.href, description: section.description });
|
|
1249
|
+
}
|
|
1035
1250
|
if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
|
|
1036
1251
|
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc Evidence and Evidence Artifacts, prepare fieldwork, and build the indexed packet." });
|
|
1037
1252
|
return destinations;
|
|
@@ -1590,7 +1805,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1590
1805
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1591
1806
|
const reviewedRequirements = reviewedRequirementIds();
|
|
1592
1807
|
const pending = entries.filter(({ record }) => (
|
|
1593
|
-
type === "requirement" &&
|
|
1808
|
+
type === "requirement" && modelSupports("program-scope")
|
|
1594
1809
|
? !reviewedRequirements.has(record.id)
|
|
1595
1810
|
: !record.applicabilityReview
|
|
1596
1811
|
|| type === "requirement" && record.applicability === "undetermined"
|
|
@@ -1662,7 +1877,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
1662
1877
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1663
1878
|
expectedRevisions: Object.fromEntries([
|
|
1664
1879
|
...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
|
|
1665
|
-
...(type === "requirement" &&
|
|
1880
|
+
...(type === "requirement" && modelSupports("program-scope")
|
|
1666
1881
|
? state.resources.filter(({ record }) => record.id === activeProgram().id).map((entry) => [entry.record.id, entry.revision])
|
|
1667
1882
|
: [])
|
|
1668
1883
|
])
|
|
@@ -1910,8 +2125,15 @@ function eventStepSummary(step) {
|
|
|
1910
2125
|
function renderList(main, type, params = new URLSearchParams()) {
|
|
1911
2126
|
const definition = state.model.resources[type];
|
|
1912
2127
|
if (!definition) return renderNotFound(main);
|
|
1913
|
-
const listStage = readinessStageForType(type);
|
|
1914
|
-
const
|
|
2128
|
+
const listStage = READINESS_STAGES.find(({ id }) => id === params.get("stage")) || readinessStageForType(type);
|
|
2129
|
+
const documentScope = type === "document" ? params.get("documentScope") : null;
|
|
2130
|
+
const listTitle = type === "document" ? documentListTitle(params) : definition.pluralTitle;
|
|
2131
|
+
const entries = resourcesOfType(type).filter(({ record }) => (
|
|
2132
|
+
!documentScope || (documentScope === "audit") === auditSpecificDocument(record)
|
|
2133
|
+
));
|
|
2134
|
+
const detailContext = params.get("stage")
|
|
2135
|
+
? "?stage=" + encodeURIComponent(params.get("stage")) + (documentScope ? "&documentScope=" + encodeURIComponent(documentScope) : "")
|
|
2136
|
+
: "";
|
|
1915
2137
|
const requestedPage = Number(params.get("page"));
|
|
1916
2138
|
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
1917
2139
|
const fields = [...new Set([
|
|
@@ -1929,7 +2151,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1929
2151
|
const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
1930
2152
|
const reviewedRequirements = reviewedRequirementIds();
|
|
1931
2153
|
const hasPendingApplicability = entries.some(({ record }) => (
|
|
1932
|
-
type === "requirement" &&
|
|
2154
|
+
type === "requirement" && modelSupports("program-scope")
|
|
1933
2155
|
? !reviewedRequirements.has(record.id)
|
|
1934
2156
|
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
1935
2157
|
));
|
|
@@ -1941,7 +2163,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1941
2163
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
1942
2164
|
const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
|
|
1943
2165
|
filters.map(({ name, label, values }) => '<select class="field-filter" data-field="' + esc(name) + '" aria-label="Filter by ' + esc(label.toLowerCase()) + '"><option value="">Any ' + esc(properCase(label)) + '</option>' + values.map((value) => '<option value="' + esc(value) + '">' + esc(filterOptionLabel(value)) + '</option>').join("") + '</select>').join("") + '<span id="result-count" aria-live="polite">' + entries.length + ' records</span>' + applicabilityButton + createButton + '</div>';
|
|
1944
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(
|
|
2166
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(listTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
1945
2167
|
collectionReviewPanel(type) +
|
|
1946
2168
|
'<section class="record-table-wrap"><table class="record-table"><thead><tr><th>' + esc(fieldLabel(type, "title")) + '</th>' + fields.map((name) => '<th>' + esc(fieldLabel(type, name)) + '</th>').join("") + '<th>Next action</th><th>Git file</th></tr></thead><tbody id="record-rows"></tbody></table></section>' +
|
|
1947
2169
|
'<nav class="pagination list-pagination" aria-label="' + esc(definition.pluralTitle) + ' pages" hidden><button class="button" type="button" data-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-page="next">Next</button></nav></div>';
|
|
@@ -1959,7 +2181,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1959
2181
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
1960
2182
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
1961
2183
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
1962
|
-
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
2184
|
+
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
1963
2185
|
pagination.hidden = totalPages === 1;
|
|
1964
2186
|
previous.disabled = pageNumber === 1;
|
|
1965
2187
|
next.disabled = pageNumber === totalPages;
|
|
@@ -1971,6 +2193,8 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1971
2193
|
main.querySelectorAll(".field-filter").forEach((select) => { select.value = params.get(select.dataset.field) || ""; });
|
|
1972
2194
|
const syncRoute = (mode = "replace") => {
|
|
1973
2195
|
const next = new URLSearchParams();
|
|
2196
|
+
if (params.get("stage")) next.set("stage", params.get("stage"));
|
|
2197
|
+
if (documentScope) next.set("documentScope", documentScope);
|
|
1974
2198
|
const query = main.querySelector("#list-search").value.trim();
|
|
1975
2199
|
if (query) next.set("q", query);
|
|
1976
2200
|
main.querySelectorAll(".field-filter").forEach((select) => { if (select.value) next.set(select.dataset.field, select.value); });
|
|
@@ -2010,6 +2234,22 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2010
2234
|
}
|
|
2011
2235
|
}
|
|
2012
2236
|
|
|
2237
|
+
function auditSpecificDocument(record) {
|
|
2238
|
+
if (record.type !== "document") return false;
|
|
2239
|
+
if (modelSupports("document-workflow-scope")) return record.workflowScope === "engagement";
|
|
2240
|
+
const kinds = new Set([
|
|
2241
|
+
...(state.model.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
|
|
2242
|
+
"soc2-engagement-terms"
|
|
2243
|
+
]);
|
|
2244
|
+
return kinds.has(record.documentKind);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
function documentListTitle(params = new URLSearchParams()) {
|
|
2248
|
+
if (params.get("documentScope") === "audit") return "Audit Documents";
|
|
2249
|
+
if (params.get("documentScope") === "program") return "Program Documents";
|
|
2250
|
+
return "Documents";
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2013
2253
|
function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
2014
2254
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
2015
2255
|
const definition = state.model.resources[type];
|
|
@@ -3393,12 +3633,18 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3393
3633
|
}
|
|
3394
3634
|
if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
|
|
3395
3635
|
const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
|
|
3396
|
-
const
|
|
3636
|
+
const activationManagedType = ["policy", "document"].includes(type)
|
|
3637
|
+
|| (type === "training" && Boolean(state.model.resources.training?.fields?.activatedContentRevisions));
|
|
3638
|
+
const availableValues = activationManagedType && name === "status" && value !== "active"
|
|
3397
3639
|
? values.filter((item) => item !== "active")
|
|
3398
3640
|
: values;
|
|
3399
3641
|
control = '<select><option value="">Select</option>' + availableValues.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
3400
|
-
const enumHelp =
|
|
3401
|
-
?
|
|
3642
|
+
const enumHelp = activationManagedType && name === "status"
|
|
3643
|
+
? type === "document"
|
|
3644
|
+
? "Approval ends at Approved. Activate program Documents from Step 3 after implementation, and engagement Documents from Step 5 after their Audit facts are complete."
|
|
3645
|
+
: type === "training"
|
|
3646
|
+
? "Step 2 ends at Approved. Activate Training from Step 3 after its Controls and assignment Obligation are ready."
|
|
3647
|
+
: "Step 2 ends at Approved. Activate approved Policies from the Step 3 Controls-page cutover."
|
|
3402
3648
|
: help;
|
|
3403
3649
|
return fieldWrap(name, "string", label, requiredMark, control, enumHelp, required);
|
|
3404
3650
|
}
|
|
@@ -4503,6 +4749,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
4503
4749
|
.workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
|
|
4504
4750
|
.evidence-attachments .panel-head{align-items:flex-start}.evidence-attachments .panel-head h3{margin:0}.evidence-attachments .panel-head p{margin:4px 0 0;color:var(--muted);font-size:10px}.evidence-attachments ul{list-style:none;margin:0;padding:0}.evidence-attachments li{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line)}.evidence-attachments li:first-child{border-top:0}.evidence-attachments strong,.evidence-attachments small{display:block}.evidence-attachments strong{font-size:11px}.evidence-attachments small{margin-top:2px;color:var(--muted);font-size:9px;overflow-wrap:anywhere}.evidence-attachments .attachment-empty{display:block;color:var(--muted);font-size:10px;line-height:1.45}.danger-text{color:var(--red)}
|
|
4505
4751
|
.policy-lifecycle-note.single{grid-template-columns:1fr}
|
|
4752
|
+
.policy-content-table{margin-top:22px}.policy-content-table>.section-head{align-items:end}.policy-content-table>.section-head h2{font:500 27.6px Georgia,serif;margin:5px 0 6px}.policy-content-table>.section-head p:not(.kicker){max-width:720px;margin:0;color:var(--muted);font-size:12px;line-height:1.5}.policy-content-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.policy-content-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.history-toggle{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}.policy-content-add{position:relative}.policy-content-add>summary{list-style:none}.policy-content-add>summary::-webkit-details-marker{display:none}.policy-content-add>div{position:absolute;right:0;z-index:5;display:grid;min-width:150px;margin-top:6px;padding:5px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.policy-content-add button{border:0;border-radius:5px;background:transparent;padding:8px 10px;color:var(--ink);text-align:left;cursor:pointer}.policy-content-add button:hover{background:var(--accent-soft)}.policy-content-type span,.policy-content-approval span{display:block}.policy-content-type small,.policy-content-approval small{display:block;margin-top:3px;color:var(--muted);font-size:9.6px}.policy-content-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}
|
|
4506
4753
|
.policy-library-proposals{min-width:0}.policy-library-proposals article,.policy-library-proposals details{min-width:0}.policy-library-proposals pre{box-sizing:border-box;max-width:100%;overflow:auto;padding:9px;border:1px solid var(--line);border-radius:6px;background:var(--paper);font-size:9px;line-height:1.45}
|
|
4507
4754
|
.policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
|
|
4508
4755
|
.policy-activation-review-warning{margin-top:14px;padding:12px 14px;border:1px solid #d8bd78;border-radius:8px;background:#fff8e8}.policy-activation-review-warning>strong{font-size:12px}.policy-activation-review-warning ul{display:grid;gap:5px;margin:9px 0;padding-left:20px;font-size:10.8px}.policy-activation-review-warning p{margin:9px 0 0;color:#6d4917;font-size:11px;line-height:1.5}
|
package/src/workflow.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { modelSupports } from "../model/index.js";
|
|
4
5
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
5
6
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
6
7
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
@@ -93,7 +94,7 @@ async function assessWorkflowUnmeasured(input, options = {}) {
|
|
|
93
94
|
if (options.auditId && selectedAuditRecords[0]?.programId && selectedAuditRecords[0].programId !== programRecord.id) {
|
|
94
95
|
throw new Error(`Audit "${options.auditId}" belongs to Program "${selectedAuditRecords[0].programId}", not "${programRecord.id}".`);
|
|
95
96
|
}
|
|
96
|
-
const audits =
|
|
97
|
+
const audits = modelSupports(loaded.model, "program-scope")
|
|
97
98
|
? selectedAuditRecords.filter(({ programId }) => programId === programRecord.id)
|
|
98
99
|
: selectedAuditRecords;
|
|
99
100
|
const timezone = options.timezone || workspace?.timezone || "UTC";
|
|
@@ -539,7 +540,11 @@ function finalizationFields(record, model) {
|
|
|
539
540
|
["reviewDueOn", record.status === "active"],
|
|
540
541
|
["nonExpiringRationale", record.status === "active" && !record.expiresOn]
|
|
541
542
|
],
|
|
542
|
-
training: [
|
|
543
|
+
training: modelSupports(model, "governed-training-activation") ? [
|
|
544
|
+
["approvedContentRevisions", ["approved", "active"].includes(record.status)],
|
|
545
|
+
["activatedContentRevisions", record.status === "active" && record.activationBasis !== "legacy-v5"],
|
|
546
|
+
["effectiveOn", record.status === "active"]
|
|
547
|
+
] : [
|
|
543
548
|
["effectiveContentRevisions", record.status === "active"],
|
|
544
549
|
["effectiveOn", record.status === "active"]
|
|
545
550
|
],
|
|
@@ -633,7 +638,7 @@ function appointmentFinding(kind, template, record, state, requiredness) {
|
|
|
633
638
|
}
|
|
634
639
|
|
|
635
640
|
function sourceCoverageFindings(loaded, program) {
|
|
636
|
-
const selected =
|
|
641
|
+
const selected = modelSupports(loaded.model, "program-scope")
|
|
637
642
|
? new Set(program?.controlIds || [])
|
|
638
643
|
: null;
|
|
639
644
|
const selectedControlIds = loaded.resources
|
|
@@ -880,7 +885,7 @@ async function assessPeriodHealth(loaded, options) {
|
|
|
880
885
|
}
|
|
881
886
|
|
|
882
887
|
function auditLifecycleFindings(loaded, audits, program) {
|
|
883
|
-
if (!
|
|
888
|
+
if (!modelSupports(loaded.model, "guided-workflow")) return [];
|
|
884
889
|
const target = program || resolveProgram(loaded);
|
|
885
890
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
886
891
|
const findings = [];
|
|
@@ -939,7 +944,7 @@ function auditLifecycleFindings(loaded, audits, program) {
|
|
|
939
944
|
subsequentEventsIssue.message
|
|
940
945
|
));
|
|
941
946
|
}
|
|
942
|
-
const signatoryIssue =
|
|
947
|
+
const signatoryIssue = modelSupports(loaded.model, "program-scope") && lateStage
|
|
943
948
|
? signatoryAppointmentIssue(audit, byId)
|
|
944
949
|
: null;
|
|
945
950
|
if (signatoryIssue) {
|
|
@@ -1239,6 +1244,8 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
|
|
|
1239
1244
|
&& !findings.some((finding) => finding.assessment === "audit-closure" && blockingFinding(finding));
|
|
1240
1245
|
const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
|
|
1241
1246
|
const policyActivations = program.policyActivations || [];
|
|
1247
|
+
const documentActivations = program.documentActivations || [];
|
|
1248
|
+
const trainingActivations = program.trainingActivations || [];
|
|
1242
1249
|
const policiesOperating = policyActivations.length > 0
|
|
1243
1250
|
&& policyActivations.every(({ state }) => state === "active-and-operating");
|
|
1244
1251
|
return {
|
|
@@ -1269,6 +1276,30 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
|
|
|
1269
1276
|
findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".policy-activation-")),
|
|
1270
1277
|
policies: policyActivations
|
|
1271
1278
|
},
|
|
1279
|
+
documentActivation: {
|
|
1280
|
+
status: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
|
|
1281
|
+
? "complete"
|
|
1282
|
+
: documentActivations.length ? "needs-work" : "not-started",
|
|
1283
|
+
message: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
|
|
1284
|
+
? "Every required governed Document is separately approved, activated, effective, and operating."
|
|
1285
|
+
: documentActivations.length
|
|
1286
|
+
? "Approve required program Documents in Step 2, implement their linked requirements, then activate their exact revisions in Step 3."
|
|
1287
|
+
: "No required governed Document activation is configured.",
|
|
1288
|
+
findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".document-")),
|
|
1289
|
+
documents: documentActivations
|
|
1290
|
+
},
|
|
1291
|
+
trainingActivation: {
|
|
1292
|
+
status: trainingActivations.length && trainingActivations.every(({ state }) => state === "active-and-operating")
|
|
1293
|
+
? "complete"
|
|
1294
|
+
: trainingActivations.length ? "needs-work" : "not-started",
|
|
1295
|
+
message: trainingActivations.length && trainingActivations.every(({ state }) => state === "active-and-operating")
|
|
1296
|
+
? "Every required Training record is separately approved, activated, effective, scheduled, and operating."
|
|
1297
|
+
: trainingActivations.length
|
|
1298
|
+
? "Approve Training in Step 2, implement its Controls and assignment Obligation, then activate its exact revision in Step 3."
|
|
1299
|
+
: "No required Training activation is configured.",
|
|
1300
|
+
findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".training-")),
|
|
1301
|
+
training: trainingActivations
|
|
1302
|
+
},
|
|
1272
1303
|
policyLibraryReview: {
|
|
1273
1304
|
status: program.policyLibraryProposals?.length ? "review" : "current",
|
|
1274
1305
|
message: program.policyLibraryProposals?.length
|
|
@@ -1634,7 +1665,7 @@ function shellArgument(value) {
|
|
|
1634
1665
|
|
|
1635
1666
|
function recordAssessment(type) {
|
|
1636
1667
|
if (["audit", "audit-request", "audit-population"].includes(type)) return "audit-readiness";
|
|
1637
|
-
if (["action-item", "obligation
|
|
1668
|
+
if (["action-item", "obligation-event", "control-activity"].includes(type)) return "period-health";
|
|
1638
1669
|
return "program-configuration";
|
|
1639
1670
|
}
|
|
1640
1671
|
|
|
@@ -1643,7 +1674,7 @@ function recordStage(type) {
|
|
|
1643
1674
|
return "scope";
|
|
1644
1675
|
}
|
|
1645
1676
|
if (["policy", "document", "training"].includes(type)) return "policies";
|
|
1646
|
-
if (["control", "complementary-control", "source-coverage"].includes(type)) return "controls";
|
|
1677
|
+
if (["control", "complementary-control", "source-coverage", "obligation"].includes(type)) return "controls";
|
|
1647
1678
|
if (["audit", "audit-request", "audit-population"].includes(type)) return "audit";
|
|
1648
1679
|
return "operate";
|
|
1649
1680
|
}
|
package/src/workspace.js
CHANGED
|
@@ -2,8 +2,13 @@ import { readFile, readdir } from "node:fs/promises";
|
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { loadModel } from "../model/index.js";
|
|
4
4
|
import { resolveWorkspaceRoot } from "./paths.js";
|
|
5
|
+
import { measureTiming } from "./timing.js";
|
|
5
6
|
|
|
6
7
|
export async function loadWorkspace(input = process.cwd()) {
|
|
8
|
+
return measureTiming("workspace-load", () => loadWorkspaceUnmeasured(input));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function loadWorkspaceUnmeasured(input) {
|
|
7
12
|
const root = resolveWorkspaceRoot(input);
|
|
8
13
|
const dataRoot = join(root, "data");
|
|
9
14
|
const diagnostics = [];
|