filegrc 0.16.7 → 0.16.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.7",
3
+ "version": "0.16.9",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@ const unorderedStringArrayFields = new Set([
21
21
  "tags"
22
22
  ]);
23
23
 
24
- export function applicabilityScopeRevision(record, program, resources, model) {
24
+ export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
25
25
  const selectedSystemIds = new Set(program?.systemIds || []);
26
26
  const selectedFrameworkIds = new Set(program?.frameworkIds || []);
27
27
  const selectedControlIds = new Set(program?.controlIds || []);
@@ -117,19 +117,19 @@ export function applicabilityScopeRevision(record, program, resources, model) {
117
117
  policies: resources
118
118
  .filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
119
119
  .sort(compareRecordIds)
120
- .map((policy) => pick(policy, [
121
- "id",
122
- "status",
123
- "policyKind",
124
- "version",
125
- "programRole",
126
- "effectiveOn",
127
- "requirementIds",
128
- "audience",
129
- "acknowledgementRequired",
130
- "relatedDocumentIds",
131
- "approvedContentRevisions"
132
- ], model)),
120
+ .map((policy) => options.legacyPolicyStatus
121
+ ? pick({
122
+ ...policy,
123
+ status: options.legacyPolicyStatus === "approved" && ["in-review", "active"].includes(policy.status)
124
+ ? "approved" : policy.status,
125
+ effectiveOn: options.legacyPolicyStatus === "approved" && policy.status === "active"
126
+ ? undefined : policy.effectiveOn
127
+ }, [
128
+ "id", "status", "policyKind", "version", "programRole", "effectiveOn",
129
+ "requirementIds", "audience", "acknowledgementRequired",
130
+ "relatedDocumentIds", "approvedContentRevisions"
131
+ ], model)
132
+ : pick(policy, ["id", "requirementIds"], model)),
133
133
  requirements: resources
134
134
  .filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
135
135
  .sort(compareRecordIds)
@@ -165,14 +165,14 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
165
165
  && !review.scopeRevision.startsWith("filegrc:applicability-scope:")
166
166
  && Number(model?.modelVersion || 0) < 7
167
167
  ) return true;
168
- return Boolean(
169
- review?.scopeRevision
170
- && revisionsMatch(
171
- "applicability-scope",
172
- review.scopeRevision,
173
- applicabilityScopeRevision(record, program, resources, model)
174
- )
175
- );
168
+ if (!review?.scopeRevision) return false;
169
+ const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
170
+ if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
171
+ // Reviews written before the scope fix included Policy lifecycle fields. Accept
172
+ // that earlier fingerprint only when its material inputs still match.
173
+ if (matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "current" }))) return true;
174
+ return resources.some(({ type, status }) => type === "policy" && ["in-review", "active"].includes(status))
175
+ && matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "approved" }));
176
176
  }
177
177
 
178
178
  function pick(record, fields, model, objectType = null) {
@@ -1831,6 +1831,9 @@ function item(id, status, title, message, resource = {}, options = {}) {
1831
1831
  message,
1832
1832
  ...(resource.type ? { resourceType: resource.type } : {}),
1833
1833
  ...(resource.id ? { resourceId: resource.id } : {}),
1834
+ ...(resource.type ? { destination: resource.id
1835
+ ? { kind: "record", resourceType: resource.type, resourceId: resource.id }
1836
+ : { kind: "collection", resourceType: resource.type } } : {}),
1834
1837
  ...(options.commands?.length ? { commands: options.commands } : {}),
1835
1838
  ...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
1836
1839
  ...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
@@ -40,6 +40,8 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
40
40
  && applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
41
41
  ))
42
42
  .map(({ requirementId }) => requirementId));
