filegrc 0.7.1 → 0.8.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";
@@ -242,7 +245,9 @@ function topbar(route) {
242
245
  ? "Work Queue"
243
246
  : route.name === "audit-packet"
244
247
  ? "Audit Readiness"
245
- : state.model.resources[route.type]?.pluralTitle || "filegrc";
248
+ : route.name === "list" && route.type === "document"
249
+ ? documentListTitle(route.params)
250
+ : state.model.resources[route.type]?.pluralTitle || "filegrc";
246
251
  const repositoryLabel = state.repository?.mode === "trunk"
247
252
  ? state.repository.label
248
253
  : state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
@@ -353,12 +358,16 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
353
358
  const progress = stageProgress(stage);
354
359
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
355
360
  '<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>';
361
+ (stage.id === "policies" ? renderPolicyApprovalGuidance() : "") + renderStagePageIndex(stage) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
357
362
  main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
358
363
  main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
359
364
  event.currentTarget.remove();
360
365
  });
361
366
  main.querySelector("[data-review-policy-activation]")?.addEventListener("click", openPolicyActivationDialog);
367
+ main.querySelector("[data-review-document-activation]")?.addEventListener("click", () => openDocumentActivationDialog());
368
+ main.querySelector("[data-review-audit-document-activation]")?.addEventListener("click", (event) => {
369
+ openDocumentActivationDialog(event.currentTarget.dataset.reviewAuditDocumentActivation);
370
+ });
362
371
  }
363
372
 
364
373
  function renderPolicyApprovalGuidance() {
@@ -370,7 +379,107 @@ function renderPolicyApprovalGuidance() {
370
379
  : "";
371
380
  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
381
  }).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>';
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
+ }
384
+
385
+ function renderDocumentActivationAssessments() {
386
+ const assessments = state.programReadiness?.documentActivations || [];
387
+ if (!assessments.length) return "";
388
+ const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
389
+ const cards = assessments.map((assessment) => {
390
+ const operating = assessment.state === "active-and-operating";
391
+ const controls = assessment.missingImplementationControlIds.length
392
+ ? '<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
+ : "";
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>';
395
+ }).join("");
396
+ 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>'
398
+ : "";
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>';
400
+ }
401
+
402
+ function renderAuditDocumentActivationAssessments() {
403
+ const audits = resourcesOfType("audit").map(({ record }) => record);
404
+ const audit = audits.find(({ status }) => !["complete", "closed", "canceled"].includes(status)) || audits[0];
405
+ const preparation = audit ? state.auditPreparations?.[audit.id] : null;
406
+ const assessments = preparation?.documentActivations || [];
407
+ if (!audit || !assessments.length) return "";
408
+ const candidates = assessments.filter(({ state }) => state === "ready-to-activate");
409
+ const cards = assessments.map((assessment) => {
410
+ const operating = assessment.state === "active-and-operating";
411
+ const issues = assessment.issues.length
412
+ ? '<ul class="policy-activation-warning">' + assessment.issues.map((issue) => '<li>' + esc(issue) + '</li>').join("") + '</ul>'
413
+ : "";
414
+ 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>';
415
+ }).join("");
416
+ const action = candidates.length && !state.readOnly
417
+ ? '<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>'
418
+ : "";
419
+ 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>';
420
+ }
421
+
422
+ function openDocumentActivationDialog(auditId = null) {
423
+ const candidates = (auditId
424
+ ? state.auditPreparations?.[auditId]?.documentActivations || []
425
+ : state.programReadiness?.documentActivations || []).filter(({ state }) => state === "ready-to-activate");
426
+ const entryById = new Map(state.resources
427
+ .filter(({ record }) => record.type === "document" && record.status === "approved")
428
+ .map((entry) => [entry.record.id, entry]));
429
+ const ready = candidates.filter(({ documentId }) => entryById.has(documentId));
430
+ if (!ready.length) return;
431
+ const activators = state.resources
432
+ .filter(({ record }) => record.type === "person" && record.status === "active")
433
+ .map(({ record }) => record);
434
+ const today = currentDate();
435
+ const dialog = document.createElement("dialog");
436
+ dialog.className = "commit-dialog event-dialog policy-activation-dialog";
437
+ dialog.setAttribute("aria-labelledby", "document-activation-dialog-title");
438
+ 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>' +
442
+ '<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
+ '<label><span>Activation date</span><input name="activatedOn" type="date" value="' + esc(today) + '" readonly required></label>' +
444
+ '<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>';
446
+ document.body.append(dialog);
447
+ const close = () => dialog.close();
448
+ dialog.querySelector(".icon-button").addEventListener("click", close);
449
+ dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
450
+ dialog.addEventListener("close", () => dialog.remove(), { once: true });
451
+ dialog.querySelector("form").addEventListener("submit", async (event) => {
452
+ event.preventDefault();
453
+ 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.";
457
+ return;
458
+ }
459
+ setMutationBusy(dialog, true, "Activating…", "Activate selected Documents");
460
+ try {
461
+ const response = await localFetch("/api/document-activations", {
462
+ method: "POST",
463
+ headers: { "content-type": "application/json" },
464
+ body: JSON.stringify({
465
+ documentIds,
466
+ ...(auditId ? { auditId } : {}),
467
+ activatedByIds: [event.currentTarget.elements.activatedById.value],
468
+ activatedOn: event.currentTarget.elements.activatedOn.value,
469
+ effectiveOn: event.currentTarget.elements.effectiveOn.value,
470
+ expectedRevisions: Object.fromEntries(documentIds.map((documentId) => [documentId, entryById.get(documentId).revision]))
471
+ })
472
+ });
473
+ if (!response.ok) throw new Error(await responseMessage(response));
474
+ applyMutationState(await response.json());
475
+ dialog.close();
476
+ render();
477
+ } catch (error) {
478
+ setMutationBusy(dialog, false, "", "Activate selected Documents");
479
+ dialog.querySelector(".dialog-error").textContent = error.message;
480
+ }
481
+ });
482
+ dialog.showModal();
374
483
  }
