diff --git a/docs/architecture.md b/docs/architecture.md index 791b37e..af2169a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,6 +130,8 @@ Historical JSON classification uses the complete owning Evidence state at each G Rule-based operating completion saves its dated result and occurrence links in one validated batch. Fixed scopes without a selector need no temporal Collection Review. Dynamic populations retain their cutoff and committed population requirements. Automatic fixed-scope reconciliation uses the actual reviewer named on a completion that covers every expected member. Separate member completions and dynamic populations keep the normal population reconciliation workflow. Partial work stays open. Initial windows use the later rule or governing-content cutover, and finalized proof is evaluated at reconciliation rather than against later evidence expiration. Browser and CLI completions bind the scaffolded rule and occurrence revisions and preserve identical retries. +Open selector occurrences retain members proven eligible in committed snapshots of the same Program, rule, window and cutoff. A later service retirement or Vendor termination preserves that in-window work. Shared population calculations verify the original eligibility through Git instead of trusting arbitrary recorded IDs. New eligible inventory enters the derived queue immediately; source edits remain writable while the owner refreshes the open occurrence, but previously recorded eligible members cannot be deleted. Cutoff reconciliation combines the exact temporal Collection Review population with proven prior members, bounded by that review's scope revision. Finalized proof keeps its existing immutable binding. + Program Overview renders its scope, navigation, and program path from bootstrap while verified readiness and scheduled work load. Pending and failed checks remain explicit. Overview summaries use the shared Program Readiness stages; detailed workflow and Audit preparation load on the pages that need them. Audit status is not inferred from an unloaded assessment. diff --git a/package-lock.json b/package-lock.json index 4bba29f..091bcb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.4", + "version": "0.20.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.4", + "version": "0.20.5", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.4", + "version": "0.20.5", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.4", + "version": "0.20.5", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 91d83a0..794cab5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.4", + "version": "0.20.5", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 9e082d6..6287d54 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.4", + "version": "0.20.5", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 36d3bb6..a0dc9ab 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.20.4", + version: "0.20.5", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.4", + version: "0.20.5", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index deb63fd..fb0a3ca 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.20.4", + "version": "0.20.5", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 34aa280..79ec7d0 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.4", + "version": "0.20.5", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligation-population.js b/packages/filegrc/src/obligation-population.js new file mode 100644 index 0000000..25896a9 --- /dev/null +++ b/packages/filegrc/src/obligation-population.js @@ -0,0 +1,68 @@ +import { currentCalendarDate } from "./time.js"; +import { coverageStart } from "./coverage.js"; +import { calculationMemo } from "./calculation-cache.js"; +import { selectScopedCollectionRecords } from "./collection-scope.js"; +import { getGitSummary, getRecordIdentityHistory, isDataHistoryAncestor } from "./git.js"; +import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js"; + +const IDENTITY = ["programId", "obligationId", "ruleId", "occurrenceKey", "membershipCutoffAt"]; +const sameIds = (left, right) => JSON.stringify([...left].sort()) === JSON.stringify([...right].sort()); +const sameWindow = (left, right) => left?.type === "obligation-occurrence" + && IDENTITY.every((key) => left[key] === right[key]) + && JSON.stringify(left.coverage) === JSON.stringify(right.coverage); +const selectorKey = (selector) => JSON.stringify({ ...selector, + ...(selector?.statuses ? { statuses: [...selector.statuses].sort() } : {}), + ...(selector?.criticalities ? { criticalities: [...selector.criticalities].sort() } : {}) }); + +// Open windows accumulate actual reviewed membership. A later retirement must +// not erase that work, but merely writing an unrelated ID into a JSON file +// does not establish eligibility. Recover only valid same-window snapshots. +export function committedOccurrencePopulation(loaded, occurrence, selector, throughCommit = null) { + if (!loaded.root || !occurrence || !selector) return { memberIds: [], established: false }; + return calculationMemo(loaded.resources, "committed-occurrence-population", [occurrence.id, + JSON.stringify(IDENTITY.map((key) => occurrence[key])), JSON.stringify(occurrence.coverage), selectorKey(selector), throughCommit], () => { + const bound = throughCommit || getGitSummary(loaded.root).commit; + const context = reviewHistoryContext(loaded.root, loaded); + const commits = reviewHistoryCommits(context); + const identities = new Set(); + let basis = occurrence; + while (sameWindow(basis, occurrence) && !identities.has(basis.id)) { + identities.add(basis.id); + basis = loaded.resources.find(({ id }) => id === basis.supersedesId); + } + const changed = new Map([...identities].flatMap((id) => ( + context.index?.available ? context.index.historiesById.get(id) || [] : getRecordIdentityHistory(loaded.root, id) + )).map((summary) => [summary.commit, summary])); + const members = new Set(); + let established = false; + for (const commit of commits) { + if (!changed.has(commit) || !isDataHistoryAncestor(loaded, commit, bound)) continue; + const snapshot = reviewHistoricalWorkspace(context, commit); + if (!snapshot) continue; + const program = snapshot.resources.find(({ id }) => id === occurrence.programId); + const rule = snapshot.resources.find(({ id }) => id === occurrence.ruleId); + if (selectorKey(rule?.selector) !== selectorKey(selector)) continue; + const recordedAt = new Date(changed.get(commit).timestamp); + if (Number.isNaN(recordedAt.getTime())) continue; + const recordedOn = currentCalendarDate(rule?.timezone || snapshot.workspace.timezone || "UTC", recordedAt); + if (recordedOn < coverageStart(occurrence.coverage) || recordedOn > occurrence.membershipCutoffAt) continue; + const eligible = new Set(selectScopedCollectionRecords(snapshot, selector, program).map(({ id }) => id)); + for (const prior of snapshot.resources.filter((item) => identities.has(item.id) && item.status === "open" && sameWindow(item, occurrence))) { + const ids = (prior.members || []).map(({ resourceId }) => resourceId); + if (new Set(ids).size !== ids.length || [...members].some((id) => !ids.includes(id)) + || ids.some((id) => !eligible.has(id) && !members.has(id)) + || !established && !sameIds(ids, eligible)) continue; + established = true; + ids.forEach((id) => members.add(id)); + } + } + return { memberIds: [...members].sort(), established }; + }); +} + +export function occurrenceSelectorPopulation(loaded, occurrence, selector, program, { selectedIds = null, throughCommit = null } = {}) { + const currentIds = selectedIds || selectScopedCollectionRecords(loaded, selector, program).map(({ id }) => id); + const committed = committedOccurrencePopulation(loaded, occurrence, selector, throughCommit); + return { ...committed, currentIds: [...currentIds].sort(), + expectedIds: [...new Set([...currentIds, ...committed.memberIds])].sort() }; +} diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 27b3f72..75895d0 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,3 +1,4 @@ +import { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { assessProgramAccessReview, providerAccessControls } from "./provider-access-completion.js"; import { readFile } from "node:fs/promises"; @@ -244,7 +245,7 @@ export function planObligations(resources, options = {}) { const recordedMemberIds = (reconciliation?.members || []).map(({ resourceId }) => resourceId); const expectedMemberIds = reconciliation ? reconciliation.status === "open" && !membershipFinal - ? [...new Set([...recordedMemberIds, ...selectedMemberIds])].sort() + ? occurrenceSelectorPopulation({ resources: records, model, root: options.root }, reconciliation, schedule.selector, program, { selectedIds: selectedMemberIds }).expectedIds : recordedMemberIds : selectedMemberIds; const completedMemberIds = new Set((reconciliation?.members || []) @@ -343,7 +344,7 @@ export function planObligations(resources, options = {}) { const membershipFinal = membershipIsFinal(rule.selector, window, asOf); const recordedMemberIds = (occurrence.members || []).map(({ resourceId }) => resourceId); const expectedMemberIds = occurrence.status === "open" && !membershipFinal && rule.selector - ? [...new Set([...recordedMemberIds, ...selectScopedCollectionRecords({ resources: records, model }, rule.selector, program).map(({ id }) => id)])].sort() + ? occurrenceSelectorPopulation({ resources: records, model, root: options.root }, occurrence, rule.selector, program).expectedIds : recordedMemberIds; const completedMemberIds = (occurrence.members || []) .filter(({ disposition, result }) => disposition === "expected" && result === "passed") @@ -866,7 +867,11 @@ export async function scaffoldObligationOccurrence(input, options = {}) { rule?.selector, entry.relativePath ); - return snapshot ? [{ entry, snapshot }] : []; + return snapshot ? [{ entry, snapshot: { ...snapshot, + populationIds: occurrenceSelectorPopulation(loaded, current?.record, rule.selector, program, { + selectedIds: snapshot.selectedIds, throughCommit: entry.record.scopeRevision + }).expectedIds + } }] : []; }); const supersededIds = new Set(candidates.map(({ entry }) => entry.record.supersedesId).filter(Boolean)); candidates.sort((left, right) => ( @@ -888,16 +893,16 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const activity = obligationActivity(loaded.model, obligation); const completionMemberField = activity.aggregate?.completionMemberField; const populationIds = existing - ? item.expectedMemberIds + ? historicalSnapshot?.populationIds || item.expectedMemberIds : predecessor ? predecessor.record.members.map(({ resourceId }) => resourceId) : historicalReview - ? historicalSnapshot.selectedIds + ? historicalSnapshot.populationIds : item.expectedMemberIds; if (existing && historicalSnapshot) { const frozenIds = [...existingMembers.keys()].sort(); - const reviewedIds = [...historicalSnapshot.selectedIds].sort(); - if (JSON.stringify(frozenIds) !== JSON.stringify(reviewedIds)) { + const reviewedIds = [...historicalSnapshot.populationIds].sort(); + if (frozenIds.some((id) => !reviewedIds.includes(id))) { throw new Error( `Open occurrence "${existing.record.id}" does not match the committed ${cutoff} Collection Review population. ` + "Correct the open occurrence before reconciliation." diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 534d67e..efcfa1c 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,4 @@ +import { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { attestationBindingIssue } from "./attestation-integrity.js"; import { withAssessmentCache } from "./calculation-cache.js"; @@ -596,7 +597,11 @@ function validateObligationOccurrence(record, model, resources, entries, root, w record.collectionReviewCommit ); const memberIds = [...(record.members || []).map(({ resourceId }) => resourceId)].sort(); - const reviewedIds = [...(historicalSnapshot?.selectedIds || [])].sort(); + const reviewedIds = historicalSnapshot ? occurrenceSelectorPopulation( + { root, resources, model }, record, rule.selector, byId.get(record.programId), { + selectedIds: historicalSnapshot.selectedIds, throughCommit: review.scopeRevision + } + ).expectedIds : []; const reviewCoversCutoff = review?.coverage?.kind === "as-of" ? review.coverage.on === record.membershipCutoffAt : review?.coverage?.kind === "range" @@ -626,16 +631,14 @@ function validateObligationOccurrence(record, model, resources, entries, root, w "A historical occurrence must bind an immutable Collection Review for its exact population cutoff." )); } else if (rule?.selector) { - const selectedIds = selectScopedCollectionRecords( - { resources, model }, - rule.selector, - byId.get(record.programId) - ).map(({ id }) => id).sort(); - if (JSON.stringify([...memberIds].sort()) !== JSON.stringify(selectedIds)) { + const population = occurrenceSelectorPopulation({ root, resources, model }, record, rule.selector, byId.get(record.programId)); + const missingRecorded = population.memberIds.some((id) => !memberIds.includes(id)); + const unsupported = memberIds.some((id) => !population.expectedIds.includes(id)); + if (missingRecorded || unsupported || !population.established && JSON.stringify([...memberIds].sort()) !== JSON.stringify(population.currentIds)) { diagnostics.push(error( "invalid-obligation-occurrence-population", path, - "An occurrence without a historical Collection Review must contain the exact current selector population." + "An open occurrence must preserve its committed eligible members and contain no unsupported population members. Reconcile newly eligible members before finalizing the window." )); } } diff --git a/packages/filegrc/test/occurrence-lifecycle.test.js b/packages/filegrc/test/occurrence-lifecycle.test.js new file mode 100644 index 0000000..7c12499 --- /dev/null +++ b/packages/filegrc/test/occurrence-lifecycle.test.js @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { commitWorkspaceFiles, initializeGitWorkspace, writeJson } from "./helpers.js"; +import { createResource, updateResource } from "../src/files.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { validateWorkspace } from "../src/validate.js"; +import { scaffoldObligationOccurrence, saveObligationOccurrence, planObligations } from "../src/obligations.js"; +import { applyCollectionReview } from "../src/collection-review.js"; +import { committedOccurrencePopulation, occurrenceSelectorPopulation } from "../src/obligation-population.js"; +import { withAssessmentCache } from "../src/calculation-cache.js"; +import { getGitSummary, setGitSubprocessObserverForTests } from "../src/git.js"; +import { currentCalendarDate } from "../src/time.js"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "filegrc-occurrence-lifecycle-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const today = currentCalendarDate(loaded.workspace.timezone); + const yesterday = new Date(`${today}T12:00:00Z`); yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const start = yesterday.toISOString().slice(0, 10); + const obligationEntry = loaded.entries.find(({ record }) => record.type === "obligation"); + const ruleEntry = loaded.entries.find(({ record }) => record.type === "obligation-rule"); + const obligation = { ...obligationEntry.record, activityType: "vendor-review", scopeResourceIds: [] }; + const rule = { ...ruleEntry.record, + recurrence: { mode: "calendar", unit: "year", interval: 1, anchorDate: start }, + window: { precision: "date", startsAfter: 0, dueAfter: 1 }, + selector: { resourceType: "vendor", membershipMode: "as-of", cutoff: "window-end", statuses: ["active"] } }; + await writeJson(obligationEntry.path, obligation); await writeJson(ruleEntry.path, rule); + for (const entry of loaded.entries.filter(({ record }) => record.type === "obligation-occurrence")) await rm(entry.path); + const component = loaded.entries.find(({ record }) => record.type === "component"); + await writeJson(component.path, { ...component.record, vendorId: "vendor-example" }); + const reviewEntry = loaded.entries.find(({ record }) => record.type === "vendor-review"); + await writeJson(reviewEntry.path, { ...reviewEntry.record, completedOn: today, coverage: { kind: "range", startsOn: start, endsOn: today } }); + await initializeGitWorkspace(root); + await writeJson(join(root, "data/workspace.json"), { ...loaded.workspace, dataModelVersion: "13" }); + await commitWorkspaceFiles(root, "Adopt current model while preserving historical activation"); + const preOccurrenceCommit = getGitSummary(root).commit; + const scaffold = await scaffoldObligationOccurrence(root, { obligationId: obligation.id, windowStart: start, asOf: today }); + await saveObligationOccurrence(root, { record: scaffold.record }); + await commitWorkspaceFiles(root, "Record eligible vendor review in open window"); + return { root, today, start, obligation, rule, occurrenceId: scaffold.record.id, reviewEntry, preOccurrenceCommit }; +} + +test("vendor termination preserves committed in-window work through cutoff reconciliation", async (t) => { + const f = await fixture(t); + let loaded = await loadWorkspace(f.root); + let occurrence = loaded.entries.find(({ record }) => record.id === f.occurrenceId); + const original = await readFile(occurrence.path, "utf8"); + const reviewBytes = await readFile(f.reviewEntry.path, "utf8"); + assert.equal(occurrence.record.members[0].result, "passed"); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-example"); + await updateResource(f.root, "vendor", vendor.record.id, { ...vendor.record, status: "terminated", endDate: f.today, + statusTransition: { changedByIds: ["person-example"], changedOn: f.today, reason: "The service ended after its review within this operating window." } + }, { expectedRevision: vendor.revision }); + assert.equal(await readFile(occurrence.path, "utf8"), original); + assert.equal(await readFile(f.reviewEntry.path, "utf8"), reviewBytes); + await commitWorkspaceFiles(f.root, "End vendor service without deleting its reviewed window member"); + loaded = await loadWorkspace(f.root); + const program = loaded.resources.find(({ type }) => type === "program"); + const population = occurrenceSelectorPopulation(loaded, occurrence.record, f.rule.selector, program); + assert.deepEqual(population.currentIds, []); + assert.deepEqual(population.memberIds, [vendor.record.id]); + const item = planObligations(loaded.resources, { root: f.root, model: loaded.model, asOf: f.today, + from: f.start, through: f.today, includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.deepEqual(item.expectedMemberIds, [vendor.record.id]); + assert.equal(item.completedCount, 1); + assert.equal((await validateWorkspace(f.root)).ok, true, JSON.stringify((await validateWorkspace(f.root)).diagnostics)); + // A later inventory addition remains writable; it enters the derived queue + // immediately and the owner must include it in the next reconciliation. + await createResource(f.root, { ...vendor.record, id: "vendor-added", title: "New service in this window" }); + loaded = await loadWorkspace(f.root); + const next = await scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: f.start, asOf: f.today }); + assert.deepEqual(next.record.members.map(({ resourceId }) => resourceId), ["vendor-added", "vendor-example"]); + assert.equal(next.record.members.find(({ resourceId }) => resourceId === vendor.record.id).result, "passed"); + occurrence = loaded.entries.find(({ record }) => record.id === f.occurrenceId); + const dropped = { ...occurrence.record, members: [], expectedCount: 0, completedCount: 0 }; + await writeJson(occurrence.path, dropped); + assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-population")); + await writeJson(occurrence.path, occurrence.record); + const injected = { ...occurrence.record, members: [...occurrence.record.members, + { resourceId: "system-example", disposition: "expected", result: "pending" }], expectedCount: 2 }; + await writeJson(occurrence.path, injected); + assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-population")); + await writeJson(occurrence.path, occurrence.record); + // Remove the test-only new current service through normal termination before + // the cutoff, leaving its not-yet-recorded eligibility outside this review. + const added = loaded.entries.find(({ record }) => record.id === "vendor-added"); + await updateResource(f.root, "vendor", added.record.id, { ...added.record, status: "terminated", endDate: f.today, + statusTransition: { changedByIds: ["person-example"], changedOn: f.today, reason: "The proposed service was withdrawn before any review was recorded." } + }, { expectedRevision: added.revision }); + await commitWorkspaceFiles(f.root, "Preserve cutoff inventory for temporal collection review"); + await applyCollectionReview(f.root, { resourceType: "vendor", decision: "complete", rationale: "Reviewed the current supplier inventory and ended services at the cutoff.", + reviewedByIds: ["person-example"], reviewedOn: f.today, confirmed: true }); + await commitWorkspaceFiles(f.root, "Record actual cutoff collection review"); + const tomorrow = new Date(`${f.today}T23:59:59Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + t.mock.timers.enable({ apis: ["Date"], now: tomorrow }); + const final = await scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: f.start }); + assert.equal(final.membershipFinal, true); + assert.deepEqual(final.record.members.map(({ resourceId }) => resourceId), [vendor.record.id]); + await saveObligationOccurrence(f.root, { record: { ...final.record, status: "reconciled", conclusion: "complete", + reviewedByIds: ["person-example"], reconciledAt: new Date().toISOString() }, expectedRevision: final.revision }); + await commitWorkspaceFiles(f.root, "Finalize reviewed vendor population without losing its ended member"); + assert.equal((await validateWorkspace(f.root)).ok, true, JSON.stringify((await validateWorkspace(f.root)).diagnostics)); + assert.equal(await readFile(f.reviewEntry.path, "utf8"), reviewBytes); +}); + +test("committed population cannot admit injected IDs, another window or later scope, and is memoized", async (t) => { + const f = await fixture(t); + let loaded = await loadWorkspace(f.root); + const entry = loaded.entries.find(({ record }) => record.id === f.occurrenceId); + assert.deepEqual(committedOccurrencePopulation(loaded, entry.record, f.rule.selector, f.preOccurrenceCommit), { memberIds: [], established: false }); + const rewrittenWindow = { ...entry.record, occurrenceKey: `${entry.record.occurrenceKey}-different`, + coverage: { ...entry.record.coverage, startsOn: f.today } }; + assert.deepEqual(committedOccurrencePopulation(loaded, rewrittenWindow, f.rule.selector), { memberIds: [], established: false }); + await writeJson(entry.path, { ...entry.record, members: [...entry.record.members, + { resourceId: "system-example", disposition: "expected", result: "pending" }], expectedCount: 2 }); + await commitWorkspaceFiles(f.root, "Retain an invalid source version to test provenance rejection"); + loaded = await loadWorkspace(f.root); + const current = loaded.resources.find(({ id }) => id === entry.record.id); + assert.deepEqual(committedOccurrencePopulation(loaded, current, f.rule.selector).memberIds, ["vendor-example"]); + assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-population")); + const calls = []; + const priorObserver = setGitSubprocessObserverForTests((event) => calls.push(event)); + try { + await withAssessmentCache(async () => { + const first = committedOccurrencePopulation(loaded, current, f.rule.selector); + const count = calls.length; + for (let index = 0; index < 25; index++) assert.deepEqual(committedOccurrencePopulation(loaded, current, f.rule.selector), first); + assert.equal(calls.length, count, "repeat planner and validator passes reuse one bounded provenance snapshot"); + }); + } finally { setGitSubprocessObserverForTests(priorObserver); } +}); + +test("pre-window and post-cutoff imports cannot establish historical membership", async (t) => { + const f = await fixture(t); + let loaded = await loadWorkspace(f.root); + const entry = loaded.entries.find(({ record }) => record.id === f.occurrenceId); + for (const yearOffset of [-1, 1]) { + const start = new Date(`${f.start}T12:00:00Z`); start.setUTCFullYear(start.getUTCFullYear() + yearOffset); + const end = new Date(`${f.today}T12:00:00Z`); end.setUTCFullYear(end.getUTCFullYear() + yearOffset); + const record = { ...entry.record, occurrenceKey: `${entry.record.occurrenceKey}-${yearOffset}`, + coverage: { kind: "range", startsOn: start.toISOString().slice(0, 10), endsOn: end.toISOString().slice(0, 10) }, + membershipCutoffAt: end.toISOString().slice(0, 10) }; + await writeJson(entry.path, record); + await commitWorkspaceFiles(f.root, "Keep outside-window import for provenance rejection test"); + loaded = await loadWorkspace(f.root); + assert.deepEqual(committedOccurrencePopulation(loaded, record, f.rule.selector), { memberIds: [], established: false }); + } +});