v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
/**
* 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'
}