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/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
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
|
|
309
|
-
|
|
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
|
|
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) {
|