filegrc 0.8.0 → 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/src/web.js CHANGED
@@ -176,7 +176,8 @@ function buildNavigation(route) {
176
176
  const current = route.type === type && (!contextualStageId || contextualStageId === stage.id);
177
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>';
178
178
  }).join("") + (section.relatedLinks || []).map((link) => {
179
- 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;
180
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>';
181
182
  }).join("") + renderSidebarUtility(section.utility, route, direct);
182
183
  if (direct) return links;
@@ -358,7 +359,7 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
358
359
  const progress = stageProgress(stage);
359
360
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
360
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>' +
361
- (stage.id === "policies" ? renderPolicyApprovalGuidance() : "") + renderStagePageIndex(stage) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
362
+ (stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
362
363
  main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
363
364
  main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
364
365
  event.currentTarget.remove();
@@ -379,11 +380,92 @@ function renderPolicyApprovalGuidance() {
379
380
  : "";
380
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>';
381
382
  }).join("");
382
- return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">Step 2 approval</p><h3>Approve the requirements and intended values</h3><p>Approval means your company reviewed and accepted the Policy requirements and the intended values in each required governed plan or schedule. It does not mean the linked Controls are implemented yet.</p><p>Bind each approval to the exact Markdown revision here. Build the Controls in Step 3, activate the unchanged governed Documents with a separate date and revision, then activate the Policies.</p></div>' + (proposalRows ? '<div class="policy-library-proposals">' + proposalRows + '</div>' : "") + '</section>';
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>';
383
462
  }
384
463
 
385
464
  function renderDocumentActivationAssessments() {
386
- const assessments = state.programReadiness?.documentActivations || [];
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
+ ];
387
469
  if (!assessments.length) return "";
388
470
  const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
389
471
  const cards = assessments.map((assessment) => {
@@ -391,12 +473,16 @@ function renderDocumentActivationAssessments() {
391
473
  const controls = assessment.missingImplementationControlIds.length
392
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>'
393
475
  : "";
394
- 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=controls&documentScope=program">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + controls + '<div class="policy-activation-actions"><a class="button" href="#/resource/document/' + encodeURIComponent(assessment.documentId) + '?stage=controls&documentScope=program">View Document</a></div></article>';
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>';
395
481
  }).join("");
396
482
  const action = candidates.length && !state.readOnly
397
- ? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-document-activation>Review Document activation</button></div>'
483
+ ? '<div class="evidence-map-actions"><button class="button primary" type="button" data-review-document-activation>Review content activation</button></div>'
398
484
  : "";
399
- return '<section class="policy-activation"><div class="evidence-map-head"><div><p class="kicker">Governed Document cutover</p><h2>Activate the approved plans and schedules</h2><p>After the linked requirements are implemented, activate each unchanged approved Document. FileGRC records a separate activation date and binds the exact activated revision.</p></div>' + action + '</div><div class="policy-activation-grid">' + cards + '</div></section>';
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>';
400
486
  }
401
487
 
