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
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
getDataRecordHistoryIndex,
|
|
4
|
+
getFileAtRevision,
|
|
5
|
+
getFileHistory,
|
|
6
|
+
getFileObjectIdAtRevision,
|
|
7
|
+
getFilePathAtRevision,
|
|
8
|
+
getRecordIdentityHistories,
|
|
9
|
+
getWorkingFileObjectId,
|
|
10
|
+
isDataHistoryAncestor
|
|
11
|
+
} from "./git.js";
|
|
12
|
+
import { resolveDataPath } from "./paths.js";
|
|
13
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
14
|
+
import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } from "./time.js";
|
|
15
|
+
import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js";
|
|
16
|
+
|
|
17
|
+
const FINAL_STATUSES = new Map([
|
|
18
|
+
["collection-review", new Set(["active", "retired"])],
|
|
19
|
+
["obligation-rule", new Set(["active", "retired"])],
|
|
20
|
+
["obligation-occurrence", new Set(["reconciled", "superseded"])],
|
|
21
|
+
["audit-population", new Set(["reconciled", "not-applicable", "superseded"])],
|
|
22
|
+
["reporting-route", new Set(["active", "retired"])],
|
|
23
|
+
["reporting-route-set", new Set(["approved", "canceled", "historical"])],
|
|
24
|
+
["attestation", new Set(["completed"])],
|
|
25
|
+
["obligation-event", new Set(["complete", "canceled"])],
|
|
26
|
+
["action-item", new Set(["done", "canceled"])],
|
|
27
|
+
["exception", new Set(["approved", "revoked", "closed"])]
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const PROOF_FIELDS = [
|
|
31
|
+
"completionResourceIds",
|
|
32
|
+
"evidenceIds",
|
|
33
|
+
"sampleEvidenceIds",
|
|
34
|
+
"sourceEvidenceId",
|
|
35
|
+
"sourceResourceIds",
|
|
36
|
+
"exceptionId",
|
|
37
|
+
"exceptionIds",
|
|
38
|
+
"attestationIds"
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
|
|
42
|
+
const historical = historicalState(loaded);
|
|
43
|
+
if (!historical.available) {
|
|
44
|
+
if (!loaded.resources.some(isFinalized)) return;
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
severity: "error",
|
|
47
|
+
code: "workflow-history-unavailable",
|
|
48
|
+
path: "data/workspace.json",
|
|
49
|
+
message: "Complete Git history is required to verify finalized workflow records. Restore Git history access, unshallow the repository if needed, and validate again."
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { commits, historicalFinalized, revisionRecords, historiesById } = historical;
|
|
54
|
+
const historyStart = commits[0];
|
|
55
|
+
const workspaceHistoryCommits = (getFileHistory(loaded.root, "data/workspace.json", Number.MAX_SAFE_INTEGER) || [])
|
|
56
|
+
.map(({ commit }) => commit)
|
|
57
|
+
.filter((commit) => !historyStart || isDataHistoryAncestor(loaded, historyStart, commit));
|
|
58
|
+
const chronologyCommits = [...new Set([...commits, ...workspaceHistoryCommits])];
|
|
59
|
+
const invalidWorkspaceCommit = chronologyCommits.find((commit) => !historicalWorkspaceTimezoneAtRevision(loaded, commit));
|
|
60
|
+
if (invalidWorkspaceCommit) {
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
severity: "error",
|
|
63
|
+
code: "invalid-historical-workspace-timezone",
|
|
64
|
+
path: "data/workspace.json",
|
|
65
|
+
message: `Workspace history at commit ${invalidWorkspaceCommit} does not contain a readable Workspace with a valid IANA timezone. Restore or correct the historical record before relying on dated workflow facts.`
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const firstFinalized = new Map();
|
|
70
|
+
const currentById = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
71
|
+
for (const [id, historical] of historicalFinalized) {
|
|
72
|
+
const current = currentById.get(id);
|
|
73
|
+
if (!current || !isFinalized(current)) {
|
|
74
|
+
diagnostics.push({
|
|
75
|
+
severity: "error",
|
|
76
|
+
code: current ? "reopened-finalized-record" : "deleted-finalized-record",
|
|
77
|
+
path: historical.path,
|
|
78
|
+
message: `Finalized ${historical.record.type} "${id}" was ${current ? "moved back to a non-final state" : "deleted"}. Restore its finalized state and use its correction or supersession workflow.`
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const entry of loaded.entries) {
|
|
83
|
+
if (!isFinalized(entry.record)) continue;
|
|
84
|
+
const versions = finalizedVersions(commits, entry.record.id, revisionRecords, historiesById);
|
|
85
|
+
const historical = versions[0];
|
|
86
|
+
if (!historical) continue;
|
|
87
|
+
firstFinalized.set(entry.record.id, historical);
|
|
88
|
+
if (
|
|
89
|
+
entry.record.type === "collection-review"
|
|
90
|
+
&& !validInitialCollectionReview(historical, loaded.workspace.timezone)
|
|
91
|
+
&& !legacyCollectionReview(loaded, historical)
|
|
92
|
+
) {
|
|
93
|
+
diagnostics.push(integrityError(
|
|
94
|
+
entry,
|
|
95
|
+
`Collection Review "${entry.record.id}" must first be committed active with a knowledge cutoff on its recorded review date. Create a current review instead of initially retiring one or changing its review binding.`
|
|
96
|
+
));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
entry.record.type === "exception"
|
|
101
|
+
&& historical.record.reportingRouteSetId
|
|
102
|
+
&& historical.commit !== commits.find((commit) => {
|
|
103
|
+
try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
|
|
104
|
+
})
|
|
105
|
+
) {
|
|
106
|
+
const records = recordsAtRevision(loaded, historical.commit);
|
|
107
|
+
const historicalTimezone = workspaceTimezoneAtRevision(loaded, historical.commit);
|
|
108
|
+
if (!reportingRouteFixedEvidence(
|
|
109
|
+
records,
|
|
110
|
+
historical.record.id,
|
|
111
|
+
historical.record.evidenceIds,
|
|
112
|
+
historical.record.approval?.approvedOn,
|
|
113
|
+
historicalTimezone,
|
|
114
|
+
{ root: loaded.root, commit: historical.commit }
|
|
115
|
+
).length) {
|
|
116
|
+
diagnostics.push(integrityError(
|
|
117
|
+
entry,
|
|
118
|
+
`Reporting-route Exception "${entry.record.id}" requires verified, fixed Evidence covering its approval event.`
|
|
119
|
+
));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const chain = [...versions, { record: entry.record, commit: null }];
|
|
123
|
+
if (chain.some((item, index) => index > 0
|
|
124
|
+
&& !permittedFinalizedTransition(chain[index - 1].record, item.record)
|
|
125
|
+
&& !legacyCollectionReviewTransition(loaded, chain[index - 1], item))) {
|
|
126
|
+
diagnostics.push(integrityError(entry, `Finalized ${entry.record.type} "${entry.record.id}" differs from its first committed final state. Use its correction or supersession workflow.`));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
await compareMarkdownAtRevision(loaded, entry, historical, diagnostics);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const firstV10Commit = commits.find((commit) => {
|
|
133
|
+
try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
|
|
134
|
+
});
|
|
135
|
+
const protectedRouteSources = new Map();
|
|
136
|
+
for (const [commit, records] of revisionRecords) {
|
|
137
|
+
for (const [id, historical] of records) {
|
|
138
|
+
if (!protectedRouteRequirementSource(historical.record)) continue;
|
|
139
|
+
const versions = protectedRouteSources.get(id) || [];
|
|
140
|
+
versions.push({
|
|
141
|
+
...historical,
|
|
142
|
+
commit,
|
|
143
|
+
...(historiesById.get(id) || []).find(({ commit: changedAt }) => changedAt === commit)
|
|
144
|
+
});
|
|
145
|
+
protectedRouteSources.set(id, versions);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const [id, protectedVersions] of protectedRouteSources) {
|
|
149
|
+
const entry = loaded.entries.find(({ record }) => record.id === id);
|
|
150
|
+
if (!entry) {
|
|
151
|
+
const historical = protectedVersions[0];
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
severity: "error",
|
|
154
|
+
code: "deleted-finalized-record",
|
|
155
|
+
path: historical.path,
|
|
156
|
+
message: `Protected ${historical.record.type} "${id}" was deleted after its structured Reporting Route requirements became effective. Restore it and use a preserved successor or retirement workflow.`
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
!routeRequirementSource(entry.record)
|
|
162
|
+
|| routeRequirementVersionsRewritten(
|
|
163
|
+
changedVersions(id, commits, revisionRecords, historiesById),
|
|
164
|
+
entry.record
|
|
165
|
+
)
|
|
166
|
+
) {
|
|
167
|
+
diagnostics.push(integrityError(
|
|
168
|
+
entry,
|
|
169
|
+
`${entry.record.type} "${entry.record.id}" changed structured Reporting Route requirements after approval without first returning the decision to draft or review. Preserve the approved revision, then use its review workflow or create a successor decision.`
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
const first = protectedVersions[0];
|
|
173
|
+
if (first.commit !== firstV10Commit) {
|
|
174
|
+
const historicalRecords = recordsAtRevision(loaded, first.commit);
|
|
175
|
+
const historicalTimezone = workspaceTimezoneAtRevision(loaded, first.commit);
|
|
176
|
+
for (const requirement of Array.isArray(first.record.reportingRouteRequirements) ? first.record.reportingRouteRequirements : []) {
|
|
177
|
+
if (!requirement?.effectiveAt) continue;
|
|
178
|
+
const support = reportingRouteFixedEvidence(
|
|
179
|
+
historicalRecords,
|
|
180
|
+
first.record.id,
|
|
181
|
+
requirement.evidenceIds,
|
|
182
|
+
requirement.effectiveAt,
|
|
183
|
+
requirement.timezone || loaded.workspace.timezone,
|
|
184
|
+
{ root: loaded.root, commit: first.commit }
|
|
185
|
+
);
|
|
186
|
+
if (!support.length) {
|
|
187
|
+
diagnostics.push(integrityError(
|
|
188
|
+
entry,
|
|
189
|
+
`${first.record.type} "${first.record.id}" requires verified, fixed Evidence covering its Reporting Route requirement decision.`
|
|
190
|
+
));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const evidence of support) {
|
|
194
|
+
const evidenceEntry = loaded.entries.find(({ record }) => record.id === evidence.id);
|
|
195
|
+
const evidencePath = evidenceEntry ? `data/${evidenceEntry.relativePath}` : null;
|
|
196
|
+
if (
|
|
197
|
+
!evidenceEntry
|
|
198
|
+
|| getFileObjectIdAtRevision(loaded.root, first.commit, evidencePath) !== getWorkingFileObjectId(loaded.root, evidencePath)
|
|
199
|
+
) {
|
|
200
|
+
diagnostics.push(integrityError(
|
|
201
|
+
evidenceEntry || entry,
|
|
202
|
+
`Evidence "${evidence.id}" supporting backfilled Reporting Route requirement source "${first.record.id}" must remain identical to its first relied-upon revision.`
|
|
203
|
+
));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
await compareMarkdownAtRevision(loaded, evidenceEntry, { record: evidenceEntry.record, commit: first.commit }, diagnostics, first.record.id);
|
|
207
|
+
await compareEvidenceAttachmentsAtRevision(loaded, evidenceEntry, first.commit, diagnostics, first.record.id);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!sameJson(first.record.effectiveOn, entry.record.effectiveOn)) {
|
|
212
|
+
diagnostics.push(integrityError(
|
|
213
|
+
entry,
|
|
214
|
+
`${entry.record.type} "${entry.record.id}" changed the effective date governing its structured Reporting Route requirements. Preserve the original interval and create a successor decision.`
|
|
215
|
+
));
|
|
216
|
+
}
|
|
217
|
+
const firstTerminal = protectedVersions.find(({ record }) => routeRequirementSourceIsTerminal(record));
|
|
218
|
+
if (firstTerminal && !sameJson(
|
|
219
|
+
pick(firstTerminal.record, ["status", "statusTransition"]),
|
|
220
|
+
pick(entry.record, ["status", "statusTransition"])
|
|
221
|
+
)) {
|
|
222
|
+
diagnostics.push(integrityError(
|
|
223
|
+
entry,
|
|
224
|
+
`${entry.record.type} "${entry.record.id}" changed the terminal lifecycle interval governing its structured Reporting Route requirements. Preserve it and create a successor decision.`
|
|
225
|
+
));
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
!firstTerminal
|
|
229
|
+
&& routeRequirementSourceIsTerminal(entry.record)
|
|
230
|
+
&& entry.record.statusTransition?.changedOn
|
|
231
|
+
&& currentCalendarDate(loaded.workspace.timezone, new Date()) > entry.record.statusTransition.changedOn
|
|
232
|
+
) {
|
|
233
|
+
diagnostics.push(integrityError(
|
|
234
|
+
entry,
|
|
235
|
+
`${entry.record.type} "${entry.record.id}" cannot introduce a backdated terminal transition for structured Reporting Route requirements. Use the current date and preserve the prior interval.`
|
|
236
|
+
));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await validateReportingRouteAuthorityHistory(loaded, commits, revisionRecords, historiesById, diagnostics);
|
|
241
|
+
|
|
242
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
243
|
+
const proofOwners = loaded.resources.filter((record) => (
|
|
244
|
+
(record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
|
|
245
|
+
|| (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
|
|
246
|
+
|| (record.type === "attestation" && record.status === "completed")
|
|
247
|
+
|| (record.type === "exception" && ["approved", "revoked", "closed"].includes(record.status))
|
|
248
|
+
));
|
|
249
|
+
const proofIdsByOwner = new Map(proofOwners.map((owner) => [owner.id, proofIds(owner, byId)]));
|
|
250
|
+
const proofIdentityHistories = getRecordIdentityHistories(
|
|
251
|
+
loaded.root,
|
|
252
|
+
new Set([...proofIdsByOwner.values()].flatMap((ids) => [...ids]))
|
|
253
|
+
);
|
|
254
|
+
for (const owner of proofOwners) {
|
|
255
|
+
const finalized = firstFinalized.get(owner.id);
|
|
256
|
+
if (!finalized) continue;
|
|
257
|
+
if (owner.type === "obligation-occurrence" && owner.collectionReviewCommit && (
|
|
258
|
+
!isDataHistoryAncestor(loaded, owner.scopeRevision, owner.collectionReviewCommit)
|
|
259
|
+
|| !isDataHistoryAncestor(loaded, owner.collectionReviewCommit, finalized.commit)
|
|
260
|
+
)) {
|
|
261
|
+
const ownerEntry = loaded.entries.find(({ record }) => record.id === owner.id);
|
|
262
|
+
diagnostics.push(integrityError(ownerEntry, `Occurrence "${owner.id}" must bind a Collection Review committed after its scope revision and before the occurrence was finalized.`));
|
|
263
|
+
}
|
|
264
|
+
const protectedIds = proofIdsByOwner.get(owner.id);
|
|
265
|
+
for (const id of protectedIds) {
|
|
266
|
+
const currentEntry = loaded.entries.find(({ record }) => record.id === id);
|
|
267
|
+
const historical = recordAtRevision(loaded, finalized.commit, id, proofIdentityHistories.get(id) || []);
|
|
268
|
+
if (!currentEntry || !historical) {
|
|
269
|
+
const ownerEntry = loaded.entries.find(({ record }) => record.id === owner.id);
|
|
270
|
+
diagnostics.push(integrityError(ownerEntry, `Proof record "${id}" must exist in the commit where ${owner.type} "${owner.id}" was first finalized.`));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!sameJson(currentEntry.record, historical.record)) {
|
|
274
|
+
diagnostics.push(integrityError(currentEntry, `Proof record "${id}" differs from the version used when ${owner.type} "${owner.id}" was first finalized. Create corrected proof and a superseding record.`));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
await compareMarkdownAtRevision(loaded, currentEntry, { ...historical, commit: finalized.commit }, diagnostics, owner.id);
|
|
278
|
+
await compareEvidenceAttachmentsAtRevision(loaded, currentEntry, finalized.commit, diagnostics, owner.id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function legacyCollectionReviewTransition(loaded, previous, current) {
|
|
284
|
+
if (
|
|
285
|
+
previous.record.type !== "collection-review"
|
|
286
|
+
|| current.record.type !== "collection-review"
|
|
287
|
+
|| previous.record.status !== "active"
|
|
288
|
+
|| current.record.status !== "active"
|
|
289
|
+
|| !current.commit
|
|
290
|
+
) return false;
|
|
291
|
+
try {
|
|
292
|
+
const workspace = JSON.parse(getFileAtRevision(loaded.root, current.commit, "data/workspace.json"));
|
|
293
|
+
return Number(workspace?.dataModelVersion) < 9;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function historicalState(loaded) {
|
|
300
|
+
const index = getDataRecordHistoryIndex(loaded.root);
|
|
301
|
+
if (!index.available) return { available: false };
|
|
302
|
+
const firstV9 = index.commits.findIndex((commit) => {
|
|
303
|
+
try {
|
|
304
|
+
const workspace = JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"));
|
|
305
|
+
return Number(workspace?.dataModelVersion) >= 9;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const commits = firstV9 >= 0 ? index.commits.slice(firstV9) : index.commits;
|
|
311
|
+
const revisionRecords = new Map(index.recordsByCommit);
|
|
312
|
+
const historicalFinalized = new Map();
|
|
313
|
+
if (firstV9 >= 0) {
|
|
314
|
+
const baselineCommit = index.commits[firstV9];
|
|
315
|
+
const baseline = new Map(revisionRecords.get(baselineCommit) || []);
|
|
316
|
+
for (const [id, identityHistory] of index.historiesById) {
|
|
317
|
+
const item = recordAtRevision(loaded, baselineCommit, id, identityHistory);
|
|
318
|
+
if (item) {
|
|
319
|
+
const prior = firstV9 > 0
|
|
320
|
+
? recordAtRevision(loaded, index.commits[firstV9 - 1], id, identityHistory)
|
|
321
|
+
: null;
|
|
322
|
+
baseline.set(id, {
|
|
323
|
+
...item,
|
|
324
|
+
...(prior && isFinalized(prior.record) ? { legacyBaseline: true } : {})
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
revisionRecords.set(baselineCommit, baseline);
|
|
329
|
+
}
|
|
330
|
+
for (const commit of commits) {
|
|
331
|
+
const records = recordMapAtRevision(loaded, commit, revisionRecords);
|
|
332
|
+
for (const [id, item] of records) {
|
|
333
|
+
if (isFinalized(item.record) && !historicalFinalized.has(id)) historicalFinalized.set(id, { ...item, commit });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { available: true, commits, revisionRecords, historicalFinalized, historiesById: index.historiesById };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function finalizedVersions(commits, id, cache, historiesById) {
|
|
340
|
+
const history = historiesById.get(id) || [];
|
|
341
|
+
return commits.flatMap((commit) => {
|
|
342
|
+
const historical = recordMapAtRevision(null, commit, cache).get(id) || null;
|
|
343
|
+
const summary = history.find((item) => item.commit === commit);
|
|
344
|
+
return historical && isFinalized(historical.record) ? [{ ...historical, ...summary, commit }] : [];
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function validInitialCollectionReview(historical, timezone) {
|
|
349
|
+
return historical.record.status === "active"
|
|
350
|
+
&& isRfc3339Timestamp(historical.record.knowledgeCutoffAt)
|
|
351
|
+
&& historical.record.reviewedOn === currentCalendarDate(
|
|
352
|
+
timezone,
|
|
353
|
+
new Date(historical.record.knowledgeCutoffAt)
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function legacyCollectionReview(loaded, historical) {
|
|
358
|
+
if (historical.legacyBaseline && historical.record.status === "active" && !historical.record.knowledgeCutoffAt) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
let workspace;
|
|
362
|
+
try {
|
|
363
|
+
workspace = JSON.parse(getFileAtRevision(loaded.root, historical.commit, "data/workspace.json"));
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return Number(workspace?.dataModelVersion) < 9
|
|
368
|
+
&& historical.record.status === "active"
|
|
369
|
+
&& !historical.record.knowledgeCutoffAt;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function recordAtRevision(loaded, commit, id, identityHistory) {
|
|
373
|
+
const item = identityHistory.find(({ commit: changedAt }) => isDataHistoryAncestor(loaded, changedAt, commit));
|
|
374
|
+
if (!item) return null;
|
|
375
|
+
const source = getFileAtRevision(loaded.root, commit, item.path);
|
|
376
|
+
if (!source) return null;
|
|
377
|
+
try {
|
|
378
|
+
const record = JSON.parse(source);
|
|
379
|
+
return record?.id === id ? { record, path: item.path } : null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function recordMapAtRevision(_loaded, commit, cache) {
|
|
386
|
+
return cache.get(commit) || new Map();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isFinalized(record) {
|
|
390
|
+
if (record?.type === "action-item" && !record.obligationId) return false;
|
|
391
|
+
return FINAL_STATUSES.get(record?.type)?.has(record.status) || false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function protectedRouteRequirementSource(record) {
|
|
395
|
+
if (!routeRequirementSource(record)) return false;
|
|
396
|
+
if (["policy", "document"].includes(record.type)) {
|
|
397
|
+
return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
398
|
+
}
|
|
399
|
+
if (record.type === "commitment") return ["active", "superseded", "retired"].includes(record.status);
|
|
400
|
+
return record.type === "risk" && record.status !== "draft";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function routeRequirementVersionsRewritten(versions, current) {
|
|
404
|
+
const chain = [...versions, { record: current }];
|
|
405
|
+
return chain.some(({ record }, index) => (
|
|
406
|
+
index > 0
|
|
407
|
+
&& protectedRouteRequirementSource(chain[index - 1].record)
|
|
408
|
+
&& !sameJson(chain[index - 1].record.reportingRouteRequirements, record.reportingRouteRequirements)
|
|
409
|
+
));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function routeRequirementSource(record) {
|
|
413
|
+
return ["policy", "document", "commitment", "risk"].includes(record?.type);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function routeRequirementSourceIsTerminal(record) {
|
|
417
|
+
return ["superseded", "retired", "closed", "archived"].includes(record?.status);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function validateReportingRouteAuthorityHistory(loaded, commits, revisionRecords, historiesById, diagnostics) {
|
|
421
|
+
const routeSets = loaded.resources.filter(({ type, status }) => (
|
|
422
|
+
type === "reporting-route-set" && ["approved", "canceled"].includes(status)
|
|
423
|
+
));
|
|
424
|
+
if (!routeSets.length) return;
|
|
425
|
+
const firstV10Commit = commits.find((commit) => {
|
|
426
|
+
try { return Number(JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"))?.dataModelVersion) >= 10; } catch { return false; }
|
|
427
|
+
});
|
|
428
|
+
for (const route of routeSets) {
|
|
429
|
+
const versions = changedVersions(route.id, commits, revisionRecords, historiesById);
|
|
430
|
+
for (const eventName of ["approval", "cancellation"]) {
|
|
431
|
+
const eventVersion = versions.find(({ record }) => record[eventName]);
|
|
432
|
+
if (!eventVersion) continue;
|
|
433
|
+
for (const evidenceId of arrayValue(eventVersion.record[eventName]?.evidenceIds)) {
|
|
434
|
+
const evidenceEntry = loaded.entries.find(({ record }) => record.id === evidenceId && record.type === "evidence");
|
|
435
|
+
const currentEvidencePath = evidenceEntry ? `data/${evidenceEntry.relativePath}` : null;
|
|
436
|
+
const historicalEvidencePath = currentEvidencePath
|
|
437
|
+
? getFilePathAtRevision(loaded.root, currentEvidencePath, eventVersion.commit)
|
|
438
|
+
: null;
|
|
439
|
+
const committedEvidenceObject = historicalEvidencePath
|
|
440
|
+
? getFileObjectIdAtRevision(loaded.root, eventVersion.commit, historicalEvidencePath)
|
|
441
|
+
: null;
|
|
442
|
+
const currentEvidenceObject = currentEvidencePath
|
|
443
|
+
? getWorkingFileObjectId(loaded.root, currentEvidencePath)
|
|
444
|
+
: null;
|
|
445
|
+
if (
|
|
446
|
+
!evidenceEntry
|
|
447
|
+
|| committedEvidenceObject !== currentEvidenceObject
|
|
448
|
+
) {
|
|
449
|
+
diagnostics.push(integrityError(
|
|
450
|
+
evidenceEntry || loaded.entries.find(({ record }) => record.id === route.id),
|
|
451
|
+
`Evidence "${evidenceId}" supporting Reporting Route ${eventName} for "${route.id}" must remain identical to the revision first committed with that event (${committedEvidenceObject || "missing"} != ${currentEvidenceObject || "missing"}).`
|
|
452
|
+
));
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
await compareMarkdownAtRevision(loaded, evidenceEntry, { record: evidenceEntry.record, commit: eventVersion.commit }, diagnostics, route.id);
|
|
456
|
+
await compareEvidenceAttachmentsAtRevision(loaded, evidenceEntry, eventVersion.commit, diagnostics, route.id);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const appointmentIds = new Set(routeSets.flatMap((route) => [
|
|
461
|
+
route.approval?.approvalAppointmentId,
|
|
462
|
+
route.cancellation?.authorityAppointmentId,
|
|
463
|
+
...loaded.resources.filter((candidate) => (
|
|
464
|
+
candidate.type === "appointment"
|
|
465
|
+
&& ["active", "ended"].includes(candidate.status)
|
|
466
|
+
&& candidate.appointmentKind === route.authorityAppointmentKind
|
|
467
|
+
&& arrayValue(candidate.scopeResourceIds).some((id) => [route.id, route.programId, loaded.workspace.id].includes(id))
|
|
468
|
+
)).map(({ id }) => id)
|
|
469
|
+
].filter(Boolean)));
|
|
470
|
+
const historicalAppointments = [...revisionRecords].flatMap(([commit, records]) => (
|
|
471
|
+
[...records.values()]
|
|
472
|
+
.filter(({ record }) => record.type === "appointment")
|
|
473
|
+
.map((item) => ({
|
|
474
|
+
...item,
|
|
475
|
+
commit,
|
|
476
|
+
...(historiesById.get(item.record.id) || []).find(({ commit: changedAt }) => changedAt === commit)
|
|
477
|
+
}))
|
|
478
|
+
));
|
|
479
|
+
for (const route of routeSets) {
|
|
480
|
+
for (const historicalAppointment of historicalAppointments) {
|
|
481
|
+
const appointment = historicalAppointment.record;
|
|
482
|
+
if (
|
|
483
|
+
[route.approval?.approvalAppointmentId, route.cancellation?.authorityAppointmentId].includes(appointment.id)
|
|
484
|
+
|| (
|
|
485
|
+
authorityAppointmentOverlapsRoute(
|
|
486
|
+
appointment,
|
|
487
|
+
route,
|
|
488
|
+
loaded.workspace.id,
|
|
489
|
+
workspaceTimezoneAtRevision(loaded, historicalAppointment.commit)
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
) appointmentIds.add(appointment.id);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const holderAppointments = new Map();
|
|
496
|
+
for (const { record } of historicalAppointments) {
|
|
497
|
+
if (!appointmentIds.has(record.id) || !record.holderId) continue;
|
|
498
|
+
const appointments = holderAppointments.get(record.holderId) || [];
|
|
499
|
+
appointments.push(record);
|
|
500
|
+
holderAppointments.set(record.holderId, appointments);
|
|
501
|
+
}
|
|
502
|
+
for (const id of appointmentIds) {
|
|
503
|
+
const entry = loaded.entries.find(({ record }) => record.id === id);
|
|
504
|
+
const versions = changedVersions(id, commits, revisionRecords, historiesById);
|
|
505
|
+
if (!versions.length) continue;
|
|
506
|
+
const first = versions[0];
|
|
507
|
+
const current = entry?.record || versions.at(-1).record;
|
|
508
|
+
const firstAuthoritative = versions.find(({ record }) => ["active", "ended"].includes(record.status));
|
|
509
|
+
const startTimezone = workspaceTimezoneAtRevision(loaded, firstAuthoritative?.commit);
|
|
510
|
+
const historicalStartSupport = historicalAuthoritySupport(
|
|
511
|
+
loaded,
|
|
512
|
+
versions,
|
|
513
|
+
current.startsOn,
|
|
514
|
+
commits,
|
|
515
|
+
revisionRecords,
|
|
516
|
+
historiesById
|
|
517
|
+
);
|
|
518
|
+
const firstRecordedEnd = versions.find(({ record }) => record.endsOn);
|
|
519
|
+
const recordedEnd = firstRecordedEnd || (current.endsOn ? {
|
|
520
|
+
record: current,
|
|
521
|
+
commit: null,
|
|
522
|
+
timestamp: new Date().toISOString()
|
|
523
|
+
} : null);
|
|
524
|
+
const endTimezone = workspaceTimezoneAtRevision(loaded, recordedEnd?.commit);
|
|
525
|
+
const historicalEndSupport = historicalAuthoritySupport(
|
|
526
|
+
loaded,
|
|
527
|
+
versions,
|
|
528
|
+
current.endsOn,
|
|
529
|
+
commits,
|
|
530
|
+
revisionRecords,
|
|
531
|
+
historiesById
|
|
532
|
+
);
|
|
533
|
+
const frozenEvidence = new Map(
|
|
534
|
+
[historicalStartSupport, historicalEndSupport]
|
|
535
|
+
.filter(Boolean)
|
|
536
|
+
.flatMap(({ evidenceVersions }) => evidenceVersions)
|
|
537
|
+
.map((version) => [version.record.id, version])
|
|
538
|
+
);
|
|
539
|
+
for (const evidenceVersion of frozenEvidence.values()) {
|
|
540
|
+
const evidenceEntry = loaded.entries.find(({ record }) => record.id === evidenceVersion.record.id);
|
|
541
|
+
if (!evidenceEntry || !sameJson(evidenceEntry.record, evidenceVersion.record)) {
|
|
542
|
+
diagnostics.push({
|
|
543
|
+
severity: "error",
|
|
544
|
+
code: evidenceEntry ? "rewritten-finalized-record" : "deleted-finalized-record",
|
|
545
|
+
path: evidenceEntry ? `data/${evidenceEntry.relativePath}` : evidenceVersion.path,
|
|
546
|
+
message: `Evidence "${evidenceVersion.record.id}" that established backfilled authority Appointment "${id}" must remain identical to its bound verified revision.`
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
await compareMarkdownAtRevision(loaded, evidenceEntry, evidenceVersion, diagnostics, id);
|
|
551
|
+
await compareAuthorityEvidenceAttachmentsAtRevision(loaded, evidenceEntry, evidenceVersion.commit, diagnostics, id);
|
|
552
|
+
}
|
|
553
|
+
if (!entry) {
|
|
554
|
+
diagnostics.push({
|
|
555
|
+
severity: "error",
|
|
556
|
+
code: "deleted-finalized-record",
|
|
557
|
+
path: first.path,
|
|
558
|
+
message: `Reporting-route authority Appointment "${id}" was deleted after it governed a finalized Route Set. Restore the historical Appointment.`
|
|
559
|
+
});
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (!firstAuthoritative) {
|
|
563
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" must be committed active or ended before its interval can be relied on.`));
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
for (const field of ["appointmentKind", "holderId", "scopeResourceIds", "startsOn"]) {
|
|
567
|
+
if (!sameJson(firstAuthoritative.record[field], current[field])) {
|
|
568
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" changed ${field} after it was recorded. Preserve the original Appointment and create a successor.`));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (firstAuthoritative.record.status === "ended" && current.status !== "ended") {
|
|
572
|
+
diagnostics.push(integrityError(entry, `Ended reporting-route authority Appointment "${id}" cannot return to ${current.status}. Preserve its final lifecycle state.`));
|
|
573
|
+
}
|
|
574
|
+
if (firstRecordedEnd && current.endsOn !== firstRecordedEnd.record.endsOn) {
|
|
575
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" changed its recorded end date. Preserve the original interval and create a correction record.`));
|
|
576
|
+
}
|
|
577
|
+
const firstEnded = versions.find(({ record }) => record.status === "ended");
|
|
578
|
+
if (firstEnded && !sameJson(
|
|
579
|
+
pick(firstEnded.record, ["status", "endsOn", "statusTransition"]),
|
|
580
|
+
pick(current, ["status", "endsOn", "statusTransition"])
|
|
581
|
+
)) {
|
|
582
|
+
diagnostics.push(integrityError(entry, `Ended reporting-route authority Appointment "${id}" changed its final interval. Preserve it and create a correction record.`));
|
|
583
|
+
}
|
|
584
|
+
const startEvidence = supportingAuthorityEvidence(loaded.resources, startTimezone, current, current.startsOn, { root: loaded.root });
|
|
585
|
+
const endEvidence = supportingAuthorityEvidence(loaded.resources, endTimezone, current, current.endsOn, { root: loaded.root });
|
|
586
|
+
const missingFrozenEvidenceIds = [...frozenEvidence.keys()].filter((evidenceId) => !arrayValue(current.evidenceIds).includes(evidenceId));
|
|
587
|
+
if (missingFrozenEvidenceIds.length) {
|
|
588
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" removed bound backfill Evidence ${missingFrozenEvidenceIds.join(", ")}. Preserve the original proof links.`));
|
|
589
|
+
}
|
|
590
|
+
if (firstAuthoritative.commit !== firstV10Commit && !startEvidence.length) {
|
|
591
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" requires verified, fixed Evidence covering the start of its authority interval.`));
|
|
592
|
+
}
|
|
593
|
+
if (recordedEnd && recordedEnd.commit !== firstV10Commit && !endEvidence.length) {
|
|
594
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority Appointment "${id}" requires verified, fixed Evidence covering the end of its authority interval.`));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const [holderId, appointments] of holderAppointments) {
|
|
598
|
+
const entry = loaded.entries.find(({ record }) => record.id === holderId && record.type === "person");
|
|
599
|
+
const versions = changedVersions(holderId, commits, revisionRecords, historiesById);
|
|
600
|
+
if (!versions.length) continue;
|
|
601
|
+
const first = versions[0];
|
|
602
|
+
if (!entry) {
|
|
603
|
+
diagnostics.push({
|
|
604
|
+
severity: "error",
|
|
605
|
+
code: "deleted-finalized-record",
|
|
606
|
+
path: first.path,
|
|
607
|
+
message: `Reporting-route authority holder "${holderId}" was deleted after their Appointment governed a finalized Route Set. Restore the historical Person record.`
|
|
608
|
+
});
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const current = entry.record;
|
|
612
|
+
if (!sameJson(first.record.startDate, current.startDate)) {
|
|
613
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority holder "${holderId}" changed their recorded start date. Preserve the historical identity interval.`));
|
|
614
|
+
}
|
|
615
|
+
const firstRecordedEnd = versions.find(({ record }) => record.endDate);
|
|
616
|
+
if (firstRecordedEnd && current.endDate !== firstRecordedEnd.record.endDate) {
|
|
617
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority holder "${holderId}" changed their recorded end date. Preserve the historical identity interval.`));
|
|
618
|
+
}
|
|
619
|
+
const firstInactive = versions.find(({ record }) => record.status === "inactive");
|
|
620
|
+
if (firstInactive && !sameJson(
|
|
621
|
+
pick(firstInactive.record, ["status", "endDate", "statusTransition"]),
|
|
622
|
+
pick(current, ["status", "endDate", "statusTransition"])
|
|
623
|
+
)) {
|
|
624
|
+
diagnostics.push(integrityError(entry, `Inactive reporting-route authority holder "${holderId}" changed their final identity interval. Preserve the historical Person record.`));
|
|
625
|
+
}
|
|
626
|
+
const recordedEnd = firstRecordedEnd || (current.endDate ? {
|
|
627
|
+
record: current,
|
|
628
|
+
commit: null,
|
|
629
|
+
timestamp: new Date().toISOString()
|
|
630
|
+
} : null);
|
|
631
|
+
const endTimezone = workspaceTimezoneAtRevision(loaded, recordedEnd?.commit);
|
|
632
|
+
const hasMatchingEndEvidence = loaded.resources.some((appointment) => (
|
|
633
|
+
appointment.type === "appointment"
|
|
634
|
+
&& appointmentIds.has(appointment.id)
|
|
635
|
+
&& appointment.holderId === holderId
|
|
636
|
+
&& ["active", "ended"].includes(appointment.status)
|
|
637
|
+
&& appointment.endsOn === current.endDate
|
|
638
|
+
&& supportingAuthorityEvidence(loaded.resources, endTimezone, appointment, current.endDate, { root: loaded.root }).length > 0
|
|
639
|
+
));
|
|
640
|
+
if (recordedEnd && recordedEnd.commit !== firstV10Commit && !hasMatchingEndEvidence) {
|
|
641
|
+
diagnostics.push(integrityError(entry, `Reporting-route authority holder "${holderId}" requires verified, fixed Evidence from the related Appointment covering the end of the identity interval.`));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function historicalAuthoritySupport(loaded, appointmentVersions, date, commits, revisionRecords, historiesById) {
|
|
647
|
+
if (!date || !appointmentVersions.length) return null;
|
|
648
|
+
const evidenceHistories = new Map();
|
|
649
|
+
for (const appointment of appointmentVersions) {
|
|
650
|
+
for (const id of arrayValue(appointment.record.evidenceIds)) {
|
|
651
|
+
if (!evidenceHistories.has(id)) {
|
|
652
|
+
evidenceHistories.set(id, changedVersions(id, commits, revisionRecords, historiesById));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
for (const commit of commits) {
|
|
657
|
+
const timezone = workspaceTimezoneAtRevision(loaded, commit);
|
|
658
|
+
const appointment = [...appointmentVersions].reverse()
|
|
659
|
+
.find((version) => isDataHistoryAncestor(loaded, version.commit, commit));
|
|
660
|
+
if (!appointment) continue;
|
|
661
|
+
const evidenceVersions = arrayValue(appointment.record.evidenceIds).flatMap((id) => {
|
|
662
|
+
const version = [...(evidenceHistories.get(id) || [])].reverse()
|
|
663
|
+
.find((candidate) => isDataHistoryAncestor(loaded, candidate.commit, commit));
|
|
664
|
+
return version ? [version] : [];
|
|
665
|
+
});
|
|
666
|
+
const qualifyingIds = new Set(
|
|
667
|
+
supportingAuthorityEvidence(
|
|
668
|
+
evidenceVersions.map(({ record }) => record),
|
|
669
|
+
timezone,
|
|
670
|
+
appointment.record,
|
|
671
|
+
date,
|
|
672
|
+
{
|
|
673
|
+
root: loaded.root,
|
|
674
|
+
commit,
|
|
675
|
+
personIds: [...new Set(evidenceVersions.flatMap(({ record }) => [
|
|
676
|
+
...arrayValue(record.collectorIds),
|
|
677
|
+
...arrayValue(record.verifierIds)
|
|
678
|
+
]))].filter((id) => (
|
|
679
|
+
recordAtRevision(loaded, commit, id, historiesById.get(id) || [])?.record.type === "person"
|
|
680
|
+
))
|
|
681
|
+
}
|
|
682
|
+
).map(({ id }) => id)
|
|
683
|
+
);
|
|
684
|
+
const qualifyingVersions = evidenceVersions.filter(({ record }) => qualifyingIds.has(record.id));
|
|
685
|
+
if (qualifyingVersions.length) return { appointmentVersion: appointment, evidenceVersions: qualifyingVersions };
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function authorityAppointmentOverlapsRoute(appointment, route, workspaceId, timezone) {
|
|
691
|
+
if (
|
|
692
|
+
appointment.appointmentKind !== route.authorityAppointmentKind
|
|
693
|
+
|| !["active", "ended"].includes(appointment.status)
|
|
694
|
+
|| !arrayValue(appointment.scopeResourceIds).some((id) => [route.id, route.programId, workspaceId].includes(id))
|
|
695
|
+
|| !appointment.startsOn
|
|
696
|
+
|| !route.approval?.effectiveAt
|
|
697
|
+
) return false;
|
|
698
|
+
let routeStart;
|
|
699
|
+
let routeEnd;
|
|
700
|
+
try {
|
|
701
|
+
routeStart = currentCalendarDate(timezone, new Date(route.approval.effectiveAt));
|
|
702
|
+
routeEnd = route.cancellation?.canceledAt
|
|
703
|
+
? currentCalendarDate(timezone, new Date(route.cancellation.canceledAt))
|
|
704
|
+
: null;
|
|
705
|
+
} catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
return (!routeEnd || appointment.startsOn <= routeEnd)
|
|
709
|
+
&& (!appointment.endsOn || appointment.endsOn >= routeStart);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function supportingAuthorityEvidence(records, timezone, appointment, date, options = {}) {
|
|
713
|
+
return reportingRouteFixedEvidence(records, appointment.id, appointment.evidenceIds, date, timezone, options);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function arrayValue(value) {
|
|
717
|
+
return Array.isArray(value) ? value : [];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function compareAuthorityEvidenceAttachmentsAtRevision(loaded, entry, commit, diagnostics, appointmentId) {
|
|
721
|
+
for (const path of arrayValue(entry.record.filePaths)) {
|
|
722
|
+
const relativePath = `data/${path}`;
|
|
723
|
+
const committed = getFileObjectIdAtRevision(loaded.root, commit, relativePath);
|
|
724
|
+
const current = getWorkingFileObjectId(loaded.root, relativePath);
|
|
725
|
+
if (committed && current && committed === current) continue;
|
|
726
|
+
diagnostics.push({
|
|
727
|
+
severity: "error",
|
|
728
|
+
code: "rewritten-finalized-attachment",
|
|
729
|
+
path: relativePath,
|
|
730
|
+
message: `Evidence attachment supporting backfilled authority Appointment "${appointmentId}" differs from its first committed verified bytes.`
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function changedVersions(id, commits, revisionRecords, historiesById) {
|
|
736
|
+
const history = historiesById.get(id) || [];
|
|
737
|
+
return commits.flatMap((commit) => {
|
|
738
|
+
const item = revisionRecords.get(commit)?.get(id);
|
|
739
|
+
if (!item) return [];
|
|
740
|
+
const summary = history.find(({ commit: changedAt }) => changedAt === commit);
|
|
741
|
+
return [{ ...item, ...summary, commit }];
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function workspaceTimezoneAtRevision(loaded, commit) {
|
|
746
|
+
if (!commit) return loaded.workspace.timezone;
|
|
747
|
+
const timezone = historicalWorkspaceTimezoneAtRevision(loaded, commit);
|
|
748
|
+
if (!timezone) throw new Error(`Workspace timezone is unavailable at ${commit}.`);
|
|
749
|
+
return timezone;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function historicalWorkspaceTimezoneAtRevision(loaded, commit) {
|
|
753
|
+
try {
|
|
754
|
+
const workspace = JSON.parse(getFileAtRevision(loaded.root, commit, "data/workspace.json"));
|
|
755
|
+
if (typeof workspace?.timezone !== "string" || !workspace.timezone.trim()) return null;
|
|
756
|
+
new Intl.DateTimeFormat("en", { timeZone: workspace.timezone }).format();
|
|
757
|
+
return workspace.timezone;
|
|
758
|
+
} catch {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function pick(record, fields) {
|
|
764
|
+
return Object.fromEntries(fields.map((field) => [field, record?.[field]]));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function permittedFinalizedTransition(previous, current) {
|
|
768
|
+
if (previous.type !== current.type || previous.id !== current.id) return false;
|
|
769
|
+
let allowed = [];
|
|
770
|
+
if (previous.type === "collection-review" && previous.status === "active" && current.status === "retired") allowed = ["status", "statusTransition"];
|
|
771
|
+
if (previous.type === "obligation-rule" && previous.status === "active" && current.status === "retired") allowed = ["status", "retiredOn"];
|
|
772
|
+
if (previous.type === "obligation-occurrence" && previous.status === "reconciled" && current.status === "superseded") allowed = ["status"];
|
|
773
|
+
if (previous.type === "audit-population" && ["reconciled", "not-applicable"].includes(previous.status) && current.status === "superseded") allowed = ["status"];
|
|
774
|
+
if (previous.type === "reporting-route" && previous.status === "active" && current.status === "retired") allowed = ["status", "endsAt"];
|
|
775
|
+
if (previous.type === "reporting-route-set" && previous.status === "approved" && current.status === "canceled") allowed = ["status", "cancellation"];
|
|
776
|
+
if (previous.type === "exception" && previous.status === "approved" && ["revoked", "closed"].includes(current.status)) allowed = ["status", "resolution"];
|
|
777
|
+
if (previous.type === "exception" && previous.status === "revoked" && current.status === "closed") allowed = ["status"];
|
|
778
|
+
if (
|
|
779
|
+
previous.type === "action-item"
|
|
780
|
+
&& previous.status === "done"
|
|
781
|
+
&& current.status === "done"
|
|
782
|
+
&& (previous.evidenceIds || []).every((id) => (current.evidenceIds || []).includes(id))
|
|
783
|
+
) allowed = ["evidenceIds"];
|
|
784
|
+
return [...new Set([...Object.keys(previous), ...Object.keys(current)])].every((key) => (
|
|
785
|
+
allowed.includes(key) || sameJson(previous[key], current[key])
|
|
786
|
+
));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function sameJson(left, right) {
|
|
790
|
+
return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function canonicalJson(value) {
|
|
794
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
795
|
+
if (!value || typeof value !== "object") return value;
|
|
796
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function proofIds(occurrence, byId) {
|
|
800
|
+
const pending = [
|
|
801
|
+
...(occurrence.evidenceIds || []),
|
|
802
|
+
...(occurrence.sourceEvidenceId ? [occurrence.sourceEvidenceId] : []),
|
|
803
|
+
...(occurrence.members || []).flatMap((member) => [
|
|
804
|
+
...(member.completionResourceIds || []),
|
|
805
|
+
member.exceptionId
|
|
806
|
+
])
|
|
807
|
+
].filter(Boolean);
|
|
808
|
+
const ids = new Set();
|
|
809
|
+
while (pending.length) {
|
|
810
|
+
const id = pending.pop();
|
|
811
|
+
if (!id || ids.has(id)) continue;
|
|
812
|
+
ids.add(id);
|
|
813
|
+
const record = byId.get(id);
|
|
814
|
+
if (!record) continue;
|
|
815
|
+
for (const field of PROOF_FIELDS) {
|
|
816
|
+
const value = record[field];
|
|
817
|
+
pending.push(...(Array.isArray(value) ? value : value ? [value] : []));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return ids;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function compareMarkdownAtRevision(loaded, entry, historical, diagnostics, occurrenceId = null) {
|
|
824
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
825
|
+
const historicalPath = markdownEntries(loaded.model, historical.record)
|
|
826
|
+
.find(({ slot }) => slot === markdown.slot)?.path;
|
|
827
|
+
const committed = historicalPath
|
|
828
|
+
? getFileAtRevision(loaded.root, historical.commit, `data/${historicalPath}`)
|
|
829
|
+
: null;
|
|
830
|
+
let current = null;
|
|
831
|
+
try {
|
|
832
|
+
current = await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
|
|
833
|
+
} catch {
|
|
834
|
+
// Presence is part of the finalized record.
|
|
835
|
+
}
|
|
836
|
+
if (current !== committed) {
|
|
837
|
+
const context = occurrenceId ? ` used by occurrence "${occurrenceId}"` : "";
|
|
838
|
+
diagnostics.push({
|
|
839
|
+
severity: "error",
|
|
840
|
+
code: "rewritten-finalized-content",
|
|
841
|
+
path: `data/${markdown.path}`,
|
|
842
|
+
message: `Finalized Markdown${context} differs from its committed final version. Use the record's correction or supersession workflow.`
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function compareEvidenceAttachmentsAtRevision(loaded, entry, commit, diagnostics, occurrenceId) {
|
|
849
|
+
if (entry.record.type !== "evidence") return;
|
|
850
|
+
for (const path of entry.record.filePaths || []) {
|
|
851
|
+
const relativePath = `data/${path}`;
|
|
852
|
+
const committed = getFileObjectIdAtRevision(loaded.root, commit, relativePath);
|
|
853
|
+
const current = getWorkingFileObjectId(loaded.root, relativePath);
|
|
854
|
+
if (!committed || !current || committed !== current) {
|
|
855
|
+
diagnostics.push({
|
|
856
|
+
severity: "error",
|
|
857
|
+
code: "rewritten-finalized-attachment",
|
|
858
|
+
path: `data/${path}`,
|
|
859
|
+
message: `Evidence attachment used by occurrence "${occurrenceId}" differs from the bytes committed when the occurrence was first finalized.`
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function integrityError(entry, message) {
|
|
866
|
+
return {
|
|
867
|
+
severity: "error",
|
|
868
|
+
code: "rewritten-finalized-record",
|
|
869
|
+
path: `data/${entry.relativePath}`,
|
|
870
|
+
message
|
|
871
|
+
};
|
|
872
|
+
}
|