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/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
- : state.model.resources[route.type]?.pluralTitle || "filegrc";
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() : "") + renderStagePageIndex(stage) + (stage.id === "controls" ? renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + '</div>';
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">Policy approval</p><h3>Approve what your company is committing to</h3><p>Approval means your company reviewed and accepted the Policy. It does not mean the linked Controls are implemented yet.</p><p>Approve the Policy in Step 2. Build the Controls in Step 3, then activate the Policy from the Controls page when you are ready for it to take effect.</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>';
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 schedules", assessment.missingScheduleControlIds, "control") +
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 and until their linked Controls are implemented.</p>' +
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 = String(state.model.modelVersion) === "4";
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" && String(state.model.modelVersion) === "4"
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" && String(state.model.modelVersion) === "4"
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 entries = resourcesOfType(type);
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" && String(state.model.modelVersion) === "4"
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(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
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 availableValues = type === "policy" && name === "status" && value !== "active"
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 = type === "policy" && name === "status"
3401
- ? "Step 2 ends at Approved. Activate approved Policies from the Step 3 Controls-page cutover."
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 = String(loaded.model.modelVersion) === "4"
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 = String(loaded.model.modelVersion) === "4"
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 (!["3", "4"].includes(String(loaded.model.modelVersion))) return [];
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 = String(loaded.model.modelVersion) === "4" && lateStage
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", "obligation-event", "control-activity"].includes(type)) return "period-health";
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 = [];