402
488
  function renderAuditDocumentActivationAssessments() {
@@ -422,11 +508,16 @@ function renderAuditDocumentActivationAssessments() {
422
508
  function openDocumentActivationDialog(auditId = null) {
423
509
  const candidates = (auditId
424
510
  ? state.auditPreparations?.[auditId]?.documentActivations || []
425
- : state.programReadiness?.documentActivations || []).filter(({ state }) => state === "ready-to-activate");
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");
426
515
  const entryById = new Map(state.resources
427
- .filter(({ record }) => record.type === "document" && record.status === "approved")
516
+ .filter(({ record }) => (auditId ? record.type === "document" : ["document", "training"].includes(record.type)) && record.status === "approved")
428
517
  .map((entry) => [entry.record.id, entry]));
429
- const ready = candidates.filter(({ documentId }) => entryById.has(documentId));
518
+ const ready = candidates.map((item) => auditId
519
+ ? { ...item, resourceType: "document", resourceId: item.documentId }
520
+ : item).filter(({ resourceId }) => entryById.has(resourceId));
430
521
  if (!ready.length) return;
431
522
  const activators = state.resources
432
523
  .filter(({ record }) => record.type === "person" && record.status === "active")
@@ -436,13 +527,13 @@ function openDocumentActivationDialog(auditId = null) {
436
527
  dialog.className = "commit-dialog event-dialog policy-activation-dialog";
437
528
  dialog.setAttribute("aria-labelledby", "document-activation-dialog-title");
438
529
  const step = auditId ? "Step 5" : "Step 3";
439
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + step + ' activation</p><h2 id="document-activation-dialog-title">Review governed Document activation</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' +
440
- '<p>' + (auditId ? 'These engagement Documents are complete and independently approved for the selected Audit.' : 'These Documents already have independent Step 2 approvals and implemented linked requirements.') + ' Activation records the Person who puts each unchanged approved revision into use.</p>' +
441
- '<div class="policy-activation-selections">' + ready.map((assessment) => '<label class="policy-activation-selection"><input type="checkbox" name="documentId" value="' + esc(assessment.documentId) + '" checked><span><strong>' + esc(assessment.title) + '</strong><small>Approved ' + esc(assessment.approvedOn) + (auditId ? ' · Engagement facts complete' : ' · All linked Controls implemented') + '</small></span></label>').join("") + '</div>' +
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>' +
442
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>' +
443
534
  '<label><span>Activation date</span><input name="activatedOn" type="date" value="' + esc(today) + '" readonly required></label>' +
444
535
  '<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
445
- '<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 Documents</button></div></form>';
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>';
446
537
  document.body.append(dialog);
447
538
  const close = () => dialog.close();
448
539
  dialog.querySelector(".icon-button").addEventListener("click", close);
@@ -451,23 +542,23 @@ function openDocumentActivationDialog(auditId = null) {
451
542
  dialog.querySelector("form").addEventListener("submit", async (event) => {
452
543
  event.preventDefault();
453
544
  if (!event.currentTarget.reportValidity() || dialog.dataset.mutationBusy === "true") return;
454
- const documentIds = [...event.currentTarget.querySelectorAll('[name="documentId"]:checked')].map(({ value }) => value);
455
- if (!documentIds.length) {
456
- dialog.querySelector(".dialog-error").textContent = "Select at least one approved Document to activate.";
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.";
457
548
  return;
458
549
  }
459
- setMutationBusy(dialog, true, "Activating…", "Activate selected Documents");
550
+ setMutationBusy(dialog, true, "Activating…", "Activate selected content");
460
551
  try {
461
- const response = await localFetch("/api/document-activations", {
552
+ const response = await localFetch(auditId ? "/api/document-activations" : "/api/governed-content-activations", {
462
553
  method: "POST",
463
554
  headers: { "content-type": "application/json" },
464
555
  body: JSON.stringify({
465
- documentIds,
556
+ ...(auditId ? { documentIds: resourceIds } : { resourceIds }),
466
557
  ...(auditId ? { auditId } : {}),
467
558
  activatedByIds: [event.currentTarget.elements.activatedById.value],
468
559
  activatedOn: event.currentTarget.elements.activatedOn.value,
469
560
  effectiveOn: event.currentTarget.elements.effectiveOn.value,
470
- expectedRevisions: Object.fromEntries(documentIds.map((documentId) => [documentId, entryById.get(documentId).revision]))
561
+ expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, entryById.get(resourceId).revision]))
471
562
  })
472
563
  });
473
564
  if (!response.ok) throw new Error(await responseMessage(response));
@@ -475,7 +566,7 @@ function openDocumentActivationDialog(auditId = null) {
475
566
  dialog.close();
476
567
  render();
477
568
  } catch (error) {
478
- setMutationBusy(dialog, false, "", "Activate selected Documents");
569
+ setMutationBusy(dialog, false, "", "Activate selected content");
479
570
  dialog.querySelector(".dialog-error").textContent = error.message;
480
571
  }
481
572
  });
@@ -499,7 +590,7 @@ function renderPolicyActivationAssessments() {
499
590
  gapGroup("Planned or partial Controls", assessment.plannedOrPartialControlIds, "control") +
500
591
  gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
501
592
  gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
502
- gapGroup("Missing enabled schedules", assessment.missingScheduleControlIds, "control") +
593
+ gapGroup("Missing enabled Obligations", assessment.missingScheduleControlIds, "control") +
503
594
  gapGroup("Inactive governed Documents", assessment.missingGovernedDocumentIds || [], "document") +
504
595
  gapGroup("Unresolved Exceptions", assessment.unresolvedExceptionIds, "exception") +
505
596
  gapGroup("Documented Exceptions", assessment.documentedExceptionIds, "exception") + '</div>' +
@@ -1031,6 +1122,13 @@ function stagePageItems(stage, destination) {
1031
1122
  && item.code === "governance.appointment.independent-policy-reviewer"
1032
1123
  )
1033
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
+ }
1034
1132
  return items.filter((item) => {
1035
1133
  if (
1036
1134
  destination.type === "requirement"
@@ -2147,8 +2245,8 @@ function auditSpecificDocument(record) {
2147
2245
  }
2148
2246
 
2149
2247
  function documentListTitle(params = new URLSearchParams()) {
2150
- if (params.get("documentScope") === "audit") return "Audit-specific Documents";
2151
- if (params.get("documentScope") === "program") return "Governed Plans and Schedules";
2248
+ if (params.get("documentScope") === "audit") return "Audit Documents";
2249
+ if (params.get("documentScope") === "program") return "Program Documents";
2152
2250
  return "Documents";
2153
2251
  }
2154
2252
 
@@ -3535,7 +3633,8 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3535
3633
  }
3536
3634
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
3537
3635
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
3538
- const activationManagedType = ["policy", "document"].includes(type);
3636
+ const activationManagedType = ["policy", "document"].includes(type)
3637
+ || (type === "training" && Boolean(state.model.resources.training?.fields?.activatedContentRevisions));
3539
3638
  const availableValues = activationManagedType && name === "status" && value !== "active"
3540
3639
  ? values.filter((item) => item !== "active")
3541
3640
  : values;
@@ -3543,6 +3642,8 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3543
3642
  const enumHelp = activationManagedType && name === "status"
3544
3643
  ? type === "document"
3545
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."
3546
3647
  : "Step 2 ends at Approved. Activate approved Policies from the Step 3 Controls-page cutover."
3547
3648
  : help;
3548
3649
  return fieldWrap(name, "string", label, requiredMark, control, enumHelp, required);
@@ -4648,6 +4749,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
4648
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}
4649
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)}
4650
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}
4651
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}
4652
4754
  .policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
4653
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
@@ -540,7 +540,11 @@ function finalizationFields(record, model) {
540
540
  ["reviewDueOn", record.status === "active"],
541
541
  ["nonExpiringRationale", record.status === "active" && !record.expiresOn]
542
542
  ],
543
- 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
+ ] : [
544
548
  ["effectiveContentRevisions", record.status === "active"],
545
549
  ["effectiveOn", record.status === "active"]
546
550
  ],
@@ -1241,6 +1245,7 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
1241
1245
  const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
1242
1246
  const policyActivations = program.policyActivations || [];
1243
1247
  const documentActivations = program.documentActivations || [];
1248
+ const trainingActivations = program.trainingActivations || [];
1244
1249
  const policiesOperating = policyActivations.length > 0
1245
1250
  && policyActivations.every(({ state }) => state === "active-and-operating");
1246
1251
  return {
@@ -1278,11 +1283,23 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
1278
1283
  message: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
1279
1284
  ? "Every required governed Document is separately approved, activated, effective, and operating."
1280
1285
  : documentActivations.length
1281
- ? "Approve required plans and schedules in Step 2, implement their linked requirements, then activate their exact revisions in Step 3."
1286
+ ? "Approve required program Documents in Step 2, implement their linked requirements, then activate their exact revisions in Step 3."
1282
1287
  : "No required governed Document activation is configured.",
1283
1288
  findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".document-")),
