/** * The ONE state machine of the decision (Δ29). * * Every mutation goes through canTransition(decision, to, ctx): the doctrinal * guards (subsidiarity, instruction, consent) are tested code, not intentions. * The context is assembled by the store; the function stays pure and testable. * * Guards, in order (BLUEPRINT-V2.md « Cycle de vie » + Δ13, Δ17, Δ27, Δ29): * a) unknown transition; * b) boundary — windowSuspendedAt suspends any exit from objection/advice; * c) influx — concernEscalateRatio reached ⇒ widen or scopeKeptNote; * d) matter — requireEffects at collective session opening; * e) resources — « Ce que ça engage » at window/session opening; * f) assent — non-easy objection windows adopt only on an explicit third-party * agreement, never by pure silence; * g) open objection — no adoption over an open objection; * h) dossier — framing→closed only when every element child is terminal; * i) crystallization — parametric sessions close by a dated human gesture, * the engine NEVER crystallizes. * * Every refusal reason is one French sentence whose subject is the collective * or the person — never the engine. */ import type { Assent, CollectiveSettings, Concern, Decision, DecisionStatus, ISODate, Objection, VoteSession, } from '../types/domain' /** Allowed transitions — statuses absent from the table are terminal. */ export const TRANSITIONS: Readonly>> = { draft: ['advice', 'objection', 'framing', 'voting', 'adopted', 'transmitted', 'rejected'], advice: ['adopted', 'voting'], objection: ['adopted', 'framing', 'voting'], framing: ['voting', 'closed'], voting: ['adopted', 'rejected'], adopted: ['revoked', 'closed'], } /** Terminal statuses of an element child — condition of the dossier closure. */ export const TERMINAL_STATUSES: readonly DecisionStatus[] = [ 'adopted', 'rejected', 'revoked', 'closed', 'transmitted', ] /** Window states — a boundary objection suspends any exit from these. */ const WINDOW_STATUSES: readonly DecisionStatus[] = ['objection', 'advice'] /** Sources whose closing is subject to the influx guard. */ const INFLUX_SOURCES: readonly DecisionStatus[] = ['advice', 'objection', 'voting'] /** Adoption/closure targets watched by the influx guard. */ const INFLUX_TARGETS: readonly DecisionStatus[] = ['adopted', 'rejected', 'closed'] const REASONS = { unknown: 'La décision ne peut pas prendre ce chemin depuis son état actuel.', boundary: 'La frontière est contestée — le compte à rebours reste suspendu jusqu’à l’intégration ou une réponse motivée.', influx: 'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.', matterMissing: 'Le collectif s’instruit avant de voter — formule au moins un effet recherché.', matterTarget: 'Une décision structurante se mesure — donne une cible à au moins un effet recherché.', resources: 'Le collectif doit savoir ce que ça engage — écris la note de ressources avant d’ouvrir.', assent: 'Il manque un accord explicite — la fenêtre se prolonge.', openObjection: 'Une objection reste ouverte — le collectif l’entend avant d’adopter.', dossierEmpty: 'Le dossier n’a pas d’éléments — découpe-le avant de le clore.', dossierPending: 'Des éléments du dossier sont encore en cours — le dossier se clôt quand tous ont abouti.', crystallization: 'Les votes sont figés — la cristallisation attend son geste.', } as const export interface TransitionContext { concerns: Concern[] settings: CollectiveSettings session?: VoteSession children?: Decision[] assents?: Assent[] objections?: Objection[] now: ISODate } export type TransitionResult = { ok: true } | { ok: false; reason: string } export type WindowOutcome = 'adopt' | 'extend' | 'wait' function refuse(reason: string): TransitionResult { return { ok: false, reason } } /** ≥1 non-archived Assent on this decision from someone other than the author. */ function hasThirdPartyAssent(decision: Decision, assents: Assent[] | undefined): boolean { return (assents ?? []).some( (assent) => assent.decisionId === decision.id && !assent.archivedAt && assent.personId !== decision.authorId, ) } /** * Can this decision move to `to`? Pure — the store assembles ctx. * Refusals carry one French sentence (subject: the collective or the person). */ export function canTransition( decision: Decision, to: DecisionStatus, ctx: TransitionContext, ): TransitionResult { const from = decision.status // ── (a) Unknown transition ───────────────────────────────────────────── // A window state may explicitly return to itself (re-arming after a // resolved boundary objection, extension of a window) — nothing else loops. const isWindowSelfReturn = WINDOW_STATUSES.includes(from) && to === from if (!isWindowSelfReturn && !(TRANSITIONS[from] ?? []).includes(to)) { return refuse(REASONS.unknown) } // ── (b) Boundary — the contestation of frontiers precedes substance ──── if (WINDOW_STATUSES.includes(from) && decision.windowSuspendedAt && to !== from) { return refuse(REASONS.boundary) } // ── (c) Influx — the scale-up is DECIDED, never evaporated (Δ17) ─────── if (INFLUX_SOURCES.includes(from) && INFLUX_TARGETS.includes(to)) { const concerns = ctx.concerns.filter( (concern) => concern.decisionId === decision.id && !concern.archivedAt, ) const computed = concerns.filter((concern) => concern.origin === 'computed').length const declared = concerns.filter((concern) => concern.origin === 'declared').length const ratioReached = computed > 0 && declared >= ctx.settings.triage.concernEscalateRatio * computed if (ratioReached && !decision.scopeKeptNote) { return refuse(REASONS.influx) } } const opensCollectiveSession = (from === 'draft' || from === 'framing') && to === 'voting' // ── (d) Matter — no engaging collective vote without instruction ─────── if (opensCollectiveSession && decision.route === 'collective') { const mode = ctx.settings.triage.requireEffects const underGuard = mode === 'binding' ? decision.weight === 'binding' || decision.weight === 'structural' : mode === 'structural' && decision.weight === 'structural' if (underGuard) { const effects = decision.brief?.effects ?? [] if (effects.length === 0) { return refuse(REASONS.matterMissing) } const hasMeasurableEffect = effects.some( (effect) => effect.target !== undefined && effect.target.trim().length > 0, ) if (decision.weight === 'structural' && !hasMeasurableEffect) { return refuse(REASONS.matterTarget) } } } // ── (e) Resources — « Ce que ça engage » lives at the opening ────────── const opensWindowOrSession = opensCollectiveSession || (from === 'draft' && (to === 'objection' || to === 'advice')) if ( opensWindowOrSession && decision.weight !== 'light' && decision.route !== 'solo' && decision.route !== 'record' ) { const note = decision.resources?.note ?? '' if (note.trim().length === 0) { return refuse(REASONS.resources) } } // ── (f) Assent — outside easy, agreement is a gesture, not silence (Δ27) if (from === 'objection' && to === 'adopted' && decision.reversibility !== 'easy') { if (!hasThirdPartyAssent(decision, ctx.assents)) { return refuse(REASONS.assent) } } // ── (g) Open objection — never adopted over someone's maintained voice ─ if (from === 'objection' && to === 'adopted') { const hasOpenObjection = (ctx.objections ?? []).some( (objection) => objection.decisionId === decision.id && !objection.archivedAt && objection.status === 'open', ) if (hasOpenObjection) { return refuse(REASONS.openObjection) } } // ── (h) Dossier — the closure is a steward gesture on a complete map (Δ13) if (from === 'framing' && to === 'closed') { const elements = (ctx.children ?? []).filter( (child) => child.chainKind === 'element' && child.parentDecisionId === decision.id && !child.archivedAt, ) if (elements.length === 0) { return refuse(REASONS.dossierEmpty) } if (elements.some((child) => !TERMINAL_STATUSES.includes(child.status))) { return refuse(REASONS.dossierPending) } } // ── (i) Crystallization — the engine NEVER crystallizes ──────────────── // A parametric session is recognized by decision.paramSpec, or by the // 'frozen' status (only parametric sessions ever freeze). if (from === 'voting' && (to === 'adopted' || to === 'rejected') && ctx.session) { const isParametric = decision.paramSpec !== undefined || ctx.session.status === 'frozen' const crystallized = ctx.session.status === 'closed' && !!ctx.session.crystallizedById if (isParametric && !crystallized) { return refuse(REASONS.crystallization) } } return { ok: true } } /** * Outcome of an objection window at its deadline, WITHOUT open objection * (the caller handles open objections — escalation or suspension): * - suspended boundary ⇒ 'wait' (the countdown is not running); * - easy ⇒ 'adopt' (silence counts as agreement — only there); * - otherwise ⇒ 'adopt' on a third-party Assent, else 'extend' by one notch * (+objectionWindowHours, Fil reminder) — never adoption by pure silence. */ export function windowOutcome(decision: Decision, ctx: TransitionContext): WindowOutcome { if (decision.windowSuspendedAt) return 'wait' if (decision.reversibility === 'easy') return 'adopt' return hasThirdPartyAssent(decision, ctx.assents) ? 'adopt' : 'extend' }