filegrc 0.12.4 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.12.4",
3
+ "version": "0.13.1",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/files.js CHANGED
@@ -965,14 +965,22 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
965
965
  const path = resolveDataPath(loaded.root, dataRelativePath);
966
966
  const previous = await readFile(path, "utf8");
967
967
  assertRevision(previous, options.expectedRevision, "The Markdown file");
968
- const before = await validateWorkspace(loaded);
968
+ const deferValidation = workspaceValidationDeferred();
969
+ const before = deferValidation ? null : await validateWorkspace(loaded);
969
970
  const nextSource = source.endsWith("\n") ? source : `${source}\n`;
970
971
  await writeTextAtomic(path, nextSource);
971
972
  try {
972
- const result = await validateWorkspace(loaded.root);
973
- const introduced = newErrors(result, before);
974
- if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
975
- return { path, dataRelativePath };
973
+ if (!deferValidation) {
974
+ const result = await validateWorkspace(loaded.root);
975
+ const introduced = newErrors(result, before);
976
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
977
+ }
978
+ return {
979
+ path,
980
+ dataRelativePath,
981
+ source: nextSource,
982
+ revision: contentRevision(nextSource)
983
+ };
976
984
  } catch (error) {
977
985
  await writeTextAtomic(path, previous);
978
986
  throw error;
package/src/git.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { rm } from "node:fs/promises";
6
6
  import { devNull } from "node:os";
7
7
  import { relative, resolve, sep } from "node:path";
@@ -23,6 +23,7 @@ const backgroundSynchronizations = new Map();
23
23
  const browserRemotePrefetches = new Map();
24
24
  const browserRemotePrefetchPromises = new Map();
25
25
  const repositorySnapshotPromises = new Map();
26
+ const repositoryObjectFormats = new Map();
26
27
  const gitCommandCaches = new AsyncLocalStorage();
27
28
  const gitCommandDeadlines = new AsyncLocalStorage();
28
29
  const gitCommandCacheBytes = new WeakMap();
@@ -30,6 +31,7 @@ let gitCommandInterceptor = null;
30
31
  let gitSubprocessObserver = null;
31
32
  let historicalBatchInterceptor = null;
32
33
  let historicalRevisionReadObserver = null;
34
+ let workspaceBlobObjectIdOverride = null;
33
35
  const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
34
36
  const GIT_DEFAULT_TIMEOUT_MS = 10_000;
35
37
  const GIT_REMOTE_TIMEOUT_MS = 30_000;
@@ -680,15 +682,50 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
680
682
  export function getWorkingFileObjectId(input, relativePath) {
681
683
  if (!isSafeDataGitPath(relativePath)) return null;
682
684
  const root = resolveWorkspaceRoot(input);
685
+ let descriptor;
683
686
  try {
684
- const objectId = git(root, ["hash-object", "--no-filters", "--", relativePath]);
685
- return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(objectId) ? objectId : null;
687
+ const objectFormat = repositoryObjectFormat(root);
688
+ if (!objectFormat) return null;
689
+ descriptor = openSync(resolve(root, relativePath), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
690
+ const before = fstatSync(descriptor);
691
+ if (!before.isFile()) return null;
692
+ const hash = createHash(objectFormat).update(`blob ${before.size}\0`);
693
+ const buffer = Buffer.allocUnsafe(64 * 1024);
694
+ let position = 0;
695
+ while (position < before.size) {
696
+ const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
697
+ if (!count) return null;
698
+ hash.update(buffer.subarray(0, count));
699
+ position += count;
700
+ }
701
+ const after = fstatSync(descriptor);
702
+ if (
703
+ after.size !== before.size
704
+ || after.dev !== before.dev
705
+ || after.ino !== before.ino
706
+ || after.mtimeMs !== before.mtimeMs
707
+ || after.ctimeMs !== before.ctimeMs
708
+ ) return null;
709
+ return hash.digest("hex");
686
710
  } catch (error) {
687
711
  rethrowGitDeadline(error);
688
712
  return null;
713
+ } finally {
714
+ if (descriptor !== undefined) closeSync(descriptor);
689
715
  }
690
716
  }
691
717
 
718
+ function repositoryObjectFormat(root) {
719
+ if (repositoryObjectFormats.has(root)) return repositoryObjectFormats.get(root);
720
+ if (!tryGit(root, ["rev-parse", "--git-dir"])) return null;
721
+ const objectFormat = tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
722
+ if (!["sha1", "sha256"].includes(objectFormat)) {
723
+ throw new Error(`FileGRC does not support the repository object format "${objectFormat}".`);
724
+ }
725
+ repositoryObjectFormats.set(root, objectFormat);
726
+ return objectFormat;
727
+ }
728
+
692
729
  export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
693
730
  const root = resolveWorkspaceRoot(input);
694
731
  const wanted = new Set(relativePaths);
@@ -1415,8 +1452,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
1415
1452
  })));
