Access reviews
A review campaign closes complete or at its deadline, and every missing decision stays visible as unresolved, never guessed into approval.
What it is
access-review is the headless campaign kernel for periodic entitlement attestation: openCampaign freezes an imported reviewer roster, recordDecision appends approve or revoke decisions to the tenant audit chain, and closeCampaign refuses an early partial close. At deadline, scanCampaignDecisions reports every undecided reviewee as unresolved, never approved. Job task definitions carry the same lifecycle onto a recurring cadence.
What ships in the module
CSV, JSON, or in-memory membership snapshots
createCsvMembershipSnapshotSource(), createJsonMembershipSnapshotSource(), and createInMemoryMembershipSnapshotSource() validate three input forms into the same frozen reviewer and reviewee roster. The adapters perform no I/O themselves, so your loader stays at the edge and every campaign receives the same parsed MembershipSnapshot shape.
Strict campaign boundaries
openCampaignSchema, recordDecisionSchema, and closeCampaignSchema reject unknown fields before the lifecycle touches Postgres or the chain. Reviewee ids are bounded, rosters must be unique, campaign windows are positive and capped by MAX_CAMPAIGN_WINDOW_MS, and every account or campaign id must be a UUID.
One chained record per lifecycle event
openCampaign(), recordDecision(), and closeCampaign() append CAMPAIGN_OPENED_RECORD, CAMPAIGN_DECISION_RECORD, and CAMPAIGN_CLOSED_RECORD payloads through the narrow CampaignChainStore port. The mutable campaign row answers operational queries; the append-only chain preserves who decided what and the unresolved roster recorded at close.
Latest decision wins, absence stays unresolved
scanCampaignDecisions() sorts loaded entries by sequence, takes the latest approve or revoke decision per reviewee, and returns the frozen roster members with no decision. closeCampaign() refuses while the campaign is incomplete and not due; at the deadline it records that unresolved list as-is, never as approval.
Recurring open and close jobs
defineCampaignOpenTask() and defineCampaignCloseTask() expose the lifecycle as @caisson/jobs task definitions. enqueueCampaignOpen() uses a tenant-and-reviewer singleton key, while enqueueCampaignClose() keys by tenant and campaign, so overlapping schedule ticks do not enqueue the same review twice.
export function scanCampaignDecisions(
entries: readonly AuditChainEntry[],
campaignId: string,
reviewees: readonly string[],
): CampaignDecisionScan {
const decisions = new Map<string, ReviewDecision>();
// "Latest wins" depends on seq-ascending iteration order — sort defensively rather than trust
// the caller's ordering (entries is fully in memory already, so this is one cheap pass).
const bySeq = [...entries].sort((a, b) => a.seq - b.seq);
for (const entry of bySeq) {
const decision = decisionFromEntry(entry, campaignId);
if (decision !== null)
decisions.set(decision.revieweeId, decision.decision);
}
const unresolved = reviewees.filter((r) => !decisions.has(r));
return { decisions, unresolved };
}- scanCampaignDecisions sorts by seq before folding, so a revised decision supersedes the earlier append even when the caller supplied entries out of order.
- scanCampaignDecisions derives unresolved from the frozen roster after the fold; no missing decision enters the decisions map as an approval.