/** * Provider-agnostic reconcile primitive. * * The reusable machinery behind a declarative reconcile loop, with NO knowledge * of any specific provider (GitHub, GitLab, a cloud, …): the change-set model, * the generic collection diff (selective-by-omission + ownership-gated deletes), * the plan renderer, and the guardrail framework (rename resolution + a removal * cap + a pluggable check runner). * * A consumer application (e.g. github-warden) builds its provider-specific resource diffing, * live-state types, and domain guardrails on top of this, and drives them with * the generic `runReconcile` loop + `Cycle` interface (below). It complements * chant's `ownership.ts` marker contract: ownership markers make a `delete` * precise; this module decides *which* entries are creates / updates / deletes * in the first place. * * Consumed as `@intentius/chant/reconcile`. The diff and guardrail primitives * are pure and clock-free; `runReconcile` is the orchestration loop and is the * only part that drives I/O (through the provider's `Cycle` implementations). */ import { type GovernanceVerb } from "./governance.js"; /** A single field-level change: what the old value was and what it will become. */ export interface FieldChange { field: string; before: unknown; after: unknown; } /** The kind of operation this change represents. */ export type ChangeKind = "create" | "update" | "delete"; /** A single entry in the change set. */ export interface ChangeSetEntry { kind: ChangeKind; /** High-level resource category (e.g. "team", "member", "branch-protection"). */ resourceType: string; /** * Cross-provider governance category. `resourceType` stays the * provider-specific display string; the verb is the shared grammar SCM and * cloud plans group by. Stamped by `runReconcile` from the cycle's `verb`. */ verb?: GovernanceVerb; /** * Unique key identifying this resource within its type. * - For top-level resources: a single name (team slug, member login, …). * - For nested resources: "/" (e.g. "backend/alice"). */ key: string; /** The live value before the change (absent for creates). */ before?: unknown; /** The desired value after the change (absent for deletes). */ after?: unknown; /** Field-level diff, populated for `update` entries. */ fields?: FieldChange[]; } /** The full set of changes to reconcile for one scope (e.g. one org). */ export interface ChangeSet { /** Scope identifier this change set applies to (e.g. a GitHub org login). */ org: string; /** All proposed changes, in stable order. */ entries: ChangeSetEntry[]; } /** Options controlling diff behaviour. */ export interface DiffOptions { /** * Ownership predicate for collection entries. The diff only emits a `delete` * for a live entry absent from desired when this returns `true`. Omitted → * deletes are never emitted ("assume nothing is owned"). */ isOwned?: (resourceType: string, key: string) => boolean; /** * Reference "now" in epoch milliseconds, used by time-based diffs. Callers * inject `Date.now()` when unset; tests pass an explicit value. */ nowMs?: number; } /** Deep value equality via JSON for plain data (config/live snapshots). */ export declare function deepEqual(a: unknown, b: unknown): boolean; /** * Diff fields of `desired` against `live`, returning one `FieldChange` per * differing field. When `keys` is given, only those keys are compared (and only * when present in `desired`); otherwise every key in `desired` is compared. * Selective-by-omission: keys absent from `desired` are never compared. */ export declare function diffFields(desired: Record, live: Record, keys?: string[]): FieldChange[]; /** Parameters for {@link diffCollection}. */ export interface DiffCollectionParams { /** Resource type stamped on emitted entries. */ resourceType: string; /** Prefix prepended to each entry key (e.g. "/"). Default "". */ keyPrefix?: string; /** Desired entries, keyed by logical key. */ desired: Map; /** Live entries, keyed by logical key. */ live: Map; /** Fields that differ → an update. Return `[]` for "no change". */ compareFields: (desired: D, live: L) => FieldChange[]; /** `after` value for a create entry. Defaults to the desired value. */ createAfter?: (key: string, desired: D) => unknown; /** `after` value for an update entry. Defaults to the desired value. */ updateAfter?: (key: string, desired: D, live: L) => unknown; opts: DiffOptions; out: ChangeSetEntry[]; } /** * The generic managed-collection diff: creates for desired-not-live, updates * when `compareFields` reports differences, and ownership-gated deletes for * live-not-desired. This is the selective-by-omission + ownership-gated-delete * pattern shared by every keyed-collection diff. */ export declare function diffCollection(params: DiffCollectionParams): void; /** Count entries per change kind. */ export declare function summarizeChangeSet(cs: ChangeSet): Record; /** Human-readable plan summary for dry-run output. Pure. */ export declare function renderChangeSet(cs: ChangeSet): string; /** A single tripped guardrail with a human-readable message. */ export interface GuardrailDiagnostic { /** Short identifier, e.g. "removalDeltaCap". */ guardrail: string; /** Clear, actionable description of why the apply was refused. */ message: string; } /** Aggregated guardrail result. */ export type GuardrailResult = { ok: true; } | { ok: false; diagnostics: GuardrailDiagnostic[]; }; /** A guardrail check over a (rename-resolved) change set. Returns null when it passes. */ export type GuardrailCheck = (resolved: ChangeSet) => GuardrailDiagnostic | null; /** Config for `removalDeltaCap`. */ export interface RemovalDeltaCapOptions { /** Max fraction of pre-existing entries that may be deleted. Must be in (0,1]. Default 0.25. */ maxFraction?: number; } /** * Resolve rename aliases. A create entry carrying a `previously` key matching a * delete entry's key is collapsed into an update, removing the delete. Returns a * new ChangeSet with renames resolved. Provider-agnostic — works on any entry * whose `after.previously` is a string. */ export declare function resolveRenames(changeSet: ChangeSet): ChangeSet; /** * Refuse if deletes exceed `maxFraction` of the pre-existing managed entries * (deletes + updates; creates excluded so a flood of new entries can't dilute * the delete fraction). Guards against a typo wiping the config in one apply. * * CONTRACT: pass a RENAME-RESOLVED change set (see {@link resolveRenames}). */ export declare function removalDeltaCap(changeSet: ChangeSet, opts?: RemovalDeltaCapOptions): GuardrailDiagnostic | null; /** * Run a set of guardrail checks against a change set. Resolves renames ONCE, * then runs every check on the resolved set, aggregating any diagnostics. The * caller composes provider-specific checks (e.g. an admin floor) as closures. */ export declare function runGuardrailChecks(changeSet: ChangeSet, checks: GuardrailCheck[]): GuardrailResult; /** Controls how a cycle tracks its API usage against a shared request budget. */ export interface RateBudget { /** Remaining request capacity for this run. */ readonly remaining: number; /** True once `remaining` has reached zero. */ readonly exhausted: boolean; /** Decrement by `n` (default 1). Throws `BudgetExhaustedError` if exhausted. */ use(n?: number): void; } /** Thrown when a cycle or apply step attempts to use an exhausted budget. */ export declare class BudgetExhaustedError extends Error { constructor(message?: string); } /** * A reconcile cycle: fetch live state for one resource domain, build desired * state from config, and apply a single `ChangeSetEntry` back to the provider. * Generic over the provider client (`TClient`), the per-scope config slice * (`TConfig`), the live snapshot (`TLive`), and caller-supplied scope (`TScope`). * * `scopeId` is the current scope being iterated (e.g. an org login or group * path); cycles use it — not `TScope` — for provider API paths, so a multi-scope * config targets the right scope. Every network call must charge `budget`. */ export interface Cycle { /** Human-readable name, e.g. "branch-protection". */ name: string; /** * Cross-provider governance category this cycle reconciles. Every * SCM reconciler cycles stamp one; cloud cycles must. Optional * only so provider-external Cycle implementations don't break. */ verb?: GovernanceVerb; fetchLive(client: TClient, scopeId: string, scope: TScope, budget: RateBudget): Promise; buildDesired(config: TConfig, scopeId: string, scope: TScope): TConfig; apply(client: TClient, entry: ChangeSetEntry, scopeId: string, scope: TScope, budget: RateBudget): Promise; } /** Per-cycle outcome recorded in the run result. */ export interface CycleResult { name: string; /** The cycle's governance verb, when it stamps one. */ verb?: GovernanceVerb; /** Scope id this result is for (e.g. an org login). */ org: string; counts: { create: number; update: number; delete: number; }; guardrails: GuardrailResult; applied: ChangeSetEntry[]; failed: Array<{ entry: ChangeSetEntry; error: string; }>; plan: string; guardrailBlocked: boolean; } /** A cycle that errored during `fetchLive`/`buildDesired` (non-budget error). */ export interface CycleError { name: string; org: string; stage: "fetchLive" | "buildDesired"; error: string; } /** Work that could not complete due to budget exhaustion. */ export interface DeferredWork { skippedCycles: string[]; skippedEntries: Array<{ cycleName: string; entry: ChangeSetEntry; }>; } /** Structured result from a single `runReconcile` call. */ export interface ReconcileResult { mode: "dry-run" | "apply"; completed: boolean; cycles: CycleResult[]; errored: CycleError[]; deferred: DeferredWork; budgetRemaining: number; } /** Options for `runReconcile`. */ export interface RunReconcileOptions { /** Per-scope configs to reconcile, keyed by scope id (e.g. org login). */ scopes: Record; /** Authed provider client, passed to every cycle. */ client: TClient; /** Cycles to run; each runs against every scope in `scopes`. */ cycles: Array>; /** Scope forwarded to each cycle (filter/cursor); does not vary by scopeId. */ scope?: TScope; /** "dry-run" (default) computes + reports; "apply" mutates after guardrails. */ mode?: "dry-run" | "apply"; /** Provider diff: turn (desired, live) into a ChangeSet for one scope. */ diff: (scopeId: string, desired: TConfig, live: TLive, opts: DiffOptions) => ChangeSet; /** Guardrail check over the change set + live. Defaults to always-ok. */ guardrails?: (changeSet: ChangeSet, live: TLive) => GuardrailResult; /** Diff options forwarded to `diff`. */ diffOptions?: DiffOptions; /** Apply even when guardrails trip. Default false. */ allowGuardrailOverride?: boolean; /** Max requests for the run (across all cycles). Default 1000. */ requestBudget?: number; } /** * Run the reconcile loop. For each scope in `scopes` and each cycle: * 1. fetchLive 2. buildDesired 3. diff 4. guardrails * 5a. dry-run: record the plan 5b. apply: apply each entry (if guardrails pass) * * Budget-aware (stops cleanly + records deferred work on exhaustion) and * fault-tolerant (a cycle that errors is recorded and the run continues). * Returns a structured `ReconcileResult`. */ export declare function runReconcile(opts: RunReconcileOptions): Promise; //# sourceMappingURL=reconcile.d.ts.map