1416
1453
  subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
1417
1454
  assertNoIgnoredAuthoritativeFiles(root);
1418
- const beforeValidation = workspaceByteManifest(root);
1419
- const validation = await validateWorkspace(root);
1455
+ const beforeValidation = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
1456
+ const validation = await withGitCommandCache(new Map(), () => validateWorkspace(root));
1420
1457
  if (!validation.ok) {
1421
1458
  throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`);
1422
1459
  }
@@ -1426,9 +1463,9 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
1426
1463
  validation,
1427
1464
  fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
1428
1465
  };
1429
- validatedManifest = workspaceByteManifest(root);
1466
+ validatedManifest = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
1430
1467
  assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
1431
- await assertNoOutsideWorktreeChangesAsync(root);
1468
+ await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
1432
1469
  } catch (error) {
1433
1470
  throw new Error(`${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`);
1434
1471
  }
@@ -1454,13 +1491,13 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
1454
1491
  }
1455
1492
  assertValidGitIdentity(root);
1456
1493
  assertNoWorkspaceContentFilters(root);
1457
- await assertNoOutsideWorktreeChangesAsync(root);
1494
+ await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
1458
1495
  let commit;
1459
1496
  let indexReconciled;
1460
1497
  try {
1461
1498
  const expectedRef = `refs/heads/${config.authoritativeBranch}`;
1462
1499
  assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
1463
- validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
1500
+ validatedManifest = measureTimingSync("workspace-manifest-objects", () => writeWorkspaceManifestObjects(root, validatedManifest));
1464
1501
  ({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
1465
1502
  expectedParent: synchronized.currentCommit,
1466
1503
  expectedRef
@@ -2261,6 +2298,8 @@ function assertNoIgnoredAuthoritativeFiles(root) {
2261
2298
 
2262
2299
  function workspaceByteManifest(root) {
2263
2300
  assertNoHiddenIndexEntries(root);
2301
+ const objectFormat = repositoryObjectFormat(root);
2302
+ if (!objectFormat) throw new Error("FileGRC could not determine the Git repository object format.");
2264
2303
  const paths = [...new Set(nulFields(gitRaw(root, [
2265
2304
  "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
2266
2305
  ])))].sort();
@@ -2298,9 +2337,36 @@ function workspaceByteManifest(root) {
2298
2337
  ) {
2299
2338
  throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
2300
2339
  }
2301
- const bytes = readFileSync(descriptor);
2302
- const objectId = hashWorkspaceBytes(root, bytes);
2303
- return [path, { objectId, mode: opened.mode & 0o111 ? "100755" : "100644", bytes }];
2340
+ const objectHash = createHash(objectFormat).update(`blob ${opened.size}\0`);
2341
+ const byteHash = createHash("sha256");
2342
+ const buffer = Buffer.allocUnsafe(64 * 1024);
2343
+ let position = 0;
2344
+ while (position < opened.size) {
2345
+ const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, opened.size - position), position);
2346
+ if (!count) throw new Error(`FileGRC could not read complete workspace entry "${path}".`);
2347
+ const chunk = buffer.subarray(0, count);
2348
+ objectHash.update(chunk);
2349
+ byteHash.update(chunk);
2350
+ position += count;
2351
+ }
2352
+ const after = fstatSync(descriptor);
2353
+ if (
2354
+ after.size !== opened.size
2355
+ || after.dev !== opened.dev
2356
+ || after.ino !== opened.ino
2357
+ || after.mtimeMs !== opened.mtimeMs
2358
+ || after.ctimeMs !== opened.ctimeMs
2359
+ ) {
2360
+ throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
2361
+ }
2362
+ const calculatedObjectId = objectHash.digest("hex");
2363
+ const objectId = workspaceBlobObjectIdOverride?.(null, objectFormat, path) ?? calculatedObjectId;
2364
+ return [path, {
2365
+ objectId,
2366
+ byteDigest: byteHash.digest("hex"),
2367
+ size: opened.size,
2368
+ mode: opened.mode & 0o111 ? "100755" : "100644"
2369
+ }];
2304
2370
  } finally {
2305
2371
  closeSync(descriptor);
2306
2372
  }
@@ -2316,24 +2382,42 @@ function assertNoHiddenIndexEntries(root) {
2316
2382
  }
2317
2383
  }
2318
2384
 
2319
- function hashWorkspaceBytes(root, bytes, write = false) {
2320
- return observedExecFileSync("git", ["hash-object", ...(write ? ["-w"] : []), "--stdin"], {
2385
+ function writeWorkspaceManifestObjects(root, manifest) {
2386
+ const valuesByObjectId = new Map();
2387
+ for (const value of manifest.values()) {
2388
+ if (!value.objectId) continue;
2389
+ const prior = valuesByObjectId.get(value.objectId);
2390
+ if (prior && (prior.size !== value.size || prior.byteDigest !== value.byteDigest)) {
2391
+ throw new Error(`Git object ${value.objectId} identifies different validated workspace bytes.`);
2392
+ }
2393
+ valuesByObjectId.set(value.objectId, value);
2394
+ }
2395
+ if (!valuesByObjectId.size) return manifest;
2396
+ const entries = [...manifest].filter(([, value]) => value.objectId);
2397
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
2398
+ const workspacePrefix = relative(topLevel, root).split(sep).join("/");
2399
+ if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) {
2400
+ throw new Error("The FileGRC workspace is outside its Git repository.");
2401
+ }
2402
+ const written = observedExecFileSync("git", ["hash-object", "-w", "--stdin-paths"], {
2321
2403
  cwd: root,
2322
- input: bytes,
2404
+ input: `${entries.map(([path]) => workspacePrefix ? `${workspacePrefix}/${path}` : path).join("\n")}\n`,
2323
2405
  encoding: "utf8",
2324
2406
  stdio: ["pipe", "pipe", "ignore"],
2325
2407
  timeout: 30_000,
2326
2408
  maxBuffer: 20_000_000,
2327
2409
  env: gitEnvironment()
2328
- }).trim();
2329
- }
2330
-
2331
- function writeWorkspaceManifestObjects(root, manifest) {
2332
- return new Map([...manifest].map(([path, value]) => (
2333
- value.objectId
2334
- ? [path, { ...value, objectId: hashWorkspaceBytes(root, value.bytes, true) }]
2335
- : [path, value]
2336
- )));
2410
+ }).trim().split("\n");
2411
+ if (written.length !== entries.length) {
2412
+ throw new Error("Git returned an unexpected number of workspace objects.");
2413
+ }
2414
+ for (let index = 0; index < entries.length; index += 1) {
2415
+ if (written[index] !== entries[index][1].objectId) {
2416
+ throw new Error(`Git wrote an unexpected object while preparing validated workspace bytes for commit.`);
2417
+ }
2418
+ }
2419
+ assertWorkspaceManifestEqual(manifest, workspaceByteManifest(root));
2420
+ return manifest;
2337
2421
  }
2338
2422
 
2339
2423
  function assertWorkspaceManifestEqual(expected, current) {
@@ -2346,7 +2430,11 @@ function workspaceManifestsEqual(expected, current) {
2346
2430
  return expected.size === current.size
2347
2431
  && [...expected].every(([path, value]) => {
2348
2432
  const other = current.get(path);
2349
- return other && value.objectId === other.objectId && value.mode === other.mode;
2433
+ return other
2434
+ && value.objectId === other.objectId
2435
+ && value.mode === other.mode
2436
+ && value.size === other.size
2437
+ && value.byteDigest === other.byteDigest;
2350
2438
  });
2351
2439
  }
2352
2440
 
@@ -2621,6 +2709,15 @@ export function setHistoricalRevisionReadObserverForTests(observer) {
2621
2709
  return () => { historicalRevisionReadObserver = previous; };
2622
2710
  }
2623
2711
 
2712
+ export function setWorkspaceBlobObjectIdOverrideForTests(override) {
2713
+ if (override !== null && typeof override !== "function") {
2714
+ throw new TypeError("The workspace blob object ID override must be a function or null.");
2715
+ }
2716
+ const previous = workspaceBlobObjectIdOverride;
2717
+ workspaceBlobObjectIdOverride = override;
2718
+ return () => { workspaceBlobObjectIdOverride = previous; };
2719
+ }
2720
+
2624
2721
  export function runGitCommand(cwd, args, options = {}) {
2625
2722
  if (gitCommandInterceptor) {
2626
2723
  return Promise.resolve().then(() => gitCommandInterceptor({
@@ -172,13 +172,14 @@ export async function assessProgramAmendmentReadiness(loaded) {
172
172
  const commitments = loaded.resources.filter((record) => (
173
173
  record.type === "commitment" && !["superseded", "retired"].includes(record.status)
174
174
  ));
175
- const sourceIds = new Set(commitments.flatMap((record) => record.sourceResourceIds || []));
175
+ const supplementalCommitments = commitments.filter((record) => (record.sourceResourceIds || []).length > 0);
176
+ const sourceIds = new Set(supplementalCommitments.flatMap((record) => record.sourceResourceIds || []));
176
177
  for (const record of loaded.resources) {
177
178
  if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
178
179
  sourceIds.add(record.id);
179
180
  }
180
181
  }
181
- const sourceRecords = [...new Set([...sourceIds, ...commitments.map(({ id }) => id)])]
182
+ const sourceRecords = [...new Set([...sourceIds, ...supplementalCommitments.map(({ id }) => id)])]
182
183
  .map((id) => byId.get(id))
183
184
  .filter((record) => record && SOURCE_TYPES.has(record.type));
184
185
  const plans = await Promise.all(sourceRecords.map((record) => (
@@ -11,8 +11,8 @@ export const RESOURCE_INSTRUCTIONS = {
11
11
  framework: "Confirm the criteria framework and version used for the program.",
12
12
  requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
13
13
  commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
14
- "requirement-mapping": "Record a reviewed relationship among Requirements, Commitments, and Controls. Choose the comparison method and relationship explicitly, explain the rationale, and bind the review to every mapped source revision.",
15
- "reporting-route-set": "Record the normal and fallback ways people send one kind of report, including where each channel goes and the role that keeps it usable. Commit the proposal before approval, use separate approval and ongoing authority Appointments, and create a successor when either channel changes.",
14
+ "requirement-mapping": "Use a Requirement Mapping when supplemental policies, contracts, privacy promises, frameworks, or other sources need explicit coverage semantics. Choose the comparison method and relationship, explain the rationale, and bind the review to every mapped source revision.",
15
+ "reporting-route-set": "Prepare the normal and fallback ways people will send each report required by proposed program content, including where each channel goes and the role that keeps it usable. Commit the proposal in Step 1, approve it before cutover, use separate approval and ongoing authority Appointments, and create a successor when either channel changes.",
16
16
  policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
17
17
  document: "Complete required program Documents in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare Audit Documents in Step 5.",
18
18
  control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
@@ -82,8 +82,8 @@ export const PROGRAM_PATH = [
82
82
  description: "Ownership, criteria, and service boundary",
83
83
  summary: "Name the owners, criteria, service, Systems, and providers in scope.",
84
84
  sections: [
85
- { id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Set a normal security reporting channel and a fallback, then commit that proposal before approval.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
86
- { 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: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and reviewed mappings. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
85
+ { id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Replace the starter reporting-channel placeholders, propose the set, and commit that proposal before Step 1 is complete. Approve it before the later implementation cutover.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
86
+ { 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: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Replace each planned service-commitment prompt with the actual promise or requirement. Use Requirement Mappings only when a supplemental source needs an explicit coverage comparison. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
87
87
  { 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: ["Create the complete bounded System and select it on the Program.", "Add only relevant Components, with a role and rationale for each System use.", "Create Vendors for material external provider relationships and link supplied Components when factual.", "Normalize Information Types and Classifications used by the System, Components, Vendors, Risks, and Evidence Artifacts."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
88
88
  ],
89
89
  resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
@@ -424,16 +424,32 @@ function reportingRouteSetItem(assessment) {
424
424
  const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
425
425
  const required = assessment.requirements.length > 0;
426
426
  const proposed = assessment.proposedRequirements.length > 0;
427
- const ready = assessment.issues.length === 0 && (!required || Boolean(current));
428
- const needsAction = assessment.issues.length > 0;
427
+ const proposedPurposeKeys = [...new Set(assessment.proposedRequirements
428
+ .map(({ purposeKey }) => purposeKey)
429
+ .filter(Boolean))];
430
+ const preparedPurposeKeys = new Set(assessment.routeSets
431
+ .filter(({ record, committed, canceled, proposedRequirementIssues }) => (
432
+ ["proposed", "approved"].includes(record.status)
433
+ && committed
434
+ && !canceled
435
+ && proposedRequirementIssues.length === 0
436
+ ))
437
+ .map(({ record }) => record.purposeKey));
438
+ const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
439
+ const ready = assessment.issues.length === 0
440
+ && (!required || Boolean(current))
441
+ && unpreparedPurposeKeys.length === 0;
442
+ const needsAction = assessment.issues.length > 0 || unpreparedPurposeKeys.length > 0;
429
443
  const target = current?.record || draft?.record || { type: "reporting-route-set" };
430
444
  let message;
431
- if (needsAction) {
445
+ if (assessment.issues.length) {
432
446
  message = assessment.issues[0].message;
447
+ } else if (unpreparedPurposeKeys.length) {
448
+ message = `Complete and commit a Reporting Channel Set proposal for ${unpreparedPurposeKeys.join(", ")} before Step 1 is complete. Approval and effectiveness remain part of the later implementation cutover.`;
433
449
  } else if (required && ready && current) {
434
450
  message = `${current.record.title} is committed, effective, and has a current responsible Appointment. Assignments bind its Git commit.`;
435
- } else if (!required && proposed && draft) {
436
- message = "A draft or approved-but-inactive rule will require normal and fallback reporting channels later. Review them before that rule becomes active; this is not a current readiness gate.";
451
+ } else if (!required && proposed) {
452
+ message = "Every reporting-channel requirement in proposed program content has a committed Reporting Channel Set proposal. Approve the exact proposal before its governing content becomes active.";
437
453
  } else if (!required && draft) {
438
454
  message = "No approved rule currently requires these reporting channels. Keep the draft for review or remove it; it is not a readiness gate.";
439
455
  } else if (!required) {
@@ -443,12 +459,15 @@ function reportingRouteSetItem(assessment) {
443
459
  }
444
460
  return item(
445
461
  "security-reporting-route-set",
446
- needsAction ? "action" : required ? ready ? "complete" : "action" : proposed && draft ? "later" : "info",
447
- needsAction ? "Fix reporting channel requirements" : required ? "Approve required reporting channels" : proposed ? "Review proposed reporting channels" : "Reporting channels are not currently required",
462
+ needsAction ? "action" : required ? ready ? "complete" : "action" : proposed ? "complete" : "info",
463
+ needsAction ? "Prepare required reporting channels" : required ? "Approve required reporting channels" : proposed ? "Reporting channel proposals are ready" : "Reporting channels are not currently required",
448
464
  message,
449
465
  target,
450
466
  {
451
467
  requirements: assessment.requirements,
468
+ proposedRequirements: assessment.proposedRequirements,
469
+ proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
470
+ unpreparedPurposeKeys,
452
471
  issues: assessment.issues,
453
472
  commands: [
454
473
  "npx filegrc reporting-route-sets --json",
@@ -456,6 +456,10 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
456
456
 
457
457
  function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
458
458
  const needsTimestamp = eventNeedsTimestamp(loaded, transition.eventType);
459
+ const requiredFacts = [
460
+ transition.eventType === "person-ended" ? "riskLevel" : null,
461
+ needsTimestamp ? "occurredAt" : "occurredOn"
462
+ ].filter(Boolean);
459
463
  return {
460
464
  id: `reconcile-${fingerprint.slice(0, 16)}`,
461
465
  eventId,
@@ -465,12 +469,13 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
465
469
  sourcePath: path,
466
470
  state: "needs-confirmation",
467
471
  message: transition.message,
468
- requiredFacts: [
469
- transition.eventType === "person-ended" ? "riskLevel" : null,
470
- needsTimestamp ? "occurredAt" : "occurredOn"
471
- ].filter(Boolean),
472
+ requiredFacts,
472
473
  action: {
473
- kind: "command",
474
+ kind: "reconcile-transition",
475
+ candidateId: fingerprint,
476
+ eventType: transition.eventType,
477
+ subject: { type: record.type, id: record.id },
478
+ requiredFacts,
474
479
  command: reconciliationCommand(transition.eventType, record.id, fingerprint, needsTimestamp)
475
480
  },
476
481
  ...extra
@@ -14,6 +14,7 @@ import { appointmentWasAuthorizedOn } from "./soc2.js";
14
14
  import { isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
15
15
 
16
16
  const CONTEMPORANEOUS_COMMIT_WINDOW_MS = 86_400_000;
17
+ const REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
17
18
 
18
19
  export function reportingRouteRevision(record) {
19
20
  const effectiveFacts = {
@@ -103,10 +104,14 @@ export function reportingRouteAssertionTiming(loaded, routeSet, eventName) {
103
104
 
104
105
  export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at, timezone = "UTC", options = {}) {
105
106
  let date;
107
+ let availableDate;
106
108
  try {
107
109
  date = /^\d{4}-\d{2}-\d{2}$/.test(String(at || ""))
108
110
  ? String(at)
109
111
  : localDateTimeValue(instant(at, "Supported event time"), timezone).slice(0, 10);
112
+ availableDate = options.availableAt
113
+ ? localDateTimeValue(instant(options.availableAt, "Evidence availability time"), timezone).slice(0, 10)
114
+ : null;
110
115
  } catch {
111
116
  return [];
112
117
  }
@@ -119,6 +124,7 @@ export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at,
119
124
  || evidence.status !== "verified"
120
125
  || !arrayValue(evidence.sourceResourceIds).includes(subjectId)
121
126
  || !verifiedEvidenceComplete(evidence, personIds)
127
+ || (availableDate && (evidence.collectedOn > availableDate || evidence.verifiedOn > availableDate))
122
128
  ) return false;
123
129
  const coversDate = coverageContains(evidence.coverage, date)
124
130
  || [evidence.businessEventAt, evidence.sourceGeneratedAt].filter(Boolean).some((value) => {
@@ -274,6 +280,150 @@ export function reportingRouteRequirementAppliesToProgram(requirement, programId
274
280
  && arrayValue(requirement.programIds).includes(programId);
275
281
  }
276
282
 
283
+ export function reportingRouteProposalIssues(records, routeSet, options = {}) {
284
+ return reportingRouteProposalIssuesForRequirements(
285
+ reportingRouteRequirementsForProposal(records, routeSet),
286
+ routeSet,
287
+ records,
288
+ options
289
+ );
290
+ }
291
+
292
+ export function reportingRouteRequirementsForProposal(records, routeSet, options = {}) {
293
+ return records.flatMap((source) => (
294
+ REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source.type)
295
+ && (
296
+ reportingRouteSourceMayBecomeEffective(source)
297
+ || reportingRouteSourceEffectiveAt(source, options.at, options.timezone)
298
+ )
299
+ ? (Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : [])
300
+ .filter((requirement) => (
301
+ requirement
302
+ && typeof requirement === "object"
303
+ && !Array.isArray(requirement)
304
+ && requirement.purposeKey === routeSet.purposeKey
305
+ && reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
306
+ ))
307
+ : []
308
+ ));
309
+ }
310
+
311
+ function reportingRouteSourceEffectiveAt(source, at, timezone = "UTC") {
312
+ if (!at) return false;
313
+ try { return reportingRouteSourceEffective(source, at, timezone); } catch { return false; }
314
+ }
315
+
316
+ export function reportingRouteSupportIssues(requirements, routeSet, proposalRecords, currentRecords, options = {}) {
317
+ const proposalIssues = reportingRouteProposalIssuesForRequirements(
318
+ requirements,
319
+ routeSet,
320
+ proposalRecords,
321
+ { ...options, commit: options.proposalCommit }
322
+ );
323
+ const currentIssues = reportingRouteProposalIssuesForRequirements(
324
+ requirements,
325
+ routeSet,
326
+ currentRecords,
327
+ { ...options, commit: options.currentCommit }
328
+ );
329
+ return [...new Map([...proposalIssues, ...currentIssues].map((item) => [
330
+ `${item.code}\0${item.resourceId}\0${item.message}`,
331
+ item
332
+ ])).values()];
333
+ }
334
+
335
+ export function reportingRouteProposalIssuesForRequirements(requirements, routeSet, records, options = {}) {
336
+ const applicableRequirements = requirements.filter((requirement) => (
337
+ requirement
338
+ && typeof requirement === "object"
339
+ && !Array.isArray(requirement)
340
+ && requirement.purposeKey === routeSet.purposeKey
341
+ && reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
342
+ ));
343
+ const issues = [];
344
+ const placeholder = (value) => /^(?:\[|tbd\b|todo\b|unknown\b|replace\b|complete before\b)/i.test(String(value || "").trim());
345
+ if (placeholder(routeSet.primaryLane?.destination)) {
346
+ issues.push({
347
+ code: "incomplete-reporting-route-proposal",
348
+ resourceId: routeSet.id,
349
+ message: `${routeSet.title} needs the real normal reporting destination before it can be proposed.`
350
+ });
351
+ }
352
+ if (applicableRequirements.some(({ requiredLanes }) => requiredLanes?.includes("alternate"))) {
353
+ if (!routeSet.alternateLane || placeholder(routeSet.alternateLane.destination)) {
354
+ issues.push({
355
+ code: "incomplete-reporting-route-proposal",
356
+ resourceId: routeSet.id,
357
+ message: `${routeSet.title} needs the real fallback reporting destination before it can be proposed.`
358
+ });
359
+ }
360
+ }
361
+ if (applicableRequirements.some(({ distinctChannels }) => distinctChannels)
362
+ && routeSet.alternateLane?.channelKind === routeSet.primaryLane?.channelKind) {
363
+ issues.push({
364
+ code: "reporting-route-channel-not-distinct",
365
+ resourceId: routeSet.id,
366
+ message: `${routeSet.title} needs different normal and fallback channel types before it can be proposed.`
367
+ });
368
+ }
369
+ if (applicableRequirements.some(({ independentDependencies }) => independentDependencies)
370
+ && !reportingRouteLanesIndependent(
371
+ routeSet,
372
+ records,
373
+ options.at || new Date(),
374
+ options
375
+ )) {
376
+ issues.push({
377
+ code: "reporting-route-dependencies-not-independent",
378
+ resourceId: routeSet.id,
379
+ message: `${routeSet.title} needs independent normal and fallback channel dependencies or an applicable Exception before it can be proposed.`
380
+ });
381
+ }
382
+ return issues;
383
+ }
384
+
385
+ export function reportingRouteLanesIndependent(record, records, at, options = {}) {
386
+ if (!record.alternateLane) return false;
387
+ for (const lane of [record.primaryLane, record.alternateLane]) {
388
+ if (!lane?.dependencyBasis) return false;
389
+ if (lane.dependencyBasis === "cataloged" && !lane.dependencySystemIds?.length) return false;
390
+ if (lane.dependencyBasis === "none" && !String(lane.dependencyRationale || "").trim()) return false;
391
+ }
392
+ const primary = new Set(record.primaryLane?.dependencySystemIds || []);
393
+ const overlap = (record.alternateLane.dependencySystemIds || []).filter((id) => primary.has(id));
394
+ if (!overlap.length) return true;
395
+ const timezone = options.timezone || record.approval?.timezone || "UTC";
396
+ let date;
397
+ let availableDate;
398
+ try {
399
+ date = localDateTimeValue(instant(at, "Reporting Route assessment time"), timezone).slice(0, 10);
400
+ availableDate = localDateTimeValue(
401
+ instant(options.availableAt || at, "Reporting Route support availability time"),
402
+ timezone
403
+ ).slice(0, 10);
404
+ } catch {
405
+ return false;
406
+ }
407
+ return records.some((candidate) => (
408
+ candidate.type === "exception"
409
+ && candidate.status === "approved"
410
+ && candidate.reportingRouteSetId === record.id
411
+ && candidate.reportingRouteLanePair === "primary-alternate"
412
+ && overlap.every((id) => candidate.dependencySystemIds?.includes(id))
413
+ && candidate.approval?.approvedOn <= date
414
+ && candidate.approval?.approvedOn <= availableDate
415
+ && candidate.approval?.expiresOn >= date
416
+ && reportingRouteFixedEvidence(
417
+ records,
418
+ candidate.id,
419
+ candidate.evidenceIds,
420
+ candidate.approval?.approvedOn,
421
+ timezone,
422
+ { root: options.root, commit: options.commit, availableAt: options.availableAt || at }
423
+ ).length > 0
424
+ ));
425
+ }
426
+
277
427
  export function reportingRouteSourceEffective(source, at, timezone = "UTC") {
278
428
  const when = instant(at, "Source assessment time");
279
429
  if (!reportingRouteSourceMayApply(source)) return false;
@@ -299,6 +449,11 @@ export function reportingRouteSourceMayApply(source) {
299
449
  return false;
300
450
  }
301
451
 
452
+ export function reportingRouteSourceMayBecomeEffective(source) {
453
+ return REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source?.type)
454
+ && !["superseded", "retired", "closed", "archived"].includes(source.status);
455
+ }
456
+
302
457
  export function reportingRouteSetInterval(routeSet) {
303
458
  if (!["approved", "canceled"].includes(routeSet?.status) || !routeSet.approval?.effectiveAt) return null;
304
459
  return {
@@ -524,6 +679,32 @@ export function reportingRouteHistory(loaded, routeSetId) {
524
679
  return getDataRecordHistoryIndex(loaded.root).historiesById.get(routeSetId) || [];
525
680
  }
526
681
 
682
+ export function reportingRouteExactHistoryEntry(loaded, routeSet, head) {
683
+ return reportingRouteHistory(loaded, routeSet.id).find((summary) => {
684
+ if (head && !isDataHistoryAncestor(loaded, summary.commit, head)) return false;
685
+ const source = getFileAtRevision(loaded.root, summary.commit, summary.path);
686
+ try { return source && JSON.stringify(JSON.parse(source)) === JSON.stringify(routeSet); } catch { return false; }
687
+ }) || null;
688
+ }
689
+
690
+ export function reportingRouteCommitTimestamp(loaded, routeSetId, commit) {
691
+ return reportingRouteHistory(loaded, routeSetId)
692
+ .find(({ commit: changedAt }) => changedAt === commit)?.timestamp || null;
693
+ }
694
+
695
+ export function reportingRouteProposalAssessmentTime(timestamp, assessmentAt = new Date()) {
696
+ let commitAt;
697
+ let assessedAt;
698
+ try {
699
+ commitAt = instant(timestamp, "Proposal commit time");
700
+ assessedAt = instant(assessmentAt, "Proposal assessment time");
701
+ } catch {
702
+ return null;
703
+ }
704
+ if (commitAt.getTime() > assessedAt.getTime() + CONTEMPORANEOUS_COMMIT_WINDOW_MS) return null;
705
+ return commitAt > assessedAt ? assessedAt : commitAt;
706
+ }
707
+
527
708
  export function recordsAtRevision(loaded, commit) {
528
709
  const index = getDataRecordHistoryIndex(loaded.root);
529
710
  const records = [];