filegrc 0.9.2 → 0.10.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.
@@ -22,7 +22,7 @@ export function governedDocumentIsOperating(document, asOf, model) {
22
22
  if (document?.type !== "document" || document.status !== "active") return false;
23
23
  if (!document.effectiveOn || document.effectiveOn > asOf) return false;
24
24
  if (!modelSupports(model, "governed-document-activation")) return true;
25
- if (document.activationBasis === "legacy-v4") {
25
+ if (["legacy-v4", "historical"].includes(document.activationBasis)) {
26
26
  return document.workflowScope === "engagement"
27
27
  && Boolean(document.approvedOn && document.approvedContentRevisions);
28
28
  }
@@ -87,11 +87,7 @@ export const PROGRAM_PATH = [
87
87
  "filegrc guide appointment --json",
88
88
  "filegrc guide system --json",
89
89
  "filegrc guide component --json",
90
- "filegrc review-collection person --scaffold",
91
- "filegrc review-collection framework --scaffold",
92
90
  "filegrc review-collection vendor --scaffold",
93
- "filegrc review-collection system --scaffold",
94
- "filegrc review-collection component --scaffold",
95
91
  "filegrc list system --json"
96
92
  ]
97
93
  },
@@ -132,6 +128,7 @@ export const PROGRAM_PATH = [
132
128
  "filegrc get CONTROL_ID --mutation",
133
129
  "filegrc guide obligation --json",
134
130
  "filegrc list obligation --json",
131
+ "filegrc review-collection component --scaffold",
135
132
  "filegrc review-collection complementary-control --scaffold",
136
133
  "filegrc activate-content --scaffold",
137
134
  "filegrc activate-policies --scaffold",
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { modelSupports } from "../model/index.js";
3
+ import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
3
4
  import { assessRequiredAppointments } from "./appointments.js";
4
5
  import { assessCollectionReviews } from "./collection-review.js";
5
6
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
@@ -52,6 +53,9 @@ export async function assessProgramReadiness(input, options = {}) {
52
53
  .map(collectionReviewReadinessItem));
53
54
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
54
55
  controlStage.items.push(...sourceStage.items);
56
+ controlStage.items.push(...collectionReviews
57
+ .filter(({ resourceType }) => resourceType === "component")
58
+ .map(collectionReviewReadinessItem));
55
59
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
56
60
  controlStage.items.push(...governedContent.items);
57
61
  const policyActivations = await assessPolicyActivations(
@@ -72,7 +76,7 @@ export async function assessProgramReadiness(input, options = {}) {
72
76
  records,
73
77
  byId,
74
78
  loaded.model,
75
- collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
79
+ collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "vendor"].includes(resourceType))
76
80
  ),
77
81
  policyStage,
78
82
  controlStage
@@ -103,6 +107,7 @@ export async function assessProgramReadiness(input, options = {}) {
103
107
  const firstAction = items.find((current) => current.status === "action") || null;
104
108
 
105
109
  return {
110
+ program,
106
111
  schemaVersion: 1,
107
112
  dataModelVersion: String(loaded.model.modelVersion),
108
113
  generatedAt: options.generatedAt || new Date().toISOString(),
@@ -261,7 +266,10 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
261
266
  record.status === "active"
262
267
  && record.statement
263
268
  && record.effectiveOn
264
- && (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
269
+ && (!model.resources.commitment?.fields?.applicabilityReview || (
270
+ record.applicabilityReview?.decision === "applicable"
271
+ && applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model)
272
+ ))
265
273
  && currentPartyPeople(record.ownerIds, byId).size > 0
266
274
  && (record.requirementIds || []).length > 0
267
275
  && (record.controlIds || []).length > 0
@@ -290,7 +298,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
290
298
  }
291
299
 
292
300
  const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
293
- const v4Decisions = new Map((workspace?.requirementApplicability || []).map((decision) => [decision.requirementId, decision.decision]));
301
+ const requirementById = new Map(records
302
+ .filter(({ type }) => type === "requirement")
303
+ .map((record) => [record.id, record]));
304
+ const v4Decisions = new Map((workspace?.requirementApplicability || [])
305
+ .filter((decision) => (
306
+ requirementById.has(decision.requirementId)
307
+ && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
308
+ ))
309
+ .map((decision) => [decision.requirementId, decision.decision]));
294
310
  const applicableRequirements = records.filter((record) => (
295
311
  record.type === "requirement"
296
312
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
@@ -1162,6 +1178,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1162
1178
  const checks = {
1163
1179
  ...(model.resources.control?.fields?.applicabilityReview ? {
1164
1180
  applicability: control.applicabilityReview?.decision === "applicable"
1181
+ && applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model)
1165
1182
  } : {}),
1166
1183
  implemented: control.status === "implemented",
1167
1184
  owner: (control.ownerIds || []).length > 0,
@@ -11,7 +11,9 @@ const TRANSITIONS = {
11
11
  person: [
12
12
  {
13
13
  eventType: "person-started",
14
- applies: (before, after) => after?.status === "active" && before?.status !== "active",
14
+ applies: (before, after) => after?.status === "active"
15
+ && after?.affiliation !== "external"
16
+ && before?.status !== "active",
15
17
  message: "Confirm whether activating this Person represents a workforce start that needs policy-event work."
16
18
  },
17
19
  {
@@ -95,16 +97,26 @@ export async function planReconciliation(input = process.cwd()) {
95
97
  const loaded = input?.resources && input?.model && input?.entries
96
98
  ? input
97
99
  : await loadWorkspace(input);
100
+ const headRevision = gitRevision(loaded.root);
98
101
  if (!modelSupports(loaded.model, "guided-workflow")) {
99
102
  return {
100
103
  contractVersion: 1,
101
- gitRevision: gitRevision(loaded.root),
104
+ gitRevision: headRevision,
102
105
  changedPaths: [],
103
106
  candidates: [],
104
107
  message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
105
108
  };
106
109
  }
107
110
  const changedPaths = gitChangedPaths(loaded.root);
111
+ if (!headRevision) {
112
+ return {
113
+ contractVersion: 1,
114
+ gitRevision: null,
115
+ changedPaths,
116
+ candidates: [],
117
+ message: "Commit the initial workspace before FileGRC checks later direct-file changes for Policy Events."
118
+ };
119
+ }
108
120
  const currentByPath = new Map(loaded.entries.map((entry) => [
109
121
  `data/${entry.relativePath}`,
110
122
  entry
@@ -176,7 +188,7 @@ export async function planReconciliation(input = process.cwd()) {
176
188
  }
177
189
  return {
178
190
  contractVersion: 1,
179
- gitRevision: gitRevision(loaded.root),
191
+ gitRevision: headRevision,
180
192
  changedPaths,
181
193
  candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
182
194
  };
package/src/server.js CHANGED
@@ -319,7 +319,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
319
319
  const payload = await readJson(request);
320
320
  const completeSetup = async () => {
321
321
  return browserMutation(input, options, {
322
- message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`
322
+ message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`,
323
+ fastResponse: prefersFastMutation(request)
323
324
  }, () => setupWorkspace(input, payload));
324
325
  };
325
326
  return json(response, 200, await completeSetup());
package/src/setup.js CHANGED
@@ -81,7 +81,7 @@ export function summarizeSetupResult(result) {
81
81
  },
82
82
  system: setupSystemSummary(result.system),
83
83
  target: setupTargetSummary(result.program || result.workspace, {
84
- modelVersion: result.workspace?.dataModelVersion || (result.program ? "6" : "3")
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "7" : "3")
85
85
  }),
86
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
87
87
  commitment: result.commitment || null,
package/src/state.js CHANGED
@@ -94,8 +94,9 @@ async function createAppStateUnlocked(input, options) {
94
94
  (audits.length ? audits : [null]).map(async (audit) => {
95
95
  const preparation = await assessAuditPreparation(loaded, {
96
96
  auditId: audit?.id,
97
+ asOf,
97
98
  generatedAt,
98
- programReadiness
99
+ ...(audit ? {} : { programReadiness })
99
100
  });
100
101
  return [audit?.id || "none", preparation];
101
102
  })
package/src/validate.js CHANGED
@@ -3,7 +3,10 @@ import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { getResourceDefinition, modelSupports } from "../model/index.js";
5
5
  import { scopedCollectionRecords } from "./collection-scope.js";
6
- import { collectionRevision } from "./collection-revision.js";
6
+ import {
7
+ collectionRevision,
8
+ collectionRevisionMatches
9
+ } from "./collection-revision.js";
7
10
  import { isSafeGitName } from "./git-name.js";
8
11
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
9
12
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
@@ -153,16 +156,20 @@ async function validateWorkspaceUnmeasured(input) {
153
156
  }
154
157
 
155
158
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
156
- return {
159
+ const result = {
157
160
  ok: !diagnostics.some(({ severity }) => severity === "error"),
158
161
  diagnostics,
159
162
  counts: {
160
163
  resources: loaded.resources.length,
161
164
  errors: diagnostics.filter(({ severity }) => severity === "error").length,
162
165
  warnings: diagnostics.filter(({ severity }) => severity === "warning").length
163
- },
164
- loaded
166
+ }
165
167
  };
168
+ Object.defineProperty(result, "loaded", {
169
+ value: loaded,
170
+ enumerable: false
171
+ });
172
+ return result;
166
173
  }
167
174
 
168
175
  function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
@@ -246,7 +253,7 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
246
253
  `Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
247
254
  ));
248
255
  }
249
- if (document.activationBasis !== "legacy-v4") continue;
256
+ if (!["legacy-v4", "historical"].includes(document.activationBasis)) continue;
250
257
  const historicalAuditIds = new Set(auditRefs
251
258
  .filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
252
259
  .map(({ audit }) => audit.id));
@@ -254,7 +261,7 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
254
261
  diagnostics.push(error(
255
262
  "invalid-legacy-document-activation",
256
263
  path,
257
- `activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
264
+ `The historical activation basis is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
258
265
  ));
259
266
  }
260
267
  }
@@ -313,7 +320,18 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
313
320
  ? record.authoritativeComponentId || record.authoritativeSystemId
314
321
  : null
315
322
  });
316
- const current = record.collectionRevision === currentRevision;
323
+ const current = collectionRevisionMatches(
324
+ loaded,
325
+ record.resourceType,
326
+ record.collectionRevision,
327
+ {
328
+ program,
329
+ authoritativeSourceId: record.decision === "externally-managed"
330
+ ? record.authoritativeComponentId || record.authoritativeSystemId
331
+ : null,
332
+ currentRevision
333
+ }
334
+ );
317
335
  if (current && !recordCount && record.decision === "complete") {
318
336
  diagnostics.push(error(
319
337
  "invalid-collection-review-decision",
package/src/web.js CHANGED
@@ -102,7 +102,7 @@ start().catch((error) => {
102
102
  async function start() {
103
103
  const embedded = document.querySelector("#filegrc-data");
104
104
  state = embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state");
105
- window.addEventListener("hashchange", render);
105
+ window.addEventListener("hashchange", handleRouteChange);
106
106
  window.addEventListener("resize", positionCurrentOnboarding);
107
107
  window.addEventListener("scroll", positionCurrentOnboarding, true);
108
108
  render();
@@ -112,6 +112,11 @@ async function start() {
112
112
  }
113
113
  }
114
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
+ }
119
+
115
120
  function render() {
116
121
  resourceGuideCleanup?.();
117
122
  resourceGuideCleanup = null;
@@ -287,20 +292,25 @@ function renderHome(main) {
287
292
  const setupPending = !setupSystem
288
293
  || setupSystem.status !== "active"
289
294
  || activeProgram().assuranceGoal === "none";
290
- const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
291
- const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
295
+ const acceptedEventTriggers = activePolicyEventTriggers(state.obligations.triggers);
296
+ const openObligations = activeOperationItems(state.obligations.items);
292
297
  const previewObligations = distinctObligationPreviews(openObligations, 3);
293
- const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
294
298
  const setupBanner = setupPending ? initialSetupBanner() : "";
295
299
  const auditPanel = program.evidenceReady
296
300
  ? '<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
301
  (activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
298
302
  : "";
303
+ const obligationPanel = program.evidenceReady || openObligations.length
304
+ ? '<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>'
305
+ : "";
306
+ const eventPanel = acceptedEventTriggers.length
307
+ ? '<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>'
308
+ : "";
309
+ const operationPanels = obligationPanel + eventPanel;
310
+ const overviewPanels = operationPanels + auditPanel;
299
311
  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()) }));
312
+ (overviewPanels ? '<div class="overview-grid">' + overviewPanels + '</div>' : '') + '</div>';
313
+ main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: true }));
304
314
  }
305
315
 
306
316
  function initialSetupSystem() {
@@ -1116,7 +1126,7 @@ function stagePageItems(stage, destination) {
1116
1126
  ].filter((item) => (
1117
1127
  activeStates.has(item.state)
1118
1128
  && (
1119
- item.stage === stage.id
1129
+ workflowUiStage(item.stage) === stage.id
1120
1130
  || stage.id === "scope"
1121
1131
  && destination.type === "appointment"
1122
1132
  && item.code === "governance.appointment.independent-policy-reviewer"
@@ -1130,6 +1140,7 @@ function stagePageItems(stage, destination) {
1130
1140
  ));
1131
1141
  }
1132
1142
  return items.filter((item) => {
1143
+ if (destination.type === "program" && item.key === "program.scope.criteria") return false;
1133
1144
  if (
1134
1145
  destination.type === "requirement"
1135
1146
  && item.subject?.type === "requirement"
@@ -1162,6 +1173,10 @@ function stagePageItems(stage, destination) {
1162
1173
  ));
1163
1174
  }
1164
1175
 
1176
+ function workflowUiStage(stageId) {
1177
+ return stageId === "operation" ? "run" : stageId;
1178
+ }
1179
+
1165
1180
  function operationProgress() {
1166
1181
  const program = state.programReadiness;
1167
1182
  const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
@@ -1173,7 +1188,7 @@ function operationProgress() {
1173
1188
  : Boolean(program?.evidenceReady);
1174
1189
  const overdue = state.obligations.counts.overdue || 0;
1175
1190
  const blocked = state.obligations.counts.blocked || 0;
1176
- const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0 && blocked === 0);
1191
+ const complete = Boolean(program?.operating);
1177
1192
  if (complete) {
1178
1193
  return {
1179
1194
  percent: 100,
@@ -1214,6 +1229,19 @@ function operationProgress() {
1214
1229
  detail: "Record the management candidate period start when evidence collection begins."
1215
1230
  };
1216
1231
  }
1232
+ if (program?.evidenceReady && candidateStarted) {
1233
+ const actions = program?.stages?.find((stage) => stage.id === "operation")?.counts?.action || 0;
1234
+ return {
1235
+ percent: 0,
1236
+ complete: 0,
1237
+ total: 1,
1238
+ status: "Needs work",
1239
+ tone: "warn",
1240
+ detail: actions
1241
+ ? actions + " Step 4 " + pluralize("item", actions) + (actions === 1 ? " needs" : " need") + " work."
1242
+ : "Complete the current Step 4 readiness work."
1243
+ };
1244
+ }
1217
1245
  return {
1218
1246
  percent: 0,
1219
1247
  complete: 0,
@@ -1278,42 +1306,67 @@ function renderExternalEvidenceSection() {
1278
1306
  function renderObligations(main, params = new URLSearchParams()) {
1279
1307
  const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
1280
1308
  const plan = state.obligations;
1309
+ const operationLocked = !state.programReadiness?.evidenceReady;
1281
1310
  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>'
1311
+ const renderBoard = (items, statuses) => statuses.map((status) => {
1312
+ const statusItems = obligationBoardItems(items, status);
1313
+ const cards = statusItems.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
1314
+ const more = statusItems.length > visibleCardLimit
1315
+ ? '<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
1316
  : "";
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>';
1317
+ 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
1318
  }).join("");
1290
1319
  const eventTriggerLimit = 6;
1291
1320
  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
- : "";
1321
+ const acceptedTriggers = activePolicyEventTriggers(orderedTriggers);
1322
+ const proposedTriggers = orderedTriggers.filter(({ programStatus }) => programStatus === "proposed");
1323
+ const currentItems = activeOperationItems(plan.items);
1324
+ const proposedItems = plan.items.filter(({ status }) => status === "proposed");
1325
+ const renderEvents = (items, scope, description) => {
1326
+ const triggers = items.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit, scope)).join("");
1327
+ const eventMore = items.length > eventTriggerLimit
1328
+ ? '<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>'
1329
+ : "";
1330
+ 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>';
1331
+ };
1332
+ 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
1333
  const feedback = policyEventFeedback
1297
1334
  ? '<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
1335
  : "";
1336
+ const operationGate = operationLocked
1337
+ ? '<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>'
1338
+ : "";
1339
+ const activeOperation = operationLocked
1340
+ ? (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.") : "") +
1341
+ (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.") : "")
1342
+ : "";
1343
+ const setupTriggers = operationLocked ? proposedTriggers : orderedTriggers;
1344
+ const setupItems = operationLocked ? proposedItems : plan.items;
1345
+ const setupStatuses = operationLocked ? ["proposed"] : ["proposed", "upcoming", "blocked", "due", "overdue"];
1346
+ const operationSetupOpen = operationLocked ? "" : " open";
1347
+ const operationSetupSummary = operationLocked
1348
+ ? '<summary>Preview ' + proposedTriggers.length + ' proposed Policy Events and ' + proposedItems.length + ' proposed Work Queue items</summary>'
1349
+ : "";
1299
1350
  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
1351
  '<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
1352
  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>';
1353
+ operationGate +
1354
+ activeOperation +
1355
+ '<details class="operation-setup-preview"' + operationSetupOpen + '>' + operationSetupSummary +
1356
+ 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.") +
1357
+ 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.") +
1358
+ renderExternalEvidenceSection() + '</details></div>';
1306
1359
  main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
1307
1360
  const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
1308
1361
  if (trigger) openObligationEventDialog(trigger);
1309
1362
  }));
1310
- main.querySelector("[data-expand-policy-events]")?.addEventListener("click", (event) => {
1363
+ main.querySelectorAll("[data-expand-policy-events]").forEach((button) => button.addEventListener("click", (event) => {
1311
1364
  const button = event.currentTarget;
1312
1365
  const expanded = button.getAttribute("aria-expanded") === "true";
1313
- main.querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
1366
+ button.closest("[data-policy-event-section]").querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
1314
1367
  button.setAttribute("aria-expanded", String(!expanded));
1315
- button.textContent = expanded ? "Show " + (orderedTriggers.length - eventTriggerLimit) + " more events" : "Show fewer events";
1316
- });
1368
+ button.textContent = expanded ? "Show " + (Number(button.dataset.total) - eventTriggerLimit) + " more events" : "Show fewer events";
1369
+ }));
1317
1370
  main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
1318
1371
  main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
1319
1372
  policyEventFeedback = null;
@@ -1336,9 +1389,9 @@ function renderObligations(main, params = new URLSearchParams()) {
1336
1389
  const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
1337
1390
  if (item) openActionCompletion(item);
1338
1391
  }));
1339
- main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
1392
+ main.querySelectorAll("[data-new-action-item]").forEach((button) => button.addEventListener("click", () => openEditor("action-item", null, {
1340
1393
  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
- }));
1394
+ })));
1342
1395
  const requestedEvent = params.get("event");
1343
1396
  if (requestedEvent || params.get("section") === "events") {
1344
1397
  queueMicrotask(() => {
@@ -1391,8 +1444,16 @@ function orderedPolicyEventTriggers(triggers) {
1391
1444
  ));
1392
1445
  }
1393
1446
 
1394
- function policyEventTrigger(trigger, index, collapsed = false) {
1395
- const tooltipId = "policy-event-tooltip-" + index;
1447
+ function activePolicyEventTriggers(triggers) {
1448
+ return triggers.filter(({ programStatus }) => programStatus === "accepted");
1449
+ }
1450
+
1451
+ function activeOperationItems(items) {
1452
+ return items.filter(({ status }) => ["upcoming", "blocked", "due", "overdue"].includes(status));
1453
+ }
1454
+
1455
+ function policyEventTrigger(trigger, index, collapsed = false, scope = "events") {
1456
+ const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
1396
1457
  const proposed = trigger.programStatus === "proposed";
1397
1458
  const unavailable = state.readOnly || proposed;
1398
1459
  const availability = proposed
@@ -1473,7 +1534,7 @@ function actionCompletionPlan(item) {
1473
1534
  href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
1474
1535
  };
1475
1536
  }
1476
- const type = state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
1537
+ const type = item.completionType || state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
1477
1538
  if (!type) {
1478
1539
  return {
1479
1540
  blocked: "Review completion type",
@@ -1484,7 +1545,7 @@ function actionCompletionPlan(item) {
1484
1545
  }
1485
1546
 
1486
1547
  function obligationCompletionPlan(item) {
1487
- const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
1548
+ const type = item.completionType || state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
1488
1549
  if (!currentPeopleForParties(item.ownerIds || []).length) {
1489
1550
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
1490
1551
  }
@@ -1552,10 +1613,13 @@ function obligationCompletionSeed(type, item, obligation) {
1552
1613
  return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: reviewerPeople, completedOn: date, outcome: "passed", changesRequired: false, evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
1553
1614
  }
1554
1615
  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 };
1616
+ 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
1617
  }
1557
1618
  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" };
1619
+ const personId = [...(item.subjectResourceIds || []), ...(obligation.scopeResourceIds || [])].find((id) => state.resources.some(({ record }) => record.id === id && record.type === "person"));
1620
+ 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))))];
1621
+ const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
1622
+ return { ...common, status: "completed", subjectResourceIds, personId, attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
1559
1623
  }
1560
1624
  if (type === "access-review") {
1561
1625
  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 +1634,23 @@ function obligationCompletionSeed(type, item, obligation) {
1570
1634
  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
1635
  }
1572
1636
  if (type === "control-activity") {
1637
+ const allowedScopeTypes = new Set(state.model.relationGroups?.["obligation-scope"] || []);
1638
+ const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
1639
+ const validScopeIds = requestedScopeIds.filter((id) => state.resources.some(({ record }) => (
1640
+ record.id === id && allowedScopeTypes.has(record.type)
1641
+ )));
1642
+ const fallbackScopeIds = inScopeSystems.length
1643
+ ? inScopeSystems
1644
+ : (item.controlIds || obligation.controlIds || []).length
1645
+ ? (item.controlIds || obligation.controlIds)
1646
+ : [state.workspace.id];
1573
1647
  return {
1574
1648
  ...common,
1575
1649
  status: "complete",
1576
1650
  profileId: item.completionProfile || item.activityType,
1577
1651
  obligationId: item.obligationId,
1578
1652
  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],
1653
+ scopeResourceIds: validScopeIds.length ? validScopeIds : fallbackScopeIds,
1582
1654
  performerIds: responsiblePeople,
1583
1655
  completedAt: timestamp,
1584
1656
  method: "",
@@ -3089,7 +3161,7 @@ async function saveOnboarding(draft = false) {
3089
3161
  try {
3090
3162
  const response = await localFetch("/api/setup", {
3091
3163
  method: "POST",
3092
- headers: { "content-type": "application/json" },
3164
+ headers: { "content-type": "application/json", prefer: "respond-async" },
3093
3165
  body: JSON.stringify({
3094
3166
  serviceName: onboardingDraft.serviceName,
3095
3167
  boundary: onboardingDraft.scope,
@@ -4538,6 +4610,12 @@ function applyFastMutationPatch(result) {
4538
4610
  state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
4539
4611
  }
4540
4612
  }
4613
+ for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
4614
+ const entry = state.resources.find(({ record: current }) => current.id === record.id);
4615
+ if (entry) entry.record = record;
4616
+ else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
4617
+ if (record.type === "workspace") state.workspace = record;
4618
+ }
4541
4619
  if (result.synchronization) {
4542
4620
  state.repository = {
4543
4621
  ...state.repository,
@@ -4548,7 +4626,7 @@ function applyFastMutationPatch(result) {
4548
4626
  backgroundSynchronization: result.synchronization.status === "syncing" ? result.synchronization : null
4549
4627
  };
4550
4628
  }
4551
- state.readOnly = true;
4629
+ if (result.synchronization?.status === "syncing") state.readOnly = true;
4552
4630
  }
4553
4631
 
4554
4632
  function scheduleMutationStateRefresh(delay = 0) {
@@ -4652,7 +4730,7 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
4652
4730
  if (status) status.textContent = "";
4653
4731
  if (busy) {
4654
4732
  dialog._stillWorkingTimer = setTimeout(() => {
4655
- if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
4733
+ if (status) status.textContent = "Still working. Recalculating readiness and repository state.";
4656
4734
  }, 1_500);
4657
4735
  }
4658
4736
  }
@@ -4860,7 +4938,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
4860
4938
  .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
4939
  .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
4940
  .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}
4941
+ .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
4942
  .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
4943
  .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
4944
  .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}