filegrc 0.12.1 → 0.12.2
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/v10.json +67 -1
- package/package.json +1 -1
- package/src/cli.js +16 -4
- package/src/files.js +14 -2
- package/src/index.js +1 -1
- package/src/obligations.js +1 -1
- package/src/reconciliation.js +241 -48
- package/src/server.js +10 -1
- package/src/state.js +9 -0
- package/src/validate.js +8 -2
- package/src/web.js +59 -2
- package/src/workflow-history-integrity.js +31 -0
package/model/v10.json
CHANGED
|
@@ -7485,6 +7485,71 @@
|
|
|
7485
7485
|
"coverage"
|
|
7486
7486
|
]
|
|
7487
7487
|
},
|
|
7488
|
+
"reconciliation-dismissal": {
|
|
7489
|
+
"title": "Reconciliation dismissal",
|
|
7490
|
+
"pluralTitle": "Reconciliation dismissals",
|
|
7491
|
+
"group": "repository",
|
|
7492
|
+
"collection": "reconciliation-dismissals",
|
|
7493
|
+
"description": "A reviewed decision that one exact Git transition candidate is a false positive and does not represent a real policy event.",
|
|
7494
|
+
"guidance": {
|
|
7495
|
+
"policyBasis": "Direct file changes can resemble real operational events. This record preserves the reviewer, date, rationale, and exact candidate fingerprint when management confirms that no event occurred.",
|
|
7496
|
+
"cadence": "Create only from a current reconciliation candidate. A later change produces a different fingerprint and requires its own review."
|
|
7497
|
+
},
|
|
7498
|
+
"required": [
|
|
7499
|
+
"transitionFingerprint",
|
|
7500
|
+
"eventType",
|
|
7501
|
+
"subjectResourceId",
|
|
7502
|
+
"reviewedByIds",
|
|
7503
|
+
"reviewedOn",
|
|
7504
|
+
"rationale"
|
|
7505
|
+
],
|
|
7506
|
+
"fields": {
|
|
7507
|
+
"transitionFingerprint": {
|
|
7508
|
+
"type": "string",
|
|
7509
|
+
"label": "Transition fingerprint"
|
|
7510
|
+
},
|
|
7511
|
+
"eventType": {
|
|
7512
|
+
"type": "string",
|
|
7513
|
+
"label": "Candidate event type",
|
|
7514
|
+
"filter": true
|
|
7515
|
+
},
|
|
7516
|
+
"subjectResourceId": {
|
|
7517
|
+
"type": "id",
|
|
7518
|
+
"relationGroup": "event-subject",
|
|
7519
|
+
"label": "Candidate subject"
|
|
7520
|
+
},
|
|
7521
|
+
"reviewedByIds": {
|
|
7522
|
+
"type": "array",
|
|
7523
|
+
"items": "id",
|
|
7524
|
+
"relation": [
|
|
7525
|
+
"person"
|
|
7526
|
+
],
|
|
7527
|
+
"label": "Reviewed by"
|
|
7528
|
+
},
|
|
7529
|
+
"reviewedOn": {
|
|
7530
|
+
"type": "date",
|
|
7531
|
+
"label": "Reviewed on"
|
|
7532
|
+
},
|
|
7533
|
+
"rationale": {
|
|
7534
|
+
"type": "string",
|
|
7535
|
+
"search": true
|
|
7536
|
+
}
|
|
7537
|
+
},
|
|
7538
|
+
"listFields": [
|
|
7539
|
+
"title",
|
|
7540
|
+
"eventType",
|
|
7541
|
+
"subjectResourceId",
|
|
7542
|
+
"reviewedOn"
|
|
7543
|
+
],
|
|
7544
|
+
"formFields": [
|
|
7545
|
+
"transitionFingerprint",
|
|
7546
|
+
"eventType",
|
|
7547
|
+
"subjectResourceId",
|
|
7548
|
+
"reviewedByIds",
|
|
7549
|
+
"reviewedOn",
|
|
7550
|
+
"rationale"
|
|
7551
|
+
]
|
|
7552
|
+
},
|
|
7488
7553
|
"collection-review": {
|
|
7489
7554
|
"title": "Collection review",
|
|
7490
7555
|
"pluralTitle": "Collection reviews",
|
|
@@ -9516,7 +9581,8 @@
|
|
|
9516
9581
|
"vendor",
|
|
9517
9582
|
"access-grant",
|
|
9518
9583
|
"vulnerability",
|
|
9519
|
-
"incident"
|
|
9584
|
+
"incident",
|
|
9585
|
+
"exception"
|
|
9520
9586
|
],
|
|
9521
9587
|
"evidence-source-record": [
|
|
9522
9588
|
"appointment",
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
import { planProgramAmendment } from "./program-amendment.js";
|
|
69
69
|
import { resolveProgram } from "./program.js";
|
|
70
70
|
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
71
|
-
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
71
|
+
import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
72
72
|
import { markdownEntries } from "./resource-markdown.js";
|
|
73
73
|
import { effectiveResourceStatus } from "./resource-status.js";
|
|
74
74
|
import { searchResources } from "./search.js";
|
|
@@ -617,7 +617,17 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
617
617
|
return result;
|
|
618
618
|
}
|
|
619
619
|
if (command === "reconcile") {
|
|
620
|
-
|
|
620
|
+
if (flags.apply && flags.dismiss) throw new Error("Choose either --apply or --dismiss for one reconciliation candidate.");
|
|
621
|
+
const result = flags.dismiss
|
|
622
|
+
? await withWorkflowDelta(root, () => dismissReconciliation(root, {
|
|
623
|
+
candidateId: flags.candidate,
|
|
624
|
+
transitionFingerprint: flags.candidate,
|
|
625
|
+
reviewedById: flags.reviewer,
|
|
626
|
+
reviewedOn: flags["reviewed-on"],
|
|
627
|
+
rationale: flags.rationale,
|
|
628
|
+
confirmed: flags.yes === true
|
|
629
|
+
}))
|
|
630
|
+
: flags.apply
|
|
621
631
|
? await withWorkflowDelta(root, () => applyReconciliation(root, {
|
|
622
632
|
candidateId: flags.candidate,
|
|
623
633
|
transitionFingerprint: flags.candidate,
|
|
@@ -630,7 +640,9 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
630
640
|
}))
|
|
631
641
|
: await planReconciliation(root);
|
|
632
642
|
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
633
|
-
else if (flags.
|
|
643
|
+
else if (flags.dismiss) {
|
|
644
|
+
console.log(`Dismissed ${result.candidate.eventType}: recorded ${result.dismissal.id}.`);
|
|
645
|
+
} else if (flags.apply) {
|
|
634
646
|
console.log(`Reconciled ${result.candidate.eventType}: created ${result.event.id} and ${result.actions.length} linked tasks.`);
|
|
635
647
|
} else if (!result.candidates.length) {
|
|
636
648
|
console.log("No direct-file transitions need confirmation.");
|
|
@@ -1328,7 +1340,7 @@ Usage:
|
|
|
1328
1340
|
filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
|
|
1329
1341
|
filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
|
|
1330
1342
|
filegrc prepare-audit <audit-id> [--json]
|
|
1331
|
-
filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--program program-id] [--risk-level normal|high] [--json]
|
|
1343
|
+
filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes|--dismiss --candidate fingerprint --reviewer person-id --reviewed-on YYYY-MM-DD --rationale text --yes] [--program program-id] [--risk-level normal|high] [--json]
|
|
1332
1344
|
filegrc external-reviewer-setup --scaffold
|
|
1333
1345
|
filegrc external-reviewer-setup <reviewer.json|-> [--preview|--yes] [--json]
|
|
1334
1346
|
filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
|
package/src/files.js
CHANGED
|
@@ -22,7 +22,8 @@ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
|
|
|
22
22
|
collectionReviewReassessment: Symbol("collection-review-reassessment"),
|
|
23
23
|
reportingRouteSetProposal: Symbol("reporting-route-set-proposal"),
|
|
24
24
|
reportingRouteSetApproval: Symbol("reporting-route-set-approval"),
|
|
25
|
-
reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation")
|
|
25
|
+
reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation"),
|
|
26
|
+
reconciliationDismissal: Symbol("reconciliation-dismissal")
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
export async function createResource(input, record, options = {}) {
|
|
@@ -671,6 +672,9 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu
|
|
|
671
672
|
const preservesExisting = (allowed = []) => [...new Set([...Object.keys(existing), ...Object.keys(next)])].every((key) => (
|
|
672
673
|
allowed.includes(key) || JSON.stringify(next[key]) === JSON.stringify(existing[key])
|
|
673
674
|
));
|
|
675
|
+
if (existing.type === "reconciliation-dismissal" && !preservesExisting()) {
|
|
676
|
+
throw new Error(`Reconciliation dismissal "${existing.id}" is immutable because it records a fingerprint-bound review decision.`);
|
|
677
|
+
}
|
|
674
678
|
if (existing.type === "collection-review" && ["active", "retired"].includes(existing.status)) {
|
|
675
679
|
const retirement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
|
|
676
680
|
&& existing.status === "active"
|
|
@@ -908,6 +912,13 @@ function finalizedOccurrenceUsingProof(loaded, targetId) {
|
|
|
908
912
|
}
|
|
909
913
|
|
|
910
914
|
function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) {
|
|
915
|
+
if (
|
|
916
|
+
record?.type === "reconciliation-dismissal"
|
|
917
|
+
&& options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
|
|
918
|
+
&& options.lifecycleOperation !== "model-migration"
|
|
919
|
+
) {
|
|
920
|
+
throw new Error(`Reconciliation dismissal "${record.id || ""}" is workflow-managed. Dismiss the current transition candidate instead of creating it directly.`);
|
|
921
|
+
}
|
|
911
922
|
if (
|
|
912
923
|
record?.type === "collection-review"
|
|
913
924
|
&& options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
|
|
@@ -984,7 +995,8 @@ async function deleteResourceUnlocked(input, type, id, options) {
|
|
|
984
995
|
assertRevision(source, options.expectedRevision, "The record");
|
|
985
996
|
const record = JSON.parse(source);
|
|
986
997
|
if (
|
|
987
|
-
|
|
998
|
+
record.type === "reconciliation-dismissal"
|
|
999
|
+
|| (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
|
|
988
1000
|
|| (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
|
|
989
1001
|
|| (record.type === "obligation-event" && ["complete", "canceled"].includes(record.status))
|
|
990
1002
|
|| (record.type === "action-item" && record.obligationId && ["done", "canceled"].includes(record.status))
|
package/src/index.js
CHANGED
|
@@ -115,7 +115,7 @@ export {
|
|
|
115
115
|
} from "./recurrence.js";
|
|
116
116
|
export { searchResources, searchableValues } from "./search.js";
|
|
117
117
|
export { effectiveResourceStatus } from "./resource-status.js";
|
|
118
|
-
export { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
118
|
+
export { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
119
119
|
export { assessRequirementMappingReadiness } from "./requirement-mapping.js";
|
|
120
120
|
export { assessRetentionReadiness, nearDuplicateInformationTypes, resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent, retentionUses } from "./retention.js";
|
|
121
121
|
export { createFilegrcServer, serveWorkspace } from "./server.js";
|
package/src/obligations.js
CHANGED
|
@@ -1856,7 +1856,7 @@ function obligationActivity(model, obligation) {
|
|
|
1856
1856
|
};
|
|
1857
1857
|
}
|
|
1858
1858
|
|
|
1859
|
-
function obligationRule(obligation, byId, options = {}) {
|
|
1859
|
+
export function obligationRule(obligation, byId, options = {}) {
|
|
1860
1860
|
const proposedId = options.includeProposed
|
|
1861
1861
|
? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
|
|
1862
1862
|
: null;
|
package/src/reconciliation.js
CHANGED
|
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { modelSupports } from "../model/index.js";
|
|
5
|
-
import { createObligationEvent } from "./obligations.js";
|
|
5
|
+
import { createObligationEvent, obligationRule } from "./obligations.js";
|
|
6
|
+
import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
6
7
|
import { getDataRecordHistoryIndex, getFileAtRevision, runGitCommandSync } from "./git.js";
|
|
7
8
|
import { markdownEntries } from "./resource-markdown.js";
|
|
8
9
|
import { loadWorkspace } from "./workspace.js";
|
|
9
10
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
11
|
+
import { currentCalendarDate } from "./time.js";
|
|
10
12
|
|
|
11
13
|
const TRANSITIONS = {
|
|
12
14
|
person: [
|
|
@@ -94,7 +96,7 @@ const TRANSITIONS = {
|
|
|
94
96
|
}]
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
export async function planReconciliation(input = process.cwd()) {
|
|
99
|
+
export async function planReconciliation(input = process.cwd(), options = {}) {
|
|
98
100
|
const loaded = input?.entries && input?.resources && input?.model
|
|
99
101
|
? input
|
|
100
102
|
: await loadWorkspace(input);
|
|
@@ -134,11 +136,24 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
134
136
|
? recordsAtRevision(loaded.root, `${reconciliationHistory[0]}^`)
|
|
135
137
|
: new Map();
|
|
136
138
|
const historyIndex = getDataRecordHistoryIndex(loaded.root);
|
|
139
|
+
const committedRecordIds = new Set(historyIndex.historiesById.keys());
|
|
140
|
+
const eventRecordSnapshots = new Map();
|
|
141
|
+
const recordsForEvent = (event) => {
|
|
142
|
+
if (eventRecordSnapshots.has(event.id)) return eventRecordSnapshots.get(event.id);
|
|
143
|
+
const history = historyIndex.historiesById.get(event.id) || [];
|
|
144
|
+
const firstCommit = history.at(-1)?.commit;
|
|
145
|
+
const records = firstCommit
|
|
146
|
+
? [...recordsAtRevision(loaded.root, firstCommit).values()].map(({ record }) => record)
|
|
147
|
+
: loaded.resources;
|
|
148
|
+
eventRecordSnapshots.set(event.id, records);
|
|
149
|
+
return records;
|
|
150
|
+
};
|
|
137
151
|
const currentMarkdownOwners = new Map();
|
|
138
152
|
for (const entry of loaded.entries) {
|
|
139
153
|
for (const markdown of markdownEntries(loaded.model, entry.record)) currentMarkdownOwners.set(`data/${markdown.path}`, entry);
|
|
140
154
|
}
|
|
141
|
-
const
|
|
155
|
+
const rawCandidates = [];
|
|
156
|
+
const filteredCandidates = [];
|
|
142
157
|
const examined = new Set();
|
|
143
158
|
|
|
144
159
|
for (const commit of reconciliationHistory) {
|
|
@@ -186,22 +201,27 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
186
201
|
].join("\n");
|
|
187
202
|
for (const transition of TRANSITIONS[record.type] || []) {
|
|
188
203
|
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
baseRevision: commit,
|
|
204
|
+
const fingerprintInput = {
|
|
205
|
+
baseRevision: parentRevision(loaded.root, commit),
|
|
192
206
|
eventType: transition.eventType,
|
|
193
207
|
subjectId: record.id,
|
|
194
208
|
path: recordPath,
|
|
195
209
|
beforeSource,
|
|
196
210
|
currentSource,
|
|
197
211
|
markdownChanged
|
|
212
|
+
};
|
|
213
|
+
const fingerprint = transitionFingerprint(fingerprintInput);
|
|
214
|
+
const legacyCommittedFingerprint = transitionFingerprint({ ...fingerprintInput, baseRevision: commit });
|
|
215
|
+
const legacyWorkingFingerprint = transitionFingerprint({
|
|
216
|
+
...fingerprintInput,
|
|
217
|
+
beforeSource: [previous ? JSON.stringify(previous) : "", ...changedMarkdownPaths.map((changed) => (
|
|
218
|
+
readRevisionSource(loaded.root, `${commit}^`, changed)
|
|
219
|
+
))].join("\n")
|
|
198
220
|
});
|
|
199
221
|
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
))) continue;
|
|
204
|
-
candidates.push(reconciliationCandidate(
|
|
222
|
+
const handledFingerprints = [fingerprint, legacyCommittedFingerprint, legacyWorkingFingerprint];
|
|
223
|
+
const handledEventIds = handledFingerprints.map((value) => `obligation-event-git-${value.slice(0, 16)}`);
|
|
224
|
+
const candidate = reconciliationCandidate(
|
|
205
225
|
loaded,
|
|
206
226
|
transition,
|
|
207
227
|
record,
|
|
@@ -209,7 +229,16 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
209
229
|
fingerprint,
|
|
210
230
|
eventId,
|
|
211
231
|
{ committedRevision: commit }
|
|
212
|
-
)
|
|
232
|
+
);
|
|
233
|
+
rawCandidates.push(candidate);
|
|
234
|
+
if (!transitionHandled(loaded.resources, handledEventIds, handledFingerprints, {
|
|
235
|
+
eventType: transition.eventType,
|
|
236
|
+
subjectId: record.id,
|
|
237
|
+
includeDismissed: options.includeDismissed,
|
|
238
|
+
committedRecordIds,
|
|
239
|
+
timezone: loaded.workspace.timezone,
|
|
240
|
+
recordsForEvent
|
|
241
|
+
})) filteredCandidates.push(candidate);
|
|
213
242
|
}
|
|
214
243
|
}
|
|
215
244
|
}
|
|
@@ -241,10 +270,10 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
241
270
|
}));
|
|
242
271
|
const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
|
|
243
272
|
const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
|
|
244
|
-
const beforeSource = [
|
|
273
|
+
const beforeSource = [readHeadSource(loaded.root, previousRecordPath), ...previousMarkdown].join("\n");
|
|
245
274
|
for (const transition of TRANSITIONS[record.type] || []) {
|
|
246
275
|
if (!transition.applies(previous, current, { markdownChanged })) continue;
|
|
247
|
-
const
|
|
276
|
+
const fingerprintInput = {
|
|
248
277
|
baseRevision,
|
|
249
278
|
eventType: transition.eventType,
|
|
250
279
|
subjectId: record.id,
|
|
@@ -252,24 +281,40 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
252
281
|
beforeSource,
|
|
253
282
|
currentSource,
|
|
254
283
|
markdownChanged
|
|
284
|
+
};
|
|
285
|
+
const fingerprint = transitionFingerprint(fingerprintInput);
|
|
286
|
+
const legacyFingerprint = transitionFingerprint({
|
|
287
|
+
...fingerprintInput,
|
|
288
|
+
beforeSource: [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n")
|
|
255
289
|
});
|
|
256
290
|
const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
const legacyEventId = `obligation-event-git-${legacyFingerprint.slice(0, 16)}`;
|
|
292
|
+
const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId);
|
|
293
|
+
rawCandidates.push(candidate);
|
|
294
|
+
if (!transitionHandled(loaded.resources, [eventId, legacyEventId], [fingerprint, legacyFingerprint], {
|
|
295
|
+
eventType: transition.eventType,
|
|
296
|
+
subjectId: record.id,
|
|
297
|
+
includeDismissed: options.includeDismissed,
|
|
298
|
+
committedRecordIds,
|
|
299
|
+
timezone: loaded.workspace.timezone,
|
|
300
|
+
recordsForEvent
|
|
301
|
+
})) filteredCandidates.push(candidate);
|
|
265
302
|
}
|
|
266
303
|
}
|
|
267
|
-
|
|
304
|
+
const result = {
|
|
268
305
|
contractVersion: 1,
|
|
269
306
|
gitRevision: gitRevision(loaded.root),
|
|
270
307
|
changedPaths,
|
|
271
|
-
candidates:
|
|
308
|
+
candidates: (options.includeHandled ? rawCandidates : filteredCandidates)
|
|
309
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
272
310
|
};
|
|
311
|
+
if (options.includeHandled) {
|
|
312
|
+
Object.defineProperty(result, "filteredPlan", {
|
|
313
|
+
value: { ...result, candidates: filteredCandidates.sort((a, b) => a.id.localeCompare(b.id)) },
|
|
314
|
+
enumerable: false
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return result;
|
|
273
318
|
}
|
|
274
319
|
|
|
275
320
|
function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
|
|
@@ -341,30 +386,14 @@ function pairCommittedRecordRenames(root, commit, changes) {
|
|
|
341
386
|
return [...changes.filter((change) => !paired.has(change)), ...replacements];
|
|
342
387
|
}
|
|
343
388
|
|
|
344
|
-
function
|
|
345
|
-
const requiredObligationIds = applicableEventObligationIds(
|
|
346
|
-
[...historicalRecords.values()].map(({ record }) => record),
|
|
347
|
-
eventType
|
|
348
|
-
);
|
|
349
|
-
return commitPaths.some((path) => {
|
|
350
|
-
if (!path.endsWith(".json")) return false;
|
|
351
|
-
const record = readRevisionRecord(root, commit, path);
|
|
352
|
-
const previous = readRevisionRecord(root, `${commit}^`, path);
|
|
353
|
-
return !previous
|
|
354
|
-
&& record?.type === "obligation-event"
|
|
355
|
-
&& record.eventType === eventType
|
|
356
|
-
&& (record.subjectResourceIds || []).includes(subjectId)
|
|
357
|
-
&& requiredObligationIds.every((id) => (record.obligationIds || []).includes(id));
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function applicableEventObligationIds(records, eventType) {
|
|
389
|
+
function applicableEventObligationIds(records, eventType, riskLevel, occurredAt) {
|
|
362
390
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
363
391
|
return records.filter((record) => {
|
|
364
392
|
if (record.type !== "obligation" || record.status !== "active") return false;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
393
|
+
const schedule = obligationRule(record, byId, { now: occurredAt }) || record;
|
|
394
|
+
return schedule.recurrence?.mode === "event"
|
|
395
|
+
&& schedule.recurrence.eventType === eventType
|
|
396
|
+
&& (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
|
|
368
397
|
}).map(({ id }) => id);
|
|
369
398
|
}
|
|
370
399
|
|
|
@@ -393,9 +422,7 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
|
|
|
393
422
|
}
|
|
394
423
|
return serializeWorkspaceMutation(input, async (root) => {
|
|
395
424
|
const plan = await planReconciliation(root);
|
|
396
|
-
const candidate = plan
|
|
397
|
-
id === options.candidateId || transitionFingerprint === options.transitionFingerprint
|
|
398
|
-
));
|
|
425
|
+
const candidate = findCandidate(plan, options);
|
|
399
426
|
if (!candidate) {
|
|
400
427
|
throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
|
|
401
428
|
}
|
|
@@ -417,6 +444,172 @@ export async function applyReconciliation(input = process.cwd(), options = {}) {
|
|
|
417
444
|
});
|
|
418
445
|
}
|
|
419
446
|
|
|
447
|
+
export async function dismissReconciliation(input = process.cwd(), options = {}) {
|
|
448
|
+
if (options.confirmed !== true) {
|
|
449
|
+
throw new Error("Dismissing a reconciliation candidate records a review decision. Preview the candidate and confirm the write.");
|
|
450
|
+
}
|
|
451
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
452
|
+
const plan = await planReconciliation(root);
|
|
453
|
+
const candidate = findCandidate(plan, options);
|
|
454
|
+
if (!candidate) {
|
|
455
|
+
throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
|
|
456
|
+
}
|
|
457
|
+
const loaded = await loadWorkspace(root);
|
|
458
|
+
if (!loaded.model.resources["reconciliation-dismissal"]) {
|
|
459
|
+
throw new Error("Reconciliation dismissals require data model v10 or newer.");
|
|
460
|
+
}
|
|
461
|
+
const reviewedById = String(options.reviewedById || "").trim();
|
|
462
|
+
const reviewedOn = String(options.reviewedOn || "").trim();
|
|
463
|
+
const rationale = String(options.rationale || "").trim();
|
|
464
|
+
if (!loaded.resources.some((record) => (
|
|
465
|
+
record.type === "person" && record.id === reviewedById && record.status === "active"
|
|
466
|
+
))) {
|
|
467
|
+
throw new Error("An active Person ID is required as the dismissal reviewer.");
|
|
468
|
+
}
|
|
469
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewedOn)) {
|
|
470
|
+
throw new Error("The dismissal review date must use YYYY-MM-DD.");
|
|
471
|
+
}
|
|
472
|
+
if (reviewedOn !== currentCalendarDate(loaded.workspace.timezone)) {
|
|
473
|
+
throw new Error("The dismissal review date must be the current date in the workspace timezone.");
|
|
474
|
+
}
|
|
475
|
+
if (!rationale) throw new Error("A rationale is required to dismiss a reconciliation candidate.");
|
|
476
|
+
const record = {
|
|
477
|
+
id: `reconciliation-dismissal-${candidate.transitionFingerprint}`,
|
|
478
|
+
type: "reconciliation-dismissal",
|
|
479
|
+
title: `Dismiss ${candidate.eventType} for ${candidate.subject.title}`,
|
|
480
|
+
transitionFingerprint: candidate.transitionFingerprint,
|
|
481
|
+
eventType: candidate.eventType,
|
|
482
|
+
subjectResourceId: candidate.subject.id,
|
|
483
|
+
reviewedByIds: [reviewedById],
|
|
484
|
+
reviewedOn,
|
|
485
|
+
rationale
|
|
486
|
+
};
|
|
487
|
+
const result = await createResource(root, record, {
|
|
488
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
|
|
489
|
+
});
|
|
490
|
+
return { candidate, dismissal: result.record, path: result.path };
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function transitionHandled(resources, eventIds, fingerprints, transition) {
|
|
495
|
+
return resources.some((item) => (
|
|
496
|
+
item.type === "obligation-event"
|
|
497
|
+
? (() => {
|
|
498
|
+
if (!eventIds.includes(item.id) && !fingerprints.includes(item.transitionFingerprint)) return false;
|
|
499
|
+
const records = transition.recordsForEvent?.(item) || resources;
|
|
500
|
+
const event = records.find((record) => record.id === item.id) || item;
|
|
501
|
+
return (eventIds.includes(event.id) || fingerprints.includes(event.transitionFingerprint))
|
|
502
|
+
&& obligationEventHandlesTransition(event, records, transition.eventType, transition.subjectId, resources);
|
|
503
|
+
})()
|
|
504
|
+
: !transition.includeDismissed && reconciliationDismissalMatches(item, {
|
|
505
|
+
transitionFingerprint: fingerprints[0],
|
|
506
|
+
eventType: transition.eventType,
|
|
507
|
+
subject: { id: transition.subjectId }
|
|
508
|
+
}, resources, {
|
|
509
|
+
requireActiveReviewer: !transition.committedRecordIds.has(item.id),
|
|
510
|
+
timezone: transition.timezone
|
|
511
|
+
})
|
|
512
|
+
));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function obligationEventHandlesTransition(event, records, eventType, subjectId, currentRecords = records) {
|
|
516
|
+
if (event?.type !== "obligation-event" || event.eventType !== eventType || event.status === "canceled") return false;
|
|
517
|
+
if (!(event.subjectResourceIds || []).includes(subjectId)) return false;
|
|
518
|
+
const currentEvent = currentRecords.find((record) => record.id === event.id);
|
|
519
|
+
if (
|
|
520
|
+
currentEvent?.type !== "obligation-event"
|
|
521
|
+
|| currentEvent.eventType !== eventType
|
|
522
|
+
|| currentEvent.status === "canceled"
|
|
523
|
+
|| !(currentEvent.subjectResourceIds || []).includes(subjectId)
|
|
524
|
+
|| currentEvent.riskLevel !== event.riskLevel
|
|
525
|
+
|| currentEvent.occurredOn !== event.occurredOn
|
|
526
|
+
|| currentEvent.occurredAt !== event.occurredAt
|
|
527
|
+
) return false;
|
|
528
|
+
const occurredAt = event.occurredAt || (event.occurredOn ? `${event.occurredOn}T23:59:59Z` : undefined);
|
|
529
|
+
const requiredObligationIds = applicableEventObligationIds(records, eventType, event.riskLevel, occurredAt);
|
|
530
|
+
if (!requiredObligationIds.length) return false;
|
|
531
|
+
return requiredObligationIds.every((obligationId) => {
|
|
532
|
+
const historicalAction = records.find((record) => (
|
|
533
|
+
record.type === "action-item"
|
|
534
|
+
&& record.sourceResourceId === event.id
|
|
535
|
+
&& record.obligationId === obligationId
|
|
536
|
+
&& record.status !== "canceled"
|
|
537
|
+
));
|
|
538
|
+
return (event.obligationIds || []).includes(obligationId)
|
|
539
|
+
&& (currentEvent.obligationIds || []).includes(obligationId)
|
|
540
|
+
&& historicalAction
|
|
541
|
+
&& currentRecords.some((record) => (
|
|
542
|
+
record.type === "action-item"
|
|
543
|
+
&& record.id === historicalAction.id
|
|
544
|
+
&& record.sourceResourceId === event.id
|
|
545
|
+
&& record.obligationId === obligationId
|
|
546
|
+
&& record.status !== "canceled"
|
|
547
|
+
));
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function reconciliationDismissalMatches(record, candidate, resources, options = {}) {
|
|
552
|
+
if (record?.type !== "reconciliation-dismissal") return false;
|
|
553
|
+
const reviewers = Array.isArray(record.reviewedByIds) ? record.reviewedByIds : [];
|
|
554
|
+
return record.id === `reconciliation-dismissal-${candidate.transitionFingerprint}`
|
|
555
|
+
&& record.transitionFingerprint === candidate.transitionFingerprint
|
|
556
|
+
&& record.eventType === candidate.eventType
|
|
557
|
+
&& record.subjectResourceId === candidate.subject.id
|
|
558
|
+
&& reviewers.length === 1
|
|
559
|
+
&& resources.some((item) => (
|
|
560
|
+
item.type === "person"
|
|
561
|
+
&& item.id === reviewers[0]
|
|
562
|
+
&& (!options.requireActiveReviewer || item.status === "active")
|
|
563
|
+
))
|
|
564
|
+
&& /^\d{4}-\d{2}-\d{2}$/.test(record.reviewedOn || "")
|
|
565
|
+
&& (!options.requireActiveReviewer
|
|
566
|
+
|| record.reviewedOn === currentCalendarDate(options.timezone || "UTC"))
|
|
567
|
+
&& typeof record.rationale === "string"
|
|
568
|
+
&& Boolean(record.rationale.trim());
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function validateReconciliationDismissals(loaded, diagnostics) {
|
|
572
|
+
const dismissals = loaded.entries.filter(({ record }) => record.type === "reconciliation-dismissal");
|
|
573
|
+
if (!dismissals.length) return;
|
|
574
|
+
const raw = await planReconciliation(loaded, { includeHandled: true });
|
|
575
|
+
const committedRecordIds = new Set(getDataRecordHistoryIndex(loaded.root).historiesById.keys());
|
|
576
|
+
const seen = new Set();
|
|
577
|
+
for (const entry of dismissals) {
|
|
578
|
+
const candidate = raw.candidates.find(({ transitionFingerprint }) => (
|
|
579
|
+
transitionFingerprint === entry.record.transitionFingerprint
|
|
580
|
+
));
|
|
581
|
+
if (
|
|
582
|
+
!candidate
|
|
583
|
+
|| seen.has(entry.record.transitionFingerprint)
|
|
584
|
+
|| !reconciliationDismissalMatches(entry.record, candidate, loaded.resources, {
|
|
585
|
+
requireActiveReviewer: !committedRecordIds.has(entry.record.id),
|
|
586
|
+
timezone: loaded.workspace.timezone
|
|
587
|
+
})
|
|
588
|
+
) {
|
|
589
|
+
diagnostics.push({
|
|
590
|
+
severity: "error",
|
|
591
|
+
code: "invalid-reconciliation-dismissal",
|
|
592
|
+
path: `data/${entry.relativePath}`,
|
|
593
|
+
message: `Reconciliation dismissal "${entry.record.id}" must bind one current raw transition candidate to its exact fingerprint, event type, subject, and one active Person reviewer.`
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
seen.add(entry.record.transitionFingerprint);
|
|
597
|
+
}
|
|
598
|
+
return raw;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parentRevision(root, commit) {
|
|
602
|
+
return runGit(root, ["rev-parse", `${commit}^`]) || commit;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function findCandidate(plan, options) {
|
|
606
|
+
return plan.candidates.find(({ id, transitionFingerprint }) => (
|
|
607
|
+
id === options.candidateId
|
|
608
|
+
|| transitionFingerprint === options.candidateId
|
|
609
|
+
|| transitionFingerprint === options.transitionFingerprint
|
|
610
|
+
));
|
|
611
|
+
}
|
|
612
|
+
|
|
420
613
|
function gitChangedEntries(root) {
|
|
421
614
|
const tracked = parseNameStatus(runGit(root, ["diff", "--name-status", "-M", "HEAD", "--", "data"]));
|
|
422
615
|
const untracked = lines(runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]))
|
package/src/server.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
} from "./external-reviewer.js";
|
|
58
58
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
59
59
|
import { activatePolicies } from "./policy-activation.js";
|
|
60
|
-
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
60
|
+
import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
61
61
|
import { resourceReviewRevisions } from "./retention.js";
|
|
62
62
|
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
|
|
63
63
|
import { setupWorkspace } from "./setup.js";
|
|
@@ -190,6 +190,15 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
190
190
|
confirmed: payload.confirmed === true
|
|
191
191
|
})));
|
|
192
192
|
}
|
|
193
|
+
if (request.method === "POST" && url.pathname === "/api/reconciliation/dismissal") {
|
|
194
|
+
const payload = await readJson(request);
|
|
195
|
+
return json(response, 201, await browserMutation(input, requestOptions, {
|
|
196
|
+
message: (result) => `Dismiss Git transition candidate: ${result.candidate?.eventType || payload.candidateId}`
|
|
197
|
+
}, () => dismissReconciliation(input, {
|
|
198
|
+
...payload,
|
|
199
|
+
confirmed: payload.confirmed === true
|
|
200
|
+
})));
|
|
201
|
+
}
|
|
193
202
|
if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance/preview") {
|
|
194
203
|
return json(response, 200, await planExternalReviewerGovernance(input, await readJson(request)));
|
|
195
204
|
}
|
package/src/state.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assessCollectionReviews } from "./collection-review.js";
|
|
|
5
5
|
import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js";
|
|
6
6
|
import { renderMarkdown } from "./markdown.js";
|
|
7
7
|
import { planObligations } from "./obligations.js";
|
|
8
|
+
import { planReconciliation } from "./reconciliation.js";
|
|
8
9
|
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
10
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
11
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -82,6 +83,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
|
|
|
82
83
|
counts: { errors: 0, warnings: 0 },
|
|
83
84
|
diagnostics: []
|
|
84
85
|
},
|
|
86
|
+
reconciliation: { contractVersion: 1, gitRevision: null, changedPaths: [], candidates: [] },
|
|
85
87
|
obligations: { loading: true, items: [], triggers: [], counts: {} },
|
|
86
88
|
collectionReviews: {},
|
|
87
89
|
applicabilityConstraints: {},
|
|
@@ -116,6 +118,8 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
116
118
|
validateWorkspace(loaded),
|
|
117
119
|
measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root))
|
|
118
120
|
]);
|
|
121
|
+
const reconciliation = validation.reconciliation
|
|
122
|
+
?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
|
|
119
123
|
const git = { ...snapshot };
|
|
120
124
|
delete git.root;
|
|
121
125
|
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
|
|
@@ -128,6 +132,7 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
128
132
|
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
129
133
|
repository,
|
|
130
134
|
git,
|
|
135
|
+
reconciliation,
|
|
131
136
|
validation: {
|
|
132
137
|
ok: validation.ok,
|
|
133
138
|
counts: validation.counts,
|
|
@@ -294,6 +299,8 @@ async function createAppStateUnlocked(input, options) {
|
|
|
294
299
|
now: options.now ?? generatedAt,
|
|
295
300
|
model: loaded.model
|
|
296
301
|
});
|
|
302
|
+
const reconciliation = validation.reconciliation
|
|
303
|
+
?? await measureTiming("state-reconciliation", () => planReconciliation(loaded));
|
|
297
304
|
const workflow = await measureTiming("state-workflow", () => assessWorkflow(loaded, {
|
|
298
305
|
programId: activeProgram.id,
|
|
299
306
|
asOf,
|
|
@@ -303,6 +310,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
303
310
|
Object.entries(auditPreparations).filter(([id]) => id !== "none")
|
|
304
311
|
),
|
|
305
312
|
obligations,
|
|
313
|
+
reconciliation,
|
|
306
314
|
git,
|
|
307
315
|
validation
|
|
308
316
|
}));
|
|
@@ -337,6 +345,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
337
345
|
diagnostics: validation.diagnostics
|
|
338
346
|
},
|
|
339
347
|
obligations,
|
|
348
|
+
reconciliation,
|
|
340
349
|
collectionReviews,
|
|
341
350
|
applicabilityConstraints,
|
|
342
351
|
programReadiness,
|
package/src/validate.js
CHANGED
|
@@ -62,6 +62,7 @@ export async function validateWorkspace(input = process.cwd()) {
|
|
|
62
62
|
async function validateWorkspaceUnmeasured(input) {
|
|
63
63
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
64
64
|
const diagnostics = [...loaded.diagnostics];
|
|
65
|
+
let reconciliation = null;
|
|
65
66
|
const { byId } = indexResources(loaded.resources);
|
|
66
67
|
const seen = new Map();
|
|
67
68
|
const pathById = new Map(loaded.entries.map((entry) => [
|
|
@@ -216,8 +217,9 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
216
217
|
validateCollectionReviewSet(loaded.resources, pathById, diagnostics);
|
|
217
218
|
}
|
|
218
219
|
if (modelSupports(loaded.model, "guided-workflow")) {
|
|
219
|
-
const { planReconciliation } = await import("./reconciliation.js");
|
|
220
|
-
const
|
|
220
|
+
const { planReconciliation, validateReconciliationDismissals } = await import("./reconciliation.js");
|
|
221
|
+
const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
|
|
222
|
+
reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
|
|
221
223
|
for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
|
|
222
224
|
diagnostics.push(warning(
|
|
223
225
|
"unreconciled-committed-transition",
|
|
@@ -241,6 +243,10 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
241
243
|
value: loaded,
|
|
242
244
|
enumerable: false
|
|
243
245
|
});
|
|
246
|
+
Object.defineProperty(result, "reconciliation", {
|
|
247
|
+
value: reconciliation,
|
|
248
|
+
enumerable: false
|
|
249
|
+
});
|
|
244
250
|
return result;
|
|
245
251
|
}
|
|
246
252
|
|
package/src/web.js
CHANGED
|
@@ -3121,10 +3121,11 @@ function renderRepository(main) {
|
|
|
3121
3121
|
const validationBody = state.validation.diagnostics.length
|
|
3122
3122
|
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
3123
3123
|
: empty("No validation problems.");
|
|
3124
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section
|
|
3124
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
|
|
3125
3125
|
main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
|
|
3126
3126
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
3127
3127
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
3128
|
+
bindReconciliationDismissals(main);
|
|
3128
3129
|
}
|
|
3129
3130
|
|
|
3130
3131
|
function renderTrunkRepository(main) {
|
|
@@ -3148,9 +3149,63 @@ function renderTrunkRepository(main) {
|
|
|
3148
3149
|
const validationBody = state.validation.diagnostics.length
|
|
3149
3150
|
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
3150
3151
|
: empty("No validation problems.");
|
|
3151
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section
|
|
3152
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section>' + reconciliationPanel() + '</div></div>';
|
|
3152
3153
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
3153
3154
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
3155
|
+
bindReconciliationDismissals(main);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function reconciliationPanel() {
|
|
3159
|
+
const candidates = state.reconciliation?.candidates || [];
|
|
3160
|
+
if (!candidates.length) return "";
|
|
3161
|
+
const items = candidates.map((candidate) => '<article class="reconciliation-candidate"><div><span class="badge warn">Review</span><strong>' + esc(titleCase(candidate.eventType)) + '</strong><p>' + esc(candidate.message) + '</p><small><code>' + esc(candidate.sourcePath) + '</code> · fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code></small></div><div class="reconciliation-actions"><button class="button" type="button" data-dismiss-reconciliation="' + esc(candidate.transitionFingerprint) + '" ' + (state.readOnly ? 'disabled title="Writes are not available in this repository state"' : "") + '>Dismiss false positive</button><code>' + esc(candidate.action.command) + '</code></div></article>').join("");
|
|
3162
|
+
return '<section class="panel span-2 reconciliation-panel"><div class="panel-head"><div><p class="kicker">Git transition review</p><h3>Transitions Need Confirmation</h3></div><span class="badge warn">' + candidates.length + '</span></div><p>Confirm real events with the shown command. Dismiss only when the exact Git change does not represent a real event.</p><div class="reconciliation-candidates">' + items + '</div></section>';
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function bindReconciliationDismissals(main) {
|
|
3166
|
+
main.querySelectorAll("[data-dismiss-reconciliation]").forEach((button) => button.addEventListener("click", () => {
|
|
3167
|
+
const candidate = state.reconciliation?.candidates?.find((item) => item.transitionFingerprint === button.dataset.dismissReconciliation);
|
|
3168
|
+
if (candidate) openReconciliationDismissal(candidate);
|
|
3169
|
+
}));
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function openReconciliationDismissal(candidate) {
|
|
3173
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
3174
|
+
const dialog = document.createElement("dialog");
|
|
3175
|
+
dialog.className = "commit-dialog event-dialog";
|
|
3176
|
+
dialog.setAttribute("aria-labelledby", "reconciliation-dismissal-title");
|
|
3177
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">False-positive review</p><h2 id="reconciliation-dismissal-title">Dismiss ' + esc(titleCase(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This decision applies only to fingerprint <code>' + esc(candidate.transitionFingerprint) + '</code>. A later change creates a new candidate.</p><label><span>Reviewer</span><select name="reviewedById" required><option value="">Select a Person</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label><span>Rationale</span><textarea name="rationale" rows="4" required placeholder="Explain why this Git change does not represent a real event."></textarea></label><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary" id="save-reconciliation-dismissal">Record dismissal</button></div></form>';
|
|
3178
|
+
document.body.append(dialog);
|
|
3179
|
+
dialog.showModal();
|
|
3180
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
3181
|
+
dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
|
|
3182
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
3183
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
3184
|
+
event.preventDefault();
|
|
3185
|
+
const form = event.currentTarget;
|
|
3186
|
+
if (!form.reportValidity()) return;
|
|
3187
|
+
try {
|
|
3188
|
+
setMutationBusy(dialog, true, "Recording…", "Record dismissal");
|
|
3189
|
+
const response = await localFetch("/api/reconciliation/dismissal", {
|
|
3190
|
+
method: "POST",
|
|
3191
|
+
headers: { "content-type": "application/json" },
|
|
3192
|
+
body: JSON.stringify({
|
|
3193
|
+
candidateId: candidate.transitionFingerprint,
|
|
3194
|
+
reviewedById: form.elements.reviewedById.value,
|
|
3195
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
3196
|
+
rationale: form.elements.rationale.value,
|
|
3197
|
+
confirmed: true
|
|
3198
|
+
})
|
|
3199
|
+
});
|
|
3200
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3201
|
+
applyMutationState(await response.json());
|
|
3202
|
+
dialog.close();
|
|
3203
|
+
render();
|
|
3204
|
+
} catch (error) {
|
|
3205
|
+
setMutationBusy(dialog, false, "", "Record dismissal");
|
|
3206
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
3154
3209
|
}
|
|
3155
3210
|
|
|
3156
3211
|
async function runRepositoryGitAction(action) {
|
|
@@ -5576,6 +5631,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
5576
5631
|
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.page-guide>.guide-review{grid-column:1/-1;border-top:1px solid var(--line);border-left:0}.guide-review ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:0;padding-left:18px;color:var(--muted);font-size:11px;line-height:1.45}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
5577
5632
|
.operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
|
|
5578
5633
|
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
5634
|
+
.reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
|
|
5579
5635
|
.onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
|
|
5580
5636
|
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
5581
5637
|
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
@@ -5588,6 +5644,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
5588
5644
|
@media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid,.evidence-map-expectation,.evidence-map-links,.external-evidence-list,.policy-lifecycle-note{grid-template-columns:1fr}.context-workflow,.evidence-map-head{align-items:stretch;flex-direction:column}.evidence-map-actions{flex-wrap:wrap}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
|
|
5589
5645
|
.detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
|
|
5590
5646
|
@media(max-width:760px){.detail-grid aside{order:-1}}
|
|
5647
|
+
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
5591
5648
|
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}.detail-grid-structured .detail-history-panel{order:1}}
|
|
5592
5649
|
@media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
|
|
5593
5650
|
@media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
|
|
@@ -15,6 +15,7 @@ import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } f
|
|
|
15
15
|
import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js";
|
|
16
16
|
|
|
17
17
|
const FINAL_STATUSES = new Map([
|
|
18
|
+
["reconciliation-dismissal", new Set([undefined])],
|
|
18
19
|
["collection-review", new Set(["active", "retired"])],
|
|
19
20
|
["obligation-rule", new Set(["active", "retired"])],
|
|
20
21
|
["obligation-occurrence", new Set(["reconciled", "superseded"])],
|
|
@@ -96,6 +97,21 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) {
|
|
|
96
97
|
));
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
100
|
+
if (
|
|
101
|
+
entry.record.type === "reconciliation-dismissal"
|
|
102
|
+
&& !validInitialReconciliationDismissal(
|
|
103
|
+
historical,
|
|
104
|
+
loaded,
|
|
105
|
+
historiesById,
|
|
106
|
+
workspaceTimezoneAtRevision(loaded, historical.commit)
|
|
107
|
+
)
|
|
108
|
+
) {
|
|
109
|
+
diagnostics.push(integrityError(
|
|
110
|
+
entry,
|
|
111
|
+
`Reconciliation dismissal "${entry.record.id}" must first be committed with exactly one active Person reviewer.`
|
|
112
|
+
));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
99
115
|
if (
|
|
100
116
|
entry.record.type === "exception"
|
|
101
117
|
&& historical.record.reportingRouteSetId
|
|
@@ -354,6 +370,21 @@ function validInitialCollectionReview(historical, timezone) {
|
|
|
354
370
|
);
|
|
355
371
|
}
|
|
356
372
|
|
|
373
|
+
function validInitialReconciliationDismissal(historical, loaded, historiesById, timezone) {
|
|
374
|
+
const reviewerIds = historical.record.reviewedByIds || [];
|
|
375
|
+
if (reviewerIds.length !== 1) return false;
|
|
376
|
+
const reviewer = recordAtRevision(
|
|
377
|
+
loaded,
|
|
378
|
+
historical.commit,
|
|
379
|
+
reviewerIds[0],
|
|
380
|
+
historiesById.get(reviewerIds[0]) || []
|
|
381
|
+
)?.record;
|
|
382
|
+
return reviewer?.type === "person"
|
|
383
|
+
&& reviewer.status === "active"
|
|
384
|
+
&& isRfc3339Timestamp(historical.timestamp)
|
|
385
|
+
&& historical.record.reviewedOn === currentCalendarDate(timezone, new Date(historical.timestamp));
|
|
386
|
+
}
|
|
387
|
+
|
|
357
388
|
function legacyCollectionReview(loaded, historical) {
|
|
358
389
|
if (historical.legacyBaseline && historical.record.status === "active" && !historical.record.knowledgeCutoffAt) {
|
|
359
390
|
return true;
|