1284
1289
  documents: documentActivations
1285
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
+ },
1286
1303
  policyLibraryReview: {
1287
1304
  status: program.policyLibraryProposals?.length ? "review" : "current",
1288
1305
  message: program.policyLibraryProposals?.length
@@ -1648,7 +1665,7 @@ function shellArgument(value) {
1648
1665
 
1649
1666
  function recordAssessment(type) {
1650
1667
  if (["audit", "audit-request", "audit-population"].includes(type)) return "audit-readiness";
1651
- if (["action-item", "obligation", "obligation-event", "control-activity"].includes(type)) return "period-health";
1668
+ if (["action-item", "obligation-event", "control-activity"].includes(type)) return "period-health";
1652
1669
  return "program-configuration";
1653
1670
  }
1654
1671
 
@@ -1657,7 +1674,7 @@ function recordStage(type) {
1657
1674
  return "scope";
1658
1675
  }
1659
1676
  if (["policy", "document", "training"].includes(type)) return "policies";
1660
- if (["control", "complementary-control", "source-coverage"].includes(type)) return "controls";
1677
+ if (["control", "complementary-control", "source-coverage", "obligation"].includes(type)) return "controls";
1661
1678
  if (["audit", "audit-request", "audit-population"].includes(type)) return "audit";
1662
1679
  return "operate";
1663
1680
  }