filegrc 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,14 +217,30 @@ 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 reconciliation = await planReconciliation(loaded);
221
- for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
222
- diagnostics.push(warning(
223
- "unreconciled-committed-transition",
224
- candidate.sourcePath,
225
- `${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
220
+ const { planReconciliation, validateReconciliationDismissals } = await import("./reconciliation.js");
221
+ try {
222
+ const rawReconciliation = await validateReconciliationDismissals(loaded, diagnostics);
223
+ reconciliation = rawReconciliation?.filteredPlan ?? await planReconciliation(loaded);
224
+ for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
225
+ diagnostics.push(warning(
226
+ "unreconciled-committed-transition",
227
+ candidate.sourcePath,
228
+ `${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
229
+ ));
230
+ }
231
+ } catch (cause) {
232
+ diagnostics.push(error(
233
+ "reconciliation-history-unavailable",
234
+ "data/workspace.json",
235
+ cause instanceof Error ? cause.message : "Git history is unavailable for reconciliation."
226
236
  ));
237
+ reconciliation = {
238
+ contractVersion: 1,
239
+ gitRevision: null,
240
+ changedPaths: [],
241
+ candidates: [],
242
+ unavailable: true
243
+ };
227
244
  }
228
245
  }
229
246
 
@@ -241,6 +258,10 @@ async function validateWorkspaceUnmeasured(input) {
241
258
  value: loaded,
242
259
  enumerable: false
243
260
  });
261
+ Object.defineProperty(result, "reconciliation", {
262
+ value: reconciliation,
263
+ enumerable: false
264
+ });
244
265
  return result;
245
266
  }
246
267
 
@@ -2128,6 +2149,23 @@ function validateLocation(record, definition, relativePath, diagnostics) {
2128
2149
  }
2129
2150
  }
2130
2151
 
2152
+ export function assertHistoricalRecordValid(record, model, relativePath) {
2153
+ const displayPath = `data/${relativePath}`;
2154
+ const diagnostics = [];
2155
+ let definition;
2156
+ try {
2157
+ definition = getResourceDefinition(model, record?.type);
2158
+ } catch {
2159
+ throw new Error(`Unknown historical resource type "${record?.type ?? ""}" at ${displayPath}.`);
2160
+ }
2161
+ validateLocation(record, definition, relativePath, diagnostics);
2162
+ validateRecord(record, definition, model, displayPath, diagnostics);
2163
+ validateDateRanges(record, displayPath, diagnostics);
2164
+ if (diagnostics.some(({ severity }) => severity === "error")) {
2165
+ throw new Error(`Invalid historical resource at ${displayPath}: ${diagnostics.map(({ message }) => message).join(" ")}`);
2166
+ }
2167
+ }
2168
+
2131
2169
  function validateRecord(record, definition, model, path, diagnostics) {
2132
2170
  const fields = { ...model.commonFields, ...definition.fields };
2133
2171
  const required = new Set([
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></div></div>';
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></div></div>';
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;