filegrc 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/files.js CHANGED
@@ -7,10 +7,24 @@ import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutat
7
7
  import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
8
8
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
9
9
  import { markdownEntries } from "./resource-markdown.js";
10
+ import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
10
11
  import { measureTiming } from "./timing.js";
12
+ import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
11
13
  import { loadWorkspace } from "./workspace.js";
12
14
  import { validateWorkspace } from "./validate.js";
13
15
 
16
+ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
17
+ auditManagementReconciliation: Symbol("audit-management-reconciliation"),
18
+ auditPopulationSupersession: Symbol("audit-population-supersession"),
19
+ obligationOccurrenceReconciliation: Symbol("obligation-occurrence-reconciliation"),
20
+ obligationOccurrenceSupersession: Symbol("obligation-occurrence-supersession"),
21
+ obligationRuleActivation: Symbol("obligation-rule-activation"),
22
+ collectionReviewReassessment: Symbol("collection-review-reassessment"),
23
+ reportingRouteSetProposal: Symbol("reporting-route-set-proposal"),
24
+ reportingRouteSetApproval: Symbol("reporting-route-set-approval"),
25
+ reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation")
26
+ });
27
+
14
28
  export async function createResource(input, record, options = {}) {
15
29
  return serializeWorkspaceMutation(input, (root) => createResourceUnlocked(root, record, options));
16
30
  }
