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>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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’à 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'
|
||||
}
|
||||
Reference in New Issue
Block a user