forked from yvv/decision
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
/**
|
||
* 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<Partial<Record<DecisionStatus, readonly DecisionStatus[]>>> = {
|
||
draft: ['advice', 'objection', 'framing', 'voting', 'adopted', 'transmitted', 'rejected'],
|
||
advice: ['adopted', 'voting'],
|
||
objection: ['adopted', 'framing', 'voting'],
|
||
framing: ['voting', 'closed'],
|
||
voting: ['adopted', 'rejected', 'framing'],
|
||
// voting→framing: ONLY the « Reformuler » choice of the crystallization gesture —
|
||
// guarded below to a frozen, non-crystallized parametric session (Δ3).
|
||
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.',
|
||
reformulate:
|
||
'Reformuler n’est possible que sur un réglage collectif figé, avant sa cristallisation.',
|
||
} 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)
|
||
}
|
||
}
|
||
|
||
// voting→framing is EXCLUSIVELY the « Reformuler » branch of the crystallization
|
||
// gesture (Δ3): a frozen, not-yet-crystallized parametric session may go back to
|
||
// formulation instead of being crystallized. Any other voting→framing is refused.
|
||
if (from === 'voting' && to === 'framing') {
|
||
const reformulable =
|
||
ctx.session?.status === 'frozen' && !ctx.session.crystallizedById
|
||
if (!reformulable) {
|
||
return refuse(REASONS.reformulate)
|
||
}
|
||
}
|
||
|
||
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'
|
||
}
|