filegrc 0.9.2 → 0.11.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
@@ -88,6 +88,7 @@ let onboardingSetupOnly = false;
88
88
  let onboardingStillWorkingTimer = null;
89
89
  let onboardingPendingDraft = false;
90
90
  const resourceDetailRequests = new Map();
91
+ const stateSectionRequests = new Map();
91
92
  let resourceGuideCleanup = null;
92
93
  let repositorySyncPollTimer = null;
93
94
  let repositorySyncPollInFlight = false;
@@ -101,15 +102,20 @@ start().catch((error) => {
101
102
 
102
103
  async function start() {
103
104
  const embedded = document.querySelector("#filegrc-data");
104
- state = embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state");
105
- window.addEventListener("hashchange", render);
105
+ state = normalizeAppState(embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state/bootstrap"));
106
+ window.addEventListener("hashchange", handleRouteChange);
106
107
  window.addEventListener("resize", positionCurrentOnboarding);
107
108
  window.addEventListener("scroll", positionCurrentOnboarding, true);
108
109
  render();
110
+ loadStateForRoute();
109
111
  scheduleRepositorySyncPoll();
110
- if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
111
- queueMicrotask(requestOnboarding);
112
- }
112
+ maybeRequestOnboarding();
113
+ }
114
+
115
+ function handleRouteChange() {
116
+ document.querySelectorAll("dialog.editor[open], dialog.commit-dialog[open], dialog.alert-dialog[open]").forEach((dialog) => dialog.close());
117
+ render();
118
+ loadStateForRoute();
113
119
  }
114
120
 
115
121
  function render() {
@@ -123,7 +129,9 @@ function render() {
123
129
  const nextNavigation = root.querySelector(".sidebar-nav");
124
130
  if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
125
131
  const main = root.querySelector("main");
126
- if (route.name === "home") renderHome(main);
132
+ const waitingFor = blockingStateSections(route).filter((section) => state.sections?.[section] !== "complete");
133
+ if (waitingFor.length) renderStateLoading(main, route, waitingFor);
134
+ else if (route.name === "home") renderHome(main);
127
135
  else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
128
136
  else if (route.name === "obligations") renderObligations(main, route.params);
129
137
  else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
@@ -135,6 +143,83 @@ function render() {
135
143
  bindCommon();
136
144
  }
137
145
 
146
+ function normalizeAppState(next) {
147
+ if (next.sections) return next;
148
+ return {
149
+ ...next,
150
+ stateToken: null,
151
+ sections: {
152
+ repository: "complete",
153
+ program: "complete",
154
+ obligations: "complete",
155
+ audits: "complete",
156
+ workflow: "complete"
157
+ }
158
+ };
159
+ }
160
+
161
+ function blockingStateSections(route) {
162
+ if (route.name === "home") return ["program", "obligations", "workflow"];
163
+ if (route.name === "repository") return ["repository"];
164
+ if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
165
+ if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
166
+ if (route.name === "stage" && route.stageId === "audit") return ["program", "workflow", "audits"];
167
+ if (route.name === "stage") return ["program", "workflow"];
168
+ return [];
169
+ }
170
+
171
+ function desiredStateSections(route) {
172
+ const sections = new Set(["repository", ...blockingStateSections(route)]);
173
+ if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
174
+ if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
175
+ if (route.name === "detail" && ["policy", "document", "framework", "requirement", "commitment", "system", "component", "vendor", "information-type", "source-coverage", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("workflow");
176
+ if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
177
+ if (route.name === "detail" && route.type === "audit") sections.add("audits");
178
+ return [...sections];
179
+ }
180
+
181
+ function renderStateLoading(main, route, sections) {
182
+ const failed = sections.find((section) => state.sections?.[section] === "error");
183
+ const title = failed ? "Could Not Finish Loading" : route.name === "home" ? "Loading Program Overview" : "Loading Current Workspace State";
184
+ const message = failed
185
+ ? state.sectionErrors?.[failed] || "Reload the workspace and try again."
186
+ : "Records are ready. FileGRC is calculating the information needed for this page.";
187
+ main.innerHTML = '<div class="page"><section class="panel state-loading" role="status" aria-live="polite"><p class="kicker">' + (failed ? "Loading error" : "One moment") + '</p><h2>' + esc(title) + '</h2><p>' + esc(message) + '</p></section></div>';
188
+ }
189
+
190
+ function loadStateForRoute() {
191
+ const route = parseRoute();
192
+ for (const section of desiredStateSections(route)) loadStateSection(section);
193
+ }
194
+
195
+ function loadStateSection(section) {
196
+ if (!state.stateToken || state.sections?.[section] === "complete") return Promise.resolve();
197
+ if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
198
+ state.sections[section] = "loading";
199
+ const token = state.stateToken;
200
+ const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token))
201
+ .then((result) => {
202
+ if (state.stateToken !== result.stateToken) return;
203
+ Object.assign(state, result.state);
204
+ state.sections[section] = "complete";
205
+ render();
206
+ loadStateForRoute();
207
+ scheduleRepositorySyncPoll();
208
+ if (section === "repository") maybeRequestOnboarding();
209
+ })
210
+ .catch((error) => {
211
+ if (state.stateToken !== token) return;
212
+ state.sections[section] = "error";
213
+ state.sectionErrors = { ...(state.sectionErrors || {}), [section]: error.message };
214
+ render();
215
+ })
216
+ .finally(() => {
217
+ if (stateSectionRequests.get(section) === request) stateSectionRequests.delete(section);
218
+ });
219
+ stateSectionRequests.set(section, request);
220
+ return request;
221
+ }
222
+
138
223
  function parseRoute() {
139
224
  const [path, query = ""] = location.hash.replace(/^#\/?/, "").split("?", 2);
140
225
  let parts;
@@ -249,16 +334,33 @@ function topbar(route) {
249
334
  : route.name === "list" && route.type === "document"
250
335
  ? documentListTitle(route.params)
251
336
  : state.model.resources[route.type]?.pluralTitle || "filegrc";
252
- const repositoryLabel = state.repository?.mode === "trunk"
337
+ const repositoryLoading = state.sections?.repository !== "complete";
338
+ const repositoryError = state.sections?.repository === "error";
339
+ const repositoryLabel = repositoryError
340
+ ? "Git check failed"
341
+ : repositoryLoading
342
+ ? "Checking Git"
343
+ : state.repository?.mode === "trunk"
253
344
  ? state.repository.label
254
345
  : state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
255
- const repositoryTone = state.repository?.mode === "trunk"
346
+ const repositoryTone = repositoryError
347
+ ? "warn"
348
+ : repositoryLoading
349
+ ? "neutral"
350
+ : state.repository?.mode === "trunk"
256
351
  ? repositoryStatusTone(state.repository.status)
257
352
  : state.git.clean ? "good" : "warn";
258
- return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><div class="topbar-status">' + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
353
+ const validationLoading = state.sections?.repository !== "complete";
354
+ const validationTone = repositoryError ? "warn" : validationLoading ? "neutral" : state.validation.ok ? "good" : "bad";
355
+ const validationLabel = repositoryError ? "Data check failed" : validationLoading ? "Checking data" : state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors";
356
+ return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><div class="topbar-status">' + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip" href="#/repository"><span class="status-dot ' + validationTone + '"></span>' + validationLabel + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
259
357
  }
260
358
 
261
359
  function topbarProgramReadiness() {
360
+ if (state.sections?.program !== "complete") {
361
+ const label = state.sections?.program === "loading" ? "…" : "View";
362
+ return '<a class="topbar-readiness" href="#/"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
363
+ }
262
364
  const progress = dashboardProgramReadiness(state.programReadiness);
263
365
  const detail = progress.complete + " of " + progress.total + " readiness items complete";
264
366
  return '<a class="topbar-readiness" href="' + nextProgramStageHref() + '" aria-label="' + esc("Program readiness: " + progress.percent + "%. " + detail + ". " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
@@ -287,20 +389,25 @@ function renderHome(main) {
287
389
  const setupPending = !setupSystem
288
390
  || setupSystem.status !== "active"
289
391
  || activeProgram().assuranceGoal === "none";
290
- const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
291
- const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
392
+ const acceptedEventTriggers = activePolicyEventTriggers(state.obligations.triggers);
393
+ const openObligations = activeOperationItems(state.obligations.items);
292
394
  const previewObligations = distinctObligationPreviews(openObligations, 3);
293
- const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
294
395
  const setupBanner = setupPending ? initialSetupBanner() : "";
295
396
  const auditPanel = program.evidenceReady
296
397
  ? '<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Optional next phase</p><h3>' + esc(activeFirm ? titleCase(activeAudit.record.title) : "Target: " + program.target.label) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Engagements</a>') + '</div>' +
297
398
  (activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
298
399
  : "";
400
+ const obligationPanel = program.evidenceReady || openObligations.length
401
+ ? '<section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>Due Windows</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>'
402
+ : "";
403
+ const eventPanel = acceptedEventTriggers.length
404
+ ? '<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">Trigger work</a></div>' + eventReminderPreview(orderedPolicyEventTriggers(acceptedEventTriggers).slice(0, 4)) + '</section>'
405
+ : "";
406
+ const operationPanels = obligationPanel + eventPanel;
407
+ const overviewPanels = operationPanels + auditPanel;
299
408
  main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
300
- '<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
301
- '<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(orderedPolicyEventTriggers(state.obligations.triggers).slice(0, 4)) + '</section>' +
302
- auditPanel + '</div></div>';
303
- main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: Boolean(initialSetupSystem()) }));
409
+ (overviewPanels ? '<div class="overview-grid">' + overviewPanels + '</div>' : '') + '</div>';
410
+ main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: true }));
304
411
  }
305
412
 
306
413
  function initialSetupSystem() {
@@ -359,7 +466,7 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
359
466
  const progress = stageProgress(stage);
360
467
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
361
468
  '<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>' +
362
- (stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
469
+ (stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderRetentionReadiness() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
363
470
  main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
364
471
  main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
365
472
  event.currentTarget.remove();
@@ -767,7 +874,8 @@ function recordWorkflowItems(type, id) {
767
874
  const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
768
875
  return [
769
876
  ...(state.workflow?.findings || []),
770
- ...(state.workflow?.workItems || [])
877
+ ...(state.workflow?.workItems || []),
878
+ ...programReadinessWorkflowItems()
771
879
  ].filter((item) => (
772
880
  activeStates.has(item.state)
773
881
  && (
@@ -782,6 +890,9 @@ function recordWorkflowItems(type, id) {
782
890
  }
783
891
 
784
892
  function recordWorkflowCell(type, entry) {
893
+ if (state.sections?.workflow !== "complete") {
894
+ return '<button class="text-button record-workflow-clear" type="button" data-load-workflow>Calculate action</button>';
895
+ }
785
896
  const items = recordWorkflowItems(type, entry.record.id);
786
897
  if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
787
898
  const item = items[0];
@@ -876,6 +987,14 @@ function workflowItemHref(item) {
876
987
  ...(item.actions || []).map((action) => action.command),
877
988
  item.nextAction?.command
878
989
  ].filter(Boolean);
990
+ if (item.createResourceType && state.model.resources[item.createResourceType]) {
991
+ const params = new URLSearchParams({ new: "1" });
992
+ if (item.title) params.set("title", item.createResourceType === "commitment"
993
+ ? item.title.replace(/^Record commitments from\s+/i, "Commitment from ")
994
+ : item.title.replace(/^Map commitments affected by\s+/i, "Mapping for "));
995
+ for (const id of item.sourceResourceIds || []) params.append("sourceResourceId", id);
996
+ return "#/resources/" + encodeURIComponent(item.createResourceType) + "?" + params;
997
+ }
879
998
  const applicabilityCommand = commands.find((command) => command.includes(" review-applicability "));
880
999
  const applicabilityType = applicabilityCommand?.match(/--type\s+([a-z0-9-]+)/)?.[1];
881
1000
  if (
@@ -886,6 +1005,9 @@ function workflowItemHref(item) {
886
1005
  ) {
887
1006
  return "#/resource/" + encodeURIComponent(applicabilityType) + "/" + encodeURIComponent(item.subject.id);
888
1007
  }
1008
+ if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
1009
+ return retentionScheduleItemHref(item);
1010
+ }
889
1011
  if (applicabilityType && state.model.resources[applicabilityType]) {
890
1012
  return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
891
1013
  }
@@ -936,6 +1058,21 @@ function workflowItemHref(item) {
936
1058
  return stage ? "#/stage/" + stage : "";
937
1059
  }
938
1060
 
1061
+ function retentionScheduleItemHref(item) {
1062
+ const params = new URLSearchParams({ new: "1" });
1063
+ if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
1064
+ if (item.resourceId || item.subject?.id) params.set("scopeResourceId", item.resourceId || item.subject.id);
1065
+ const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
1066
+ if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
1067
+ const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
1068
+ record.documentKind === "schedule"
1069
+ && record.workflowScope !== "engagement"
1070
+ && !["superseded", "retired"].includes(record.status)
1071
+ ));
1072
+ if (scheduleDocuments.length === 1) params.set("scheduleDocumentId", scheduleDocuments[0].record.id);
1073
+ return "#/resources/retention-schedule-item?" + params;
1074
+ }
1075
+
939
1076
  function workflowItemDetail(item) {
940
1077
  if (item.dueOn) return "Due " + item.dueOn;
941
1078
  if (item.availableOn) return "Available " + item.availableOn;
@@ -1010,6 +1147,59 @@ function renderEvidenceReadiness() {
1010
1147
  return '<section class="evidence-map"><div class="evidence-map-head"><div><p class="kicker">Control implementation</p><h2>' + completeCount + ' of ' + items.length + ' evidence ' + (items.length === 1 ? "family" : "families") + ' ready</h2><p>Connect each Control to the ' + sourceLabel + ' that produce its evidence.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/' + sourceType + '">Review ' + sourceLabel + '</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + more + '</section>';
1011
1148
  }
1012
1149
 
1150
+ function retentionReviewItems() {
1151
+ return (state.programReadiness?.stages || []).flatMap((stage) => (
1152
+ (stage.items || []).filter((item) => (
1153
+ item.id?.startsWith("retention-")
1154
+ || item.id?.startsWith("requirement-mapping-")
1155
+ || ["collection-review-information-type", "collection-review-retention-schedule-item"].includes(item.id)
1156
+ )).map((item) => ({ ...item, stage: stage.id }))
1157
+ ));
1158
+ }
1159
+
1160
+ function programReadinessWorkflowItems() {
1161
+ if (state.sections?.workflow === "complete") return [];
1162
+ return retentionReviewItems()
1163
+ .filter((item) => item.status === "action")
1164
+ .map((item) => ({
1165
+ ...item,
1166
+ key: "program-readiness." + item.id,
1167
+ state: "ready",
1168
+ subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "unknown" }
1169
+ }));
1170
+ }
1171
+
1172
+ function renderRetentionReadiness() {
1173
+ const items = retentionReviewItems();
1174
+ if (!items.length) return "";
1175
+ const actions = items.filter((item) => item.status === "action");
1176
+ const cards = actions.map((item) => {
1177
+ const ids = [...new Set([
1178
+ item.resourceId,
1179
+ item.informationTypeId,
1180
+ ...(item.retentionScheduleItemIds || []),
1181
+ ...(item.staleResourceIds || [])
1182
+ ].filter(Boolean))];
1183
+ const references = ids.length
1184
+ ? '<div class="evidence-map-references">' + ids.map((id) => formatReference(id)).join("") + '</div>'
1185
+ : "";
1186
+ const href = item.id?.startsWith("retention-use-")
1187
+ ? retentionScheduleItemHref(item)
1188
+ : item.id?.startsWith("collection-review-")
1189
+ ? '#/resources/' + encodeURIComponent(item.resourceType)
1190
+ : item.resourceId && item.resourceType
1191
+ ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
1192
+ : '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item");
1193
+ return '<article class="evidence-map-card"><div class="evidence-map-card-head"><div><span class="badge warn">Review</span><h3><a href="' + href + '">' + esc(item.title) + '</a></h3></div></div><p>' + esc(item.message) + '</p>' + references + '</article>';
1194
+ });
1195
+ const visibleCount = 6;
1196
+ const visibleCards = cards.slice(0, visibleCount).join("");
1197
+ const moreCards = cards.length > visibleCount
1198
+ ? '<details class="workflow-guidance-more retention-readiness-more"><summary>Show ' + (cards.length - visibleCount) + ' more retention and mapping items</summary><div class="policy-activation-grid workflow-findings-more">' + cards.slice(visibleCount).join("") + '</div></details>'
1199
+ : "";
1200
+ return '<section class="evidence-map retention-readiness"><div class="evidence-map-head"><div><p class="kicker">Information lifecycle</p><h2>' + (items.length - actions.length) + ' of ' + items.length + ' retention and mapping checks current</h2><p>Review Information Types, schedule coverage, and mappings here when source records or processing uses change.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/requirement-mapping">Review mappings</a><a class="button primary" href="#/resources/retention-schedule-item">Review schedule</a></div></div>' + (cards.length ? '<div class="policy-activation-grid">' + visibleCards + '</div>' + moreCards : '<article class="evidence-map-card complete"><span class="badge good">Current</span><p>Every retention decision and Requirement Mapping is bound to its current sources.</p></article>') + '</section>';
1201
+ }
1202
+
1013
1203
  function evidenceSourceCheckLabel(name) {
1014
1204
  return ({
1015
1205
  active: "activate source",
@@ -1112,11 +1302,12 @@ function stagePageItems(stage, destination) {
1112
1302
  const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
1113
1303
  const items = [
1114
1304
  ...(state.workflow?.findings || []),
1115
- ...(state.workflow?.workItems || [])
1305
+ ...(state.workflow?.workItems || []),
1306
+ ...programReadinessWorkflowItems()
1116
1307
  ].filter((item) => (
1117
1308
  activeStates.has(item.state)
1118
1309
  && (
1119
- item.stage === stage.id
1310
+ workflowUiStage(item.stage) === stage.id
1120
1311
  || stage.id === "scope"
1121
1312
  && destination.type === "appointment"
1122
1313
  && item.code === "governance.appointment.independent-policy-reviewer"
@@ -1130,6 +1321,7 @@ function stagePageItems(stage, destination) {
1130
1321
  ));
1131
1322
  }
1132
1323
  return items.filter((item) => {
1324
+ if (destination.type === "program" && item.key === "program.scope.criteria") return false;
1133
1325
  if (
1134
1326
  destination.type === "requirement"
1135
1327
  && item.subject?.type === "requirement"
@@ -1162,6 +1354,10 @@ function stagePageItems(stage, destination) {
1162
1354
  ));
1163
1355
  }
1164
1356
 
1357
+ function workflowUiStage(stageId) {
1358
+ return stageId === "operation" ? "run" : stageId;
1359
+ }
1360
+
1165
1361
  function operationProgress() {
1166
1362
  const program = state.programReadiness;
1167
1363
  const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
@@ -1173,7 +1369,7 @@ function operationProgress() {
1173
1369
  : Boolean(program?.evidenceReady);
1174
1370
  const overdue = state.obligations.counts.overdue || 0;
1175
1371
  const blocked = state.obligations.counts.blocked || 0;
1176
- const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0 && blocked === 0);
1372
+ const complete = Boolean(program?.operating);
1177
1373
  if (complete) {
1178
1374
  return {
1179
1375
  percent: 100,
@@ -1214,6 +1410,19 @@ function operationProgress() {
1214
1410
  detail: "Record the management candidate period start when evidence collection begins."
1215
1411
  };
1216
1412
  }
1413
+ if (program?.evidenceReady && candidateStarted) {
1414
+ const actions = program?.stages?.find((stage) => stage.id === "operation")?.counts?.action || 0;
1415
+ return {
1416
+ percent: 0,
1417
+ complete: 0,
1418
+ total: 1,
1419
+ status: "Needs work",
1420
+ tone: "warn",
1421
+ detail: actions
1422
+ ? actions + " Step 4 " + pluralize("item", actions) + (actions === 1 ? " needs" : " need") + " work."
1423
+ : "Complete the current Step 4 readiness work."
1424
+ };
1425
+ }
1217
1426
  return {
1218
1427
  percent: 0,
1219
1428
  complete: 0,
@@ -1278,42 +1487,67 @@ function renderExternalEvidenceSection() {
1278
1487
  function renderObligations(main, params = new URLSearchParams()) {
1279
1488
  const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
1280
1489
  const plan = state.obligations;
1490
+ const operationLocked = !state.programReadiness?.evidenceReady;
1281
1491
  const visibleCardLimit = 6;
1282
- const sections = ["proposed", "upcoming", "blocked", "due", "overdue"].map((status) => {
1283
- const items = obligationBoardItems(plan.items, status);
1284
- const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
1285
- const more = items.length > visibleCardLimit
1286
- ? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - visibleCardLimit) + ' more</button>'
1492
+ const renderBoard = (items, statuses) => statuses.map((status) => {
1493
+ const statusItems = obligationBoardItems(items, status);
1494
+ const cards = statusItems.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
1495
+ const more = statusItems.length > visibleCardLimit
1496
+ ? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + statusItems.length + '" aria-expanded="false">Show ' + (statusItems.length - visibleCardLimit) + ' more</button>'
1287
1497
  : "";
1288
- return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' + items.length + '</strong></div><div class="obligation-cards">' + (items.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
1498
+ return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' + statusItems.length + '</strong></div><div class="obligation-cards">' + (statusItems.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
1289
1499
  }).join("");
1290
1500
  const eventTriggerLimit = 6;
1291
1501
  const orderedTriggers = orderedPolicyEventTriggers(plan.triggers);
1292
- const triggers = orderedTriggers.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit)).join("");
1293
- const eventMore = orderedTriggers.length > eventTriggerLimit
1294
- ? '<button class="button policy-event-more" type="button" data-expand-policy-events aria-expanded="false">Show ' + (orderedTriggers.length - eventTriggerLimit) + ' more events</button>'
1295
- : "";
1502
+ const acceptedTriggers = activePolicyEventTriggers(orderedTriggers);
1503
+ const proposedTriggers = orderedTriggers.filter(({ programStatus }) => programStatus === "proposed");
1504
+ const currentItems = activeOperationItems(plan.items);
1505
+ const proposedItems = plan.items.filter(({ status }) => status === "proposed");
1506
+ const renderEvents = (items, scope, description) => {
1507
+ const triggers = items.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit, scope)).join("");
1508
+ const eventMore = items.length > eventTriggerLimit
1509
+ ? '<button class="button policy-event-more" type="button" data-expand-policy-events="' + scope + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - eventTriggerLimit) + ' more events</button>'
1510
+ : "";
1511
+ return '<section class="workflow-section event-reminders" data-policy-event-section="' + scope + '"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>' + description + '</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div>' + eventMore + '</section>';
1512
+ };
1513
+ const renderQueue = (items, statuses, description) => '<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>' + description + '</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div><div class="obligation-board">' + renderBoard(items, statuses) + '</div></section>';
1296
1514
  const feedback = policyEventFeedback
1297
1515
  ? '<section class="policy-event-feedback" role="status" aria-live="polite"><span class="status-dot good"></span><div><strong>Work added to the Work Queue</strong><p>' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '</p></div><button class="button" type="button" data-view-added-work>View Work Queue</button><button class="icon-button" type="button" data-dismiss-policy-event-feedback aria-label="Dismiss confirmation">×</button></section>'
1298
1516
  : "";
1517
+ const operationGate = operationLocked
1518
+ ? '<section class="workflow-section operation-gate"><div><p class="kicker">Finish Step 3 first</p><h2>Get the program Evidence Ready</h2><p>Confirm the remaining Controls, schedules, evidence sources, and governed content. Any adopted work and Policy Events that can happen now stay available below.</p></div><a class="button primary" href="#/stage/controls">Continue Evidence Ready work</a></section>'
1519
+ : "";
1520
+ const activeOperation = operationLocked
1521
+ ? (acceptedTriggers.length ? renderEvents(acceptedTriggers, "active", "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.") : "") +
1522
+ (currentItems.length ? renderQueue(currentItems, ["upcoming", "blocked", "due", "overdue"], "Complete work the program has already adopted. Each card shows its due window, source, and next action.") : "")
1523
+ : "";
1524
+ const setupTriggers = operationLocked ? proposedTriggers : orderedTriggers;
1525
+ const setupItems = operationLocked ? proposedItems : plan.items;
1526
+ const setupStatuses = operationLocked ? ["proposed"] : ["proposed", "upcoming", "blocked", "due", "overdue"];
1527
+ const operationSetupOpen = operationLocked ? "" : " open";
1528
+ const operationSetupSummary = operationLocked
1529
+ ? '<summary>Preview ' + proposedTriggers.length + ' proposed Policy Events and ' + proposedItems.length + ' proposed Work Queue items</summary>'
1530
+ : "";
1299
1531
  main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
1300
1532
  '<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(stageProgress(stage)) + '</section>' +
1301
1533
  feedback +
1302
- '<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div>' + eventMore + '</section>' +
1303
- '<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>Complete scheduled work and assigned follow-up here. Each card shows its due window, source, and next action.</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div>' +
1304
- '<div class="obligation-board">' + sections + '</div>' +
1305
- '</section>' + renderExternalEvidenceSection() + '</div>';
1534
+ operationGate +
1535
+ activeOperation +
1536
+ '<details class="operation-setup-preview"' + operationSetupOpen + '>' + operationSetupSummary +
1537
+ renderEvents(setupTriggers, "setup", operationLocked ? "Review these starter workflows. They become available when their governing work is adopted." : "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.") +
1538
+ renderQueue(setupItems, setupStatuses, operationLocked ? "Review proposed schedules before adopting them." : "Complete scheduled work and assigned follow-up here. Each card shows its due window, source, and next action.") +
1539
+ renderExternalEvidenceSection() + '</details></div>';
1306
1540
  main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
1307
1541
  const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
1308
1542
  if (trigger) openObligationEventDialog(trigger);
1309
1543
  }));
1310
- main.querySelector("[data-expand-policy-events]")?.addEventListener("click", (event) => {
1544
+ main.querySelectorAll("[data-expand-policy-events]").forEach((button) => button.addEventListener("click", (event) => {
1311
1545
  const button = event.currentTarget;
1312
1546
  const expanded = button.getAttribute("aria-expanded") === "true";
1313
- main.querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
1547
+ button.closest("[data-policy-event-section]").querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
1314
1548
  button.setAttribute("aria-expanded", String(!expanded));
1315
- button.textContent = expanded ? "Show " + (orderedTriggers.length - eventTriggerLimit) + " more events" : "Show fewer events";
1316
- });
1549
+ button.textContent = expanded ? "Show " + (Number(button.dataset.total) - eventTriggerLimit) + " more events" : "Show fewer events";
1550
+ }));
1317
1551
  main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
1318
1552
  main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
1319
1553
  policyEventFeedback = null;
@@ -1336,9 +1570,9 @@ function renderObligations(main, params = new URLSearchParams()) {
1336
1570
  const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
1337
1571
  if (item) openActionCompletion(item);
1338
1572
  }));
1339
- main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
1573
+ main.querySelectorAll("[data-new-action-item]").forEach((button) => button.addEventListener("click", () => openEditor("action-item", null, {
1340
1574
  description: "Create a task only when follow-up from another record needs its own assignee, deadline, and completion proof. Point it to that source record; it will remain in Work Queue until done or canceled."
1341
- }));
1575
+ })));
1342
1576
  const requestedEvent = params.get("event");
1343
1577
  if (requestedEvent || params.get("section") === "events") {
1344
1578
  queueMicrotask(() => {
@@ -1391,8 +1625,16 @@ function orderedPolicyEventTriggers(triggers) {
1391
1625
  ));
1392
1626
  }
1393
1627
 
1394
- function policyEventTrigger(trigger, index, collapsed = false) {
1395
- const tooltipId = "policy-event-tooltip-" + index;
1628
+ function activePolicyEventTriggers(triggers) {
1629
+ return triggers.filter(({ programStatus }) => programStatus === "accepted");
1630
+ }
1631
+
1632
+ function activeOperationItems(items) {
1633
+ return items.filter(({ status }) => ["upcoming", "blocked", "due", "overdue"].includes(status));
1634
+ }
1635
+
1636
+ function policyEventTrigger(trigger, index, collapsed = false, scope = "events") {
1637
+ const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
1396
1638
  const proposed = trigger.programStatus === "proposed";
1397
1639
  const unavailable = state.readOnly || proposed;
1398
1640
  const availability = proposed
@@ -1473,7 +1715,7 @@ function actionCompletionPlan(item) {
1473
1715
  href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
1474
1716
  };
1475
1717
  }
1476
- const type = state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
1718
+ const type = item.completionType || state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
1477
1719
  if (!type) {
1478
1720
  return {
1479
1721
  blocked: "Review completion type",
@@ -1484,7 +1726,7 @@ function actionCompletionPlan(item) {
1484
1726
  }
1485
1727
 
1486
1728
  function obligationCompletionPlan(item) {
1487
- const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
1729
+ const type = item.completionType || state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
1488
1730
  if (!currentPeopleForParties(item.ownerIds || []).length) {
1489
1731
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
1490
1732
  }
@@ -1552,10 +1794,13 @@ function obligationCompletionSeed(type, item, obligation) {
1552
1794
  return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: reviewerPeople, completedOn: date, outcome: "passed", changesRequired: false, evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
1553
1795
  }
1554
1796
  if (type === "risk-assessment") {
1555
- return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: reviewerPeople, methodology: state.workspace.riskMethodology?.method || "Documented risk methodology", summary: "Assessment completed; link the supporting evidence and resulting risks.", evidenceIds: [], approvedOn: date };
1797
+ return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: reviewerPeople, methodology: activeProgram().riskMethodology?.method || "Documented risk methodology", summary: "Assessment completed; link the supporting evidence and resulting risks.", evidenceIds: [], approvedOn: date };
1556
1798
  }
1557
1799
  if (type === "attestation") {
1558
- return { ...common, status: "completed", subjectResourceIds: [...new Set([obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter(Boolean))], personId: responsiblePeople[0], attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
1800
+ const personId = [...(item.subjectResourceIds || []), ...(obligation.scopeResourceIds || [])].find((id) => state.resources.some(({ record }) => record.id === id && record.type === "person"));
1801
+ const primarySubjectIds = [...new Set([obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter((id) => state.resources.some(({ record }) => record.id === id && ["policy", "document", "training", "action-item"].includes(record.type))))];
1802
+ const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
1803
+ return { ...common, status: "completed", subjectResourceIds, personId, attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
1559
1804
  }
1560
1805
  if (type === "access-review") {
1561
1806
  return { ...common, status: "complete", completedOn: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", evidenceIds: [], approvedByIds: reviewerPeople, approvedOn: date, coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
@@ -1570,15 +1815,23 @@ function obligationCompletionSeed(type, item, obligation) {
1570
1815
  return { ...common, status: "complete", controlId: obligation.controlIds?.[0] || "", testKinds: [item.activityType || "control-operation"], performedBy: "management", testerIds: responsiblePeople, reviewerIds: reviewerPeople, completedOn: date, reviewedOn: date, outcome: "passed", evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
1571
1816
  }
1572
1817
  if (type === "control-activity") {
1818
+ const allowedScopeTypes = new Set(state.model.relationGroups?.["obligation-scope"] || []);
1819
+ const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
1820
+ const validScopeIds = requestedScopeIds.filter((id) => state.resources.some(({ record }) => (
1821
+ record.id === id && allowedScopeTypes.has(record.type)
1822
+ )));
1823
+ const fallbackScopeIds = inScopeSystems.length
1824
+ ? inScopeSystems
1825
+ : (item.controlIds || obligation.controlIds || []).length
1826
+ ? (item.controlIds || obligation.controlIds)
1827
+ : [state.workspace.id];
1573
1828
  return {
1574
1829
  ...common,
1575
1830
  status: "complete",
1576
1831
  profileId: item.completionProfile || item.activityType,
1577
1832
  obligationId: item.obligationId,
1578
1833
  controlIds: item.controlIds || obligation.controlIds || [],
1579
- scopeResourceIds: (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || []).length
1580
- ? (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds)
1581
- : [state.workspace.id],
1834
+ scopeResourceIds: validScopeIds.length ? validScopeIds : fallbackScopeIds,
1582
1835
  performerIds: responsiblePeople,
1583
1836
  completedAt: timestamp,
1584
1837
  method: "",
@@ -2223,9 +2476,26 @@ function renderList(main, type, params = new URLSearchParams()) {
2223
2476
  renderRows();
2224
2477
  syncRoute();
2225
2478
  main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
2479
+ main.querySelector("#record-rows").addEventListener("click", (event) => {
2480
+ if (event.target.closest("[data-load-workflow]")) loadStateSection("workflow");
2481
+ });
2226
2482
  main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
2227
2483
  main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
2228
- if (params.get("new") === "1" && !state.readOnly && !definition.singleton && resourceCreationAllowed(type)) queueMicrotask(() => openEditor(type));
2484
+ if (params.get("new") === "1" && !state.readOnly && !definition.singleton && resourceCreationAllowed(type)) {
2485
+ const relationshipSeed = params.getAll("sourceResourceId").length
2486
+ ? { sourceResourceIds: params.getAll("sourceResourceId") }
2487
+ : {};
2488
+ const seed = type === "retention-schedule-item" ? {
2489
+ ...(params.get("title") ? { title: params.get("title") } : {}),
2490
+ ...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
2491
+ ...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
2492
+ ...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
2493
+ } : {
2494
+ ...(params.get("title") ? { title: params.get("title") } : {}),
2495
+ ...relationshipSeed
2496
+ };
2497
+ queueMicrotask(() => openEditor(type, null, { seed }));
2498
+ }
2229
2499
  if (params.get("review") === "1" && !state.readOnly) {
2230
2500
  queueMicrotask(() => main.querySelector("#review-applicability")?.click());
2231
2501
  }
@@ -2888,6 +3158,12 @@ function rendererSettingsEntry() {
2888
3158
  return state.resources.find(({ record }) => record.type === "renderer-settings");
2889
3159
  }
2890
3160
 
3161
+ function maybeRequestOnboarding() {
3162
+ if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
3163
+ queueMicrotask(requestOnboarding);
3164
+ }
3165
+ }
3166
+
2891
3167
  function requestOnboarding({ setupOnly = false } = {}) {
2892
3168
  if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
2893
3169
  if (parseRoute().name !== "home") {
@@ -3089,7 +3365,7 @@ async function saveOnboarding(draft = false) {
3089
3365
  try {
3090
3366
  const response = await localFetch("/api/setup", {
3091
3367
  method: "POST",
3092
- headers: { "content-type": "application/json" },
3368
+ headers: { "content-type": "application/json", prefer: "respond-async" },
3093
3369
  body: JSON.stringify({
3094
3370
  serviceName: onboardingDraft.serviceName,
3095
3371
  boundary: onboardingDraft.scope,
@@ -3335,6 +3611,7 @@ function openEditor(type, entry = null, options = {}) {
3335
3611
  ...required,
3336
3612
  ...(definition.listFields || []),
3337
3613
  ...(definition.formFields || []),
3614
+ ...Object.entries(fields).filter(([, field]) => field.relation || field.relationGroup).map(([name]) => name),
3338
3615
  ...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
3339
3616
  ...oneOf
3340
3617
  ])].filter((name) => !["id", "type"].includes(name) && fields[name]);
@@ -3602,27 +3879,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3602
3879
  const requiredMark = required || field.requiredWhen || oneOfRequired
3603
3880
  ? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
3604
3881
  : "";
3882
+ const relation = field.relation || state.model.relationGroups?.[field.relationGroup];
3883
+ const relationField = relation ? { ...field, relation } : field;
3605
3884
  const help = name === "title"
3606
3885
  ? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
3607
- : field.relation ? relationHelp(field)
3886
+ : relation ? relationHelp(relationField)
3608
3887
  : "";
3609
3888
  let control;
3610
3889
  if (field.managed) {
3611
3890
  control = '<textarea readonly spellcheck="false" placeholder="Filled when approval is saved">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
3612
3891
  return fieldWrap(name, "object", label, requiredMark, control, "Managed by filegrc from the exact companion Markdown revisions", false);
3613
3892
  }
3614
- if (field.relation && field.type === "array") {
3615
- const candidates = relationCandidates(field);
3893
+ if (relation && field.type === "array") {
3894
+ const candidates = relationCandidates(relationField, type, name);
3616
3895
  control = candidates.length
3617
3896
  ? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title) + '</small></span></label>').join("") + '</div>'
3618
- : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
3897
+ : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet.</div>';
3619
3898
  return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
3620
3899
  }
3621
- if (field.relation) {
3622
- const candidates = relationCandidates(field);
3900
+ if (relation) {
3901
+ const candidates = relationCandidates(relationField, type, name);
3623
3902
  control = candidates.length
3624
- ? '<select><option value="">Select ' + esc(relationTypeLabel(field).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>'
3625
- : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
3903
+ ? '<select><option value="">Select ' + esc(relationTypeLabel(relationField).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>'
3904
+ : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet.</div>';
3626
3905
  return fieldWrap(name, "relation", label, requiredMark, control, help, required);
3627
3906
  }
3628
3907
  if (name === "classificationId") {
@@ -3658,6 +3937,13 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3658
3937
  control = '<div class="structured-object-fields" data-object-type="' + esc(field.objectType) + '">' + objectPropertyFields(schema, value || {}) + '</div>';
3659
3938
  return fieldWrap(name, "structured-object", label, requiredMark, control, "Fill only the details that apply.", required);
3660
3939
  }
3940
+ if (schema?.additionalProperties?.type === "string") {
3941
+ control = stringMapEditor(value || {}, name, name === "reviewedSourceRevisions");
3942
+ const mapHelp = name === "reviewedSourceRevisions"
3943
+ ? "Bind the exact current JSON and Markdown revisions after reviewing every selected record."
3944
+ : "Add one named value per row.";
3945
+ return fieldWrap(name, "string-map", label, requiredMark, control, mapHelp, required);
3946
+ }
3661
3947
  control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
3662
3948
  return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
3663
3949
  }
@@ -3752,10 +4038,11 @@ function objectArrayItem(schema, value = {}) {
3752
4038
  return '<fieldset class="object-array-item"><legend>' + esc(humanize(schema?.title || "Relationship")) + '</legend><div class="structured-object-fields">' + objectPropertyFields(schema, value) + '</div><button type="button" class="text-button" data-remove-object-item>Remove</button></fieldset>';
3753
4039
  }
3754
4040
 
3755
- function stringMapEditor(value = {}, name = "item") {
4041
+ function stringMapEditor(value = {}, name = "item", bindCurrent = false) {
3756
4042
  const entries = Object.entries(value);
3757
4043
  const row = ([key = "", item = ""] = []) => '<div class="string-map-row"><input data-map-key value="' + esc(key) + '" placeholder="Name"><input data-map-value value="' + esc(item) + '" placeholder="Value"><button type="button" class="text-button" data-remove-string-map aria-label="Remove ' + esc(humanize(name).toLowerCase()) + '">Remove</button></div>';
3758
- return '<div class="string-map-editor"><div class="string-map-items">' + (entries.length ? entries.map(row).join("") : row()) + '</div><button type="button" class="button" data-add-string-map>Add ' + esc(humanize(name).replace(/s$/, "").toLowerCase()) + '</button><template>' + row() + '</template></div>';
4044
+ const bind = bindCurrent ? '<button type="button" class="button" data-bind-review-revisions>Bind current revisions</button>' : "";
4045
+ return '<div class="string-map-editor"><div class="string-map-items">' + (entries.length ? entries.map(row).join("") : row()) + '</div><div class="string-map-actions">' + bind + '<button type="button" class="button" data-add-string-map>Add ' + esc(humanize(name).replace(/s$/, "").toLowerCase()) + '</button></div><template>' + row() + '</template></div>';
3759
4046
  }
3760
4047
 
3761
4048
  function wireStructuredObjectEditors(dialog) {
@@ -3772,6 +4059,17 @@ function wireStructuredObjectEditors(dialog) {
3772
4059
  });
3773
4060
  });
3774
4061
  dialog.querySelector("form").addEventListener("click", (event) => {
4062
+ const bind = event.target.closest("[data-bind-review-revisions]");
4063
+ if (bind) {
4064
+ const previousLabel = bind.textContent;
4065
+ bindCurrentReviewRevisions(dialog, bind).catch((error) => {
4066
+ dialog.querySelector(".dialog-error").textContent = error.message;
4067
+ }).finally(() => {
4068
+ bind.disabled = false;
4069
+ if (bind.textContent === "Binding…") bind.textContent = previousLabel;
4070
+ });
4071
+ return;
4072
+ }
3775
4073
  const add = event.target.closest("[data-add-string-map]");
3776
4074
  if (add) {
3777
4075
  const editor = add.closest(".string-map-editor");
@@ -3830,6 +4128,60 @@ function wireStructuredObjectEditors(dialog) {
3830
4128
  });
3831
4129
  }
3832
4130
 
4131
+ async function bindCurrentReviewRevisions(dialog, button) {
4132
+ const selected = Object.fromEntries(["sourceResourceIds", "targetResourceIds", "informationTypeIds", "scopeResourceIds"].map((name) => [
4133
+ name,
4134
+ [...dialog.querySelectorAll('[data-field-group="' + name + '"] input[type="checkbox"]:checked')].map((input) => input.value)
4135
+ ]));
4136
+ const scheduleDocumentId = dialog.querySelector('[data-field-group="scheduleDocumentId"] select')?.value || "";
4137
+ const mapping = Boolean(dialog.querySelector('[data-field-group="targetResourceIds"]'));
4138
+ if (mapping && (!selected.sourceResourceIds.length || !selected.targetResourceIds.length)) {
4139
+ throw new Error("Select records on both sides of the Requirement Mapping before binding their current revisions.");
4140
+ }
4141
+ if (!mapping && (!scheduleDocumentId || !selected.informationTypeIds.length || !selected.scopeResourceIds.length)) {
4142
+ throw new Error("Select the retention schedule, Information Types, and operational scope before binding their current revisions.");
4143
+ }
4144
+ const ids = [...new Set(Object.values(selected).flat().concat(scheduleDocumentId, retentionUseReviewIds(dialog)).filter(Boolean))];
4145
+ button.disabled = true;
4146
+ button.textContent = "Binding…";
4147
+ const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
4148
+ const response = await localFetch("/api/review-revisions?" + query);
4149
+ if (!response.ok) throw new Error(await responseMessage(response));
4150
+ const { revisions } = await response.json();
4151
+ const editor = button.closest(".string-map-editor");
4152
+ const items = editor.querySelector(".string-map-items");
4153
+ items.replaceChildren();
4154
+ for (const [key, value] of Object.entries(revisions)) {
4155
+ items.append(editor.querySelector("template").content.cloneNode(true));
4156
+ const row = items.lastElementChild;
4157
+ row.querySelector("[data-map-key]").value = key;
4158
+ row.querySelector("[data-map-value]").value = value;
4159
+ }
4160
+ button.textContent = "Refresh current revisions";
4161
+ dialog.querySelector(".dialog-error").textContent = "";
4162
+ }
4163
+
4164
+ function retentionUseReviewIds(dialog) {
4165
+ if (!dialog.querySelector('[data-field-group="scheduleDocumentId"]')) return [];
4166
+ const informationTypeIds = new Set([...dialog.querySelectorAll('[data-field-group="informationTypeIds"] input:checked')].map(({ value }) => value));
4167
+ const scopeIds = new Set([...dialog.querySelectorAll('[data-field-group="scopeResourceIds"] input:checked')].map(({ value }) => value));
4168
+ const records = state.resources.map(({ record }) => record);
4169
+ const programs = records.filter((record) => record.type === "program" && scopeIds.has(record.id));
4170
+ const useIds = [];
4171
+ for (const program of programs) {
4172
+ const systemIds = new Set(program.systemIds || []);
4173
+ const vendorIds = new Set(program.vendorIds || records
4174
+ .filter((record) => record.type === "vendor" && record.status !== "retired")
4175
+ .map(({ id }) => id));
4176
+ for (const record of records) {
4177
+ if (record.type === "system" && systemIds.has(record.id) && (record.informationTypeIds || []).some((id) => informationTypeIds.has(id))) useIds.push(record.id);
4178
+ if (record.type === "component" && record.status !== "retired" && (record.systemUses || []).some(({ systemId }) => systemIds.has(systemId)) && (record.informationUses || []).some(({ informationTypeId }) => informationTypeIds.has(informationTypeId))) useIds.push(record.id);
4179
+ if (record.type === "vendor" && vendorIds.has(record.id) && (record.informationTypeIds || []).some((id) => informationTypeIds.has(id))) useIds.push(record.id);
4180
+ }
4181
+ }
4182
+ return useIds;
4183
+ }
4184
+
3833
4185
  function readObjectProperty(field) {
3834
4186
  if (field.hidden) return undefined;
3835
4187
  const kind = field.dataset.objectKind;
@@ -4002,6 +4354,7 @@ function readGuidedRecord(dialog, base, fields) {
4002
4354
  .filter((item) => Object.keys(item).length);
4003
4355
  }
4004
4356
  else if (kind === "structured-object") value = readStructuredObject(group.querySelector(":scope > .structured-object-fields"));
4357
+ else if (kind === "string-map") value = readStringMap(group.querySelector(":scope > .string-map-editor"));
4005
4358
  else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
4006
4359
  else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
4007
4360
  else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
@@ -4018,8 +4371,16 @@ function readGuidedRecord(dialog, base, fields) {
4018
4371
  return record;
4019
4372
  }
4020
4373
 
4021
- function relationCandidates(field) {
4022
- return state.resources.filter(({ record }) => field.relation.includes("*") || field.relation.includes(record.type));
4374
+ function relationCandidates(field, resourceType, fieldName) {
4375
+ return state.resources.filter(({ record }) => {
4376
+ if (!(field.relation.includes("*") || field.relation.includes(record.type))) return false;
4377
+ if (resourceType === "retention-schedule-item" && fieldName === "scheduleDocumentId") {
4378
+ return record.documentKind === "schedule"
4379
+ && record.workflowScope === "program"
4380
+ && !["superseded", "retired"].includes(record.status);
4381
+ }
4382
+ return true;
4383
+ });
4023
4384
  }
4024
4385
 
4025
4386
  function relationTypeLabel(field, plural = false) {
@@ -4515,7 +4876,7 @@ function renderNotFound(main) {
4515
4876
  }
4516
4877
  function applyMutationState(result) {
4517
4878
  if (result?.state) {
4518
- state = result.state;
4879
+ state = normalizeAppState(result.state);
4519
4880
  } else if (result?.stateRefresh) {
4520
4881
  applyFastMutationPatch(result);
4521
4882
  scheduleMutationStateRefresh();
@@ -4526,6 +4887,7 @@ function applyMutationState(result) {
4526
4887
  }
4527
4888
 
4528
4889
  function applyFastMutationPatch(result) {
4890
+ state.stateToken = null;
4529
4891
  if (result.operation === "collection-review" && result.assessment?.resourceType) {
4530
4892
  state.collectionReviews[result.assessment.resourceType] = result.assessment;
4531
4893
  }
@@ -4538,6 +4900,12 @@ function applyFastMutationPatch(result) {
4538
4900
  state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
4539
4901
  }
4540
4902
  }
4903
+ for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
4904
+ const entry = state.resources.find(({ record: current }) => current.id === record.id);
4905
+ if (entry) entry.record = record;
4906
+ else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
4907
+ if (record.type === "workspace") state.workspace = record;
4908
+ }
4541
4909
  if (result.synchronization) {
4542
4910
  state.repository = {
4543
4911
  ...state.repository,
@@ -4548,7 +4916,7 @@ function applyFastMutationPatch(result) {
4548
4916
  backgroundSynchronization: result.synchronization.status === "syncing" ? result.synchronization : null
4549
4917
  };
4550
4918
  }
4551
- state.readOnly = true;
4919
+ if (result.synchronization?.status === "syncing") state.readOnly = true;
4552
4920
  }
4553
4921
 
4554
4922
  function scheduleMutationStateRefresh(delay = 0) {
@@ -4562,10 +4930,12 @@ async function refreshMutationState() {
4562
4930
  mutationStateRefreshInFlight = true;
4563
4931
  let retry = false;
4564
4932
  try {
4565
- const response = await fetch("/api/state");
4933
+ const response = await fetch("/api/state/bootstrap");
4566
4934
  if (!response.ok) throw new Error(await responseMessage(response));
4567
- state = await response.json();
4935
+ state = normalizeAppState(await response.json());
4936
+ stateSectionRequests.clear();
4568
4937
  render();
4938
+ loadStateForRoute();
4569
4939
  scheduleRepositorySyncPoll();
4570
4940
  } catch {
4571
4941
  retry = true;
@@ -4652,7 +5022,7 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
4652
5022
  if (status) status.textContent = "";
4653
5023
  if (busy) {
4654
5024
  dialog._stillWorkingTimer = setTimeout(() => {
4655
- if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
5025
+ if (status) status.textContent = "Still working. Recalculating readiness and repository state.";
4656
5026
  }, 1_500);
4657
5027
  }
4658
5028
  }
@@ -4860,7 +5230,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
4860
5230
  .onboarding-git-status{display:flex;align-items:flex-start;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status .status-dot{margin-top:4px}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:12px}.onboarding-git-status small{color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:10.8px}
4861
5231
  .badge.status-overdue,.badge.status-blocked{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
4862
5232
  .obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:flex-start;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:12px}.obligation-preview small,.event-reminder-preview small{font-size:10.8px;color:var(--muted);margin-top:2px}.event-reminder-preview{grid-template-columns:repeat(2,minmax(0,1fr))}.event-reminder-preview a{padding:10px;border-radius:7px;background:var(--surface-soft);text-decoration:none}
4863
- .obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
5233
+ .obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.operation-gate{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px;border:1px solid var(--accent-light);border-radius:12px;background:var(--accent-wash)}.operation-gate h2{margin:5px 0 7px;font:500 25px Georgia,serif}.operation-gate p:not(.kicker){margin:0;max-width:680px;color:var(--muted)}.operation-setup-preview>summary{margin-top:18px;padding:14px 16px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-weight:700;cursor:pointer}.operation-setup-preview[open]>summary{margin-bottom:0}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
4864
5234
  .obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px}
4865
5235
  .event-dialog label{display:block;margin-top:13px}.event-dialog label[hidden]{display:none}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select,.event-dialog textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog textarea{resize:vertical}.commit-dialog .form-grid label.full{grid-column:1/-1}.workflow-preview{margin-top:14px;padding:0 12px;border-radius:7px;background:var(--surface-soft)}.workflow-preview:not(:empty){padding-top:10px;padding-bottom:10px}.workflow-preview strong,.workflow-preview p{display:block;margin:0}.workflow-preview p{margin-top:5px;color:var(--muted);font-size:12px;line-height:1.5}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
4866
5236
  .applicability-dialog{width:min(980px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:0;border-radius:12px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 25px 80px rgba(0,0,24,.28)}.applicability-dialog form{padding:23px}.applicability-dialog form>p{color:var(--muted);font-size:13.2px}.applicability-dialog form>.applicability-baseline-note{padding:10px 12px;border-radius:7px;background:var(--accent-soft);color:var(--ink)}.review-context label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.review-context input,.review-context select,.applicability-row input,.applicability-row select{width:100%;min-height:38px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:8px 9px;font-size:13.2px}.review-context label.full{grid-column:1/-1}.applicability-rows{display:grid;gap:7px;max-height:46vh;overflow:auto;margin-top:16px;padding-right:4px}.applicability-row{display:grid;grid-template-columns:minmax(210px,1fr) 180px minmax(240px,1.3fr);gap:9px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:8px}.applicability-row strong,.applicability-row small{display:block}.applicability-row small{margin-top:3px;color:var(--muted);font-size:10.8px}.applicability-row .applicability-constraint{color:var(--accent);line-height:1.35}
@@ -4878,6 +5248,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
4878
5248
  @media(max-width:520px){.applicability-dialog form{padding:18px}.applicability-row{grid-template-columns:1fr}.applicability-rows{max-height:42vh}}
4879
5249
  @media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
4880
5250
  .choice-tag{overflow-wrap:normal;font-size:10px}.form-field[data-kind="structured-object"],.string-map-property{grid-column:1/-1}.string-map-row .text-button{text-transform:none;letter-spacing:0;color:var(--muted);font-size:11px}
5251
+ .form-field[data-kind="string-map"]{grid-column:1/-1}.string-map-actions{display:flex;flex-wrap:wrap;gap:7px}
4881
5252
  @media(max-width:760px){.setup-banner>.button{justify-self:start}.setup-draft-state{justify-content:flex-start;flex-wrap:wrap;gap:12px 22px}.setup-draft-state .button{width:100%}}
4882
5253
  @media(max-width:760px){.string-map-row{grid-template-columns:1fr 1fr}.string-map-row .text-button{grid-column:1/-1;justify-self:start}}
4883
5254