/** * Pact settings resolution — the Pact IS the settings store (Δ10). * * resolveSettings() reads the Pact clauses that carry a settingKey, finds the * 'current' ClauseVersion bearing a settingValue for each, and produces the * resolved CollectiveSettings consumed by triage.ts and state.ts. Pure * function — NOT a settings table: every threshold is a voted clause. * * Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10): * - any absent key falls back to SETTINGS_DEFAULTS; * - invalid values (wrong type, negative number, unknown enum member) fall * back to the default SILENTLY — a corrupted Pact never crashes the engine; * - any unresolved OPTIONAL protocolByRange key stays undefined: the caller * falls back to the consent protocol; * - a missing 'protocols.consent' (corrupted bundle) still returns a full * settings object with protocolByRange.consent === '' — hasConsentProtocol() * lets the triage route 'advice' with the banner « Aucun protocole — crée-le * ou décide sur avis », NEVER a crash. */ import type { Clause, ClauseVersion, CollectiveSettings, Id, InertiaPreset, Json, } from '../types/domain' /** Default triage settings — used for every key the Pact does not resolve. */ export const SETTINGS_DEFAULTS = { smallGroupMax: 5, collectiveMin: 50, consentMax: 7, objectionWindowHours: 48, adviceWindowHours: 72, framingDays: 14, concernEscalateRatio: 0.5, recurrenceThreshold: 3, reviewDelayDays: 90, requireEffects: 'binding', } as const satisfies CollectiveSettings['triage'] const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max'] /** Most recent wins when a clause carries several 'current' versions (data anomaly). */ function stampOf(version: ClauseVersion): string { return version.adoptedAt ?? version.updatedAt } /** The settingValue of the clause's 'current' version, if any. */ function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined { let best: ClauseVersion | undefined for (const version of versions) { if (version.clauseId !== clause.id || version.archivedAt) continue if (version.status !== 'current' || version.settingValue === undefined) continue if (!best || stampOf(version) > stampOf(best)) best = version } return best?.settingValue } /** First non-archived clause holding this settingKey with a resolvable current value. */ function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined { for (const clause of clauses) { if (clause.settingKey !== key || clause.archivedAt) continue const value = currentValueOf(clause, versions) if (value !== undefined) return value } return undefined } /** Wrong type or negative number ⇒ default, silently. */ function numberOrDefault(value: Json | undefined, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback } /** Unknown enum member ⇒ default, silently. */ function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' { return value === 'none' || value === 'structural' || value === 'binding' ? value : SETTINGS_DEFAULTS.requireEffects } /** Protocol ids must be non-empty strings — anything else counts as unresolved. */ function idOrUndefined(value: Json | undefined): Id | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } /** * Resolve the collective's settings from its Pact clauses. * * Recognized setting keys: * triage.smallGroupMax · triage.collectiveMin · triage.consentMax · * triage.objectionWindowHours · triage.adviceWindowHours · * triage.framingDays · triage.concernEscalateRatio · * triage.recurrenceThreshold · triage.reviewDelayDays · * triage.requireEffects · protocols.consent · protocols.nuanced · * protocols.large · protocols.parametric · protocols.election · * protocols.clauseByInertia.{low,standard,high,max} */ export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings { const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions) const triage: CollectiveSettings['triage'] = { smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax), collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin), consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax), objectionWindowHours: numberOrDefault( raw('triage.objectionWindowHours'), SETTINGS_DEFAULTS.objectionWindowHours, ), adviceWindowHours: numberOrDefault( raw('triage.adviceWindowHours'), SETTINGS_DEFAULTS.adviceWindowHours, ), framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays), concernEscalateRatio: numberOrDefault( raw('triage.concernEscalateRatio'), SETTINGS_DEFAULTS.concernEscalateRatio, ), recurrenceThreshold: numberOrDefault( raw('triage.recurrenceThreshold'), SETTINGS_DEFAULTS.recurrenceThreshold, ), reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays), requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')), } // Consent is the MANDATORY invariant key. When unfindable (corrupted // bundle), we still return a complete object with consent === '' so the // caller can degrade gracefully (hasConsentProtocol) — never a crash. const protocolByRange: CollectiveSettings['protocolByRange'] = { consent: idOrUndefined(raw('protocols.consent')) ?? '', } const nuanced = idOrUndefined(raw('protocols.nuanced')) if (nuanced) protocolByRange.nuanced = nuanced const large = idOrUndefined(raw('protocols.large')) if (large) protocolByRange.large = large const parametric = idOrUndefined(raw('protocols.parametric')) if (parametric) protocolByRange.parametric = parametric const election = idOrUndefined(raw('protocols.election')) if (election) protocolByRange.election = election // The Ğ1 heritage lives here, intact: the map is exposed only when ALL four // presets resolve (the type is a complete Record — a partial map would lie). // Otherwise the caller falls back to consent, like any optional key. const byInertia: Partial> = {} for (const preset of INERTIA_PRESETS) { const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`)) if (id) byInertia[preset] = id } if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) { protocolByRange.clauseByInertia = byInertia as Record } return { triage, protocolByRange } } /** * True when the collective has a resolvable consent protocol. * False ⇒ the triage routes 'advice' with the banner * « Aucun protocole — crée-le ou décide sur avis » — never a crash. */ export function hasConsentProtocol(settings: CollectiveSettings): boolean { return settings.protocolByRange.consent.trim().length > 0 }