43
+ const requirementReviews = new Map((program.requirementApplicability || [])
44
+ .map((review) => [review.requirementId, review]));
43
45
  const records = loaded.resources.filter((record) => (
44
46
  REVIEWABLE_TYPES.has(record.type)
45
47
  && (!requestedType || record.type === requestedType)
@@ -56,10 +58,18 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
56
58
  .sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
57
59
  .map((record) => {
58
60
  const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
61
+ const saved = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
62
+ ? requirementReviews.get(record.id)
63
+ : record.applicabilityReview;
64
+ const prior = saved && ["applicable", "not-applicable", "externally-managed", "zero-population"].includes(saved.decision)
65
+ ? saved : null;
59
66
  return {
60
67
  id: record.id,
61
- decision: constraint?.requiredDecision || null,
62
- rationale: constraint?.defaultRationale || null,
68
+ decision: constraint?.requiredDecision || prior?.decision || null,
69
+ rationale: constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
70
+ ? constraint.defaultRationale || null
71
+ : prior?.rationale || constraint?.defaultRationale || null,
72
+ reviewStatus: prior ? "stale" : "missing",
63
73
  ...(constraint ? { constraint } : {})
64
74
  };
65
75
  })
@@ -493,12 +493,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
493
493
  const requirementById = new Map(records
494
494
  .filter(({ type }) => type === "requirement")
495
495
  .map((record) => [record.id, record]));
496
- const v4Decisions = new Map((workspace?.requirementApplicability || [])
496
+ const reviewedRequirements = (workspace?.requirementApplicability || [])
497
497
  .filter((decision) => (
498
498
  requirementById.has(decision.requirementId)
499
499
  && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
500
- ))
501
- .map((decision) => [decision.requirementId, decision.decision]));
500
+ ));
501
+ const v4Decisions = new Map(reviewedRequirements.map((decision) => [decision.requirementId, decision.decision]));
502
+ const staleRequirementIds = new Set((workspace?.requirementApplicability || [])
503
+ .filter((decision) => requirementById.has(decision.requirementId)
504
+ && ["applicable", "not-applicable"].includes(decision.decision)
505
+ && !v4Decisions.has(decision.requirementId))
506
+ .map(({ requirementId }) => requirementId));
502
507
  const applicableRequirements = records.filter((record) => (
503
508
  record.type === "requirement"
504
509
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
@@ -514,6 +519,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
514
519
  const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
515
520
  const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
516
521
  const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
522
+ const staleRequirements = unresolvedRequirements.filter(({ id }) => staleRequirementIds.has(id));
523
+ const missingDecisionRequirements = unresolvedRequirements.filter(({ id }) => !staleRequirementIds.has(id));
517
524
  const uncoveredRequirements = scope.requirements.filter((requirement) => (
518
525
  !isDescriptionRequirement(requirement)
519
526
  && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
@@ -541,6 +548,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
541
548
  const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
542
549
  v4Decisions.get(requirement.id) !== "applicable"
543
550
  ));
551
+ const invalidMandatoryStaleCount = invalidMandatoryDecisions.filter(({ id }) => staleRequirementIds.has(id)).length;
552
+ const invalidMandatoryOtherCount = invalidMandatoryDecisions.length - invalidMandatoryStaleCount;
544
553
  const criteriaComplete = Boolean(
545
554
  scope.frameworks.length
546
555
  && scope.requirements.length
@@ -564,11 +573,13 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
564
573
  ...missingRequiredDescriptionReferences
565
574
  ].join(", ")}.`
566
575
  : invalidMandatoryDecisions.length
567
- ? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision is" : "decisions are"} missing, undetermined, or not applicable.`
568
- : `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services applicability decisions and ${unresolvedDescriptionRequirements.length} Description Criteria decisions remain undetermined, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
576
+ ? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision needs" : "decisions need"} attention: ${invalidMandatoryStaleCount} prior reviews are stale and ${invalidMandatoryOtherCount} are missing, undetermined, or not applicable.`
577
+ : `Resolve the program criteria and Controls. ${missingDecisionRequirements.length} applicability decisions are missing or undetermined and ${staleRequirements.length} prior reviews are stale. ${unresolvedTrustServicesRequirements.length} Trust Services criteria and ${unresolvedDescriptionRequirements.length} Description Criteria need current decisions, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
569
578
  workspace || { type: "workspace" },
570
579
  {
571
580
  unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
581
+ staleRequirementIds: staleRequirements.map(({ id }) => id),
582
+ missingDecisionRequirementIds: missingDecisionRequirements.map(({ id }) => id),
572
583
  missingRequirementIds: missingRequirements.map(({ id }) => id),
573
584
  uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
574
585
  invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
@@ -763,9 +774,23 @@ export function collectionReviewReadinessItem(assessment) {
763
774
  ? { id: firstIncomplete.resourceId, type: assessment.resourceType }
764
775
  : needsFirstRecord ? { type: assessment.resourceType }
765
776
  : approvalAction ? { type: approvalAction.resourceType, ...(approvalAction.resourceId ? { id: approvalAction.resourceId } : {}) }
766
- : assessment.review || { type: assessment.resourceType },
777
+ : { type: assessment.resourceType },
767
778
  {
768
779
  resourceType: approvalAction?.resourceType || assessment.resourceType,
780
+ ...(!proposalAction && !needsFirstRecord && !approvalAction && assessment.review?.id
781
+ ? { subject: { type: "collection-review", id: assessment.review.id } }
782
+ : {}),
783
+ destination: proposalAction
784
+ ? { kind: "record", resourceType: assessment.resourceType, resourceId: firstIncomplete.resourceId }
785
+ : needsFirstRecord
786
+ ? { kind: "collection", resourceType: assessment.resourceType, create: true }
787
+ : approvalAction
788
+ ? approvalAction.reviewCollection
789
+ ? { kind: "collection-review", resourceType: approvalAction.resourceType }
790
+ : approvalAction.resourceId
791
+ ? { kind: "record", resourceType: approvalAction.resourceType, resourceId: approvalAction.resourceId }
792
+ : { kind: "collection", resourceType: approvalAction.resourceType, ...(approvalAction.createResource ? { create: true, scheduleDocument: true } : {}) }
793
+ : { kind: "collection-review", resourceType: assessment.resourceType },
769
794
  reviewPoints: assessment.configuration.reviewPoints,
770
795
  ...(approvalAction?.createResource ? { createResource: true } : {}),
771
796
  ...(pendingRetentionRow && !proposalAction ? { unresolvedAssignments: [{
@@ -831,6 +856,7 @@ function collectionApprovalIssueAction(assessment) {
831
856
  if (issue.code === "unreviewed-similar-information-types") return {
832
857
  title: "Review similar Information Types",
833
858
  resourceType: "information-type",
859
+ reviewCollection: true,
834
860
  commands: ["npx filegrc list information-type --workflow --json", "npx filegrc review-collection information-type --scaffold > REVIEW.json", "npx filegrc review-collection information-type REVIEW.json --preview --json", "npx filegrc review-collection information-type REVIEW.json --yes --json"]
835
861
  };
836
862
  return {
package/src/state.js CHANGED
@@ -17,6 +17,7 @@ import { measureTiming } from "./timing.js";
17
17
  import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
18
18
  import { modelSupports } from "../model/index.js";
19
19
  import { calculateRevision } from "./revisions.js";
20
+ import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
20
21
 
21
22
  const renderedMarkdownCache = new Map();
22
23
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -87,6 +88,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
87
88
  obligations: { loading: true, items: [], triggers: [], counts: {} },
88
89
  collectionReviews: {},
89
90
  applicabilityConstraints: {},
91
+ applicabilityReviewStatuses: {},
90
92
  programReadiness: null,
91
93
  auditPreparations: {},
92
94
  workflow: { loading: true, findings: [], workItems: [], assessments: {} },
@@ -156,7 +158,8 @@ export async function createAppStateSection(input, section, options = {}) {
156
158
  applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
157
159
  const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
158
160
  return constraint ? [[record.id, constraint]] : [];
159
- }))
161
+ })),
162
+ applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram)
160
163
  };
161
164
  }
162
165
  if (section === "obligations") {
@@ -330,6 +333,7 @@ async function createAppStateUnlocked(input, options) {
330
333
  reconciliation,
331
334
  collectionReviews,
332
335
  applicabilityConstraints,
336
+ applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram),
333
337
  programReadiness,
334
338
  auditPreparations,
335
339
  workflow,
@@ -337,6 +341,21 @@ async function createAppStateUnlocked(input, options) {
337
341
  };
338
342
  }
339
343
 
344
+ function assessApplicabilityReviewStatuses(loaded, program) {
345
+ const decisions = new Map((program.requirementApplicability || []).map((review) => [review.requirementId, review]));
346
+ return Object.fromEntries(loaded.resources
347
+ .filter(({ type }) => ["requirement", "control", "commitment", "complementary-control"].includes(type))
348
+ .map((record) => {
349
+ const review = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
350
+ ? decisions.get(record.id)
351
+ : record.applicabilityReview;
352
+ return [record.id, !review || !["applicable", "not-applicable", "externally-managed", "zero-population"].includes(review.decision)
353
+ ? "missing" : applicabilityReviewIsCurrent(
354
+ review, record, program, loaded.resources, loaded.model
355
+ ) ? "current" : "stale"];
356
+ }));
357
+ }
358
+
340
359
  function serializeCollectionReviewAssessment(assessment) {
341
360
  return {
342
361
  resourceType: assessment.resourceType,
package/src/web.js CHANGED
@@ -53,6 +53,38 @@ export function pageActionIdentity(href = "", fallback = "") {
53
53
  return route.startsWith("#/resource/") ? route : fallback || href;
54
54
  }
55
55
 
56
+ export function resolveWorkflowDestination(destination, options = {}) {
57
+ if (!destination?.resourceType) return "";
58
+ if (options.resourceTypes && !options.resourceTypes[destination.resourceType]) return "";
59
+ const type = encodeURIComponent(destination.resourceType);
60
+ if (destination.kind === "record") {
61
+ const exists = !options.resources || options.resources.some((entry) => {
62
+ const record = entry.record || entry;
63
+ return record.type === destination.resourceType && record.id === destination.resourceId;
64
+ });
65
+ return destination.resourceId && exists
66
+ ? `#/resource/${type}/${encodeURIComponent(destination.resourceId)}${destination.edit ? "?edit=1" : ""}`
67
+ : resolveWorkflowDestination({ kind: "collection", resourceType: destination.resourceType }, options);
68
+ }
69
+ if (destination.kind === "collection-review") {
70
+ if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
71
+ return "#/retention-schedule";
72
+ }
73
+ return `#/resources/${type}?review-collection=1`;
74
+ }
75
+ if (destination.kind === "collection") {
76
+ const create = destination.create && !options.readOnly;
77
+ if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
78
+ return create ? "#/retention-schedule?new=1" : "#/retention-schedule";
79
+ }
80
+ if (destination.resourceType === "document" && create && destination.scheduleDocument) {
81
+ return "#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule";
82
+ }
83
+ return `#/resources/${type}${create ? "?new=1" : ""}`;
84
+ }
85
+ return "";
86
+ }
87
+
56
88
  export function renderIndex(state = null) {
57
89
  const snapshot = state
58
90
  ? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
@@ -100,6 +132,7 @@ const RESOURCE_GUIDE_OUTPUTS = ${JSON.stringify(RESOURCE_OUTPUTS)};
100
132
  const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
101
133
  const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
102
134
  const pageActionIdentity = ${pageActionIdentity.toString()};
135
+ const resolveWorkflowDestination = ${resolveWorkflowDestination.toString()};
103
136
  const selectDefaultAudit = ${selectDefaultAudit.toString()};
104
137
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
105
138
  ...RESOURCE_PAGE_SUMMARIES,
@@ -247,7 +280,8 @@ function normalizeAppState(next) {
247
280
  function blockingStateSections(route) {
248
281
  if (route.name === "home") return ["program", "obligations", "workflow"];
249
282
  if (route.name === "repository") return ["repository"];
250
- if (route.name === "list" && route.type === "control") return ["program"];
283
+ if (route.name === "list" && ["requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
284
+ if (route.name === "detail" && ["program", "requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
251
285
  if (route.name === "list" && route.type === "obligation") return ["obligations"];
252
286
  if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
253
287
  if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
@@ -416,12 +450,6 @@ function activeProgram() {
416
450
  || state.workspace;
417
451
  }
418
452
 
419
- function reviewedRequirementIds() {
420
- return new Set((activeProgram().requirementApplicability || [])
421
- .filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
422
- .map(({ requirementId }) => requirementId));
423
- }
424
-
425
453
  function renderSidebarUtility(utility, route, direct = false) {
426
454
  const directClass = direct ? "nav-direct " : "";
427
455
  if (utility === "obligation-board") return "";
@@ -791,11 +819,7 @@ function renderRetentionScheduleIssues() {
791
819
  ));
792
820
  if (!actions.length) return "";
793
821
  return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
794
- const href = item.href || (item.id?.startsWith("retention-use-")
795
- ? retentionScheduleItemHref(item)
796
- : item.resourceId && item.resourceType
797
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
798
- : '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item"));
822
+ const href = item.href || workflowItemHref(item) || "#/retention-schedule";
799
823
  return '<a href="' + href + '"><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span><b>Review</b></a>';
800
824
  }).join("") + '</div></section>';
801
825
  }
@@ -1291,9 +1315,7 @@ function renderFinishStepThree() {
1291
1315
  ));
1292
1316
  const next = implementationItems.find(({ status }) => status !== "complete");
1293
1317
  if (!oversightItem) {
1294
- const href = next?.id?.startsWith("collection-review-") && next.resourceType
1295
- ? "#/resources/" + encodeURIComponent(next.resourceType) + "?review-collection=1"
1296
- : next?.id?.startsWith("source-family-")
1318
+ const href = next?.id?.startsWith("source-family-")
1297
1319
  ? "#/evidence-sources"
1298
1320
  : next ? workflowItemHref({ ...next, state: next.status }) || "#/resources/control" : "#/resources/control";
1299
1321
  return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(next?.message || "Finish the Step 3 work areas before the final review becomes available.") + '</p></div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
@@ -1495,6 +1517,16 @@ function openCollectionReviewDialog(type) {
1495
1517
  }
1496
1518
 
1497
1519
  function workflowItemHref(item) {
1520
+ const destinationOptions = {
1521
+ retentionScheduleApproval: modelSupports("retention-schedule-approval"),
1522
+ readOnly: state.readOnly,
1523
+ resourceTypes: state.model.resources,
1524
+ resources: state.resources
1525
+ };
1526
+ const destinationHref = (destination) => resolveWorkflowDestination(destination, destinationOptions);
1527
+ if (item.destination) {
1528
+ return destinationHref(item.destination);
1529
+ }
1498
1530
  const source = item.source?.id && item.source?.type && item.source.type !== "unknown"
1499
1531
  ? item.source
1500
1532
  : null;
@@ -1523,19 +1555,18 @@ function workflowItemHref(item) {
1523
1555
  && item.subject?.id
1524
1556
  && state.resources.some(({ record }) => record.type === applicabilityType && record.id === item.subject.id)
1525
1557
  ) {
1526
- return "#/resource/" + encodeURIComponent(applicabilityType) + "/" + encodeURIComponent(item.subject.id);
1558
+ return destinationHref({ kind: "record", resourceType: applicabilityType, resourceId: item.subject.id });
1527
1559
  }
1528
1560
  if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
1529
1561
  return retentionScheduleItemHref(item);
1530
1562
  }
1531
1563
  if (applicabilityType && state.model.resources[applicabilityType]) {
1532
- return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
1564
+ return destinationHref({ kind: "collection", resourceType: applicabilityType }) + "?review=1";
1533
1565
  }
1534
1566
  const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
1535
1567
  const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
1536
1568
  if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
1537
- if (collectionReviewType === "retention-schedule-item" && modelSupports("retention-schedule-approval")) return "#/retention-schedule";
1538
- return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
1569
+ return destinationHref({ kind: "collection-review", resourceType: collectionReviewType });
1539
1570
  }
1540
1571
  if (
1541
1572
  commands.some((command) => command.includes(" get ") && command.includes(" --mutation"))
@@ -1543,25 +1574,25 @@ function workflowItemHref(item) {
1543
1574
  && item.subject?.type
1544
1575
  && state.model.resources[item.subject.type]
1545
1576
  ) {
1546
- return "#/resource/" + encodeURIComponent(item.subject.type) + "/" + encodeURIComponent(item.subject.id) + "?edit=1";
1577
+ return destinationHref({ kind: "record", resourceType: item.subject.type, resourceId: item.subject.id, edit: true });
1547
1578
  }
1548
1579
  if (commands.some((command) => command.includes(" external-reviewer-setup"))) {
1549
1580
  const reviewer = resourcesOfType("appointment")
1550
1581
  .find(({ record }) => record.appointmentKind === "independent-policy-reviewer");
1551
- if (reviewer) return "#/resource/appointment/" + encodeURIComponent(reviewer.record.id);
1582
+ if (reviewer) return destinationHref({ kind: "record", resourceType: "appointment", resourceId: reviewer.record.id });
1552
1583
  }
1553
1584
  if (commands.some((command) => command.includes(" evidence-map"))) return "#/stage/controls";
1554
1585
  const reference = item.subject?.id && item.subject?.type
1555
1586
  ? item.subject
1556
1587
  : source;
1557
1588
  if (reference && reference.type !== "unknown") {
1558
- return "#/resource/" + encodeURIComponent(reference.type) + "/" + encodeURIComponent(reference.id);
1589
+ return destinationHref({ kind: "record", resourceType: reference.type, resourceId: reference.id });
1559
1590
  }
1560
1591
  const missingType = item.subject?.type && state.model.resources[item.subject.type]
1561
1592
  ? item.subject.type
1562
1593
  : null;
1563
1594
  if (missingType) {
1564
- return "#/resources/" + encodeURIComponent(missingType) + (state.readOnly ? "" : "?new=1");
1595
+ return destinationHref({ kind: "collection", resourceType: missingType, create: !state.readOnly });
1565
1596
  }
1566
1597
  if (item.auditId) return "#/audit-packet?auditId=" + encodeURIComponent(item.auditId);
1567
1598
  const stage = {
@@ -1719,13 +1750,9 @@ function retentionSchedulePageItems() {
1719
1750
  ...item,
1720
1751
  key: item.id,
1721
1752
  state: "ready",
1722
- href: item.id === "collection-review-retention-schedule-item"
1723
- ? retentionCollectionActionHref(item)
1724
- : item.id?.startsWith("retention-use-")
1753
+ href: item.id?.startsWith("retention-use-")
1725
1754
  ? retentionScheduleItemHref(item)
1726
- : item.resourceId && item.resourceType
1727
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
1728
- : workflowItemHref(item) || "#/retention-schedule",
1755
+ : workflowItemHref(item) || "#/retention-schedule",
1729
1756
  subject: item.resourceId && item.resourceType
1730
1757
  ? { type: item.resourceType, id: item.resourceId }
1731
1758
  : item.subject
@@ -1733,19 +1760,6 @@ function retentionSchedulePageItems() {
1733
1760
  ]);
1734
1761
  }
1735
1762
 
1736
- function retentionCollectionActionHref(item) {
1737
- if (item.resourceType === "document") return item.resourceId
1738
- ? '#/resource/document/' + encodeURIComponent(item.resourceId) + '?stage=policies'
1739
- : item.createResource
1740
- ? '#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule'
1741
- : '#/resources/document';
1742
- if (item.resourceType === "information-type") return '#/resources/information-type?review-collection=1';
1743
- if (item.resourceType === "retention-schedule-item" && item.resourceId && item.resourceId !== item.id) {
1744
- return '#/resource/retention-schedule-item/' + encodeURIComponent(item.resourceId) + '?stage=policies';
1745
- }
1746
- return "#/retention-schedule";
1747
- }
1748
-
1749
1763
  function distinctPageActionItems(items) {
1750
1764
  const distinct = new Map();
1751
1765
  for (const item of items) {
@@ -2778,24 +2792,28 @@ function openNextAuditCycleDialog(prior) {
2778
2792
  }
2779
2793
 
2780
2794
  function controlApplicabilityPending(record) {
2795
+ if (state.applicabilityReviewStatuses?.[record.id]) {
2796
+ return state.applicabilityReviewStatuses[record.id] !== "current";
2797
+ }
2781
2798
  if (!record.applicabilityReview) return true;
2782
2799
  const item = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
2783
2800
  .find(({ resourceType, resourceId }) => resourceType === "control" && resourceId === record.id);
2784
2801
  return item?.checks?.applicability === false;
2785
2802
  }
2786
2803
 
2804
+ function applicabilityPending(record) {
2805
+ if (state.applicabilityReviewStatuses?.[record.id]) {
2806
+ return state.applicabilityReviewStatuses[record.id] !== "current";
2807
+ }
2808
+ return record.type === "control" ? controlApplicabilityPending(record)
2809
+ : !record.applicabilityReview || record.type === "requirement" && record.applicability === "undetermined";
2810
+ }
2811
+
2787
2812
  function openApplicabilityReviewDialog(type, entries) {
2788
2813
  const definition = state.model.resources[type];
2789
2814
  const reviewPoints = definition?.guidance?.reviewPoints || [];
2790
2815
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
2791
- const reviewedRequirements = reviewedRequirementIds();
2792
- const pending = entries.filter(({ record }) => (
2793
- type === "requirement" && modelSupports("program-scope")
2794
- ? !reviewedRequirements.has(record.id)
2795
- : type === "control"
2796
- ? controlApplicabilityPending(record)
2797
- : !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
2798
- ));
2816
+ const pending = entries.filter(({ record }) => applicabilityPending(record));
2799
2817
  const dialog = document.createElement("dialog");
2800
2818
  dialog.className = "commit-dialog applicability-dialog";
2801
2819
  dialog.setAttribute("aria-labelledby", "applicability-dialog-title");
@@ -2805,10 +2823,19 @@ function openApplicabilityReviewDialog(type, entries) {
2805
2823
  const decisions = constraint?.allowedDecisions || (type === "requirement"
2806
2824
  ? ["applicable", "not-applicable"]
2807
2825
  : ["applicable", "not-applicable", "externally-managed", "zero-population"]);
2808
- const selected = constraint?.requiredDecision || "";
2826
+ const prior = type === "requirement" && modelSupports("program-scope")
2827
+ ? (activeProgram().requirementApplicability || []).find(({ requirementId }) => requirementId === entry.record.id)
2828
+ : entry.record.applicabilityReview;
2829
+ const selected = constraint?.requiredDecision || (state.applicabilityReviewStatuses?.[entry.record.id] === "stale" ? prior?.decision : "") || "";
2830
+ const rationale = constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
2831
+ ? constraint.defaultRationale || ""
2832
+ : state.applicabilityReviewStatuses?.[entry.record.id] === "stale"
2833
+ ? prior?.rationale || constraint?.defaultRationale || ""
2834
+ : constraint?.defaultRationale || "";
2809
2835
  const options = decisions.map((decision) => '<option value="' + esc(decision) + '" ' + (decision === selected ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>').join("");
2810
2836
  const note = constraint ? '<small class="applicability-constraint">' + esc(constraint.message) + '</small>' : "";
2811
- return '<div class="applicability-row" data-review-id="' + esc(entry.record.id) + '"><div><strong>' + esc(entry.record.title) + '</strong><small>' + esc(entry.record.code || entry.record.id) + '</small>' + note + '</div><select name="decision"><option value="">Review later</option>' + options + '</select><input name="rationale" value="' + esc(constraint?.defaultRationale || "") + '" placeholder="Decision rationale"></div>';
2837
+ const stale = state.applicabilityReviewStatuses?.[entry.record.id] === "stale";
2838
+ return '<div class="applicability-row" data-review-id="' + esc(entry.record.id) + '"><div><strong>' + esc(entry.record.title) + '</strong><small>' + esc(entry.record.code || entry.record.id) + (stale ? '<small>Prior review is stale. Confirm this decision against the current scope.</small>' : '') + note + '</div><select name="decision"><option value="">Review later</option>' + options + '</select><input name="rationale" value="' + esc(rationale) + '" placeholder="Decision rationale"></div>';
2812
2839
  }).join("");
2813
2840
  const reviewChecks = reviewPoints.length
2814
2841
  ? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
@@ -2816,7 +2843,7 @@ function openApplicabilityReviewDialog(type, entries) {
2816
2843
  const baselineNote = constrainedCount
2817
2844
  ? '<p class="applicability-baseline-note"><strong>' + constrainedCount + ' required baseline ' + pluralize("criterion", constrainedCount) + '.</strong> FileGRC has prefilled the only decision compatible with the selected SOC 2 Security goal and a review rationale. Leave an item at Review later only when management is not ready to record it.</p>'
2818
2845
  : '<p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>';
2819
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
2846
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">' + (pending.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale") ? 'Reconfirm' : 'Review') + ' ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
2820
2847
  document.body.append(dialog);
2821
2848
  dialog.showModal();
2822
2849
  const form = dialog.querySelector("form");
@@ -3009,11 +3036,7 @@ function renderAuditPreparation(preparation) {
3009
3036
  const status = preparation.status === "management-ready" ? "Management work complete" : preparation.counts.action + " actions remaining";
3010
3037
  const stages = preparation.stages.map((stage) => {
3011
3038
  const items = stage.items.map((item) => {
3012
- const href = item.resourceId
3013
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
3014
- : item.resourceType
3015
- ? '#/resources/' + encodeURIComponent(item.resourceType)
3016
- : "";
3039
+ const href = workflowItemHref(item);
3017
3040
  const content = '<span class="preparation-status ' + esc(item.status) + '" aria-hidden="true">' + preparationStatusMark(item.status) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span>';
3018
3041
  return href ? '<a href="' + href + '">' + content + '</a>' : '<div>' + content + '</div>';
3019
3042
  }).join("");
@@ -3139,18 +3162,12 @@ function renderList(main, type, params = new URLSearchParams()) {
3139
3162
  return { name, label: field.label || humanize(name), values };
3140
3163
  }).filter(({ values }) => values.length > 1);
3141
3164
  const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
3142
- const reviewedRequirements = reviewedRequirementIds();
3143
- const hasPendingApplicability = entries.some(({ record }) => (
3144
- type === "requirement" && modelSupports("program-scope")
3145
- ? !reviewedRequirements.has(record.id)
3146
- : type === "control"
3147
- ? controlApplicabilityPending(record)
3148
- : !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
3149
- ));
3165
+ const hasPendingApplicability = entries.some(({ record }) => applicabilityPending(record));
3166
+ const hasStaleApplicability = entries.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale");
3150
3167
  const applicabilityButton = !state.readOnly
3151
3168
  && ["requirement", "control", "commitment", "complementary-control"].includes(type)
3152
3169
  && hasPendingApplicability
3153
- ? '<button class="button" id="review-applicability">Review applicability</button>'
3170
+ ? '<button class="button" id="review-applicability">' + (hasStaleApplicability ? 'Reconfirm applicability' : 'Review applicability') + '</button>'
3154
3171
  : "";
3155
3172
  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>';
3156
3173
  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>' +
@@ -3299,6 +3316,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3299
3316
  && !fields[name]?.content
3300
3317
  && !narrativeNames.has(name)
3301
3318
  ));
3319
+ if (type === "program" && !Object.hasOwn(entry.record, "requirementApplicability")) {
3320
+ visible.push(["requirementApplicability", []]);
3321
+ }
3302
3322
  const content = Object.entries(entry.content);
3303
3323
  const narrativeContent = narrative.length
3304
3324
  ? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
@@ -3353,7 +3373,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3353
3373
  : "";
3354
3374
  const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
3355
3375
  const recordFieldsPanel = visible.length
3356
- ? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
3376
+ ? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type, false, entry.record) + '</dd></div>').join("") + '</dl></section>'
3357
3377
  : "";
3358
3378
  const supportPanels = renderDetailSupport({
3359
3379
  hasRecordBody,
@@ -3362,9 +3382,17 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3362
3382
  attachmentPanel
3363
3383
  });
3364
3384
  const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
3385
+ const applicabilityStatus = state.applicabilityReviewStatuses?.[entry.record.id];
3386
+ const applicabilityAction = !state.readOnly && ["requirement", "control", "commitment", "complementary-control"].includes(type)
3387
+ && ["stale", "missing"].includes(applicabilityStatus)
3388
+ ? '<a class="button" href="#/resources/' + encodeURIComponent(type) + '?review=1">' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability</a>'
3389
+ : "";
3365
3390
  const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
3366
- main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence &amp; Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
3391
+ main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence &amp; Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
3367
3392
  main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
3393
+ main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => {
3394
+ selectProgram(entry.record.id, "#/resources/requirement?review=1");
3395
+ });
3368
3396
  main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
3369
3397
  main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
3370
3398
  button.closest("details")?.removeAttribute("open");
@@ -5770,17 +5798,21 @@ function openContentEditor(entry, name) {
5770
5798
  });
5771
5799
  }
5772
5800
 
5801
+ async function selectProgram(programId, destination = null) {
5802
+ const selectionGeneration = ++programSelectionGeneration;
5803
+ const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
5804
+ if (!response.ok) throw new Error(await responseMessage(response));
5805
+ if (selectionGeneration !== programSelectionGeneration) return;
5806
+ state = normalizeAppState(await response.json());
5807
+ stateSectionRequests.clear();
5808
+ if (destination) history.replaceState(null, "", destination);
5809
+ render();
5810
+ loadStateForRoute();
5811
+ }
5812
+
5773
5813
  function bindCommon() {
5774
- root.querySelector("[data-program-select]")?.addEventListener("change", async (event) => {
5775
- const programId = event.currentTarget.value;
5776
- const selectionGeneration = ++programSelectionGeneration;
5777
- const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
5778
- if (!response.ok) throw new Error(await responseMessage(response));
5779
- if (selectionGeneration !== programSelectionGeneration) return;
5780
- state = normalizeAppState(await response.json());
5781
- stateSectionRequests.clear();
5782
- render();
5783
- loadStateForRoute();
5814
+ root.querySelector("[data-program-select]")?.addEventListener("change", (event) => {
5815
+ selectProgram(event.currentTarget.value);
5784
5816
  });
5785
5817
  root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
5786
5818
  const group = button.closest(".nav-group");
@@ -6070,14 +6102,22 @@ function conditionMatchesValues(condition, valueFor) {
6070
6102
  function conditionValueMatches(actual, expected) {
6071
6103
  return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
6072
6104
  }
6073
- function formatValue(value, field, type, compact = false) {
6105
+ function formatValue(value, field, type, compact = false, record = null) {
6074
6106
  if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
6075
6107
  const definition = fieldDefinition(type, field);
6076
6108
  if (type === "program" && field === "requirementApplicability" && Array.isArray(value)) {
6077
- const reviewed = value.filter(({ decision }) => ["applicable", "not-applicable"].includes(decision)).length;
6078
- const pending = value.length - reviewed;
6079
- const label = reviewed + " reviewed" + (pending ? " · " + pending + " to review" : "");
6080
- return '<a class="tag relation" href="#/resources/requirement' + (pending ? "?review=1" : "") + '">' + esc(label) + '</a>';
6109
+ if (record && record.id !== state.selectedProgramId) {
6110
+ return '<button class="button" type="button" data-select-program-for-review="' + esc(record.id) + '">Select this Program to review applicability</button>';
6111
+ }
6112
+ const frameworkIds = new Set(record?.frameworkIds || activeProgram().frameworkIds || []);
6113
+ const requirementIds = state.resources.filter(({ record: candidate }) => (
6114
+ candidate.type === "requirement" && frameworkIds.has(candidate.frameworkId)
6115
+ )).map(({ record: candidate }) => candidate.id);
6116
+ const reviewed = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "current").length;
6117
+ const stale = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "stale").length;
6118
+ const pending = requirementIds.length - reviewed - stale;
6119
+ const label = reviewed + " reviewed" + (stale ? " · " + stale + " stale" : "") + (pending ? " · " + pending + " to review" : "");
6120
+ return '<a class="tag relation" href="#/resources/requirement' + (stale || pending ? "?review=1" : "") + '">' + esc(label) + '</a>';
6081
6121
  }
6082
6122
  if (definition?.type === "date") return esc(formatCalendarDate(value));
6083
6123
  if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
package/src/workflow.js CHANGED
@@ -1115,9 +1115,9 @@ export function packetDeliveryIssue(delivery) {
1115
1115
 
1116
1116
  function normalizeFinding(code, item, context) {
1117
1117
  const state = findingState(item.status);
1118
- const subject = item.resourceType
1118
+ const subject = item.subject || (item.resourceType
1119
1119
  ? { type: item.resourceType, ...(item.resourceId ? { id: item.resourceId } : {}) }
1120
- : null;
1120
+ : null);
1121
1121
  const dependencies = (item.unresolvedAssignments || []).map((assignment) => ({
1122
1122
  type: assignment.resourceType,
1123
1123
  id: assignment.resourceId,
@@ -1138,6 +1138,7 @@ function normalizeFinding(code, item, context) {
1138
1138
  .filter(([, passed]) => passed === false)
1139
1139
  .map(([name]) => name) } : {}),
1140
1140
  ...(item.resourceId ? { resourceId: item.resourceId } : {}),
1141
+ ...(item.destination ? { destination: item.destination } : {}),
1141
1142
  ...(item.createResourceType ? { createResourceType: item.createResourceType } : {}),
1142
1143
  ...(item.sourceResourceIds ? { sourceResourceIds: item.sourceResourceIds } : {}),
1143
1144
  ...(item.informationTypeId ? { informationTypeId: item.informationTypeId } : {}),