filegrc 0.2.0 → 0.3.1

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.
@@ -1,5 +1,5 @@
1
1
  export const RESOURCE_INSTRUCTIONS = {
2
- person: "Confirm the people who will own, approve, review, or operate the program. Replace the starter names and contact details with the organization’s actual people.",
2
+ person: "Confirm the policy owner created during setup, then add the actual people who will approve, review, or operate the program.",
3
3
  team: "Review the starter Security and Risk Oversight team, including its members and chair. Add another team only when the organization assigns shared responsibility to it.",
4
4
  system: "Catalog all in-scope systems for the program. Treat anything that operates a control or produces evidence as a System, including software provided by a vendor (like HR software).",
5
5
  vendor: "Catalog the companies that provide in-scope software or services. Link each vendor-provided System to the company that provides it.",
@@ -14,7 +14,7 @@ export const RESOURCE_INSTRUCTIONS = {
14
14
  "risk-assessment": "Complete and approve an assessment of the risks to the in-scope service, systems, vendors, and commitments.",
15
15
  risk: "Record each risk identified by an assessment or operating activity. Assign an owner, rate it, and document the chosen response.",
16
16
  obligation: "Review the recurring work proposed by effective policies. Confirm who owns it, when it is due, and what proof completion requires.",
17
- "obligation-event": "When a policy-triggering event occurs, record it here and complete the actions FileGRC creates for it.",
17
+ "obligation-event": "When a policy-triggering event occurs, record it here and complete the actions filegrc creates for it.",
18
18
  "policy-review": "Record scheduled and change-driven reviews of policies and governed documents, including the decision and any follow-up.",
19
19
  meeting: "Record required oversight meetings, including attendees, decisions, minutes, and follow-up work.",
20
20
  exception: "Record and approve any time-limited departure from a policy or control before the departure begins.",
@@ -64,13 +64,13 @@ export const PROGRAM_PATH = [
64
64
  description: "Ownership, criteria, and service boundary",
65
65
  summary: "Confirm the people and teams responsible for the program, set the management goal, review the criteria and customer commitments in scope, then define the customer-facing service, supporting systems, and supplier dependencies.",
66
66
  sections: [
67
- { id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["Replace the starter people with the organization’s actual owners and reviewers.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "team"], defaultOpen: true },
67
+ { id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["Confirm the policy owner created during setup, then add the organization’s actual reviewers and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "team"], defaultOpen: true },
68
68
  { id: "criteria", title: "Criteria", description: "Confirm the criteria used for the program, resolve whether each requirement applies, and record customer commitments that shape the service or control design.", steps: ["Review the included Security criteria references.", "Mark each requirement applicable or not applicable with a rationale.", "Record customer commitments and keep optional criteria out until management deliberately adds them."], types: ["framework", "requirement", "commitment"], defaultOpen: true },
69
69
  { id: "boundary", title: "Service Boundary", description: "Record the service and its supporting technology and providers. An application or platform is a System because it operates controls or produces evidence; the company providing it is a Vendor because contracts, due diligence, and supplier risk belong to that relationship.", steps: ["Create a Vendor record for each material provider.", "Create System records for the customer-facing service and each supporting application, platform, or internal system that is in scope or produces evidence, then connect vendor-provided Systems to their providers.", "Assign owners, classification, dependencies, and a clear in-scope decision to each System, and keep supplier reviews with the Vendor."], types: ["vendor", "system"], defaultOpen: false }
70
70
  ],
71
71
  resourceTypes: ["person", "team", "framework", "requirement", "commitment", "vendor", "system"],
72
72
  commands: [
73
- "filegrc setup --help",
73
+ "filegrc setup",
74
74
  "filegrc guide person --json",
75
75
  "filegrc guide system --json",
76
76
  "filegrc list system --json"
@@ -99,7 +99,7 @@ export const PROGRAM_PATH = [
99
99
  description: "Tailor and finish the starter control set",
100
100
  summary: "Review the starter catalog against the scoped service, then give every applicable internal control an actual procedure, owner, system scope, cadence, policy and criteria mappings, authoritative evidence source, and implementation date. Mark it implemented only after the procedure is operating, then record any controls that customers or carved-out providers must perform.",
101
101
  sections: [
102
- { id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether FileGRC tracks operation through Work Queue or evidence records.", steps: ["Open every planned control and confirm its mappings and suggested frequency.", "Write the real procedure in Record Markdown and add system scope and evidence sources.", "Record any required customer or carved-out provider controls as Complementary Controls."], types: ["control", "complementary-control"], defaultOpen: true }
102
+ { id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether filegrc tracks operation through Work Queue or evidence records.", steps: ["Open every planned control and confirm its mappings and suggested frequency.", "Write the real procedure in Record Markdown and add system scope and evidence sources.", "Record any required customer or carved-out provider controls as Complementary Controls."], types: ["control", "complementary-control"], defaultOpen: true }
103
103
  ],
104
104
  resourceTypes: ["control", "complementary-control"],
105
105
  commands: [
@@ -119,7 +119,7 @@ export const PROGRAM_PATH = [
119
119
  ],
120
120
  resourceTypes: ["evidence"],
121
121
  commands: [
122
- "filegrc evidence-test-drafts --json",
122
+ "filegrc evidence-test-drafts --preview --json",
123
123
  "filegrc guide evidence --json",
124
124
  "filegrc list evidence --json",
125
125
  "filegrc program-readiness --json"
@@ -170,7 +170,7 @@ export const PROGRAM_PATH = [
170
170
  {
171
171
  id: "policy-events",
172
172
  title: "Policy Events",
173
- instructions: "Trigger the matching workflow when an event occurs. FileGRC adds every required action to the Work Queue with its owner and deadline.",
173
+ instructions: "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.",
174
174
  use: "Preview the full workflow before triggering it, then create the event and every linked task in one validated write.",
175
175
  policyBasis: "Active event obligations translate policy-triggering changes into owned, deadline-bound Action Items. Proposed workflows remain unavailable until their governing policies and linked controls are ready.",
176
176
  commands: ["filegrc obligations --json", "filegrc trigger EVENT_TYPE (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --subject RESOURCE_ID --json"]
@@ -201,14 +201,14 @@ export const PROGRAM_PATH = [
201
201
  summary: "After the program is collecting reliable evidence, create an Audit record for the real CPA engagement, keep the firm-agreed report period separate from management’s candidate dates, complete management documents, populations, requests, evidence delivery, and fieldwork, then preserve the findings, responses, opinion, and final report.",
202
202
  sections: [
203
203
  { id: "engagement", title: "Engagement", description: "Record the actual CPA engagement, formal scope and dates, requests, and management responses.", steps: ["Create the Audit after the CPA firm is engaged.", "Record the firm-agreed type, scope, systems, criteria, and dates.", "Track incoming requests and approved response material."], types: ["audit", "audit-request"], defaultOpen: true },
204
- { id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated FileGRC Evidence and verified External Evidence for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and findings.", "Build the packet from a clean Git revision; it includes FileGRC records, Markdown, External Evidence, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], utility: "audit-packet", defaultOpen: true }
204
+ { id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated filegrc Evidence and verified External Evidence for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and findings.", "Build the packet from a clean Git revision; it includes filegrc records, Markdown, External Evidence, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], utility: "audit-packet", defaultOpen: true }
205
205
  ],
206
206
  resourceTypes: ["audit", "audit-request", "audit-population", "control-test"],
207
207
  utilities: [
208
208
  {
209
209
  id: "audit-packet",
210
210
  title: "Audit Evidence & Packet",
211
- instructions: "Review FileGRC Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet.",
211
+ instructions: "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet.",
212
212
  use: "Prepare management documents and populations, answer fieldwork requests, review both evidence paths, and compile a delivery bound to a clean Git revision.",
213
213
  policyBasis: "Management prepares the scoped records, evidence, populations, assertions, and responses. The CPA firm selects samples, evaluates evidence and exceptions, and issues the report.",
214
214
  commands: ["filegrc audit-readiness AUDIT_ID --json", "filegrc evidence-packet --audit AUDIT_ID --preview --json"]
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import { planObligations } from "./obligations.js";
3
3
  import { resolveDataPath } from "./paths.js";
4
4
  import { obligationIsRunning } from "./program-lifecycle.js";
5
+ import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
5
6
  import { markdownEntries } from "./resource-markdown.js";
6
7
  import { currentCalendarDate } from "./time.js";
7
8
  import { loadWorkspace } from "./workspace.js";
@@ -33,7 +34,7 @@ export async function assessProgramReadiness(input, options = {}) {
33
34
  [...sourceStage.items, ...collectionStage.items]
34
35
  );
35
36
  const evidenceGateStages = [
36
- scopeStage(workspace, scope, records),
37
+ scopeStage(workspace, scope, records, byId),
37
38
  await policiesStage(scope, byId, readMarkdown, asOf),
38
39
  await controlsStage(scope, byId, readMarkdown, asOf),
39
40
  evidenceStage
@@ -116,7 +117,7 @@ function programScope(workspace, records, byId) {
116
117
  };
117
118
  }
118
119
 
119
- function scopeStage(workspace, scope, records) {
120
+ function scopeStage(workspace, scope, records, byId) {
120
121
  const items = [];
121
122
  const goal = workspace?.assuranceGoal || "none";
122
123
  items.push(item(
@@ -129,6 +130,8 @@ function scopeStage(workspace, scope, records) {
129
130
  workspace || { type: "workspace" }
130
131
  ));
131
132
 
133
+ items.push(programOwnershipItem(records, byId));
134
+
132
135
  const completeSystems = scope.systems.filter((system) => (
133
136
  system.status === "active"
134
137
  && system.inScope === true
@@ -175,7 +178,75 @@ function scopeStage(workspace, scope, records) {
175
178
  workspace || { type: "workspace" }
176
179
  ));
177
180
 
178
- return stage("scope", "Define Scope", "Set the management objective, service boundary, criteria, controls, and dependencies.", items);
181
+ return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
182
+ }
183
+
184
+ function programOwnershipItem(records, byId) {
185
+ const ownedRecords = records.filter((record) => (
186
+ ["policy", "control", "obligation"].includes(record.type)
187
+ && !["retired", "superseded", "not-applicable"].includes(record.status)
188
+ ));
189
+ const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
190
+ const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
191
+ const oversight = byId.get("team-security-risk-oversight");
192
+ const policyOwnerIds = new Set(records
193
+ .filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
194
+ .flatMap((record) => [...partyPeople(record.ownerIds || [], byId)]));
195
+ const oversightChairs = oversight?.type === "team"
196
+ ? currentPartyPeople(oversight.chairIds || [], byId)
197
+ : new Set();
198
+ const oversightComplete = !oversight || (
199
+ oversight.type === "team"
200
+ && oversight.status === "active"
201
+ && currentPartyPeople(oversight.memberIds || [], byId).size > 0
202
+ && oversightChairs.size > 0
203
+ && ![...oversightChairs].some((id) => policyOwnerIds.has(id))
204
+ );
205
+ const complete = currentOwners.size > 0 && unresolved.length === 0 && oversightComplete;
206
+ const unresolvedAssignments = unresolved.map((record) => ({
207
+ resourceType: record.type,
208
+ resourceId: record.id,
209
+ title: record.title,
210
+ ownerIds: record.ownerIds || [],
211
+ reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
212
+ }));
213
+ const detail = [];
214
+ if (!currentOwners.size) detail.push("No current person owns the program records.");
215
+ if (unresolved.length) {
216
+ detail.push(
217
+ `${unresolved.length} ${unresolved.length === 1 ? "record has" : "records have"} no current person owner: ` +
218
+ `${unresolvedAssignments.map(({ title, resourceId }) => `${title} (${resourceId})`).join(", ")}.`
219
+ );
220
+ }
221
+ if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
222
+ return item(
223
+ "program-ownership",
224
+ complete ? "complete" : "action",
225
+ "Confirm program owners and oversight",
226
+ complete
227
+ ? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
228
+ : detail.join(" "),
229
+ !oversightComplete ? oversight : unresolved[0] || { type: "person" },
230
+ { unresolvedAssignments }
231
+ );
232
+ }
233
+
234
+ function ownershipResolutionReasons(ownerIds, byId) {
235
+ if (!ownerIds.length) return [{ ownerId: null, reason: "missing-owner" }];
236
+ return ownerIds.flatMap((ownerId) => {
237
+ const owner = byId.get(ownerId);
238
+ if (!owner) return [{ ownerId, reason: "missing-record" }];
239
+ if (owner.type === "person" && owner.status !== "active") {
240
+ return [{ ownerId, reason: "inactive-person" }];
241
+ }
242
+ if (owner.type === "team" && owner.status !== "active") {
243
+ return [{ ownerId, reason: "inactive-team" }];
244
+ }
245
+ if (currentPartyPeople([ownerId], byId).size === 0) {
246
+ return [{ ownerId, reason: owner.type === "team" ? "team-has-no-current-members" : "no-current-person" }];
247
+ }
248
+ return [];
249
+ });
179
250
  }
180
251
 
181
252
  async function policiesStage(scope, byId, readMarkdown, asOf) {
@@ -183,14 +254,11 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
183
254
  const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
184
255
  record?.type === "policy" && !["superseded", "retired"].includes(record.status)
185
256
  ));
186
- const approvers = policies.flatMap((policy) => partyPeople(policy.approverIds || [], byId))
257
+ const appointedReviewer = policies
258
+ .filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
259
+ .flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
187
260
  .map((id) => byId.get(id))
188
- .filter(Boolean);
189
- const appointedReviewer = approvers.find((person) => (
190
- ["active", "external"].includes(person.status)
191
- && person.email
192
- && !(person.id === "person-independent-approver" && ["Independent Approver", "Independent Reviewer"].includes(person.title))
193
- ));
261
+ .find(Boolean);
194
262
  const items = [
195
263
  item(
196
264
  "independent-reviewer",
@@ -199,7 +267,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
199
267
  appointedReviewer
200
268
  ? `${appointedReviewer.title} is recorded as a reviewer separate from policy ownership.`
201
269
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
202
- appointedReviewer || { type: "person", id: "person-independent-approver" }
270
+ appointedReviewer || { type: "person" }
203
271
  )
204
272
  ];
205
273
  if (!policies.length) {
@@ -355,7 +423,7 @@ function evidenceCollectionStage(scope, records, byId, model) {
355
423
  ? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
356
424
  : capture
357
425
  ? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
358
- : `Run and verify one test export or test capture from an authoritative source outside FileGRC, then link it to a family control.`,
426
+ : `Run and verify one test export or test capture from an authoritative source outside filegrc, then link it to a family control.`,
359
427
  capture || { type: "evidence" },
360
428
  {
361
429
  familyId: family.id,
@@ -549,26 +617,6 @@ function substantiveMarkdown(source) {
549
617
  return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
550
618
  }
551
619
 
552
- function partiesIndependent(ownerIds = [], approverIds = [], byId) {
553
- const owners = new Set(partyPeople(ownerIds, byId));
554
- const approvers = partyPeople(approverIds, byId);
555
- return owners.size > 0 && approvers.length > 0 && !approvers.some((id) => owners.has(id));
556
- }
557
-
558
- function partyPeople(ids = [], byId, seen = new Set()) {
559
- const people = [];
560
- for (const id of ids) {
561
- if (seen.has(id)) continue;
562
- seen.add(id);
563
- const record = byId.get(id);
564
- if (record?.type === "person") people.push(id);
565
- if (record?.type === "team") {
566
- people.push(...partyPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen));
567
- }
568
- }
569
- return [...new Set(people)];
570
- }
571
-
572
620
  function policyCheckLabel(name) {
573
621
  return ({
574
622
  reviewed: "draft review",
package/src/server.js CHANGED
@@ -15,7 +15,7 @@ import { setupWorkspace } from "./setup.js";
15
15
  import { loadWorkspace } from "./workspace.js";
16
16
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
17
17
 
18
- export function createFileGRCServer(input = process.cwd(), options = {}) {
18
+ export function createFilegrcServer(input = process.cwd(), options = {}) {
19
19
  return createHttpServer(async (request, response) => {
20
20
  try {
21
21
  if (!expectedHost(request, options.allowedHosts)) {
@@ -184,7 +184,7 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
184
184
  }
185
185
  const loaded = await loadWorkspace(input);
186
186
  getResourceDefinition(loaded.model, "workspace");
187
- const server = createFileGRCServer(loaded.root, { allowedHosts: [host] });
187
+ const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
188
188
  await new Promise((resolve, reject) => {
189
189
  server.once("error", reject);
190
190
  server.listen(port, host, resolve);
package/src/setup.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createResource, updateResource } from "./files.js";
2
- import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
3
2
  import { createResourceId } from "./id.js";
4
3
  import { loadWorkspace } from "./workspace.js";
5
4
 
@@ -10,81 +9,61 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
9
  const loaded = await loadWorkspace(input);
11
10
  const setup = normalizeSetupPayload(payload);
12
11
  validateSetup(loaded, setup);
12
+ const plan = buildSetupRecords(loaded, setup);
13
13
 
14
- let current = loaded;
15
- let system = findSetupSystem(current.resources, setup);
16
- const systemId = system?.id || createResourceId("system", setup.serviceName, current.resources.map(({ id }) => id));
17
- system = {
18
- ...(system || {}),
19
- schemaVersion: 1,
20
- id: systemId,
21
- type: "system",
22
- title: setup.serviceName,
23
- status: setup.draft ? system?.status || "planned" : (system?.status === "planned" ? "active" : system?.status || "active"),
24
- criticality: setup.criticality,
25
- ownerIds: [setup.ownerId],
26
- description: setup.boundary,
27
- systemKind: system?.systemKind || "service",
28
- dataClassification: setup.dataClassification,
29
- internetExposed: setup.internetExposed,
30
- inScope: true
31
- };
32
- await upsertResource(current.root, current.resources.find(({ id }) => id === systemId), system);
33
-
34
- current = await loadWorkspace(current.root);
35
- const linkedControlIds = [];
36
- for (const control of current.resources.filter(({ type, status }) => (
37
- type === "control" && !["not-applicable", "retired"].includes(status)
38
- ))) {
39
- if ((control.systemIds || []).includes(systemId)) continue;
40
- await updateResource(current.root, "control", control.id, {
41
- ...control,
42
- systemIds: [...new Set([...(control.systemIds || []), systemId])]
43
- });
44
- linkedControlIds.push(control.id);
45
- }
14
+ await upsertResource(loaded.root, plan.existingSystem, plan.system);
15
+ await updateResource(loaded.root, "workspace", plan.workspace.id, plan.workspace);
16
+ if (plan.renderer) await updateResource(loaded.root, plan.renderer.type, plan.renderer.id, plan.renderer);
46
17
 
47
- current = await loadWorkspace(current.root);
48
- const existingWorkspace = current.resources.find(({ type }) => type === "workspace");
49
- if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
50
- const workspace = {
51
- ...existingWorkspace,
52
- assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
53
- frameworkIds: current.resources
54
- .filter(({ type, status }) => type === "framework" && status === "active")
55
- .map(({ id }) => id),
56
- requirementIds: current.resources
57
- .filter(({ type, applicability }) => type === "requirement" && applicability === "applicable")
58
- .map(({ id }) => id),
59
- controlIds: current.resources
60
- .filter(({ type, status }) => type === "control" && !["not-applicable", "retired"].includes(status))
61
- .map(({ id }) => id),
62
- systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
18
+ return {
19
+ draft: setup.draft,
20
+ system: plan.system,
21
+ workspace: plan.workspace,
22
+ linkedControlIds: [],
23
+ evidenceTestDraftIds: [],
24
+ onboardingComplete: !setup.draft
63
25
  };
64
- await updateResource(current.root, "workspace", workspace.id, workspace);
65
-
66
- current = await loadWorkspace(current.root);
67
- const renderer = current.resources.find(({ type }) => type === "renderer-settings");
68
- if (renderer) {
69
- await updateResource(current.root, renderer.type, renderer.id, {
70
- ...renderer,
71
- showOnboarding: setup.draft
72
- });
73
- }
74
- const evidenceTestDrafts = setup.draft
75
- ? { created: [], existing: [], total: 0 }
76
- : await ensureEvidenceTestDrafts(current.root);
26
+ }
77
27
 
28
+ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
29
+ const loaded = await loadWorkspace(input);
30
+ const setup = normalizeSetupPayload(payload);
31
+ validateSetup(loaded, setup);
32
+ const plan = buildSetupRecords(loaded, setup);
78
33
  return {
34
+ schemaVersion: 1,
35
+ preview: true,
79
36
  draft: setup.draft,
80
- system,
81
- workspace,
82
- linkedControlIds,
83
- evidenceTestDraftIds: evidenceTestDrafts.created.map(({ id }) => id),
37
+ changes: {
38
+ system: plan.existingSystem ? "update" : "create",
39
+ workspace: "update",
40
+ renderer: plan.renderer ? "update" : "unchanged",
41
+ controls: 0,
42
+ evidenceDrafts: 0
43
+ },
44
+ system: setupSystemSummary(plan.system),
45
+ target: setupTargetSummary(plan.workspace),
84
46
  onboardingComplete: !setup.draft
85
47
  };
86
48
  }
87
49
 
50
+ export function summarizeSetupResult(result) {
51
+ return {
52
+ schemaVersion: 1,
53
+ preview: false,
54
+ draft: result.draft,
55
+ changes: {
56
+ system: "saved",
57
+ workspace: "updated",
58
+ controls: result.linkedControlIds?.length || 0,
59
+ evidenceDrafts: result.evidenceTestDraftIds?.length || 0
60
+ },
61
+ system: setupSystemSummary(result.system),
62
+ target: setupTargetSummary(result.workspace),
63
+ onboardingComplete: result.onboardingComplete
64
+ };
65
+ }
66
+
88
67
  export function normalizeSetupPayload(payload = {}) {
89
68
  if (!payload || Array.isArray(payload) || typeof payload !== "object") {
90
69
  throw new Error("Setup input must be a JSON object.");
@@ -146,6 +125,44 @@ function findSetupSystem(resources, setup) {
146
125
  ));
147
126
  }
148
127
 
128
+ function buildSetupRecords(loaded, setup) {
129
+ const existingSystem = findSetupSystem(loaded.resources, setup);
130
+ const systemId = existingSystem?.id || createResourceId(
131
+ "system",
132
+ setup.serviceName,
133
+ loaded.resources.map(({ id }) => id)
134
+ );
135
+ const system = {
136
+ ...(existingSystem || {}),
137
+ schemaVersion: 1,
138
+ id: systemId,
139
+ type: "system",
140
+ title: setup.serviceName,
141
+ status: setup.draft
142
+ ? existingSystem?.status || "planned"
143
+ : existingSystem?.status === "planned"
144
+ ? "active"
145
+ : existingSystem?.status || "active",
146
+ criticality: setup.criticality,
147
+ ownerIds: [setup.ownerId],
148
+ description: setup.boundary,
149
+ systemKind: existingSystem?.systemKind || "service",
150
+ dataClassification: setup.dataClassification,
151
+ internetExposed: setup.internetExposed,
152
+ inScope: true
153
+ };
154
+ const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
155
+ if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
156
+ const workspace = {
157
+ ...existingWorkspace,
158
+ assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
159
+ systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
160
+ };
161
+ const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
162
+ const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
163
+ return { existingSystem, system, workspace, renderer };
164
+ }
165
+
149
166
  async function upsertResource(root, existing, record) {
150
167
  return existing
151
168
  ? updateResource(root, record.type, record.id, record)
@@ -159,6 +176,27 @@ function assuranceGoalFromSetup(goal) {
159
176
  return "none";
160
177
  }
161
178
 
179
+ function setupSystemSummary(system) {
180
+ return {
181
+ id: system.id,
182
+ title: system.title,
183
+ status: system.status,
184
+ inScope: system.inScope
185
+ };
186
+ }
187
+
188
+ function setupTargetSummary(workspace) {
189
+ return {
190
+ assuranceGoal: workspace.assuranceGoal,
191
+ scopeCounts: {
192
+ systems: workspace.systemIds?.length || 0,
193
+ frameworks: workspace.frameworkIds?.length || 0,
194
+ requirements: workspace.requirementIds?.length || 0,
195
+ controls: workspace.controlIds?.length || 0
196
+ }
197
+ };
198
+ }
199
+
162
200
  function booleanValue(value, name) {
163
201
  if (value === true || value === false) return value;
164
202
  if (value === "true") return true;
package/src/state.js CHANGED
@@ -47,7 +47,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
47
47
  dataModelVersion: loaded.model.modelVersion,
48
48
  id: "workspace",
49
49
  type: "workspace",
50
- title: "FileGRC workspace",
50
+ title: "filegrc workspace",
51
51
  organizationName: "Workspace configuration unavailable",
52
52
  timezone: "UTC"
53
53
  };
package/src/validate.js CHANGED
@@ -3,6 +3,7 @@ import { getResourceDefinition } from "../model/index.js";
3
3
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
4
4
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
5
5
  import { obligationIsRunning } from "./program-lifecycle.js";
6
+ import { partyPeople } from "./parties.js";
6
7
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
7
8
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
8
9
  import { indexResources, loadWorkspace } from "./workspace.js";
@@ -223,9 +224,10 @@ function validateEvidencePaths(record, path, diagnostics) {
223
224
  }
224
225
 
225
226
  function validateIndependentApproval(record, byId, path, diagnostics) {
226
- if (!["policy", "document"].includes(record.type) || !(record.approverIds || []).length) return;
227
- const owners = expandPeople(record.ownerIds || [], byId);
228
- const approvers = expandPeople(record.approverIds || [], byId);
227
+ if (!["policy", "document"].includes(record.type)) return;
228
+ if (!(record.approverIds || []).length) return;
229
+ const owners = partyPeople(record.ownerIds || [], byId);
230
+ const approvers = partyPeople(record.approverIds || [], byId);
229
231
  const overlap = [...owners].filter((id) => approvers.has(id));
230
232
  if (overlap.length) {
231
233
  diagnostics.push(error(
@@ -234,37 +236,6 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
234
236
  `Approvers must be separate from owners, including through team membership: ${overlap.join(", ")}.`
235
237
  ));
236
238
  }
237
- if (["approved", "active"].includes(record.status)) {
238
- const incompleteStarterApprover = [...approvers]
239
- .map((id) => byId.get(id))
240
- .find((person) => (
241
- person?.id === "person-independent-approver"
242
- && ["Independent Approver", "Independent Reviewer"].includes(person.title)
243
- ));
244
- if (incompleteStarterApprover) {
245
- diagnostics.push(error(
246
- "independent-approver-not-appointed",
247
- path,
248
- `The selected approver "${incompleteStarterApprover.title}" is still the starter placeholder. Open People and replace it with the reviewer's actual name before approving this record. The reviewer may be internal or external but must be separate from the owner.`
249
- ));
250
- }
251
- }
252
- }
253
-
254
- function expandPeople(ids, byId, seen = new Set()) {
255
- const people = new Set();
256
- for (const id of ids) {
257
- if (seen.has(id)) continue;
258
- seen.add(id);
259
- const record = byId.get(id);
260
- if (record?.type === "person") people.add(id);
261
- if (record?.type === "team") {
262
- for (const personId of expandPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen)) {
263
- people.add(personId);
264
- }
265
- }
266
- }
267
- return people;
268
239
  }
269
240
 
270
241
  function validateObligation(record, path, diagnostics) {