@@ -27,6 +41,7 @@ async function addEvidenceAttachmentUnlocked(input, evidenceId, sourcePath, opti
27
41
  const loaded = await loadWorkspace(input);
28
42
  const entry = loaded.entries.find(({ record }) => record.type === "evidence" && record.id === evidenceId);
29
43
  if (!entry) throw new Error(`Evidence "${evidenceId}" was not found.`);
44
+ assertFinalizedOccurrenceProofMutable(loaded, entry.record);
30
45
  const source = resolve(String(sourcePath || ""));
31
46
  let sourceStat;
32
47
  try {
@@ -83,6 +98,7 @@ async function removeEvidenceAttachmentUnlocked(input, evidenceId, attachment, o
83
98
  const loaded = await loadWorkspace(input);
84
99
  const entry = loaded.entries.find(({ record }) => record.type === "evidence" && record.id === evidenceId);
85
100
  if (!entry) throw new Error(`Evidence "${evidenceId}" was not found.`);
101
+ assertFinalizedOccurrenceProofMutable(loaded, entry.record);
86
102
  const requested = String(attachment || "").trim();
87
103
  const matches = (entry.record.filePaths || []).filter((path) => (
88
104
  path === requested || basename(path) === requested
@@ -304,14 +320,25 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio
304
320
  const allowedPathMoves = new Set();
305
321
  for (const record of creates) {
306
322
  validateBatchRecord(record, ids);
323
+ assertSpecializedWorkflowCreate(record, { ...changes, lifecycleOperation }, loaded);
307
324
  if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
308
- const path = resourcePath(loaded.root, writeModel, record);
309
- writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
325
+ const hasContentUpdate = Object.hasOwn(contentUpdates, record.id);
326
+ const recordContentWrites = await prepareContentWrites(loaded, record, contentUpdates[record.id], {
327
+ requireExpectedRevisions: false
328
+ });
329
+ if (hasContentUpdate) preparedContentIds.add(record.id);
330
+ contentWrites.push(...recordContentWrites);
331
+ const nextRecord = hasContentUpdate
332
+ ? await prepareApprovalBinding(loaded, record, recordContentWrites, null)
333
+ : record;
334
+ const path = resourcePath(loaded.root, writeModel, nextRecord);
335
+ writes.push({ operation: "create", path, record: nextRecord, previous: null, fileMode: 0o666 });
310
336
  }
311
337
  for (const record of updates) {
312
338
  validateBatchRecord(record, ids);
313
339
  const existing = existingById.get(record.id);
314
340
  if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
341
+ assertImmutableWorkflowRecord(existing.record, record, { ...changes, lifecycleOperation }, loaded);
315
342
  if (existing.record.type !== record.type && !targetModelVersion) {
316
343
  throw new Error(`Resource "${record.id}" cannot change type.`);
317
344
  }
@@ -349,6 +376,7 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio
349
376
  requireExpectedRevisions: hasContentUpdate
350
377
  });
351
378
  if (hasContentUpdate) preparedContentIds.add(record.id);
379
+ if (recordContentWrites.length) assertWorkflowContentMutable(loaded, existing.record);
352
380
  contentWrites.push(...recordContentWrites);
353
381
  const nextRecord = hasContentUpdate
354
382
  ? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record)
@@ -363,6 +391,7 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio
363
391
  if (approvalBound(existing.record, loaded.model)) {
364
392
  throw new Error(`Batch content for approved or active resource "${resourceId}" needs a matching resource update and validation of its approval binding.`);
365
393
  }
394
+ assertWorkflowContentMutable(loaded, existing.record);
366
395
  contentWrites.push(...await prepareContentWrites(loaded, existing.record, contentUpdates[resourceId], {
367
396
  expectedRevisions: expectedContentRevisions[resourceId],
368
397
  requireExpectedRevisions: true
@@ -528,6 +557,7 @@ async function createResourcesUnlocked(input, records) {
528
557
  const ids = new Set();
529
558
  const writes = [];
530
559
  for (const record of records) {
560
+ assertSpecializedWorkflowCreate(record, {}, loaded);
531
561
  if (!record || Array.isArray(record) || typeof record !== "object") throw new Error("Every resource must be a JSON object.");
532
562
  if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
533
563
  ids.add(record.id);
@@ -560,6 +590,7 @@ async function createResourcesUnlocked(input, records) {
560
590
 
561
591
  async function createResourceUnlocked(input, record, options) {
562
592
  const loaded = await loadWorkspace(input);
593
+ assertSpecializedWorkflowCreate(record, options, loaded);
563
594
  const deferValidation = workspaceValidationDeferred();
564
595
  const before = deferValidation ? null : await validateWorkspace(loaded);
565
596
  const path = resourcePath(loaded.root, loaded.model, record);
@@ -612,6 +643,8 @@ async function updateResourceUnlocked(input, type, id, record, options) {
612
643
  requireExpectedRevisions: options.requireExpectedContentRevisions
613
644
  });
614
645
  const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
646
+ assertImmutableWorkflowRecord(existing, record, options, loaded);
647
+ if (contentWrites.length) assertWorkflowContentMutable(loaded, existing);
615
648
  const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
616
649
  assertGovernedContentLifecycleMutation(existing, nextRecord, loaded.model, null);
617
650
  try {
@@ -633,6 +666,274 @@ async function updateResourceUnlocked(input, type, id, record, options) {
633
666
  return { record: nextRecord, path };
634
667
  }
635
668
 
669
+ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = null) {
670
+ if (!existing) return;
671
+ const preservesExisting = (allowed = []) => [...new Set([...Object.keys(existing), ...Object.keys(next)])].every((key) => (
672
+ allowed.includes(key) || JSON.stringify(next[key]) === JSON.stringify(existing[key])
673
+ ));
674
+ if (existing.type === "collection-review" && ["active", "retired"].includes(existing.status)) {
675
+ const retirement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
676
+ && existing.status === "active"
677
+ && next.status === "retired"
678
+ && next.statusTransition?.changedOn
679
+ && next.statusTransition?.changedByIds?.length
680
+ && next.statusTransition?.reason
681
+ && preservesExisting(["status", "statusTransition"]);
682
+ const legacyReplacement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
683
+ && existing.status === "active"
684
+ && next.status === "active";
685
+ if (!retirement && !legacyReplacement && options.lifecycleOperation !== "model-migration") {
686
+ throw new Error(`Finalized Collection Review "${existing.id}" is immutable. Record a superseding review instead.`);
687
+ }
688
+ }
689
+ if (options.lifecycleOperation === "model-migration") return;
690
+ if (
691
+ modelSupports(loaded?.model || 0, "reporting-route-sets")
692
+ && ["policy", "document", "commitment", "risk"].includes(existing.type)
693
+ && JSON.stringify(existing.reportingRouteRequirements || []) !== JSON.stringify(next.reportingRouteRequirements || [])
694
+ ) {
695
+ const protectedStatus = existing.type === "policy" || existing.type === "document"
696
+ ? ["approved", "active", "superseded", "retired"].includes(existing.status)
697
+ : existing.type === "commitment"
698
+ ? ["active", "superseded", "retired"].includes(existing.status)
699
+ : existing.status !== "draft";
700
+ if (protectedStatus) {
701
+ throw new Error(`${getResourceDefinition(loaded.model, existing.type).title} "${existing.id}" reporting route requirements are part of its approved decision. Create a successor or return the decision to draft before changing them.`);
702
+ }
703
+ }
704
+ if (existing.type === "reporting-route-set") {
705
+ const capability = options.workflowCapability;
706
+ if (existing.status === "draft" && next.status === "proposed") {
707
+ if (capability !== INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetProposal || !preservesExisting(["status"])) {
708
+ throw new Error(`Reporting Route Set "${existing.id}" must use the managed proposal action.`);
709
+ }
710
+ } else if (existing.status === "proposed" && next.status === "approved") {
711
+ if (
712
+ capability !== INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetApproval
713
+ || !preservesExisting(["status", "proposalCommit", "approval"])
714
+ ) throw new Error(`Reporting Route Set "${existing.id}" must use the managed approval action.`);
715
+ } else if (existing.status === "approved" && next.status === "canceled") {
716
+ if (
717
+ ![
718
+ INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetApproval,
719
+ INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetCancellation
720
+ ].includes(capability)
721
+ || !preservesExisting(["status", "cancellation"])
722
+ ) throw new Error(`Reporting Route Set "${existing.id}" must use the managed cancellation action.`);
723
+ } else if (existing.status !== "draft" || next.status !== "draft") {
724
+ throw new Error(`Finalized Reporting Route Set "${existing.id}" is immutable. Create a successor revision instead.`);
725
+ }
726
+ }
727
+ if (!modelSupports(loaded?.model || 0, "rolled-up-obligations")) return;
728
+ const capability = options.workflowCapability;
729
+ if (
730
+ existing.type === "obligation-occurrence"
731
+ && existing.status === "open"
732
+ && ![
733
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation,
734
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession,
735
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation
736
+ ].includes(capability)
737
+ ) {
738
+ throw new Error(`Obligation occurrence "${existing.id}" is workflow-managed. Scaffold and save its reconciliation instead of editing it directly.`);
739
+ }
740
+ const finalizedOccurrence = finalizedOccurrenceUsingProof(loaded, existing.id);
741
+ if (finalizedOccurrence && !preservesExisting()) {
742
+ throw new Error(
743
+ `Completion record "${existing.id}" is immutable because finalized occurrence "${finalizedOccurrence.id}" relies on it. `
744
+ + "Create a corrected completion and superseding occurrence instead."
745
+ );
746
+ }
747
+ if (existing.type === "obligation-rule" && ["active", "retired"].includes(existing.status)) {
748
+ const retirement = capability === INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation
749
+ && existing.status === "active"
750
+ && next.status === "retired"
751
+ && preservesExisting(["status", "retiredOn"]);
752
+ if (!retirement) throw new Error(`Effective Obligation rule "${existing.id}" is immutable. Create and activate a new rule revision instead.`);
753
+ }
754
+ if (
755
+ existing.type === "obligation-rule"
756
+ && existing.status !== "active"
757
+ && next.status === "active"
758
+ && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation
759
+ ) {
760
+ throw new Error(`Obligation rule "${existing.id}" activation is workflow-managed. Review and activate the rule instead of editing it directly.`);
761
+ }
762
+ if (existing.type === "obligation" && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation) {
763
+ const managedFields = ["scheduleMode", "ruleIds", "activeRuleId"];
764
+ if (managedFields.some((field) => JSON.stringify(next[field]) !== JSON.stringify(existing[field]))) {
765
+ throw new Error(`Obligation "${existing.id}" rule adoption is workflow-managed. Activate a reviewed rule instead of editing its schedule binding directly.`);
766
+ }
767
+ }
768
+ if (
769
+ existing.type === "obligation"
770
+ && existing.scheduleMode === "rule"
771
+ && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation
772
+ && JSON.stringify(next.completionResourceIds) !== JSON.stringify(existing.completionResourceIds)
773
+ ) {
774
+ throw new Error(`Obligation "${existing.id}" historical completion links are immutable after rule activation.`);
775
+ }
776
+ if (existing.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(existing.status)) {
777
+ const supersession = [
778
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession,
779
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation
780
+ ].includes(capability)
781
+ && existing.status === "reconciled"
782
+ && next.status === "superseded"
783
+ && preservesExisting(["status"]);
784
+ if (!supersession) throw new Error(`Finalized Obligation occurrence "${existing.id}" is immutable. Create a superseding reconciliation instead.`);
785
+ }
786
+ if (existing.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(existing.status)) {
787
+ const supersession = capability === INTERNAL_WORKFLOW_CAPABILITIES.auditPopulationSupersession
788
+ && ["reconciled", "not-applicable"].includes(existing.status)
789
+ && next.status === "superseded"
790
+ && preservesExisting(["status"]);
791
+ if (!supersession) throw new Error(`Finalized Audit population "${existing.id}" is immutable. Create a superseding correction instead.`);
792
+ }
793
+ if (
794
+ (existing.type === "obligation-event" && ["complete", "canceled"].includes(existing.status))
795
+ || (existing.type === "action-item" && ["done", "canceled"].includes(existing.status) && existing.obligationId)
796
+ ) {
797
+ const existingEvidenceIds = existing.evidenceIds || [];
798
+ const nextEvidenceIds = next.evidenceIds || [];
799
+ const evidenceRepair = existing.type === "action-item"
800
+ && existing.status === "done"
801
+ && next.status === "done"
802
+ && existingEvidenceIds.every((id) => nextEvidenceIds.includes(id))
803
+ && preservesExisting(["evidenceIds"]);
804
+ if (!evidenceRepair && !preservesExisting()) {
805
+ throw new Error(`Finalized ${existing.type === "obligation-event" ? "Policy Event" : "generated Action Item"} "${existing.id}" is immutable. Preserve it and record a new corrective event or task.`);
806
+ }
807
+ }
808
+ if (existing.type === "reporting-route" && ["active", "retired"].includes(existing.status)) {
809
+ const retirement = existing.status === "active"
810
+ && next.status === "retired"
811
+ && next.endsAt
812
+ && preservesExisting(["status", "endsAt"]);
813
+ if (!retirement) throw new Error(`Effective Reporting Route "${existing.id}" is immutable. Create a new route revision instead.`);
814
+ }
815
+ if (existing.type === "attestation" && existing.status === "completed" && !preservesExisting()) {
816
+ throw new Error(`Completed Attestation "${existing.id}" is immutable. Preserve it and record a correction separately.`);
817
+ }
818
+ }
819
+
820
+ function assertFinalizedOccurrenceProofMutable(loaded, record) {
821
+ const occurrence = finalizedOccurrenceUsingProof(loaded, record.id);
822
+ if (occurrence) {
823
+ throw new Error(
824
+ `Proof record "${record.id}" is immutable because finalized occurrence "${occurrence.id}" relies on it. `
825
+ + "Create corrected proof and a superseding occurrence instead."
826
+ );
827
+ }
828
+ }
829
+
830
+ function assertWorkflowContentMutable(loaded, record) {
831
+ assertFinalizedOccurrenceProofMutable(loaded, record);
832
+ if (
833
+ (record.type === "obligation-rule" && ["active", "retired"].includes(record.status))
834
+ || (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
835
+ || (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
836
+ || (record.type === "obligation-event" && ["complete", "canceled"].includes(record.status))
837
+ || (record.type === "action-item" && record.obligationId && ["done", "canceled"].includes(record.status))
838
+ || (record.type === "collection-review" && ["active", "retired"].includes(record.status))
839
+ || (record.type === "reporting-route" && ["active", "retired"].includes(record.status))
840
+ || (record.type === "attestation" && record.status === "completed")
841
+ ) {
842
+ throw new Error(`Finalized ${record.type} Markdown is immutable. Preserve it and use the record's correction workflow.`);
843
+ }
844
+ }
845
+
846
+ function finalizedOccurrenceUsingProof(loaded, targetId) {
847
+ if (!loaded?.resources?.length || !targetId) return null;
848
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
849
+ const proofTypes = new Set(["evidence", "exception", "attestation"]);
850
+ const proofFields = [
851
+ "completionResourceIds",
852
+ "evidenceIds",
853
+ "sampleEvidenceIds",
854
+ "sourceEvidenceId",
855
+ "sourceResourceIds",
856
+ "exceptionId",
857
+ "exceptionIds",
858
+ "attestationIds"
859
+ ];
860
+ for (const occurrence of loaded.resources.filter((record) => (
861
+ record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status)
862
+ ))) {
863
+ const pending = (occurrence.members || []).flatMap((member) => [
864
+ ...(member.completionResourceIds || []),
865
+ ...(member.exceptionId ? [member.exceptionId] : [])
866
+ ]);
867
+ const visited = new Set();
868
+ while (pending.length) {
869
+ const id = pending.pop();
870
+ if (!id || visited.has(id)) continue;
871
+ if (id === targetId) return occurrence;
872
+ visited.add(id);
873
+ const record = byId.get(id);
874
+ if (!record) continue;
875
+ for (const field of proofFields) {
876
+ const value = record[field];
877
+ for (const relatedId of Array.isArray(value) ? value : value ? [value] : []) {
878
+ if (proofTypes.has(byId.get(relatedId)?.type)) pending.push(relatedId);
879
+ }
880
+ }
881
+ }
882
+ }
883
+ for (const owner of loaded.resources.filter((record) => (
884
+ (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
885
+ || (record.type === "attestation" && record.status === "completed")
886
+ ))) {
887
+ const pending = [
888
+ ...(owner.evidenceIds || []),
889
+ ...(owner.sourceEvidenceId ? [owner.sourceEvidenceId] : [])
890
+ ];
891
+ const visited = new Set();
892
+ while (pending.length) {
893
+ const id = pending.pop();
894
+ if (!id || visited.has(id)) continue;
895
+ if (id === targetId) return owner;
896
+ visited.add(id);
897
+ const record = byId.get(id);
898
+ if (!record) continue;
899
+ for (const field of proofFields) {
900
+ const value = record[field];
901
+ for (const relatedId of Array.isArray(value) ? value : value ? [value] : []) {
902
+ if (proofTypes.has(byId.get(relatedId)?.type)) pending.push(relatedId);
903
+ }
904
+ }
905
+ }
906
+ }
907
+ return null;
908
+ }
909
+
910
+ function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) {
911
+ if (
912
+ record?.type === "collection-review"
913
+ && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
914
+ && options.lifecycleOperation !== "model-migration"
915
+ ) {
916
+ throw new Error(`Collection Review "${record.id || ""}" is workflow-managed. Preview and confirm the collection review instead of creating it directly.`);
917
+ }
918
+ if (
919
+ record?.type === "reporting-route-set"
920
+ && record.status !== "draft"
921
+ && options.lifecycleOperation !== "model-migration"
922
+ ) {
923
+ throw new Error(`Reporting Route Set "${record.id || ""}" must be created as a draft and advanced through managed actions.`);
924
+ }
925
+ if (!modelSupports(loaded?.model || 0, "rolled-up-obligations")) return;
926
+ if (
927
+ record?.type === "obligation-occurrence"
928
+ && ![
929
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation,
930
+ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession
931
+ ].includes(options.workflowCapability)
932
+ ) {
933
+ throw new Error(`Obligation occurrence "${record.id || ""}" is workflow-managed. Scaffold and save its reconciliation instead of creating it directly.`);
934
+ }
935
+ }
936
+
636
937
  export async function updateContent(input, dataRelativePath, source, options = {}) {
637
938
  return serializeWorkspaceMutation(input, (root) => updateContentUnlocked(root, dataRelativePath, source, options));
638
939
  }
@@ -640,14 +941,16 @@ export async function updateContent(input, dataRelativePath, source, options = {
640
941
  async function updateContentUnlocked(input, dataRelativePath, source, options) {
641
942
  if (typeof source !== "string") throw new Error("Markdown content must be a string.");
642
943
  const loaded = await loadWorkspace(input);
643
- const allowed = loaded.entries.some(({ record }) => (
944
+ const owner = loaded.entries.find(({ record }) => (
644
945
  markdownEntries(loaded.model, record).some(({ path }) => path === dataRelativePath)
645
946
  ));
947
+ const allowed = Boolean(owner);
646
948
  if (!allowed) {
647
949
  const error = new Error(`Markdown path "${dataRelativePath}" was not found.`);
648
950
  error.code = "ENOENT";
649
951
  throw error;
650
952
  }
953
+ assertWorkflowContentMutable(loaded, owner.record);
651
954
  const path = resolveDataPath(loaded.root, dataRelativePath);
652
955
  const previous = await readFile(path, "utf8");
653
956
  assertRevision(previous, options.expectedRevision, "The Markdown file");
@@ -680,6 +983,23 @@ async function deleteResourceUnlocked(input, type, id, options) {
680
983
  const source = await readFile(path, "utf8");
681
984
  assertRevision(source, options.expectedRevision, "The record");
682
985
  const record = JSON.parse(source);
986
+ if (
987
+ (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
988
+ || (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
989
+ || (record.type === "obligation-event" && ["complete", "canceled"].includes(record.status))
990
+ || (record.type === "action-item" && record.obligationId && ["done", "canceled"].includes(record.status))
991
+ || (record.type === "collection-review" && ["active", "retired"].includes(record.status))
992
+ || (record.type === "obligation-rule" && ["active", "retired"].includes(record.status))
993
+ || (record.type === "reporting-route" && ["active", "retired"].includes(record.status))
994
+ || (record.type === "reporting-route-set" && ["proposed", "approved", "canceled", "historical"].includes(record.status))
995
+ || (record.type === "attestation" && record.status === "completed")
996
+ || (["policy", "document", "commitment", "risk"].includes(record.type)
997
+ && record.reportingRouteRequirements?.length
998
+ && protectedReportingRouteRequirementStatus(record))
999
+ ) {
1000
+ throw new Error(`Finalized ${record.type} "${id}" cannot be deleted. Preserve it and create a superseding correction.`);
1001
+ }
1002
+ assertFinalizedOccurrenceProofMutable(loaded, record);
683
1003
  if (record.type === "evidence" && (record.filePaths || []).length) {
684
1004
  throw new Error(`Evidence "${id}" still has local attachments. Detach them explicitly before deleting the record.`);
685
1005
  }
@@ -702,6 +1022,12 @@ async function deleteResourceUnlocked(input, type, id, options) {
702
1022
  return { type, id, path, deletedContent: contentFiles.filter(({ source }) => source !== null).map(({ dataRelativePath }) => dataRelativePath) };
703
1023
  }
704
1024
 
1025
+ function protectedReportingRouteRequirementStatus(record) {
1026
+ if (["policy", "document"].includes(record.type)) return ["approved", "active", "superseded", "retired"].includes(record.status);
1027
+ if (record.type === "commitment") return ["active", "superseded", "retired"].includes(record.status);
1028
+ return record.type === "risk" && record.status !== "draft";
1029
+ }
1030
+
705
1031
  export function resourcePath(input, model, record) {
706
1032
  const root = resolveWorkspaceRoot(input);
707
1033
  const definition = getResourceDefinition(model, record.type);
@@ -927,7 +1253,7 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
927
1253
  const bound = record.status === "completed" && record.attestationMethod === "git-approval";
928
1254
  if (!bound) {
929
1255
  delete nextRecord.contentRevisions;
930
- return nextRecord;
1256
+ return bindAttestationReportingRoute(loaded, nextRecord);
931
1257
  }
932
1258
  if (
933
1259
  previousRecord?.status === "completed"
@@ -935,7 +1261,7 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
935
1261
  && previousRecord.contentRevisions
936
1262
  ) {
937
1263
  nextRecord.contentRevisions = structuredClone(previousRecord.contentRevisions);
938
- return nextRecord;
1264
+ return bindAttestationReportingRoute(loaded, nextRecord);
939
1265
  }
940
1266
  const revisions = {};
941
1267
  for (const id of record.subjectResourceIds || []) {
@@ -951,7 +1277,39 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
951
1277
  }
952
1278
  }
953
1279
  nextRecord.contentRevisions = revisions;
954
- return nextRecord;
1280
+ return bindAttestationReportingRoute(loaded, nextRecord);
1281
+ }
1282
+
1283
+ function bindAttestationReportingRoute(loaded, record) {
1284
+ if (
1285
+ record?.type !== "attestation"
1286
+ || record.status !== "completed"
1287
+ || (!loaded.model.resources.attestation?.fields?.reportingRouteId
1288
+ && !loaded.model.resources.attestation?.fields?.reportingRouteSetId)
1289
+ ) return record;
1290
+ const date = record.assignedOn || record.completedOn || currentCalendarDate(loaded.workspace.timezone);
1291
+ const cutoff = timestampFromLocalDateTime(`${date}T23:59:59`, loaded.workspace.timezone);
1292
+ if (loaded.model.resources.attestation.fields.reportingRouteSetId) {
1293
+ return bindAttestationReportingRouteSet(loaded, record);
1294
+ }
1295
+ const route = loaded.resources
1296
+ .filter((candidate) => (
1297
+ candidate.type === "reporting-route"
1298
+ && ["active", "retired"].includes(candidate.status)
1299
+ && candidate.purpose === "security-reporting"
1300
+ && candidate.priority === "primary"
1301
+ && new Date(candidate.effectiveAt) <= new Date(cutoff)
1302
+ && (!candidate.endsAt || new Date(candidate.endsAt) > new Date(cutoff))
1303
+ ))
1304
+ .sort((left, right) => right.effectiveAt.localeCompare(left.effectiveAt))[0] || null;
1305
+ const bound = { ...record };
1306
+ delete bound.reportingRouteId;
1307
+ delete bound.reportingRouteRevision;
1308
+ return route ? {
1309
+ ...bound,
1310
+ reportingRouteId: route.id,
1311
+ reportingRouteRevision: reportingRouteRevision(route)
1312
+ } : bound;
955
1313
  }
956
1314
 
957
1315
  function approvalBound(record, model) {