filegrc 0.16.17 → 0.16.19

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/model/v11.json CHANGED
@@ -2603,7 +2603,7 @@
2603
2603
  "cadence": "Create before relying on a promise, review during audit scoping, and supersede it when the service or agreement changes.",
2604
2604
  "reviewPoints": [
2605
2605
  "Confirm the statement reflects a real customer promise, service requirement, or approved business objective that applies to the current scope.",
2606
- "Confirm which parts of the service the promise or objective affects.",
2606
+ "Confirm which parts of the service the promise or objective affects. When a linked Policy or Document has independent sections, name the exact governing Markdown heading in sourceSectionHeadings before reviewing downstream mappings.",
2607
2607
  "Use \"External\", \"Zero Population\", or \"Not Applicable\" only when it is true for the current scope."
2608
2608
  ],
2609
2609
  "sourceResourceIds": [
@@ -2657,6 +2657,12 @@
2657
2657
  "requirement"
2658
2658
  ]
2659
2659
  },
2660
+ "sourceSectionHeadings": {
2661
+ "type": "object",
2662
+ "objectType": "string-map",
2663
+ "label": "Reviewed source sections",
2664
+ "description": "Map a source Policy or Document ID to one exact Markdown heading when the Commitment comes from that section."
2665
+ },
2660
2666
  "requirementIds": {
2661
2667
  "type": "array",
2662
2668
  "items": "id",
@@ -2731,6 +2737,7 @@
2731
2737
  "formFields": [
2732
2738
  "systemIds",
2733
2739
  "sourceResourceIds",
2740
+ "sourceSectionHeadings",
2734
2741
  "requirementIds",
2735
2742
  "controlIds",
2736
2743
  "applicabilityReview",
@@ -7706,6 +7713,7 @@
7706
7713
  "reviewPoints": [
7707
7714
  "Confirm the Information Types and operational scope covered by this item.",
7708
7715
  "Confirm the cutoff, retention period, and disposition action from management sources.",
7716
+ "When a linked Policy or Document has independent sections, set sourceSectionHeadings to the exact governing Markdown heading. Keep a whole-source binding when the entire source governs the row.",
7709
7717
  "Keep the item planned until the owner completes every organization-specific value and binds the current source revisions."
7710
7718
  ],
7711
7719
  "sourceResourceIds": [
@@ -7779,6 +7787,12 @@
7779
7787
  ],
7780
7788
  "label": "Authority and source records"
7781
7789
  },
7790
+ "sourceSectionHeadings": {
7791
+ "type": "object",
7792
+ "objectType": "string-map",
7793
+ "label": "Reviewed source sections",
7794
+ "description": "Map a source Policy or Document ID to one exact Markdown heading when only that section governs this decision. Omit the ID when the complete source governs it."
7795
+ },
7782
7796
  "cutoff": {
7783
7797
  "type": "object",
7784
7798
  "objectType": "retention-cutoff",
@@ -7882,6 +7896,7 @@
7882
7896
  "scopeResourceIds",
7883
7897
  "scheduleDocumentId",
7884
7898
  "sourceResourceIds",
7899
+ "sourceSectionHeadings",
7885
7900
  "cutoff",
7886
7901
  "retentionPeriod",
7887
7902
  "dispositionAction",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.17",
3
+ "version": "0.16.19",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -122,13 +122,15 @@ export function buildAgentGuide(loaded, type, options = {}) {
122
122
  oneOf: definition.oneOf ?? [],
123
123
  markdown,
124
124
  workflow: [
125
- "Inspect existing records and relation candidates before writing.",
125
+ "Inspect the recommended action, existing records, relationships, source Markdown, and available evidence. Do the research, drafting, record edits, and validation you can do yourself.",
126
126
  definition.singleton
127
127
  ? "Open the existing singleton record and update only gaps supported by facts from an authoritative source."
128
128
  : "Reuse and update a suitable existing record. Create a scaffold only when no record covers the work, then replace every null value and empty required array with facts from an authoritative source.",
129
129
  recommendedMarkdown.length
130
130
  ? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
131
131
  : "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
132
+ "If a material fact, external action, approval, or management decision is still missing, ask the user one specific question. State what you inspected, the exact missing input, and what it unlocks. Do not hand off FileGRC commands, routine record entry, or a vague request to confirm. Continue the workflow after the answer without asking for another handoff.",
133
+ ...(type === "control" ? ["Separate implementation from operation: document the working configuration, scope, owner, procedure, source, and enabled rules when its operation pattern needs them before marking Implemented. Record dated operating work and evidence only after a real cycle or event occurs; a ready schedule is not proof of operation."] : []),
132
134
  ...(type === "program" && modelSupports(loaded.model, "program-scope")
133
135
  ? ["Review Requirement applicability with npx filegrc review-applicability --type requirement --scaffold, then preview and apply the reviewed decisions as one validated batch."]
134
136
  : []),
package/src/cli.js CHANGED
@@ -586,12 +586,14 @@ export async function runCli(argv = process.argv.slice(2)) {
586
586
  ? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
587
587
  : null;
588
588
  if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule records and Requirement Mappings.");
589
- const revisions = await resourceReviewRevisions(loaded, dependencyIds);
589
+ const revisions = await resourceReviewRevisions(loaded, dependencyIds, "legacy", record);
590
+ const missingSectionIds = dependencyIds.filter((id) => Object.hasOwn(record.sourceSectionHeadings || {}, id) && !revisions.has(id));
590
591
  const result = {
591
592
  resource: { type: record.type, id: record.id, title: record.title },
592
593
  dependencyIds,
593
594
  reviewedSourceRevisions: Object.fromEntries(revisions),
594
- missingResourceIds: dependencyIds.filter((id) => !revisions.has(id))
595
+ missingResourceIds: dependencyIds.filter((id) => !revisions.has(id) && !missingSectionIds.includes(id)),
596
+ ...(missingSectionIds.length ? { missingSectionIds } : {})
595
597
  };
596
598
  if (flags.json) console.log(JSON.stringify(result, null, 2));
597
599
  else {
@@ -1743,7 +1745,7 @@ function agentOverview(model) {
1743
1745
  commit: "git diff --check && git diff && git add <reviewed-paths> && git commit -m <reason>"
1744
1746
  };
1745
1747
  return {
1746
- rule: "Treat data/ as the source of truth. Run guide before creating an unfamiliar type, validate after every write, review the Git diff, then commit a focused change.",
1748
+ rule: "Treat data/ as the source of truth. Inspect existing records and sources, then do the research, edits, and validation you can do. Ask the user only for a specific material fact, external action, approval, or decision you cannot establish; explain what it unlocks and continue after the answer. Run guide before creating an unfamiliar type, validate every write, review the Git diff, and commit a focused change.",
1747
1749
  programPath: buildAgentProgramPath(model),
1748
1750
  actions: Object.fromEntries(Object.entries(commands).map(([name, command]) => [
1749
1751
  name,
@@ -30,6 +30,10 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
30
30
  // Accept both the pre-0.16 scope: input and the digest-only input used by
31
31
  // 0.16.0, as well as the older collection basis from 0.9.1.
32
32
  if (revisionsMatch("collection", storedRevision, currentRevision)) return true;
33
+ if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
34
+ && revisionsMatch("collection", storedRevision, calculateCollectionRevision(
35
+ loaded, resourceType, { ...options, legacyRetentionSourceBasis: true }, false
36
+ ))) return true;
33
37
  const allowOlderBasis = resourceType !== "retention-schedule-item"
34
38
  || !modelSupports(loaded.model, "retention-schedule-approval");
35
39
  for (const olderBasis of [false, true]) {
@@ -152,13 +156,17 @@ function historicallyEquivalentCollection(loaded, resourceType, stored, options,
152
156
  // Require the complete reviewed basis to be unchanged under the current
153
157
  // semantic calculation before accepting an older byte-sensitive digest.
154
158
  if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue;
155
- for (const legacy of [false, true]) {
156
- for (const scopeHashInput of ["legacy", "digest"]) {
157
- for (const scopeFactsInput of ["legacy", "source"]) {
158
- if (revisionsMatch("collection", stored, calculateCollectionRevision(
159
- snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata: true },
160
- legacy, scopeHashInput, scopeFactsInput
161
- ))) return true;
159
+ for (const legacyRetentionSourceBasis of resourceType === "retention-schedule-item" ? [false, true] : [false]) {
160
+ for (const historicalReviewMetadata of legacyRetentionSourceBasis ? [false] : [true]) {
161
+ for (const legacy of [false, true]) {
162
+ for (const scopeHashInput of ["legacy", "digest"]) {
163
+ for (const scopeFactsInput of ["legacy", "source"]) {
164
+ if (revisionsMatch("collection", stored, calculateCollectionRevision(
165
+ snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata, legacyRetentionSourceBasis },
166
+ legacy, scopeHashInput, scopeFactsInput
167
+ ))) return true;
168
+ }
169
+ }
162
170
  }
163
171
  }
164
172
  }
@@ -175,7 +183,12 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
175
183
  : Object.hasOwn(options, "program")
176
184
  ? options.program
177
185
  : resolveProgram(loaded, options.programId);
178
- const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy, historicalReviewMetadata: options.historicalReviewMetadata })
186
+ const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, {
187
+ legacy,
188
+ historicalReviewMetadata: options.historicalReviewMetadata,
189
+ historicalCommit: options.historicalCommit,
190
+ legacyRetentionSourceBasis: options.legacyRetentionSourceBasis
191
+ })
179
192
  .map((input) => [input.record.id, input]));
180
193
  const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
181
194
  if (authoritativeSource) {
@@ -1,6 +1,6 @@
1
1
  import { modelSupports } from "../model/index.js";
2
2
  import { programComponents, selectedRequirementIds } from "./program.js";
3
- import { retentionReviewResourceIds, retentionUses } from "./retention.js";
3
+ import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionUses } from "./retention.js";
4
4
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
5
5
 
6
6
  export function scopedCollectionRecords(loaded, resourceType, program) {
@@ -210,6 +210,8 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
210
210
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
211
211
  const records = new Map(reviewed.map((record) => [record.id, record]));
212
212
  const reviewedIds = new Set(records.keys());
213
+ const scheduleDocumentIds = new Set(resourceType === "retention-schedule-item"
214
+ ? reviewed.map((record) => record.scheduleDocumentId).filter(Boolean) : []);
213
215
  const addIds = (ids) => {
214
216
  for (const id of ids || []) {
215
217
  const record = byId.get(id);
@@ -363,7 +365,7 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
363
365
  record,
364
366
  value: reviewedIds.has(record.id)
365
367
  ? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
366
- ? retentionScheduleRowProposalValue(record, options.historicalReviewMetadata)
368
+ ? retentionScheduleRowProposalValue(record, loaded, options)
367
369
  : options.historicalReviewMetadata ? record : substantiveRevisionValue(record)
368
370
  : resourceType === "retention-schedule-item"
369
371
  && modelSupports(loaded.model, "retention-schedule-approval")
@@ -371,19 +373,25 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
371
373
  ? retentionScheduleDocumentProposalValue(record)
372
374
  : options.historicalReviewMetadata
373
375
  ? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
374
- : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel)),
376
+ : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)),
375
377
  includeContent: legacy
376
378
  || reviewedIds.has(record.id)
377
- || dependencyContentAffectsRevision(resourceType, record.type, options.historicalReviewMetadata, currentModel)
379
+ || dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)
378
380
  }));
379
381
  }
380
382
 
381
- function retentionScheduleRowProposalValue(record, historicalReviewMetadata = false) {
382
- return Object.fromEntries(Object.entries(record).filter(([field]) => ![
383
+ function retentionScheduleRowProposalValue(record, loaded, options = {}) {
384
+ const value = Object.fromEntries(Object.entries(record).filter(([field]) => ![
383
385
  "approvedByIds",
384
386
  "approvedOn",
385
- ...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
387
+ ...(options.historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
386
388
  ].includes(field)));
389
+ if (!options.historicalReviewMetadata && !options.legacyRetentionSourceBasis) {
390
+ value.sourceDecisionRevisions = Object.fromEntries(resourceReviewRevisionsSync(
391
+ loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record
392
+ ));
393
+ }
394
+ return value;
387
395
  }
388
396
 
389
397
  function retentionScheduleDocumentProposalValue(record) {
@@ -533,7 +541,7 @@ const dependencyFields = {
533
541
  obligation: ["id", "type", "status", "activityType", "scheduleMode", "ownerIds", "activeRuleId", "policyIds", "controlIds", "ruleIds"],
534
542
  "source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "retrieverIds", "collectionCadence", "retentionScheduleItemIds", "reconciliationMethod", "validFrom", "validThrough", "applicabilityReview", "readinessTestEvidenceIds", "ownerIds", "componentId"],
535
543
  evidence: ["id", "type", "status", "artifactKind", "readinessTest", "retrievalResult", "accessConfirmed", "coveredSourceFamilyIds", "sourceComponentId", "componentIds", "systemIds", "collectedOn", "collectorIds", "verifierIds", "verifiedOn"],
536
- "retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
544
+ "retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "sourceSectionHeadings", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
537
545
  document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
538
546
  policy: ["id", "type", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "relatedDocumentIds", "controlIds"],
539
547
  training: ["id", "type", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
@@ -597,7 +605,12 @@ const dependencyContentTypes = {
597
605
  "complementary-control": new Set(["system", "document", "component"])
598
606
  };
599
607
 
600
- function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false) {
608
+ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false, scheduleDocumentIds = new Set(), legacyRetentionSourceBasis = false) {
609
+ if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata && !legacyRetentionSourceBasis
610
+ && (["policy", "requirement", "commitment", "control"].includes(record.type)
611
+ || record.type === "document" && record.documentKind !== "schedule" && !scheduleDocumentIds.has(record.id))) {
612
+ return { id: record.id, type: record.type };
613
+ }
601
614
  const controlDecision = currentModel && !historicalReviewMetadata && record.type === "control"
602
615
  && ["retention-schedule-item", "complementary-control"].includes(resourceType);
603
616
  const fields = controlDecision
@@ -616,9 +629,12 @@ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewM
616
629
  return value;
617
630
  }
618
631
 
619
- function dependencyContentAffectsRevision(resourceType, dependencyType, historicalReviewMetadata = false, currentModel = false) {
620
- return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependencyType === "control")
621
- || dependencyContentTypes[resourceType]?.has(dependencyType) || false;
632
+ function dependencyContentAffectsRevision(resourceType, dependency, historicalReviewMetadata = false, currentModel = false, scheduleDocumentIds = new Set(), legacyRetentionSourceBasis = false) {
633
+ if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata && !legacyRetentionSourceBasis
634
+ && (dependency.type === "policy" || dependency.type === "document"
635
+ && dependency.documentKind !== "schedule" && !scheduleDocumentIds.has(dependency.id))) return false;
636
+ return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependency.type === "control")
637
+ || dependencyContentTypes[resourceType]?.has(dependency.type) || false;
622
638
  }
623
639
 
624
640
  function sorted(values) {
@@ -128,7 +128,7 @@ export async function planProgramAmendment(input, options = {}) {
128
128
  const retention = related.filter((record) => (
129
129
  record.type === "retention-schedule-item"
130
130
  && record.status === "active"
131
- && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded)
131
+ && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded, record)
132
132
  ));
133
133
  if (retention.length) {
134
134
  missing.push({
@@ -161,10 +161,10 @@ export async function planProgramAmendment(input, options = {}) {
161
161
  };
162
162
  }
163
163
 
164
- function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded) {
164
+ function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded, reviewer = null) {
165
165
  const expected = new Set(expectedIds);
166
166
  if (Object.keys(reviewed).length !== expected.size) return true;
167
- return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id]));
167
+ return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id], reviewer));
168
168
  }
169
169
 
170
170
  export async function assessProgramAmendmentReadiness(loaded) {
@@ -4,11 +4,11 @@ export const RESOURCE_INSTRUCTIONS = {
4
4
  program: "Set the Program goal, owners, Systems, Frameworks, criteria decisions, and risk method.",
5
5
  person: "Record the person’s actual job title and current status.",
6
6
  appointment: "Assign a named authority to one Person with its start date and scope.",
7
- team: "Confirm the Team’s members and chair.",
7
+ team: "Use the current Person and Appointment records to set the Team’s members and chair; ask who holds a role only when those sources cannot establish it.",
8
8
  system: "Define the service boundary, exclusions, owners, and information it handles.",
9
9
  component: "Record a material service or evidence source and its role in each linked System.",
10
10
  vendor: "Record each material external provider and the Components it supplies.",
11
- classification: "Confirm the handling categories and apply them to information and evidence.",
11
+ classification: "Use current Policy content and recorded information needs to propose handling categories, then apply the management decision to information and evidence. Ask about any category those sources do not settle.",
12
12
  "information-type": "Name a stable category of information, set its default Classification, and link its uses.",
13
13
  framework: "Select the criteria Framework and version used by the Program.",
14
14
  requirement: "Review whether this criterion applies to the Program and record the decision and rationale.",
@@ -22,7 +22,7 @@ export const RESOURCE_INSTRUCTIONS = {
22
22
  evidence: "Record a real artifact or approved reference, link its source and supported work, then have another person verify it.",
23
23
  "risk-assessment": "Assess the in-scope service, record conclusions, and obtain an independent review.",
24
24
  risk: "Name the threat and impact, assign an owner, rate the risk, and record its response.",
25
- obligation: "Confirm the owner, cadence or trigger, deadline, and proof, then enable the rule when its Control is ready.",
25
+ obligation: "Read the linked Policy and Control, fill the owner, cadence or trigger, deadline, and proof from those sources, then enable the rule when its Control is ready. Ask only for an unresolved management choice.",
26
26
  "retention-schedule-item": "Set the Information Types, scope, cutoff, period, disposition, and sources for one retention rule.",
27
27
  "obligation-event": "Record the actual event date and subject, then complete its linked Work Queue actions.",
28
28
  "policy-review": "Record the review date, reviewer, decision, and any required follow-up.",
@@ -101,11 +101,11 @@ export const RESOURCE_OUTPUTS = {
101
101
  };
102
102
 
103
103
  export const RESOURCE_PAGE_SUMMARIES = {
104
- person: "Confirm who works on the program.",
104
+ person: "Use current people already recorded; ask for a missing person's identity or role only when needed.",
105
105
  appointment: "Assign named authority.",
106
- team: "Confirm shared owners and members.",
106
+ team: "Set shared owners and members from current People and Appointments.",
107
107
  program: "Choose the goal, scope, criteria, controls, owners, and risk method.",
108
- framework: "Confirm the SOC 2 framework.",
108
+ framework: "Select the applicable SOC 2 framework and version from the installed catalog.",
109
109
  requirement: "Decide which SOC 2 criteria apply.",
110
110
  commitment: "Record customer promises that affect scope.",
111
111
  "requirement-mapping": "Review how supplemental promises relate to Requirements and Controls.",
@@ -134,8 +134,8 @@ export const PROGRAM_PATH = [
134
134
  description: "Ownership, criteria, and service boundary",
135
135
  summary: "A reviewed Program scope with owners, criteria, Systems, and material providers.",
136
136
  sections: [
137
- { id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the program lead, policy owner, independent reviewer, and oversight team.", "Replace the reporting-channel placeholders with real normal and fallback routes, then commit the proposal."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
138
- { id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Set the Program goal, owners, risk method, and tentative timing.", "Review each selected criterion for this service and record its applicability decision.", "Replace the starter Commitment prompt with the real promise; add only needed supplemental mappings."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
137
+ { id: "ownership", title: "Program Ownership", description: "Assign program owners and set the normal security reporting channel and its fallback.", steps: ["Inspect current People and Appointments, then assign the program lead, policy owner, independent reviewer, and oversight team. Ask only for missing assignments.", "Use the actual normal and fallback routes to replace reporting-channel placeholders, then commit the proposal."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
138
+ { id: "criteria", title: "Program and Criteria", description: "Define the Program, select its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Set the Program goal, owners, risk method, and tentative timing from current records; ask for unresolved management choices.", "Compare each selected criterion with the service scope and record its applicability decision.", "Replace the starter Commitment prompt with the actual promise; add only needed supplemental mappings."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
139
139
  { id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Define the service and exclusions in a bounded System selected by the Program.", "Add material Components and Vendors with their actual System relationships.", "Record the Information Types and Classifications the service uses."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
140
140
  ],
141
141
  resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
@@ -160,7 +160,7 @@ export const PROGRAM_PATH = [
160
160
  description: "Approve governed content and retention decisions",
161
161
  summary: "Approved Policies, program Documents, Training content, and retention decisions.",
162
162
  sections: [
163
- { id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record. Replace placeholders, confirm the owner and intended values, then have a separate person approve the exact Markdown revision.", output: "Approved content bound to the reviewed revisions.", steps: ["Replace placeholders with the company's real rules, plans, and training material.", "Confirm the owner, separate approver, linked Controls, audience, and intended values.", "Record the independent approval and date against each exact Markdown revision."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
163
+ { id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record. Draft from current approved decisions, replace placeholders, and resolve only material choices with management. A separate person then approves the exact Markdown revision.", output: "Approved content bound to the reviewed revisions.", steps: ["Draft the company's real rules, plans, and training material from current scope and approved decisions.", "Resolve missing owner, separate approver, Control links, audience, or intended values from authoritative records; ask only when a material decision remains.", "Have the independent approver review the exact Markdown revision and record their actual decision and date."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
164
164
  { id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its completed rows together.", steps: ["Review the governing schedule and any legal hold or exception rules.", "Complete a row for each distinct information use, cutoff, retention period, and disposition.", "Have a separate reviewer approve the complete document and row set."], types: [], utility: "retention-schedule", defaultOpen: true }
165
165
  ],
166
166
  resourceTypes: [],
@@ -184,7 +184,7 @@ export const PROGRAM_PATH = [
184
184
  description: "Implement and prepare to operate",
185
185
  summary: "Implemented Controls with ready evidence sources and enabled work schedules.",
186
186
  sections: [
187
- { id: "controls", title: "Controls", description: "Put each selected Control into use and record what is actually in place.", steps: ["Open each Control for its specific setup action and current missing checks.", "Configure the activity and record its procedure, Systems, owner, and evidence sources.", "Mark it Implemented with its real start date after its sources and schedules are ready."], types: ["control"], defaultOpen: true },
187
+ { id: "controls", title: "Controls", description: "Put each selected Control into use and record its working design. Dated operating proof follows when work occurs.", steps: ["Open each Control, linked records, and current missing checks; do the setup that can be established from those sources.", "Configure the activity and record its procedure, Systems, owner, evidence sources, and any work rules its operation pattern requires.", "Mark it Implemented with its real start date when it works. Record later cycles and events separately with dated proof."], types: ["control"], defaultOpen: true },
188
188
  { id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Identify any customer or carved-out provider action an in-scope Control depends on.", "Record each dependency, or confirm that the current scope has none."], types: ["complementary-control"], defaultOpen: true },
189
189
  { id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["For each Control family, link an active Component with the required source kind, access owners, and retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
190
190
  { id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Check the owner, trigger or cadence, due window, and required proof for each Obligation.", "Enable the rules that will run the implemented Controls."], types: ["obligation"], defaultOpen: true }
@@ -217,7 +217,7 @@ export async function assessProgramReadiness(input, options = {}) {
217
217
  ? item
218
218
  : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
219
219
  }));
220
- controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
220
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations for scheduled or event work, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
221
221
  const scopeReadinessStage = scopeStage(
222
222
  program,
223
223
  scope,
@@ -596,7 +596,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
596
596
  items.push(item(
597
597
  "criteria",
598
598
  criteriaComplete ? "complete" : "action",
599
- "Confirm Trust Services criteria, Description Criteria, and Controls",
599
+ "Resolve criteria decisions and Control coverage",
600
600
  criteriaComplete
601
601
  ? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
602
602
  : missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
@@ -992,7 +992,7 @@ function programOwnershipItem(records, byId) {
992
992
  return item(
993
993
  "program-ownership",
994
994
  complete ? "complete" : "action",
995
- "Confirm program owners and oversight",
995
+ "Assign program owners and oversight",
996
996
  complete
997
997
  ? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
998
998
  : detail.join(" "),
@@ -1619,7 +1619,7 @@ function policyActivationItem(assessment) {
1619
1619
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
1620
1620
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
1621
1621
  const message = assessment.state === "active-and-operating"
1622
- ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
1622
+ ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations where scheduled or event work requires them.`
1623
1623
  : assessment.state === "ready-to-activate"
1624
1624
  ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
1625
1625
  : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
@@ -2128,7 +2128,7 @@ const starterControlChecks = {
2128
2128
  "control-monitoring-remediation": "Schedule the first Control review. Name who will inspect incidents, tests, Exceptions, overdue work, and follow-up closure when those records exist.",
2129
2129
  "control-access-authorization": "Open access lists for in-scope Systems and Vendor tools. Check that accounts use unique identities and only needed permissions, and new grants require a business reason and approval.",
2130
2130
  "control-strong-authentication": "Open the System and Vendor lists. For each tool used for production, source code, email, identity, or sensitive data, check MFA, unique accounts, default credentials, and admin roles.",
2131
- "control-access-review-offboarding": "Confirm access lists can be exported from key Systems and Vendor tools. Assign the first access review and the owner who will remove access when a departure occurs.",
2131
+ "control-access-review-offboarding": "Check whether key Systems and Vendor tools can export access lists. Set the first access review and name who removes access after a departure.",
2132
2132
  "control-physical-workspace-security": "Walk through actual work areas and visitor access. Check who can enter nonpublic spaces and how devices, papers, screens, and conversations are protected.",
2133
2133
  "control-data-classification-inventory": "List important data stores in Systems and Vendor tools. Check each has an owner, classification, approved purpose, and recorded location.",
2134
2134
  "control-encryption-transmission": "For Systems, Vendor tools, and devices holding Confidential or Restricted data, check encryption at rest and in transit and who can manage the keys.",
@@ -2154,16 +2154,16 @@ export function controlImplementationSteps(control, checks, options = {}) {
2154
2154
  if (!checks.implemented) steps.push(control.id === "control-strong-authentication" && options.targets?.length
2155
2155
  ? `Ensure MFA and unique accounts are enabled for ${options.targets.join(", ")}; check default credentials and admin roles.`
2156
2156
  : starterControlChecks[control.id]
2157
- || "Inspect the systems, people, vendors, or work this Control covers. Compare current practice with its statement and fix any gaps.");
2157
+ || `Inspect the Systems, people, Vendors, and work linked to ${control.title || control.id}. Compare the actual configuration with this Control's statement and fix the specific gaps.`);
2158
2158
  const record = [];
2159
- if (!checks.owner) record.push("choose an owner");
2160
- if (!checks.scope) record.push("select the Systems this covers");
2161
- if (!checks.operationPattern) record.push("set when it runs");
2162
- if (!checks.procedure) record.push("write who does what and when in Procedure");
2163
- if (!checks.evidenceSource) record.push("link the tool or system that can show it happened");
2159
+ if (!checks.owner) record.push("assign the actual owner from existing People or Appointments");
2160
+ if (!checks.scope) record.push("link the in-scope Systems already in the Program");
2161
+ if (!checks.operationPattern) record.push("record whether it runs continuously, per transaction, on a schedule, or after an event");
2162
+ if (!checks.procedure) record.push("write the current actors, configuration, and procedure in Record Markdown");
2163
+ if (!checks.evidenceSource) record.push("link the authoritative Component that can produce proof");
2164
2164
  if (!checks.policyMapping && checks.policyMapping !== undefined) record.push("link the Policy it implements");
2165
2165
  if (!checks.criteriaMapping && checks.criteriaMapping !== undefined) record.push("link the criteria it covers");
2166
- if (!checks.implementationDate) record.push("record the actual date it starts working when marking Implemented");
2166
+ if (!checks.implementationDate) record.push("record the date the working implementation took effect when marking Implemented");
2167
2167
  if (checks.procedureRevision === false || checks.procedureEffective === false) {
2168
2168
  record.push("record the Procedure revision and effective date");
2169
2169
  }
@@ -2174,16 +2174,16 @@ export function controlImplementationSteps(control, checks, options = {}) {
2174
2174
  ? `Cover the missing evidence families: ${sourceTargets.length ? sourceTargets.join(" and ") : "this Control"}. For each, link an active Component with the matching kind, evidence owners, and report retrieval instructions.`
2175
2175
  : null;
2176
2176
  const queueStep = checks.workQueue === false
2177
- ? "Check the linked Obligations. Enable one with the owner and schedule for this Control's recurring or event work, or add one if none covers it."
2177
+ ? "Inspect linked Obligations and their Policy basis. Enable a suitable existing rule with its owner and deadline, or add a rule only if no existing one covers this work. A scheduled rule does not prove a cycle occurred."
2178
2178
  : null;
2179
2179
  if (!checks.implemented) {
2180
2180
  if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
2181
- else if (checks.applicability === false) steps.push("Confirm this Control applies to the current Program scope with `review-applicability --scaffold --type control`.");
2182
- else steps.push("When these launch conditions hold, mark the Control Implemented. This asserts the practice works now; record proof from future cycles and events as they occur.");
2181
+ else if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
2182
+ else steps.push("When these launch conditions hold, mark the Control Implemented with its real effective date. Record dated proof later, when a cycle or event actually occurs.");
2183
2183
  } else {
2184
2184
  if (sourceStep) steps.push(sourceStep);
2185
2185
  if (queueStep) steps.push(queueStep);
2186
- if (checks.applicability === false) steps.push("Confirm this Control applies to the current Program scope with `review-applicability --scaffold --type control`.");
2186
+ if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
2187
2187
  }
2188
2188
  return steps;
2189
2189
  }
package/src/retention.js CHANGED
@@ -156,79 +156,106 @@ export async function resourceReviewRevision(loaded, resourceId) {
156
156
  return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null;
157
157
  }
158
158
 
159
- export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy") {
159
+ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy", reviewer = null) {
160
160
  const wanted = new Set(ids);
161
161
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
162
162
  const revisions = new Map();
163
163
  const reviewing = new Set();
164
- const review = async (id) => {
165
- if (revisions.has(id)) return revisions.get(id);
166
- const entry = entries.get(id);
167
- if (!entry || reviewing.has(id)) return null;
168
- reviewing.add(id);
169
- const parts = [reviewSource(loaded, entry, scopeHashInput)];
164
+ const review = async (id, heading = null) => {
165
+ const key = `${id}\0${heading || ""}`;
166
+ if (revisions.has(key)) return revisions.get(key);
167
+ const entry = effectiveReviewEntry(loaded, entries.get(id));
168
+ if (!entry || reviewing.has(key)) return null;
169
+ reviewing.add(key);
170
+ const parts = [reviewSource(loaded, entry, scopeHashInput, false, heading)];
171
+ let matchedSection = !heading;
170
172
  for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
171
173
  try {
172
- parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
174
+ const source = entry.reviewCommit
175
+ ? entry.reviewFiles?.get(`data/${markdown.path}`)
176
+ ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
177
+ : await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
178
+ if (source !== null) {
179
+ const section = reviewedMarkdownSection(source, heading);
180
+ if (section !== null) { parts.push(section); matchedSection = true; }
181
+ }
173
182
  } catch (error) {
174
183
  if (error.code !== "ENOENT") throw error;
175
184
  }
176
185
  }
186
+ if (!matchedSection) { reviewing.delete(key); return null; }
187
+ let missingDependency = false;
177
188
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
178
- const revision = await review(sourceId);
189
+ const revision = await review(sourceId, sourceHeading(entry.record, sourceId));
179
190
  if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
191
+ else missingDependency = true;
180
192
  }
181
- reviewing.delete(id);
193
+ reviewing.delete(key);
194
+ if (missingDependency) return null;
182
195
  const revision = contentRevision(parts.join("\n"));
183
- revisions.set(id, revision);
196
+ revisions.set(key, revision);
184
197
  return revision;
185
198
  };
199
+ const selected = new Map();
186
200
  for (const id of wanted) {
187
- await review(id);
201
+ selected.set(id, await review(id, sourceHeading(reviewer, id)));
188
202
  }
189
- return new Map([...revisions].filter(([id]) => wanted.has(id)));
203
+ return new Map([...selected].filter(([, revision]) => revision));
190
204
  }
191
205
 
192
- export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null) {
206
+ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null) {
193
207
  const wanted = new Set(ids);
194
208
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
195
209
  const revisions = new Map();
196
210
  const reviewing = new Set();
197
- const review = (id) => {
198
- if (revisions.has(id)) return revisions.get(id);
199
- const entry = entries.get(id);
200
- if (!entry || reviewing.has(id)) return null;
201
- reviewing.add(id);
202
- const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
211
+ const review = (id, heading = null) => {
212
+ const key = `${id}\0${heading || ""}`;
213
+ if (revisions.has(key)) return revisions.get(key);
214
+ const entry = historicalCommit ? entries.get(id) : effectiveReviewEntry(loaded, entries.get(id));
215
+ if (!entry || reviewing.has(key)) return null;
216
+ reviewing.add(key);
217
+ const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, heading)];
218
+ let matchedSection = !heading;
203
219
  for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
204
220
  try {
205
- const source = historicalCommit
206
- ? loaded.historicalFiles
207
- ? loaded.historicalFiles.get(`data/${markdown.path}`) ?? null
208
- : getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
221
+ const source = (historicalCommit || entry.reviewCommit)
222
+ ? loaded.historicalFiles?.get(`data/${markdown.path}`)
223
+ ?? entry.reviewFiles?.get(`data/${markdown.path}`)
224
+ ?? getFileAtRevision(loaded.root, historicalCommit || entry.reviewCommit, `data/${markdown.path}`)
209
225
  : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
210
- if (source !== null) parts.push(source);
226
+ if (source !== null) {
227
+ const section = legacySource ? source : reviewedMarkdownSection(source, heading);
228
+ if (section !== null) { parts.push(section); matchedSection = true; }
229
+ }
211
230
  } catch (error) {
212
231
  if (error.code !== "ENOENT") throw error;
213
232
  }
214
233
  }
234
+ if (!matchedSection) { reviewing.delete(key); return null; }
235
+ let missingDependency = false;
215
236
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
216
- const revision = review(sourceId);
237
+ const revision = review(sourceId, sourceHeading(entry.record, sourceId));
217
238
  if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
239
+ else missingDependency = true;
218
240
  }
219
- reviewing.delete(id);
241
+ reviewing.delete(key);
242
+ if (missingDependency) return null;
220
243
  const revision = contentRevision(parts.join("\n"));
221
- revisions.set(id, revision);
244
+ revisions.set(key, revision);
222
245
  return revision;
223
246
  };
224
- for (const id of wanted) review(id);
225
- return new Map([...revisions].filter(([id]) => wanted.has(id)));
247
+ const selected = new Map();
248
+ for (const id of wanted) selected.set(id, review(id, sourceHeading(reviewer, id)));
249
+ return new Map([...selected].filter(([, revision]) => revision));
226
250
  }
227
251
 
228
- export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
229
- const current = revisions.get(id);
252
+ export function resourceReviewRevisionMatches(loaded, revisions, id, stored, reviewer = null) {
253
+ const current = sourceHeading(reviewer, id)
254
+ ? resourceReviewRevisionsSync(loaded, [id], "legacy", false, null, reviewer).get(id)
255
+ : revisions.get(id);
230
256
  if (!current || !revisionDigest("content", stored)) return false;
231
257
  if (revisionsMatch("content", stored, current)) return true;
258
+ if (sourceHeading(reviewer, id)) return false;
232
259
  const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
233
260
  if (revisionsMatch("content", stored, legacy)) return true;
234
261
  // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
@@ -293,7 +320,20 @@ export function retentionUses(loaded, program) {
293
320
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
294
321
  }
295
322
 
296
- function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false) {
323
+ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null) {
324
+ if (heading && !legacySource) {
325
+ const governingFields = entry.record.type === "policy"
326
+ ? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"]
327
+ : ["documentKind", "workflowScope", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"];
328
+ return JSON.stringify({
329
+ id: entry.record.id,
330
+ type: entry.record.type,
331
+ governingStatus: ["approved", "active"].includes(entry.record.status)
332
+ ? "approved" : entry.record.status || null,
333
+ ...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined)
334
+ .map((field) => [field, entry.record[field]]))
335
+ });
336
+ }
297
337
  if (
298
338
  modelSupports(loaded.model, "retention-schedule-approval")
299
339
  && entry.record.type === "document"
@@ -330,9 +370,93 @@ function reviewMarkdownEntries(loaded, entry, legacySource) {
330
370
  return markdownEntries(loaded.model, entry.record);
331
371
  }
332
372
 
373
+ const approvedReviewEntries = new WeakMap();
374
+
375
+ function effectiveReviewEntry(loaded, entry) {
376
+ if (!entry || !loaded.root || !["policy", "document", "training"].includes(entry.record.type)) return entry;
377
+ if (!["draft", "in-review", "approved", "active"].includes(entry.record.status)) return entry;
378
+ if (approvedReviewContentMatches(loaded, entry)) return entry;
379
+ if (["approved", "active"].includes(entry.record.status)) return entry;
380
+ let cache = approvedReviewEntries.get(loaded.resources);
381
+ if (!cache) {
382
+ cache = new Map();
383
+ approvedReviewEntries.set(loaded.resources, cache);
384
+ }
385
+ if (cache.has(entry.record.id)) return cache.get(entry.record.id) || entry;
386
+ const context = reviewHistoryContext(loaded.root, loaded);
387
+ for (const commit of [...reviewHistoryCommits(context)].reverse()) {
388
+ const snapshot = reviewHistoricalWorkspace(context, commit);
389
+ const historical = snapshot?.entries.find(({ record }) => record.id === entry.record.id);
390
+ if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) continue;
391
+ const approved = { ...historical, reviewCommit: commit, reviewFiles: snapshot.historicalFiles };
392
+ cache.set(entry.record.id, approved);
393
+ return approved;
394
+ }
395
+ cache.set(entry.record.id, null);
396
+ return entry;
397
+ }
398
+
399
+ function approvedReviewContentMatches(loaded, entry, commit = null) {
400
+ if (!["approved", "active"].includes(entry.record.status)) return false;
401
+ const bindings = entry.record.approvedContentRevisions || entry.record.effectiveContentRevisions;
402
+ if (!bindings) return true;
403
+ for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
404
+ const source = commit
405
+ ? loaded.historicalFiles?.get(`data/${markdown.path}`)
406
+ ?? getFileAtRevision(loaded.root, commit, `data/${markdown.path}`)
407
+ : (() => {
408
+ try { return readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"); }
409
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
410
+ })();
411
+ if (!source || !revisionsMatch("content", bindings[markdown.path], contentRevision(source))) return false;
412
+ }
413
+ return true;
414
+ }
415
+
416
+ export function reviewedMarkdownSection(source, heading) {
417
+ if (!heading) return source;
418
+ const lines = source.split("\n");
419
+ const wanted = heading.trim().replace(/^#{1,6}\s+/, "").toLowerCase();
420
+ const headings = [];
421
+ const fencedLines = new Set();
422
+ let fence = null;
423
+ for (const [index, line] of lines.entries()) {
424
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
425
+ if (fence) {
426
+ fencedLines.add(index);
427
+ if (marker?.[0] === fence[0] && marker.length >= fence.length
428
+ && /^\s*$/.test(line.slice(line.indexOf(marker) + marker.length))) fence = null;
429
+ continue;
430
+ }
431
+ if (marker) { fence = marker; fencedLines.add(index); continue; }
432
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
433
+ if (match) headings.push({ index, level: match[1].length, title: match[2].trim().toLowerCase() });
434
+ }
435
+ const matches = headings.filter(({ title }) => title === wanted);
436
+ if (matches.length !== 1) return null;
437
+ const selected = matches[0];
438
+ const ancestors = [];
439
+ for (const candidate of headings.filter(({ index }) => index < selected.index)) {
440
+ while (ancestors.length && ancestors.at(-1).level >= candidate.level) ancestors.pop();
441
+ ancestors.push(candidate);
442
+ }
443
+ const next = headings.find(({ index, level }) => index > selected.index && level <= selected.level);
444
+ const context = ancestors.map(({ index }) => {
445
+ const following = headings.find((candidate) => candidate.index > index);
446
+ return lines.slice(index, following?.index).filter((_, offset) => !fencedLines.has(index + offset)).join("\n").trim();
447
+ });
448
+ return [...context, lines.slice(selected.index, next?.index).join("\n").trim()].join("\n\n");
449
+ }
450
+
451
+ function sourceHeading(record, sourceId) {
452
+ const headings = record?.sourceSectionHeadings;
453
+ return headings && Object.hasOwn(headings, sourceId) && typeof headings[sourceId] === "string"
454
+ ? headings[sourceId] : null;
455
+ }
456
+
333
457
  const reviewDecisionFields = {
334
458
  control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
335
- commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
459
+ commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "sourceSectionHeadings", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
336
460
  "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
337
461
  "source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
338
462
  system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
@@ -450,7 +574,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
450
574
  const dependencyIds = retentionReviewResourceIds(rule, loaded);
451
575
  if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
452
576
  return dependencyIds.every((id) => (
453
- resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id])
577
+ resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id], rule)
454
578
  ));
455
579
  }
456
580
 
package/src/server.js CHANGED
@@ -627,9 +627,23 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
627
627
  return json(response, 400, { error: "Pass one or more safe resource IDs." });
628
628
  }
629
629
  const loaded = await loadWorkspace(input);
630
- const revisions = await resourceReviewRevisions(loaded, ids);
630
+ const sourceSectionHeadings = Object.create(null);
631
+ for (const value of url.searchParams.getAll("section")) {
632
+ const separator = value.indexOf(":");
633
+ const id = value.slice(0, separator);
634
+ const heading = value.slice(separator + 1).trim();
635
+ if (separator < 1 || !ids.includes(id) || !heading || heading.length > 200) {
636
+ return json(response, 400, { error: "Each reviewed section must name a selected source and a Markdown heading." });
637
+ }
638
+ sourceSectionHeadings[id] = heading;
639
+ }
640
+ const revisions = await resourceReviewRevisions(loaded, ids, "legacy", { sourceSectionHeadings });
631
641
  const missing = ids.filter((id) => !revisions.has(id));
632
- if (missing.length) return json(response, 404, { error: `Resources not found: ${missing.join(", ")}.` });
642
+ if (missing.length) return json(response, 404, {
643
+ error: missing.some((id) => sourceSectionHeadings[id])
644
+ ? `A selected source heading is missing or ambiguous: ${missing.filter((id) => sourceSectionHeadings[id]).join(", ")}.`
645
+ : `Resources not found: ${missing.join(", ")}.`
646
+ });
633
647
  return json(response, 200, { revisions: Object.fromEntries(revisions) });
634
648
  }
635
649
  if (request.method === "PUT" && url.pathname === "/api/content") {
package/src/validate.js CHANGED
@@ -219,6 +219,19 @@ async function validateWorkspaceUnmeasured(input) {
219
219
  }
220
220
  validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
221
221
  }
222
+ if (record.sourceSectionHeadings) {
223
+ for (const [sourceId, heading] of Object.entries(record.sourceSectionHeadings)) {
224
+ if (!(record.sourceResourceIds || []).includes(sourceId)
225
+ || !["policy", "document"].includes(byId.get(sourceId)?.type)
226
+ || !String(heading || "").trim()) {
227
+ diagnostics.push(error(
228
+ "invalid-source-section",
229
+ displayPath,
230
+ `sourceSectionHeadings.${sourceId} must name a linked Policy or Document and a Markdown heading.`
231
+ ));
232
+ }
233
+ }
234
+ }
222
235
  validateIndependentApproval(record, byId, displayPath, diagnostics);
223
236
  validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
224
237
  validateActionObligationRule(record, byId, displayPath, diagnostics);
@@ -1995,7 +2008,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi
1995
2008
  const sources = retentionReviewResourceIds(record, loaded);
1996
2009
  const revisions = record.reviewedSourceRevisions || {};
1997
2010
  const missing = sources.filter((id) => (
1998
- !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id])
2011
+ !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id], record)
1999
2012
  )).concat(Object.keys(revisions).filter((id) => !sources.includes(id)));
2000
2013
  if (missing.length) {
2001
2014
  diagnostics.push(error(
package/src/web.js CHANGED
@@ -1231,7 +1231,7 @@ function controlLaunchLabel(state) {
1231
1231
  return ({
1232
1232
  planned: "Planned: launch conditions remain",
1233
1233
  partial: "Partially implemented: launch conditions remain",
1234
- "ready-to-implement": "Launch checks ready: confirm it works, then record the start date",
1234
+ "ready-to-implement": "Launch checks ready: check the live configuration, then record its start date",
1235
1235
  "implemented-with-gaps": "Implemented with gaps: fix the launch checks",
1236
1236
  implemented: "Implemented"
1237
1237
  })[state] || "Control launch";
@@ -5358,6 +5358,8 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
5358
5358
  control = stringMapEditor(value || {}, name, name === "reviewedSourceRevisions");
5359
5359
  const mapHelp = name === "reviewedSourceRevisions"
5360
5360
  ? "Bind the exact current JSON and Markdown revisions after reviewing every selected record."
5361
+ : name === "sourceSectionHeadings"
5362
+ ? "Use a linked Policy or Document ID and its exact Markdown heading. The selected section governs this decision."
5361
5363
  : "Add one named value per row.";
5362
5364
  return fieldWrap(name, "string-map", label, requiredMark, control, mapHelp, required);
5363
5365
  }
@@ -5558,7 +5560,11 @@ async function bindCurrentReviewRevisions(dialog, button) {
5558
5560
  setButtonBusy(button, true, "Binding…");
5559
5561
  let bound = false;
5560
5562
  try {
5561
- const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
5563
+ const sections = [...dialog.querySelectorAll('[data-field-group="sourceSectionHeadings"] .string-map-row')]
5564
+ .map((row) => [row.querySelector('[data-map-key]')?.value?.trim(), row.querySelector('[data-map-value]')?.value?.trim()])
5565
+ .filter(([id, heading]) => id && heading);
5566
+ const query = ids.map((id) => "id=" + encodeURIComponent(id))
5567
+ .concat(sections.map(([id, heading]) => "section=" + encodeURIComponent(id + ":" + heading))).join("&");
5562
5568
  const response = await localFetch("/api/review-revisions?" + query);
5563
5569
  if (!response.ok) throw new Error(await responseMessage(response));
5564
5570
  const { revisions } = await response.json();
@@ -6775,6 +6781,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
6775
6781
  @media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
6776
6782
  .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}
6777
6783
  .form-field[data-kind="string-map"]{grid-column:1/-1}.string-map-actions{display:flex;flex-wrap:wrap;gap:7px}
6784
+ .form-field[data-field-group="sourceSectionHeadings"] .string-map-row{grid-template-columns:minmax(0,1fr) minmax(0,2fr) auto}
6778
6785
  @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%}}
6779
6786
  @media(max-width:760px){.string-map-row{grid-template-columns:1fr 1fr}.string-map-row .text-button{grid-column:1/-1;justify-self:start}}
6780
6787
 
package/src/workflow.js CHANGED
@@ -466,13 +466,17 @@ function recordIncompleteReason(record, loaded, program) {
466
466
  return {
467
467
  state: "ready",
468
468
  requiredness: appointmentRequiredness(record, loaded.model),
469
- message: "Assign a holder, confirm the authority and independence needed, then activate this Appointment on its actual start date."
469
+ message: "Check current People and scoped Appointments, assign a qualified holder, and record the actual authority and start date. Ask management only if the holder or independence decision is unresolved."
470
470
  };
471
471
  }
472
472
  const messages = {
473
473
  draft: "Add the required details, relationships, and Markdown before review.",
474
474
  planned: "Check this planned record against how your program works, then add the missing details.",
475
- proposed: "Confirm the work, owner, schedule, and completion requirements before activation.",
475
+ proposed: ["obligation", "obligation-rule"].includes(record.type)
476
+ ? "Read the linked Policy and Control, fill the actual owner, schedule, deadline, and proof requirement, then activate the rule. Ask management only for a choice those sources do not settle."
477
+ : record.type === "reporting-route-set"
478
+ ? "Fill the normal and fallback reporting routes from approved contact details, commit the proposal, and obtain the required separate approval before it takes effect. Ask only for missing route or authority decisions."
479
+ : "Inspect the source facts and type guide, complete the proposal, and obtain the required review or approval before activation.",
476
480
  "in-review": "Have an independent reviewer approve the exact content revision.",
477
481
  open: "Complete this work with the required proof, or formally close it without completion.",
478
482
  "in-progress": "Finish the work, record the result, and link the completion proof.",
@@ -724,7 +728,7 @@ function reconciliationFindings(reconciliation) {
724
728
  state: "ready",
725
729
  severity: "warning",
726
730
  requiredness: "conditional",
727
- title: `Confirm ${fieldLabel(candidate.eventType)}`,
731
+ title: `Resolve ${fieldLabel(candidate.eventType)} transition`,
728
732
  message: candidate.message,
729
733
  subject: candidate.subject,
730
734
  fieldPath: candidate.sourcePath,
@@ -1630,12 +1634,12 @@ function periodFinding(key, title, message, state, subject, actions, dependencie
1630
1634
  function auditClosureNextStep(status) {
1631
1635
  const steps = {
1632
1636
  planned: {
1633
- title: "Confirm the engagement and start audit preparation",
1634
- message: "Link the agreed engagement terms, confirm scope and management acknowledgement, then move the audit to in progress."
1637
+ title: "Record the agreed engagement and start audit preparation",
1638
+ message: "Read the signed engagement terms, link them, record the agreed scope and management acknowledgement, then move the audit to in progress. Ask for missing terms or acknowledgement only if unavailable."
1635
1639
  },
1636
1640
  "in-progress": {
1637
1641
  title: "Begin fieldwork",
1638
- message: "Finish management preparation, confirm the fieldwork dates, and move the audit to fieldwork when the CPA firm begins testing."
1642
+ message: "Finish management preparation, record the fieldwork dates agreed with the CPA firm, and move the audit to fieldwork when testing begins. Ask for the dates only if they are not in the engagement correspondence."
1639
1643
  },
1640
1644
  fieldwork: {
1641
1645
  title: "Finish fieldwork and prepare the report",
@@ -1647,7 +1651,7 @@ function auditClosureNextStep(status) {
1647
1651
  },
1648
1652
  issued: {
1649
1653
  title: "Record final report delivery",
1650
- message: "Confirm the issued report evidence, opinion, report date, authorized recipient, and delivery receipt, then move the audit to delivered."
1654
+ message: "Read the issued report and delivery record, enter the opinion, report date, authorized recipient, and receipt, then move the audit to delivered. Ask only for missing delivery facts."
1651
1655
  },
1652
1656
  delivered: {
1653
1657
  title: "Close the audit",