Files
decision/frontend/app/engine/state.ts
T
YvvandClaude Fable 5 f707b5f15d v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:22:19 +02:00

249 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'],
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’à lintégration ou une réponse motivée.',
influx: 'Le périmètre déborde — élargis dun cran ou motive publiquement son maintien.',
matterMissing: 'Le collectif sinstruit 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 douvrir.',
assent: 'Il manque un accord explicite — la fenêtre se prolonge.',
openObjection: 'Une objection reste ouverte — le collectif lentend avant dadopter.',
dossierEmpty: 'Le dossier na 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'
}