375
484
 
376
485
  function renderPolicyActivationAssessments() {
@@ -391,6 +500,7 @@ function renderPolicyActivationAssessments() {
391
500
  gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
392
501
  gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
393
502
  gapGroup("Missing enabled schedules", assessment.missingScheduleControlIds, "control") +
503
+ gapGroup("Inactive governed Documents", assessment.missingGovernedDocumentIds || [], "document") +
394
504
  gapGroup("Unresolved Exceptions", assessment.unresolvedExceptionIds, "exception") +
395
505
  gapGroup("Documented Exceptions", assessment.documentedExceptionIds, "exception") + '</div>' +
396
506
  (assessment.activationWarning ? '<p class="policy-activation-warning">' + esc(assessment.activationWarning) + '</p>' : "") +
@@ -418,6 +528,7 @@ function openPolicyActivationDialog() {
418
528
  [assessment.missingComponentControlIds.length, "missing Components"],
419
529
  [assessment.missingEvidenceSourceControlIds.length, "missing evidence sources"],
420
530
  [assessment.missingScheduleControlIds.length, "missing schedules"],
531
+ [(assessment.missingGovernedDocumentIds || []).length, "inactive governed Documents"],
421
532
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
422
533
  ].filter(([count]) => count).map(([count, label]) => count + " " + label).join(" · ");
423
534
  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 +537,7 @@ function openPolicyActivationDialog() {
426
537
  dialog.className = "commit-dialog event-dialog policy-activation-dialog";
427
538
  dialog.setAttribute("aria-labelledby", "policy-activation-dialog-title");
428
539
  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>' +
540
+ '<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
541
  '<div class="policy-activation-selections">' + candidateRows + '</div>' +
431
542
  '<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
543
  '<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
@@ -593,7 +704,7 @@ function openCollectionReviewDialog(type) {
593
704
  if (!assessment) return;
594
705
  const configuration = assessment.configuration;
595
706
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
596
- const v4 = String(state.model.modelVersion) === "4";
707
+ const v4 = modelSupports("program-scope");
597
708
  const sourceType = v4 ? "component" : "system";
598
709
  const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
599
710
  const allowedDecisions = configuration.decisions || ["complete"];
@@ -830,6 +941,9 @@ function stagePageDestinations(stage) {
830
941
  }
831
942
 
832
943
  function stagePageSummary(destination) {
944
+ if (destination.section && !destination.section.types.includes(destination.type)) {
945
+ return destination.description || destination.section.description || "";
946
+ }
833
947
  const summaryKey = destination.type || "utility:" + destination.utility;
834
948
  const section = destination.section || (destination.type
835
949
  ? readinessStageForType(destination.type)?.sections.find((candidate) => candidate.types.includes(destination.type))
@@ -1032,6 +1146,9 @@ function sectionDestinations(section) {
1032
1146
  if (!definition) continue;
1033
1147
  destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
1034
1148
  }
1149
+ for (const link of section.relatedLinks || []) {
1150
+ destinations.push({ type: link.type, kind: "Record page", label: link.label, href: link.href, description: section.description });
1151
+ }
1035
1152
  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
1153
  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
1154
  return destinations;
@@ -1590,7 +1707,7 @@ function openApplicabilityReviewDialog(type, entries) {
1590
1707
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
1591
1708
  const reviewedRequirements = reviewedRequirementIds();
1592
1709
  const pending = entries.filter(({ record }) => (
1593
- type === "requirement" && String(state.model.modelVersion) === "4"
1710
+ type === "requirement" && modelSupports("program-scope")
1594
1711
  ? !reviewedRequirements.has(record.id)
1595
1712
  : !record.applicabilityReview
1596
1713
  || type === "requirement" && record.applicability === "undetermined"
@@ -1662,7 +1779,7 @@ function openApplicabilityReviewDialog(type, entries) {
1662
1779
  reviewedOn: form.elements.reviewedOn.value,
1663
1780
  expectedRevisions: Object.fromEntries([
1664
1781
  ...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
1665
- ...(type === "requirement" && String(state.model.modelVersion) === "4"
1782
+ ...(type === "requirement" && modelSupports("program-scope")
1666
1783
  ? state.resources.filter(({ record }) => record.id === activeProgram().id).map((entry) => [entry.record.id, entry.revision])
1667
1784
  : [])
1668
1785
  ])
@@ -1910,8 +2027,15 @@ function eventStepSummary(step) {
1910
2027
  function renderList(main, type, params = new URLSearchParams()) {
1911
2028
  const definition = state.model.resources[type];
1912
2029
  if (!definition) return renderNotFound(main);
1913
- const listStage = readinessStageForType(type);
1914
- const entries = resourcesOfType(type);
2030
+ const listStage = READINESS_STAGES.find(({ id }) => id === params.get("stage")) || readinessStageForType(type);
2031
+ const documentScope = type === "document" ? params.get("documentScope") : null;
2032
+ const listTitle = type === "document" ? documentListTitle(params) : definition.pluralTitle;
2033
+ const entries = resourcesOfType(type).filter(({ record }) => (
2034
+ !documentScope || (documentScope === "audit") === auditSpecificDocument(record)
2035
+ ));
2036
+ const detailContext = params.get("stage")
2037
+ ? "?stage=" + encodeURIComponent(params.get("stage")) + (documentScope ? "&documentScope=" + encodeURIComponent(documentScope) : "")
2038
+ : "";
1915
2039
  const requestedPage = Number(params.get("page"));
1916
2040
  let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
1917
2041
  const fields = [...new Set([
@@ -1929,7 +2053,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1929
2053
  const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
1930
2054
  const reviewedRequirements = reviewedRequirementIds();
1931
2055
  const hasPendingApplicability = entries.some(({ record }) => (
1932
- type === "requirement" && String(state.model.modelVersion) === "4"
2056
+ type === "requirement" && modelSupports("program-scope")
1933
2057
  ? !reviewedRequirements.has(record.id)
1934
2058
  : !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
1935
2059
  ));
@@ -1941,7 +2065,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1941
2065
  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
2066
  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
2067
  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) +
2068
+ 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
2069
  collectionReviewPanel(type) +
1946
2070
  '<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
2071
  '<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 +2083,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1959
2083
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
1960
2084
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
1961
2085
  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>';
2086
+ 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
2087
  pagination.hidden = totalPages === 1;
1964
2088
  previous.disabled = pageNumber === 1;
1965
2089
  next.disabled = pageNumber === totalPages;
@@ -1971,6 +2095,8 @@ function renderList(main, type, params = new URLSearchParams()) {
1971
2095
  main.querySelectorAll(".field-filter").forEach((select) => { select.value = params.get(select.dataset.field) || ""; });
1972
2096
  const syncRoute = (mode = "replace") => {
1973
2097
  const next = new URLSearchParams();
2098
+ if (params.get("stage")) next.set("stage", params.get("stage"));
2099
+ if (documentScope) next.set("documentScope", documentScope);
1974
2100
  const query = main.querySelector("#list-search").value.trim();
1975
2101
  if (query) next.set("q", query);
1976
2102
  main.querySelectorAll(".field-filter").forEach((select) => { if (select.value) next.set(select.dataset.field, select.value); });
@@ -2010,6 +2136,22 @@ function renderList(main, type, params = new URLSearchParams()) {
2010
2136
  }
2011
2137
  }
2012
2138
 
2139
+ function auditSpecificDocument(record) {
2140
+ if (record.type !== "document") return false;
2141
+ if (modelSupports("document-workflow-scope")) return record.workflowScope === "engagement";
2142
+ const kinds = new Set([
2143
+ ...(state.model.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
2144
+ "soc2-engagement-terms"
2145
+ ]);
2146
+ return kinds.has(record.documentKind);
2147
+ }
2148
+
2149
+ 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";
2152
+ return "Documents";
2153
+ }
2154
+
2013
2155
  function renderDetail(main, type, id, params = new URLSearchParams()) {
2014
2156
  const entry = resourcesOfType(type).find(({ record }) => record.id === id);
2015
2157
  const definition = state.model.resources[type];
@@ -3393,12 +3535,15 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3393
3535
  }
3394
3536
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
3395
3537
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
3396
- const availableValues = type === "policy" && name === "status" && value !== "active"
3538
+ const activationManagedType = ["policy", "document"].includes(type);
3539
+ const availableValues = activationManagedType && name === "status" && value !== "active"
3397
3540
  ? values.filter((item) => item !== "active")
3398
3541
  : values;
3399
3542
  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."
3543
+ const enumHelp = activationManagedType && name === "status"
3544
+ ? type === "document"
3545
+ ? "Approval ends at Approved. Activate program Documents from Step 3 after implementation, and engagement Documents from Step 5 after their Audit facts are complete."
3546
+ : "Step 2 ends at Approved. Activate approved Policies from the Step 3 Controls-page cutover."
3402
3547
  : help;
3403
3548
  return fieldWrap(name, "string", label, requiredMark, control, enumHelp, required);
3404
3549
  }
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";
@@ -633,7 +634,7 @@ function appointmentFinding(kind, template, record, state, requiredness) {
633
634
  }
634
635
 
635
636
  function sourceCoverageFindings(loaded, program) {
636
- const selected = String(loaded.model.modelVersion) === "4"
637
+ const selected = modelSupports(loaded.model, "program-scope")
637
638
  ? new Set(program?.controlIds || [])
638
639
  : null;
639
640
  const selectedControlIds = loaded.resources
@@ -880,7 +881,7 @@ async function assessPeriodHealth(loaded, options) {
880
881
  }
881
882
 
882
883
  function auditLifecycleFindings(loaded, audits, program) {
883
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) return [];
884
+ if (!modelSupports(loaded.model, "guided-workflow")) return [];
884
885
  const target = program || resolveProgram(loaded);
885
886
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
886
887
  const findings = [];
@@ -939,7 +940,7 @@ function auditLifecycleFindings(loaded, audits, program) {
939
940
  subsequentEventsIssue.message
940
941
  ));
941
942
  }
942
- const signatoryIssue = String(loaded.model.modelVersion) === "4" && lateStage
943
+ const signatoryIssue = modelSupports(loaded.model, "program-scope") && lateStage
943
944
  ? signatoryAppointmentIssue(audit, byId)
944
945
  : null;
945
946
  if (signatoryIssue) {
@@ -1239,6 +1240,7 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
1239
1240
  && !findings.some((finding) => finding.assessment === "audit-closure" && blockingFinding(finding));
1240
1241
  const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
1241
1242
  const policyActivations = program.policyActivations || [];
1243
+ const documentActivations = program.documentActivations || [];
1242
1244
  const policiesOperating = policyActivations.length > 0
1243
1245
  && policyActivations.every(({ state }) => state === "active-and-operating");
1244
1246
  return {
@@ -1269,6 +1271,18 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan,
1269
1271
  findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".policy-activation-")),
1270
1272
  policies: policyActivations
1271
1273
  },
1274
+ documentActivation: {
1275
+ status: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
1276
+ ? "complete"
1277
+ : documentActivations.length ? "needs-work" : "not-started",
1278
+ message: documentActivations.length && documentActivations.every(({ state }) => state === "active-and-operating")
1279
+ ? "Every required governed Document is separately approved, activated, effective, and operating."
1280
+ : documentActivations.length
1281
+ ? "Approve required plans and schedules in Step 2, implement their linked requirements, then activate their exact revisions in Step 3."
1282
+ : "No required governed Document activation is configured.",
1283
+ findingKeys: findingKeys(findings, "program-configuration", ({ key }) => key.includes(".document-")),
1284
+ documents: documentActivations
1285
+ },
1272
1286
  policyLibraryReview: {
1273
1287
  status: program.policyLibraryProposals?.length ? "review" : "current",
1274
1288
  message: program.policyLibraryProposals?.length
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 = [];