diff --git a/backend/scripts/export_seed_bundle.py b/backend/scripts/export_seed_bundle.py new file mode 100644 index 0000000..ccb5cd4 --- /dev/null +++ b/backend/scripts/export_seed_bundle.py @@ -0,0 +1,1617 @@ +#!/usr/bin/env python3 +"""Export du seed backend v1 (backend/seed.py) vers un Bundle JSON v2. + +Émet frontend/app/data/seeds/duniter-g1.bundle.json au schéma Bundle v2 +(frontend/app/types/domain.ts), sans perte de contenu Ğ1. + +ISOLATION DES DONNÉES (aucune dépendance DB) : + Importer seed.py déclencherait `from app.database import …` (SQLAlchemy, + driver async, config). Ce script n'importe donc PAS seed.py : il le PARSE + avec `ast` et extrait les structures par évaluation littérale tolérante : + - littéraux au niveau module : GENESIS_CERTIFICATION, + ENGAGEMENT_CERTIFICATION_ITEMS (33), GENESIS_FORGERON, + ENGAGEMENT_FORGERON_ITEMS (59), VOTER_NAMES (11), RUNTIME_UPGRADE_STEPS ; + - littéraux internes aux fonctions : configs (FormulaConfigs ×7), + protocols (VotingProtocols ×3), mandates_data (×3), steps (licence), + orgs_data, kwargs des appels get_or_create / QualificationProtocol. + Les valeurs non littérales (ids SQLAlchemy, datetime.now()…) sont ignorées + par l'extracteur et recalculées côté v2 (dates historiques fixes, + uuid5 déterministes). + +REPRODUCTIBILITÉ : uuid5(NAMESPACE fixe, slug) partout + dates constantes + (aucun datetime.now) → deux exécutions produisent un JSON identique + octet à octet. + +ÉCARTS ASSUMÉS (voir rapport imprimé en fin d'exécution) : + - les 3 VoteSessions de démo v1 (10 pour / 1 contre simulés sur les 3 + premiers items forgeron) ne sont PAS portées : votes factices sans + contenu Ğ1 réel — le vrai vote (97/23/19) vit dans provenance.voteRecord ; + - l'organisation v1 'axiom-team' n'est pas exportée (un bundle = UN + collectif) ; + - icon 'i-lucide-network' (spec BLUEPRINT-V2 « Seeds ») remplace + 'i-lucide-globe' (v1) ; la description de l'organisation v1 est conservée + dans la provenance du Pacte ; + - v1 mappait TOUS les items cert sur « Vote WoT standard » (son + inertia_protocol_map pointait le même protocole pour les 4 presets) ; + v2 câble clauseByInertia par preset (règle R3 du blueprint) — les + FormulaConfigs v1 basse/haute/très haute deviennent des protocoles ; + - item_type v1 (preamble/section/clause/rule/verification) conservé dans + les tags de la décision fondatrice de chaque clause (`v1:`). + +Usage : backend/.venv/bin/python backend/scripts/export_seed_bundle.py +""" + +from __future__ import annotations + +import ast +import hashlib +import json +import uuid +from pathlib import Path + +HERE = Path(__file__).resolve() +BACKEND = HERE.parents[1] +REPO = HERE.parents[2] +SEED_PATH = BACKEND / "seed.py" +OUT_PATH = REPO / "frontend" / "app" / "data" / "seeds" / "duniter-g1.bundle.json" + +# ───────────────────────────────────────────────────────────── +# Extraction AST — aucun import de seed.py +# ───────────────────────────────────────────────────────────── + +_SKIP = object() + + +def _tolerant(node: ast.AST): + """Évaluation littérale tolérante : tout nœud non littéral est ignoré.""" + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Dict): + out = {} + # (clé None = **unpacking — absent des littéraux extraits de seed.py) + for key_node, value_node in zip(node.keys, node.values): + if key_node is None: + continue + key = _tolerant(key_node) + value = _tolerant(value_node) + if key is _SKIP or value is _SKIP: + continue + out[key] = value + return out + if isinstance(node, (ast.List, ast.Tuple)): + return [v for v in (_tolerant(e) for e in node.elts) if v is not _SKIP] + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + operand = _tolerant(node.operand) + return -operand if operand is not _SKIP else _SKIP + return _SKIP + + +def _assign_value(scope: ast.AST, name: str): + """Valeur littérale de la première assignation `name = …` dans le scope.""" + for node in ast.walk(scope): + target = None + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + ): + target = node.targets[0].id + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + target = node.target.id + if target == name and getattr(node, "value", None) is not None: + value = _tolerant(node.value) + if value is not _SKIP: + return value + raise KeyError(f"assignation littérale introuvable : {name}") + + +def _function(tree: ast.Module, name: str) -> ast.AST: + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + return node + raise KeyError(f"fonction introuvable : {name}") + + +def _call_kwargs(scope: ast.AST, callee: str) -> dict: + """Kwargs littéraux du premier appel `callee(...)` du scope.""" + for node in ast.walk(scope): + if not isinstance(node, ast.Call): + continue + func = node.func + fname = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None) + if fname != callee: + continue + kwargs = {} + for kw in node.keywords: + if kw.arg is None: + continue + value = _tolerant(kw.value) + if value is not _SKIP: + kwargs[kw.arg] = value + return kwargs + raise KeyError(f"appel introuvable : {callee}") + + +def _json_dumps_arg(scope: ast.AST): + """Premier argument littéral d'un appel json.dumps(...) dans le scope.""" + for node in ast.walk(scope): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "dumps" + and node.args + ): + value = _tolerant(node.args[0]) + if value is not _SKIP: + return value + return None + + +_tree = ast.parse(SEED_PATH.read_text(encoding="utf-8")) + +GEN_CERT = _assign_value(_tree, "GENESIS_CERTIFICATION") +CERT_ITEMS = _assign_value(_tree, "ENGAGEMENT_CERTIFICATION_ITEMS") +GEN_FORGE = _assign_value(_tree, "GENESIS_FORGERON") +FORGE_ITEMS = _assign_value(_tree, "ENGAGEMENT_FORGERON_ITEMS") +VOTER_NAMES = _assign_value(_tree, "VOTER_NAMES") +RUNTIME_STEPS = _assign_value(_tree, "RUNTIME_UPGRADE_STEPS") + +FORMULAS_V1 = _assign_value(_function(_tree, "seed_formula_configs"), "configs") +PROTOCOLS_V1 = _assign_value(_function(_tree, "seed_voting_protocols"), "protocols") +MANDATES_V1 = _assign_value(_function(_tree, "seed_mandates"), "mandates_data") +ORGS_V1 = _assign_value(_function(_tree, "seed_organizations"), "orgs_data") +LICENCE_STEPS = _assign_value(_function(_tree, "seed_decision_licence_evolution"), "steps") + +_fn_qual = _function(_tree, "seed_qualification_protocol") +QUAL_V1 = _call_kwargs(_fn_qual, "QualificationProtocol") +QUAL_MODALITIES_V1 = _json_dumps_arg(_fn_qual) or [] + +CERT_DOC_META = _call_kwargs( + _function(_tree, "seed_document_engagement_certification"), "get_or_create" +) +FORGE_DOC_META = _call_kwargs( + _function(_tree, "seed_document_engagement_forgeron"), "get_or_create" +) +RUNTIME_META = _call_kwargs(_function(_tree, "seed_decision_runtime_upgrade"), "get_or_create") +LICENCE_META = _call_kwargs(_function(_tree, "seed_decision_licence_evolution"), "get_or_create") + +# ── Garde-fous de non-perte à l'extraction ─────────────────── +assert len(CERT_ITEMS) == 33, f"attendu 33 items Certification, extrait {len(CERT_ITEMS)}" +assert len(FORGE_ITEMS) == 59, f"attendu 59 items Forgeron, extrait {len(FORGE_ITEMS)}" +assert len(VOTER_NAMES) == 11, f"attendu 11 votants, extrait {len(VOTER_NAMES)}" +assert len(MANDATES_V1) == 3, f"attendu 3 mandats v1, extrait {len(MANDATES_V1)}" +assert len(FORMULAS_V1) == 7, f"attendu 7 FormulaConfigs, extrait {len(FORMULAS_V1)}" +assert len(PROTOCOLS_V1) == 3, f"attendu 3 VotingProtocols, extrait {len(PROTOCOLS_V1)}" +_vr = GEN_FORGE["vote_record"]["result"] +assert (_vr["pour"], _vr["contre"], _vr["nuls_invalides"]) == (97, 23, 19) +assert (_vr["wot_size"], _vr["threshold_required"]) == (7224, 97) + +# ───────────────────────────────────────────────────────────── +# Constantes v2 — ids déterministes et dates historiques fixes +# ───────────────────────────────────────────────────────────── + +NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://libredecision.org/seeds/duniter-g1") + + +def uid(slug: str) -> str: + return str(uuid.uuid5(NAMESPACE, slug)) + + +COLLECTIVE_ID = uid("collective:duniter-g1") +EXPORTED_AT = "2026-08-11T12:00:00Z" + +T_GENESIS = "2025-12-01T10:00:00Z" # création du collectif, adoption cert + Pacte +T_CERT_DRAFT = "2025-11-15T10:00:00Z" +T_FORGE_OPEN = "2026-01-07T09:00:00Z" # ouverture du vote forgeron (seed v1) +T_FORGE_ADOPT = "2026-02-06T18:00:00Z" # clôture/adoption (97/23, WoT 7224) +T_COMTECH_OPEN = "2026-02-04T09:00:00Z" +T_COMTECH_ADOPT = "2026-03-06T18:00:00Z" + +PID = {name: uid(f"person:{name.lower()}") for name in VOTER_NAMES} +SMITH_NAMES = VOTER_NAMES[:5] # v1 : is_smith=(i < 5) + +C_ROOT = uid("circle:toile-de-confiance") +C_FORGE = uid("circle:forgerons") +C_TECH = uid("circle:comite-technique") + +DOC_CERT = uid("doc:engagement-certification") +DOC_FORGE = uid("doc:engagement-forgeron") +DOC_TECH = uid("doc:engagement-comite-tech") +DOC_PACT = uid("doc:pacte-duniter-g1") + +P_CONSENT = uid("protocol:consentement") +P_WOT = uid("protocol:vote-wot-standard") +P_NUANCED = uid("protocol:vote-nuance") +P_SMITH = uid("protocol:vote-forgeron-smith") +P_TECHCOMM = uid("protocol:vote-comite-technique") +P_PARAMETRIC = uid("protocol:reglage-collectif") +P_ELECTION = uid("protocol:election-sans-candidat") +P_INERTIA_LOW = uid("protocol:inertie-basse") +P_INERTIA_HIGH = uid("protocol:inertie-haute") +P_INERTIA_MAX = uid("protocol:inertie-tres-haute") + +# Presets v1 → InertiaPreset v2 (spec : very_high → 'max') +PRESET_MAP = {"low": "low", "standard": "standard", "high": "high", "very_high": "max"} + +# R3 : protocole d'amendement d'une clause = clauseByInertia[preset] +PROTO_BY_INERTIA = { + "low": P_INERTIA_LOW, + "standard": P_WOT, + "high": P_INERTIA_HIGH, + "max": P_INERTIA_MAX, +} + + +def _fmt(value: float) -> str: + """0.1 → '.1', 50 → '50' (DSL modeParams, forme canonique).""" + if value == int(value): + return str(int(value)) + s = str(value) + return s[1:] if s.startswith("0.") else s + + +def entity(eid: str, created: str, updated: str | None = None, **fields) -> dict: + return { + "id": eid, + "collectiveId": COLLECTIVE_ID, + "createdAt": created, + "updatedAt": updated or created, + **fields, + } + + +# ───────────────────────────────────────────────────────────── +# Personnes (11, dont 5 smiths — v1 VOTER_NAMES) +# ───────────────────────────────────────────────────────────── + +people = [] +for i, name in enumerate(VOTER_NAMES): + # Même dérivation d'adresse que seed.py (sha256(name)[:32] préfixé '5') + address = "5" + hashlib.sha256(name.encode()).hexdigest()[:32] + people.append( + entity( + PID[name], + T_GENESIS, + displayName=name, + isMe=False, + duniterAddress=address, + wotStatus="smith" if i < 5 else "member", + ) + ) + +# ───────────────────────────────────────────────────────────── +# Cercles (3, typés kind) +# ───────────────────────────────────────────────────────────── + +COMTECH_MEMBERS = ["Elois", "Cgeek", "Maaltir", "Hugo", "Tuxmain"] # v1 : outcome de l'élection + +circles = [ + entity( + C_ROOT, + T_GENESIS, + name="Toile de confiance", + purpose="Tous les membres de la toile de confiance Ğ1 — le corps souverain du collectif.", + memberIds=[PID[n] for n in VOTER_NAMES], + domains=["toile", "certification", "licence"], + ), + entity( + C_FORGE, + T_GENESIS, + name="Forgerons", + purpose="Les forgerons (validateurs de blocs) de la blockchain Duniter V2 — la sous-toile Smith.", + kind="team", + memberIds=[PID[n] for n in SMITH_NAMES], + parentCircleId=C_ROOT, + domains=["forge", "noeuds", "runtime"], + ), + entity( + C_TECH, + T_GENESIS, + name="Comité Technique", + purpose="Le Comité Technique protège la souveraineté de la communauté sur les mises à jour de la blockchain.", + kind="team", + memberIds=[PID[n] for n in COMTECH_MEMBERS], + parentCircleId=C_ROOT, + domains=["runtime", "code", "on-chain"], + ), +] + +# ───────────────────────────────────────────────────────────── +# Protocoles (10) — FormulaParams complets depuis les FormulaConfigs v1 +# ───────────────────────────────────────────────────────────── + +_f_std = FORMULAS_V1["Standard Licence G1"] +_f_low = FORMULAS_V1["Inertie basse (Annexes)"] +_f_high = FORMULAS_V1["Inertie haute (Formule)"] +_f_vhigh = FORMULAS_V1["Inertie très haute (Méta-réglage)"] +_f_smith = FORMULAS_V1["Forgeron avec Smith"] +_f_tech = FORMULAS_V1["Comité Tech"] +_f_nuanced = FORMULAS_V1["Vote Nuance"] + + +def _formula(cfg: dict, **extra) -> dict: + return { + "majorityPct": cfg["majority_pct"], + "baseExponent": cfg["base_exponent"], + "gradientExponent": cfg["gradient_exponent"], + "constantBase": cfg["constant_base"], + **extra, + } + + +def _binary_mode(cfg: dict, suffix: str = "") -> str: + return ( + f"D{cfg['duration_days']}M{_fmt(cfg['majority_pct'])}" + f"B{_fmt(cfg['base_exponent'])}G{_fmt(cfg['gradient_exponent'])}{suffix}" + ) + + +def protocol(pid, name, method, description, days, formula, mode, pact_clause=None): + p = entity( + pid, + T_GENESIS, + name=name, + method=method, + description=description, + durationDays=days, + ballot="open", + formula=formula, + modeParams=mode, + ) + if pact_clause: + p["pactClauseId"] = uid(f"clause:pacte-duniter-g1:{pact_clause}") + return p + + +protocols = [ + protocol( + P_CONSENT, + "Consentement", + "consent", + "Ça me va / J'objecte — zéro objection argumentée maintenue vaut adoption. " + "La modalité par défaut des petits corps, et le repli universel de toute " + "clé de protocole absente. Hors du réversible, au moins un accord " + "explicite est requis à l'échéance.", + 7, + {"majorityPct": 100, "baseExponent": 0.1, "gradientExponent": 0, "constantBase": 0}, + "D7M100B.1G0", + pact_clause="M1", + ), + protocol( + P_WOT, + "Vote WoT standard", + "binary", + "L'outil de dernier recours des très grands corps — héritage Toile de " + "Confiance. " + PROTOCOLS_V1["Vote WoT standard"]["description"], + _f_std["duration_days"], + _formula(_f_std), + PROTOCOLS_V1["Vote WoT standard"]["mode_params"], + pact_clause="M2", + ), + protocol( + P_NUANCED, + "Vote nuancé", + "nuanced", + "Vote nuancé à 6 niveaux — 0 « Pas du tout » à 5 « Tout à fait » : chacun " + "se prononce en nuances, pas en camps. Histogramme des 6 niveaux visible " + "pendant et après le vote ; commentaire obligatoire sur 0-1. " + "(v1 : « " + _f_nuanced["description"] + " »)", + _f_nuanced["duration_days"], + _formula( + _f_nuanced, + nuancedMinParticipants=_f_nuanced["nuanced_min_participants"], + nuancedThresholdPct=_f_nuanced["nuanced_threshold_pct"], + ), + _binary_mode(_f_nuanced), + pact_clause="M3", + ), + protocol( + P_SMITH, + "Vote forgeron (Smith)", + "binary", + PROTOCOLS_V1["Vote forgeron (Smith)"]["description"], + _f_smith["duration_days"], + _formula(_f_smith, smithExponent=_f_smith["smith_exponent"]), + PROTOCOLS_V1["Vote forgeron (Smith)"]["mode_params"], + ), + protocol( + P_TECHCOMM, + "Vote Comité Technique", + "binary", + PROTOCOLS_V1["Vote Comité Technique"]["description"], + _f_tech["duration_days"], + _formula(_f_tech, techcommExponent=_f_tech["techcomm_exponent"]), + PROTOCOLS_V1["Vote Comité Technique"]["mode_params"], + ), + protocol( + P_PARAMETRIC, + "Réglage collectif", + "parametric", + "Décider au curseur — chacun pose sa valeur, la médiane basse éclaire, " + "le geste du garant cristallise. Quorum : 5 participants.", + 14, + { + "majorityPct": 50, + "baseExponent": 0.1, + "gradientExponent": 0, + "constantBase": 0, + "parametricMinParticipants": 5, + }, + "D14M50B.1G0", + pact_clause="M4", + ), + protocol( + P_ELECTION, + "Élection sans candidat", + "election", + "Désigner sans candidature — chacun nomme la personne qu'il estime la " + "mieux placée. Vote blanc possible (compte pour la participation, pas " + "pour la désignation). En cas d'égalité : session de départage entre " + "ex æquo — jamais l'outil.", + 14, + { + "majorityPct": 50, + "baseExponent": 0.1, + "gradientExponent": 0, + "constantBase": 0, + "electionMinParticipants": 5, + "tieBreak": "runoff", + }, + "D14M50B.1G0", + pact_clause="M5", + ), + protocol( + P_INERTIA_LOW, + "Inertie basse (Annexes)", + "binary", + _f_low["description"], + _f_low["duration_days"], + _formula(_f_low), + _binary_mode(_f_low), + pact_clause="N1", + ), + protocol( + P_INERTIA_HIGH, + "Inertie haute (Formule)", + "binary", + _f_high["description"], + _f_high["duration_days"], + _formula(_f_high), + _binary_mode(_f_high), + pact_clause="N3", + ), + protocol( + P_INERTIA_MAX, + "Inertie très haute (Méta-réglage)", + "binary", + _f_vhigh["description"], + _f_vhigh["duration_days"], + _formula(_f_vhigh), + _binary_mode(_f_vhigh), + pact_clause="N4", + ), +] + +# ───────────────────────────────────────────────────────────── +# TextDocs, Clauses, ClauseVersions, Décisions fondatrices +# ───────────────────────────────────────────────────────────── + +docs: list[dict] = [] +clauses: list[dict] = [] +versions: list[dict] = [] +decisions: list[dict] = [] + + +def _steps_md(steps: list[dict]) -> str: + lines = [] + for s in steps: + line = f"{s.get('step_order')}. **{s.get('title')}** — {s.get('description', '').strip()}" + if s.get("status"): + line += f" *(v1 : {s['status']}" + if s.get("outcome"): + line += f" — {s['outcome']}" + line += ")*" + lines.append(line) + return "\n".join(lines) + + +def add_doc(doc_id, slug, title, role, description, provenance, created, updated=None): + docs.append( + entity( + doc_id, + created, + updated, + slug=slug, + title=title, + role=role, + description=description, + provenance=provenance, + ) + ) + + +def add_clause( + *, + doc_slug: str, + doc_id: str, + code: str, + section: str, + position: int, + title: str, + inertia: str, + content: str, + version_label: str, + created: str, + adopted: str, + author: str, + protocol_id: str, + scope_circles: list[str], + tags: list[str], + setting_key: str | None = None, + setting_value=None, + body: str | None = None, +): + cid = uid(f"clause:{doc_slug}:{code}") + vid = uid(f"version:{doc_slug}:{code}:current") + did = uid(f"decision:fondatrice:{doc_slug}:{code}") + + clause = entity( + cid, + adopted, + docId=doc_id, + section=section, + position=position, + code=code, + title=title, + inertia=inertia, + currentVersionId=vid, + ) + if setting_key is not None: + clause["settingKey"] = setting_key + clauses.append(clause) + + version = entity( + vid, + created, + adopted, + clauseId=cid, + decisionId=did, + versionLabel=version_label, + content=content, + status="current", + adoptedAt=adopted, + ) + if setting_value is not None: + version["settingValue"] = setting_value + versions.append(version) + + decisions.append( + entity( + did, + created, + adopted, + authorId=PID[author], + title=f"Adoption fondatrice — {code}", + body=body + or ( + f"Adoption fondatrice de la clause {code} « {title} » " + f"(document {doc_slug}). Import du seed v1 — contenu en vigueur " + "dans la communauté Ğ1." + ), + tags=tags, + reversibility="costly", + weight="binding", + urgent=False, + scope={"selfOnly": False, "circleIds": scope_circles, "personIds": []}, + route="collective", + triageRule="R3", + routeOverridden=False, + amendsClauseId=cid, + protocolId=protocol_id, + status="adopted", + decidedAt=adopted, + stewardIds=[], + measurerIds=[], + visibility="collective", + ) + ) + return cid + + +def _sources_from_genesis(genesis: dict) -> list[dict]: + sources = [] + src = genesis.get("source_document", {}) + if src: + item = {"title": src["title"], "url": src["url"]} + if src.get("date"): + item["date"] = src["date"] + if src.get("version"): + item["version"] = src["version"] + sources.append(item) + if src.get("repo"): + sources.append({"title": "Dépôt git officiel (licence Ğ1)", "url": src["repo"]}) + for topic in genesis.get("forum_synthesis", []): + sources.append({"title": topic["title"], "url": topic["url"]}) + tools = genesis.get("référence_tools", {}) + tool_labels = { + "g1vote_repo": "g1vote-view (dépôt)", + "g1vote_live": "g1vote-view (live)", + "cesium": "Cesium", + "gecko": "Gecko", + "pad_source": "Pad source (charte forgeron)", + } + for key, label in tool_labels.items(): + if tools.get(key): + sources.append({"title": label, "url": tools[key]}) + return sources + + +def _forum_status_notes(genesis: dict) -> str: + lines = [] + for topic in genesis.get("forum_synthesis", []): + line = f"- {topic['title']} : {topic.get('status', '?')}" + if topic.get("posts"): + line += f" ({topic['posts']} messages)" + lines.append(line) + return "\n".join(lines) + + +# ── Doc 1 : Acte d'engagement Certification (33 clauses) ───── + +add_doc( + DOC_CERT, + "engagement-certification", + CERT_DOC_META["title"], + "reference", + CERT_DOC_META["description"], + { + "sources": _sources_from_genesis(GEN_CERT), + "contributors": [f"{c['name']} — {c['role']}" for c in GEN_CERT["contributors"]], + "notes": ( + "Déclencheur formule (v1) : " + + GEN_CERT["formula_trigger"] + + "\n\nÉtat des discussions forum au moment du seed v1 :\n" + + _forum_status_notes(GEN_CERT) + + f"\n\nDocument v1 : version {CERT_DOC_META['version']}, " + f"type {CERT_DOC_META['doc_type']}, statut {CERT_DOC_META['status']}." + ), + }, + T_GENESIS, +) + +for item in CERT_ITEMS: + preset = PRESET_MAP[item["inertia_preset"]] + add_clause( + doc_slug="engagement-certification", + doc_id=DOC_CERT, + code=item["position"], + section=item["section_tag"], + position=item["sort_order"], + title=item["title"], + inertia=preset, + content=item["current_text"], + version_label=CERT_DOC_META["version"], + created=T_CERT_DRAFT, + adopted=T_GENESIS, + author="Galuel", + protocol_id=PROTO_BY_INERTIA[preset], + scope_circles=[C_ROOT], + tags=["engagement-certification", item["section_tag"], f"v1:{item['item_type']}"], + ) + +# ── Doc 2 : Acte d'engagement forgeron (59 clauses v2.0.0-fr) ─ + +_vote_record_v1 = GEN_FORGE["vote_record"] +add_doc( + DOC_FORGE, + "engagement-forgeron", + FORGE_DOC_META["title"], + "reference", + FORGE_DOC_META["description"], + { + "sources": _sources_from_genesis(GEN_FORGE) + + [{"title": "g1vote — suivi du vote 33165", "url": _vote_record_v1["g1vote_url"]}], + "voteRecord": { + "url": _vote_record_v1["topic_url"], + "modeParams": _vote_record_v1["mode_params"], + "period": _vote_record_v1["period"], + "result": { + "for": _vr["pour"], + "against": _vr["contre"], + "invalid": _vr["nuls_invalides"], + "wotSize": _vr["wot_size"], + "thresholdRequired": _vr["threshold_required"], + "status": _vr["status"], + }, + }, + "contributors": [f"{c['name']} — {c['role']}" for c in GEN_FORGE["contributors"]], + "notes": ( + f"Critère Smith du vote v2.0.0-fr : {_vr['smith_pour']} forgerons pour / " + f"{_vr['smith_contre']} contre. Adresses de vote — pour : " + f"{_vote_record_v1['addresses']['pour']} ; contre : " + f"{_vote_record_v1['addresses']['contre']}.\n\n" + "Actions d'implémentation votées :\n- " + + "\n- ".join(GEN_FORGE["implementation_actions"]) + + "\n\nDéclencheur formule (v1) : " + + GEN_FORGE["formula_trigger"] + + "\n\nÉtat des discussions forum au moment du seed v1 :\n" + + _forum_status_notes(GEN_FORGE) + ), + }, + T_FORGE_OPEN, + T_FORGE_ADOPT, +) + +for item in FORGE_ITEMS: + preset = PRESET_MAP[item["inertia_preset"]] + add_clause( + doc_slug="engagement-forgeron", + doc_id=DOC_FORGE, + code=item["position"], + section=item["section_tag"], + position=item["sort_order"], + title=item["title"], + inertia=preset, + content=item["current_text"], + version_label=GEN_FORGE["source_document"]["version"], # '2.0.0-fr' + created=T_FORGE_OPEN, + adopted=T_FORGE_ADOPT, + author="Elois", + # v1 : tous les items forgeron votés au protocole Smith (double critère) + protocol_id=P_SMITH, + scope_circles=[C_ROOT, C_FORGE], + tags=["engagement-forgeron", item["section_tag"], f"v1:{item['item_type']}"], + ) + +# ── Doc 3 : Acte d'engagement du Comité Technique (6 clauses) ─ +# Contenu ABSENT de seed.py — reconstruit depuis research_duniter_forum.md §3 +# (Engagement Comité Tech v2.0.0-fr, vote 2026-02-04 → 2026-03-06, en cours +# au moment de la recherche). Documenté dans provenance.notes. + +COMTECH_CLAUSES = [ + { + "code": "CT1", + "section": "mission", + "title": "Mission du Comité Technique", + "inertia": "standard", + "content": ( + "Le Comité Technique protège la souveraineté de la communauté Ğ1 " + "sur les mises à jour de la blockchain. Ses missions :\n\n" + "- Auditer le code\n" + "- Détecter le code malveillant\n" + "- Vérifier les fonctionnalités annoncées\n" + "- Déployer uniquement les mises à jour servant la communauté" + ), + }, + { + "code": "CT2", + "section": "engagements", + "title": "Respect des règles en vigueur", + "inertia": "standard", + "content": ( + "Chaque membre du Comité Technique s'engage à respecter les règles " + "décrites dans la version en vigueur du présent document." + ), + }, + { + "code": "CT3", + "section": "engagements", + "title": "Démission en cas de désaccord", + "inertia": "standard", + "content": ( + "Chaque membre s'engage à démissionner s'il est en désaccord " + "avec les règles en vigueur." + ), + }, + { + "code": "CT4", + "section": "engagements", + "title": "Révocation des membres en violation", + "inertia": "standard", + "content": ( + "Chaque membre s'engage à voter pour retirer le mandat des " + "collègues qui violent visiblement les directives du présent document." + ), + }, + { + "code": "CT5", + "section": "formule", + "title": "Conditions d'adoption", + "inertia": "high", + "content": ( + "L'adoption d'une modification exige le seuil unani-majoritaire " + "de la toile ET le support minimum des membres du comité :\n\n" + "```\nvotesPour >= ceil(WotSize^0.1 + (0.5 + (1 - 0.5) " + "× (1 - (TotalVotes/WotSize)^0.2)) × TotalVotes)\n```\n" + "ET\n" + "```\nvotesCoTecPour >= ceil(CoTecSize^0.1)\n```\n\n" + "Mode compact : D30M50B.1G.2T.1." + ), + }, + { + "code": "CT6", + "section": "composition", + "title": "Composition du comité", + "inertia": "high", + "content": ( + "L'entrée et la sortie d'un membre nécessitent l'approbation des " + "2/3 des membres existants (modifiable uniquement par mise à jour " + "runtime avec validation des 2/3)." + ), + }, +] + +add_doc( + DOC_TECH, + "engagement-comite-tech", + "Acte d'engagement du Comité Technique", + "reference", + "Acte d'engagement des membres du Comité Technique Duniter V2 " + "(v2.0.0-fr). Protège la souveraineté de la communauté Ğ1 sur les mises " + "à jour de la blockchain : audit du code, détection de code malveillant, " + "déploiement des seules mises à jour servant la communauté.", + { + "sources": [ + { + "title": "Vote : Engagement Comité Tech 2.0.0-fr", + "url": "https://forum.monnaie-libre.fr/t/vote-engagement-comite-tech-2-0-0-fr/33293", + "date": "2026-02-04", + "version": "2.0.0-fr", + }, + { + "title": "g1vote — mode D30M50B.1G.2T.1 (talk 32960)", + "url": "https://g1vote-view-237903.pages.duniter.org/", + }, + ], + "notes": ( + "Contenu ABSENT du seed v1 (seed.py) — reconstruit depuis la " + "synthèse interne research_duniter_forum.md (§3). Le vote " + "(2026-02-04 → 2026-03-06) était en cours au moment de la " + "recherche : texte intégral non disponible, clauses condensées " + "depuis la synthèse. Méthode de vote : 0,01 Ğ1 envoyés aux " + "adresses désignées (POUR ou CONTRE)." + ), + }, + T_COMTECH_OPEN, + T_COMTECH_ADOPT, +) + +for i, c in enumerate(COMTECH_CLAUSES): + add_clause( + doc_slug="engagement-comite-tech", + doc_id=DOC_TECH, + code=c["code"], + section=c["section"], + position=i + 1, + title=c["title"], + inertia=c["inertia"], + content=c["content"], + version_label="2.0.0-fr", + created=T_COMTECH_OPEN, + adopted=T_COMTECH_ADOPT, + author="Cgeek", + protocol_id=P_TECHCOMM, + scope_circles=[C_ROOT, C_TECH], + tags=["engagement-comite-tech", c["section"]], + ) + +# ── Doc 4 : Pacte duniter-g1 (20 clauses, settingKeys) ─────── + +_org_v1 = next(o for o in ORGS_V1 if o["slug"] == "duniter-g1") + +A1_TEXT = ( + "Nous, membres de la toile de confiance Ğ1, nous donnons cet outil pour " + "décider ensemble sans remettre notre souveraineté à quiconque.\n\n" + "**Autonomie** — chacun décide de ce qui ne concerne que lui. La monnaie " + "libre garantit à chacun sa part de création monétaire ; notre gouvernance " + "garantit à chacun sa part de décision.\n\n" + "**Équilibre** — aucun pouvoir sans contrepoids : tout mandat est borné, " + "révocable et rend compte ; toute règle porte son inertie et reste " + "amendable par ceux qu'elle gouverne.\n\n" + "**Liaison** — la confiance circule de personne à personne, comme les " + "certifications : nos décisions relient ceux qu'elles concernent, du petit " + "cercle à la toile entière.\n\n" + "Notre finalité : une toile de confiance vivante où la monnaie et la " + "décision appartiennent également à chacun — sacralisée dans ce Pacte, " + "jamais immuable." +) + +PACT_CLAUSES = [ + # (code, section, title, inertia, settingKey, settingValue, content) + ("A1", "preambule", "Notre finalité", "high", None, None, A1_TEXT), + ( + "P1", "triage", "Petit cercle", "standard", + "triage.smallGroupMax", QUAL_V1["small_group_max"], + "Jusqu'à 5 personnes concernées, la voie légère suffit : demander " + "l'avis de chacun, écouter, puis décider.", + ), + ( + "P2", "triage", "Grand corps", "standard", + "triage.collectiveMin", QUAL_V1["collective_wot_min"], + "Au-delà de 50 personnes concernées, la décision passe par le " + "protocole des grands corps — pour la toile Ğ1 : le vote binaire " + "inertiel, héritage Toile de Confiance.", + ), + ( + "P3", "triage", "Tour de consentement", "standard", + "triage.consentMax", 7, + "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection " + "argumentée maintenue, c'est adopté.", + ), + ( + "P4", "triage", "Fenêtre d'objection", "standard", + "triage.objectionWindowHours", 48, + "Toute décision sous mandat ou en voie légère reste ouverte à " + "l'objection pendant 48 heures avant d'entrer en vigueur.", + ), + ( + "P5", "triage", "Fenêtre d'avis", "standard", + "triage.adviceWindowHours", 72, + "Quand la voie est l'avis, chacun dispose de 72 heures pour le donner " + "avant que l'auteur décide.", + ), + ( + "P6", "triage", "Temps de formulation", "standard", + "triage.framingDays", 14, + "Quatorze jours pour s'instruire et formuler des contre-propositions " + "avant l'ouverture du vote des décisions qui engagent.", + ), + ( + "P7", "triage", "Affluence", "standard", + "triage.concernEscalateRatio", 0.5, + "Quand la moitié des personnes concernées se déclare, le traitement " + "collectif devient obligatoire : élargir le périmètre, ou motiver " + "publiquement son maintien.", + ), + ( + "P8", "triage", "Récurrence", "standard", + "triage.recurrenceThreshold", 3, + "À la troisième décision semblable, l'outil propose de réclamer un " + "mandat ou de créer une règle.", + ), + ( + "P9", "triage", "Épreuve du réel", "standard", + "triage.reviewDelayDays", 90, + "Toute décision structurante ou irréversible est revue au plus tard " + "90 jours après son adoption : ça tient, à revoir, ou à révoquer.", + ), + ( + "P10", "triage", "Matière exigée", "standard", + "triage.requireEffects", "binding", + "Ouvrir un vote collectif sur une décision qui engage exige d'énoncer " + "au moins un effet recherché — et une cible mesurable quand elle est " + "structurante.", + ), + ( + "M1", "protocoles", "Protocole de consentement", "high", + "protocols.consent", P_CONSENT, + "Le consentement est notre modalité de base et le repli universel : " + "toute clé de protocole non résolue y retombe.", + ), + ( + "M2", "protocoles", "Protocole des grands corps", "high", + "protocols.large", P_WOT, + "Au-delà du grand corps, la toile Ğ1 vote pour/contre sous la formule " + "d'inertie héritée de la Toile de Confiance — l'outil de dernier " + "recours des très grands corps.", + ), + ( + "M3", "protocoles", "Protocole nuancé", "high", + "protocols.nuanced", P_NUANCED, + "Entre le tour de consentement et le grand corps : le vote nuancé à " + "6 niveaux — chacun se prononce en nuances, pas en camps.", + ), + ( + "M4", "protocoles", "Réglage collectif", "high", + "protocols.parametric", P_PARAMETRIC, + "Quand la décision est un nombre, un taux ou un montant : décider au " + "curseur — la médiane basse éclaire, le geste du garant cristallise.", + ), + ( + "M5", "protocoles", "Élection sans candidat", "high", + "protocols.election", P_ELECTION, + "Pour désigner une personne : élection sans candidat, vote blanc " + "possible, départage entre ex æquo décidé d'avance.", + ), + ( + "N1", "inertie", "Inertie basse", "max", + "protocols.clauseByInertia.low", P_INERTIA_LOW, + "Amender une clause d'inertie basse (annexes, recommandations) : " + "G = 0,1 — M = 50 %.", + ), + ( + "N2", "inertie", "Inertie standard", "max", + "protocols.clauseByInertia.standard", P_WOT, + "Amender une clause d'inertie standard (engagements fondamentaux et " + "techniques) : G = 0,2 — M = 50 %.", + ), + ( + "N3", "inertie", "Inertie haute", "max", + "protocols.clauseByInertia.high", P_INERTIA_HIGH, + "Amender une clause d'inertie haute (formule de vote) : " + "G = 0,4 — M = 60 %.", + ), + ( + "N4", "inertie", "Inertie très haute — méta-réglage", "max", + "protocols.clauseByInertia.max", P_INERTIA_MAX, + "Amender le réglage de l'inertie lui-même exige la quasi-unanimité : " + "G = 0,6 — M = 66 %. La protection des règles de modification — " + "l'héritage N1 des actes Ğ1.", + ), +] + +add_doc( + DOC_PACT, + "pacte-duniter-g1", + "Pacte de la toile Ğ1", + "pact", + "Notre contrat social — sacralisé, jamais immuable", + { + "sources": [], + "notes": ( + f"Import v1 : organisation « {_org_v1['name']} » " + f"({_org_v1['org_type']}, transparente) — " + f"« {_org_v1['description']} » Seuils importés du protocole de " + f"qualification v1 « {QUAL_V1['name']} » " + f"(small_group_max={QUAL_V1['small_group_max']}, " + f"collective_wot_min={QUAL_V1['collective_wot_min']}, " + f"modalités par défaut : {', '.join(QUAL_MODALITIES_V1)})." + ), + }, + T_GENESIS, +) + +for i, (code, section, title, inertia, key, value, content) in enumerate(PACT_CLAUSES): + add_clause( + doc_slug="pacte-duniter-g1", + doc_id=DOC_PACT, + code=code, + section=section, + position=i + 1, + title=title, + inertia=inertia, + content=content, + version_label="1.0.0", + created=T_CERT_DRAFT, + adopted=T_GENESIS, + author="Cgeek", + protocol_id=P_CONSENT, + scope_circles=[C_ROOT], + tags=["pacte-duniter-g1", section], + setting_key=key, + setting_value=value, + ) + +# ───────────────────────────────────────────────────────────── +# Décisions historiques +# ───────────────────────────────────────────────────────────── + +# ── Runtime Upgrade 1100 — adoptée, structurelle, gravée ───── + +D_RUNTIME = uid("decision:runtime-upgrade-1100") +_runtime_decided = "2026-05-20T17:00:00Z" +decisions.append( + entity( + D_RUNTIME, + "2026-05-02T09:00:00Z", + _runtime_decided, + authorId=PID["Cgeek"], + title="Runtime Upgrade 1100", + body=( + RUNTIME_META["description"] + + "\n\n" + + RUNTIME_META["context"] + + "\n\n**Processus (import v1 — titre v1 : « Runtime Upgrade », " + "type runtime_upgrade) :**\n" + + _steps_md(RUNTIME_STEPS) + ), + brief={ + "context": RUNTIME_META["context"], + "sources": [ + {"label": "Dépôt Duniter V2", "url": "https://git.duniter.org/nodes/rust/duniter-v2s"}, + ], + "effects": [ + { + "label": "Réseau stable après la mise à niveau", + "target": "zéro anomalie critique sous 7 jours", + "measured": { + "note": "Aucune anomalie critique observée sur 7 jours — métriques réseau nominales.", + "at": "2026-05-27T17:00:00Z", + "byId": PID["Moul"], + }, + }, + { + "label": "Nœuds validateurs synchronisés", + "target": "100 % des validateurs à jour sous 48 h", + "measured": { + "note": "Ensemble des validateurs en ligne à jour en 36 h.", + "at": "2026-05-22T09:00:00Z", + "byId": PID["Tuxmain"], + }, + }, + ], + }, + resources={ + "note": "Coordination des forgerons pour la synchronisation des nœuds — fenêtre de maintenance.", + "unit": "heures", + }, + tags=["runtime", "upgrade", "on-chain"], + reversibility="costly", + weight="structural", + urgent=False, + scope={"selfOnly": False, "circleIds": [C_ROOT, C_FORGE, C_TECH], "personIds": []}, + route="collective", + triageRule="R5", + routeOverridden=False, + protocolId=P_TECHCOMM, + status="adopted", + decidedAt=_runtime_decided, + review={"dueAt": "2026-08-18T17:00:00Z"}, + stewardIds=[PID["Cgeek"]], + measurerIds=[PID["Moul"], PID["Tuxmain"]], + visibility="collective", + engraving={ + "sha256": hashlib.sha256( + f"runtime-upgrade-1100|{_runtime_decided}".encode() + ).hexdigest(), + "engravedAt": _runtime_decided, + "proofLevel": "local", + }, + ) +) + +# ── Évolution Licence Ğ1 v0.4.0 — en formulation, versions proposées ── + +D_LICENCE = uid("decision:evolution-licence-0.4.0") +decisions.append( + entity( + D_LICENCE, + "2026-07-28T09:00:00Z", + "2026-08-01T09:00:00Z", + authorId=PID["Poka"], + title="Évolution Licence G1 v0.4.0", + body=( + LICENCE_META["description"] + + "\n\n**Processus (import v1) :**\n" + + _steps_md(LICENCE_STEPS) + ), + baselineNote="Aujourd'hui : Licence Ğ1 v0.3.0 en vigueur.", + brief={ + "context": LICENCE_META["context"], + "sources": [ + { + "label": "Préparation licence v0.4.0 (topic 32375)", + "url": "https://forum.monnaie-libre.fr/t/preparation-dune-proposition-devolution-de-la-licence-1/32375", + }, + { + "label": "Proposition Charte 1.0, rejetée (topic 31066)", + "url": "https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066", + }, + ], + "effects": [ + { + "label": "Une licence clarifiée, comprise par les nouveaux certificateurs", + "target": "adoption de la v0.4.0 au seuil WoT", + } + ], + }, + resources={ + "note": "Co-rédaction et animation du vote — temps bénévole des contributeurs.", + "unit": "heures", + }, + tags=["licence", "certification"], + reversibility="costly", + weight="structural", + urgent=False, + scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []}, + route="collective", + triageRule="R5", + routeOverridden=False, + protocolId=P_NUANCED, + status="framing", + windowEndsAt="2026-08-15T09:00:00Z", + stewardIds=[PID["Poka"]], + measurerIds=[], + visibility="collective", + ) +) + +# Versions proposées (liées aux clauses cert E3 et E7, contre-propositions v0.4.0) +versions.append( + entity( + uid("version:engagement-certification:E3:0.4.0-draft"), + "2026-08-05T09:00:00Z", + clauseId=uid("clause:engagement-certification:E3"), + decisionId=D_LICENCE, + versionLabel="0.4.0-draft", + content=( + "Je me suis assuré de connaître suffisamment la personne qui gère " + "cette clé publique : je sais la recontacter par au moins deux " + "canaux indépendants, je suis en mesure de repérer un double-compte, " + "et je m'engage à signaler tout doute aux experts de la communauté. " + "« Avoir vu » quelqu'un ne suffit pas." + ), + status="proposed", + ) +) +versions.append( + entity( + uid("version:engagement-certification:E7:0.4.0-draft"), + "2026-08-05T09:00:00Z", + clauseId=uid("clause:engagement-certification:E7"), + decisionId=D_LICENCE, + versionLabel="0.4.0-draft", + content=( + "J'ai rencontré la personne physiquement (préférable), **OU** j'ai " + "vérifié à distance le lien personne / clé publique par au moins " + "trois moyens de communication différents et indépendants, dont un " + "échange en visio en direct." + ), + status="proposed", + ) +) + +# ───────────────────────────────────────────────────────────── +# Mandats (3) + décisions d'origine, session de vote, votes +# ───────────────────────────────────────────────────────────── + +_m_comtech = next(m for m in MANDATES_V1 if m["mandate_type"] == "techcomm") +_m_smith = next(m for m in MANDATES_V1 if m["mandate_type"] == "smith") +_m_forum = next(m for m in MANDATES_V1 if m["mandate_type"] == "custom") + +# ── Mandat 1 : Comité Technique — actif, holder Cgeek, 1 rapport dû ── + +D_M_COMTECH = uid("decision:mandat-comite-technique") +M_COMTECH = uid("mandate:comite-technique") +decisions.append( + entity( + D_M_COMTECH, + "2025-08-20T09:00:00Z", + "2025-09-10T18:00:00Z", + authorId=PID["Elois"], + title=_m_comtech["title"], + body=( + _m_comtech["description"] + + "\n\n**Parcours (import v1 — type techcomm) :**\n" + + _steps_md(_m_comtech["steps"]) + ), + tags=["mandat", "comite-technique", "v1:techcomm"], + reversibility="costly", + weight="binding", + urgent=False, + scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []}, + route="collective", + triageRule="R5", + routeOverridden=False, + protocolId=P_NUANCED, + createsMandate={ + "title": _m_comtech["title"], + "domainCircleIds": [C_TECH], + "domainTags": ["runtime", "code", "on-chain"], + "durationDays": 365, + "reportEveryDays": 180, + }, + status="adopted", + decidedAt="2025-09-10T18:00:00Z", + stewardIds=[PID["Cgeek"]], + measurerIds=[], + visibility="collective", + ) +) +mandates = [ + entity( + M_COMTECH, + "2025-09-15T09:00:00Z", + title=_m_comtech["title"], + holderId=PID["Cgeek"], + originDecisionId=D_M_COMTECH, + domain={"circleIds": [C_TECH], "tags": ["runtime", "code", "on-chain"]}, + startsAt="2025-09-15T09:00:00Z", + endsAt="2026-09-15T09:00:00Z", + electorCircleId=C_ROOT, + nominationMethod="nuanced-vote", + reports=[{"dueAt": "2026-03-15T09:00:00Z"}], # rapport de mi-mandat dû, non remis + status="active", + ) +] + +# ── Mandat 2 : Administrateur des Forgerons — proposé, EN vote nuancé ── +# v1 : status 'voting' (rotation 2026-Q1) ; la session v2 est positionnée +# OUVERTE à la date du seed (2026-07-25 → 2026-08-24) — écart documenté. + +D_M_SMITH = uid("decision:mandat-admin-forgerons") +M_SMITH = uid("mandate:admin-forgerons") +S_SMITH = uid("session:mandat-admin-forgerons") +decisions.append( + entity( + D_M_SMITH, + "2026-07-10T09:00:00Z", + "2026-07-25T09:00:00Z", + authorId=PID["Inso"], + title=_m_smith["title"], + body=( + _m_smith["description"] + + "\n\n**Parcours (import v1 — type smith, statut v1 : voting) :**\n" + + _steps_md(_m_smith["steps"]) + ), + tags=["mandat", "forgerons", "v1:smith"], + reversibility="costly", + weight="binding", + urgent=False, + scope={"selfOnly": False, "circleIds": [C_ROOT, C_FORGE], "personIds": []}, + route="collective", + triageRule="R5", + routeOverridden=False, + protocolId=P_NUANCED, + createsMandate={ + "title": "Administrateur des Forgerons", + "domainCircleIds": [C_FORGE], + "domainTags": ["forge", "noeuds"], + "durationDays": 180, + }, + status="voting", + windowEndsAt="2026-08-24T09:00:00Z", + stewardIds=[PID["Inso"]], + measurerIds=[], + visibility="collective", + ) +) +mandates.append( + entity( + M_SMITH, + "2026-07-10T09:00:00Z", + title=_m_smith["title"], + holderId=PID["Moul"], # v1 : mandatee = Moul (Forgeron senior) + originDecisionId=D_M_SMITH, + domain={"circleIds": [C_FORGE], "tags": ["forge", "noeuds"]}, + startsAt="2026-09-01T09:00:00Z", + endsAt="2027-03-01T09:00:00Z", + electorCircleId=C_FORGE, + nominationMethod="nuanced-vote", + reports=[], + status="proposed", + ) +) +sessions = [ + entity( + S_SMITH, + "2026-07-25T09:00:00Z", + decisionId=D_M_SMITH, + protocolId=P_NUANCED, + corpusPersonIds=[PID[n] for n in VOTER_NAMES], + corpusSize=len(VOTER_NAMES), + opensAt="2026-07-25T09:00:00Z", + closesAt="2026-08-24T09:00:00Z", + status="open", + ) +] +votes = [ + entity( + uid("vote:mandat-admin-forgerons:tuxmain"), + "2026-07-26T14:00:00Z", + sessionId=S_SMITH, + voterId=PID["Tuxmain"], + value=5, + comment="Rôle nécessaire — l'embarquement des nouveaux forgerons traîne depuis des mois.", + ), + entity( + uid("vote:mandat-admin-forgerons:hugo"), + "2026-07-27T10:30:00Z", + sessionId=S_SMITH, + voterId=PID["Hugo"], + value=4, + ), + entity( + uid("vote:mandat-admin-forgerons:matograine"), + "2026-07-29T19:45:00Z", + sessionId=S_SMITH, + voterId=PID["Matograine"], + value=2, + comment="Réservé : je préfère une rotation plus courte, renouvelée à chaque trimestre.", + ), +] + +# ── Mandat 3 : Modération Forum — actif, tags forum/moderation ── +# v1 : status 'draft' — la spec Seeds v2 le pose actif (écart documenté). + +D_M_FORUM = uid("decision:mandat-moderation-forum") +M_FORUM = uid("mandate:moderation-forum") +decisions.append( + entity( + D_M_FORUM, + "2026-04-10T09:00:00Z", + "2026-04-25T18:00:00Z", + authorId=PID["Hugo"], + title=_m_forum["title"], + body=( + _m_forum["description"] + + "\n\n**Parcours (import v1 — type custom, statut v1 : draft) :**\n" + + _steps_md(_m_forum["steps"]) + ), + tags=["mandat", "forum", "moderation", "v1:custom"], + reversibility="easy", + weight="binding", + urgent=False, + scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []}, + route="collective", + triageRule="R5", + routeOverridden=False, + protocolId=P_ELECTION, + createsMandate={ + "title": _m_forum["title"], + "domainCircleIds": [C_ROOT], + "domainTags": ["forum", "moderation"], + "durationDays": 365, + "reportEveryDays": 180, + }, + status="adopted", + decidedAt="2026-04-25T18:00:00Z", + stewardIds=[PID["Tortue"]], + measurerIds=[], + visibility="collective", + ) +) +mandates.append( + entity( + M_FORUM, + "2026-05-01T09:00:00Z", + title=_m_forum["title"], + holderId=PID["Tortue"], + originDecisionId=D_M_FORUM, + domain={"circleIds": [C_ROOT], "tags": ["forum", "moderation"]}, + startsAt="2026-05-01T09:00:00Z", + endsAt="2027-05-01T09:00:00Z", + electorCircleId=C_ROOT, + nominationMethod="election-no-candidate", + reports=[{"dueAt": "2026-11-01T09:00:00Z"}], + status="active", + ) +) + +# ───────────────────────────────────────────────────────────── +# Collectif + Bundle +# ───────────────────────────────────────────────────────────── + +collective = { + "id": COLLECTIVE_ID, + "slug": "duniter-g1", + "name": _org_v1["name"], + "color": _org_v1["color"], # '#22c55e' (v1 == spec) + "icon": "i-lucide-network", # spec Seeds v2 (v1 : i-lucide-globe, noté) + "template": "free-currency", + "isTransparent": _org_v1["is_transparent"], + "pactDocId": DOC_PACT, + "rootCircleId": C_ROOT, + "createdAt": T_GENESIS, + "updatedAt": EXPORTED_AT, +} + +bundle = { + "schemaVersion": 2, + "exportedAt": EXPORTED_AT, + "collective": collective, + "people": people, + "circles": circles, + "decisions": decisions, + "concerns": [], + "objections": [], + "advices": [], + "assents": [], + "mandates": mandates, + "docs": docs, + "clauses": clauses, + "versions": versions, + "protocols": protocols, + "sessions": sessions, + "votes": votes, +} + +# ───────────────────────────────────────────────────────────── +# Validation interne (invariants) puis écriture +# ───────────────────────────────────────────────────────────── + + +def _validate() -> None: + ids: set[str] = set() + for key in ( + "people", "circles", "decisions", "mandates", "docs", + "clauses", "versions", "protocols", "sessions", "votes", + ): + for item in bundle[key]: + assert item["id"] not in ids, f"id dupliqué : {item['id']} ({key})" + ids.add(item["id"]) + assert item["collectiveId"] == COLLECTIVE_ID, f"collectiveId manquant ({key})" + assert item["createdAt"] and item["updatedAt"], f"dates manquantes ({key})" + + decision_ids = {d["id"] for d in decisions} + version_ids = {v["id"] for v in versions} + protocol_ids = {p["id"] for p in protocols} + person_ids = {p["id"] for p in people} + circle_ids = {c["id"] for c in circles} + doc_ids = {d["id"] for d in docs} + + for clause in clauses: + assert clause["docId"] in doc_ids + assert clause["currentVersionId"] in version_ids, f"version manquante : {clause['code']}" + for version in versions: + assert version["decisionId"] in decision_ids, f"décision manquante : {version['id']}" + for decision in decisions: + assert decision["authorId"] in person_ids + if decision.get("protocolId"): + assert decision["protocolId"] in protocol_ids + for cid in decision["scope"]["circleIds"]: + assert cid in circle_ids + for mandate in mandates: + assert mandate["holderId"] in person_ids + assert mandate["originDecisionId"] in decision_ids + assert mandate["electorCircleId"] in circle_ids + for session_ in sessions: + assert session_["decisionId"] in decision_ids + assert session_["protocolId"] in protocol_ids + for vote in votes: + assert vote["voterId"] in person_ids + assert vote["sessionId"] in {s["id"] for s in sessions} + + # Décision fondatrice pour CHAQUE clause courante + by_id_version = {v["id"]: v for v in versions} + by_id_decision = {d["id"]: d for d in decisions} + for clause in clauses: + version = by_id_version[clause["currentVersionId"]] + founding = by_id_decision[version["decisionId"]] + assert founding["status"] == "adopted", f"fondatrice non adoptée : {clause['code']}" + assert founding["title"].startswith("Adoption fondatrice — ") + + # Invariant consent (importBundle le valide aussi) + consent = [p for p in protocols if p["method"] == "consent"] + assert len(consent) == 1 + + # Comptages de non-perte + cert = [c for c in clauses if c["docId"] == DOC_CERT] + forge = [c for c in clauses if c["docId"] == DOC_FORGE] + assert len(cert) == 33 and len(forge) == 59 + assert len(people) == 11 + assert sum(1 for p in people if p["wotStatus"] == "smith") == 5 + assert len(mandates) == 3 + assert len(protocols) >= 7 + + +def _dist(doc_id: str) -> dict: + out: dict[str, int] = {} + for clause in clauses: + if clause["docId"] == doc_id: + out[clause["inertia"]] = out.get(clause["inertia"], 0) + 1 + return dict(sorted(out.items())) + + +def main() -> None: + _validate() + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(bundle, ensure_ascii=False, indent=2) + "\n" + OUT_PATH.write_text(payload, encoding="utf-8") + + print("=" * 64) + print("Export seed v1 → Bundle v2 : duniter-g1.bundle.json") + print("=" * 64) + print(f"Sortie : {OUT_PATH}") + print(f"Taille : {len(payload.encode('utf-8'))} octets") + print() + print("Diff de comptage seed.py (extrait) → bundle (émis) :") + print(f" items Certification : {len(CERT_ITEMS):>3} → {len([c for c in clauses if c['docId'] == DOC_CERT]):>3} clauses {_dist(DOC_CERT)}") + print(f" items Forgeron : {len(FORGE_ITEMS):>3} → {len([c for c in clauses if c['docId'] == DOC_FORGE]):>3} clauses {_dist(DOC_FORGE)}") + print(f" items Comité Tech : 0 → {len([c for c in clauses if c['docId'] == DOC_TECH]):>3} clauses (research_duniter_forum.md §3)") + print(f" clauses Pacte : 0 → {len([c for c in clauses if c['docId'] == DOC_PACT]):>3} (settingKeys v2 ; seuils v1 qualification)") + print(f" VOTER_NAMES : {len(VOTER_NAMES):>3} → {len(people):>3} personnes ({sum(1 for p in people if p['wotStatus'] == 'smith')} smiths)") + print(f" mandates_data : {len(MANDATES_V1):>3} → {len(mandates):>3} mandats") + print(f" VotingProtocols : {len(PROTOCOLS_V1):>3} → {len(protocols):>3} protocoles (7 FormulaConfigs portées)") + print(f" décisions : 2 → {len(decisions):>3} (dont {len([d for d in decisions if d['title'].startswith('Adoption fondatrice')])} fondatrices)") + print(f" sessions / votes : 3 → {len(sessions):>3} / {len(votes):>3} (3 sessions de démo v1 NON portées — votes factices)") + print(f" versions : — → {len(versions):>3} ({len([v for v in versions if v['status'] == 'proposed'])} proposées, licence v0.4.0)") + print() + print("voteRecord forgeron :", json.dumps( + next(d for d in docs if d["id"] == DOC_FORGE)["provenance"]["voteRecord"]["result"], + ensure_ascii=False, + )) + + +if __name__ == "__main__": + main() diff --git a/frontend/app/assets/css/moods.css b/frontend/app/assets/css/moods.css index ebb7df8..2504dec 100644 --- a/frontend/app/assets/css/moods.css +++ b/frontend/app/assets/css/moods.css @@ -1,158 +1,196 @@ /* ========================================================================== - libreDecision — Mood / Ambiance System - Palettes harmoniques variees, colores en lite, lumineux en dark. + libreDecision v2 — Ambiances (champ lexical du puits 井) + Source (light, eau claire) · Margelle (light, pierre chaude) + Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne) + Borderless : profondeur par background + box-shadow, jamais de bordures. ========================================================================== */ /* -------------------------------------------------------------------------- - Peps — Chaud, colore, tonique (Light) - Palette: corail vif / ocre / indigo / vert franc + Source — l'eau vive au grand jour (Light) -------------------------------------------------------------------------- */ -.mood-peps { - --mood-bg: #faf8f5; - --mood-surface: #ffffff; - --mood-surface-hover: #fdf2ec; - --mood-text: #1e1410; - --mood-text-muted: #7a5e48; - --mood-accent: #d44a10; - --mood-accent-soft: rgba(212, 74, 16, 0.08); +.mood-source { + --mood-bg: #f0f5f6; + --mood-surface: #fcfefe; + --mood-surface-hover: #e6eff2; + --mood-text: #102126; + --mood-text-muted: #52707a; + --mood-accent: #0f7fa8; + --mood-accent-soft: rgba(15, 127, 168, 0.09); --mood-accent-text: #ffffff; - --mood-border: #e8d8c8; - --mood-secondary: #4a5ec0; - --mood-tertiary: #c07820; - --mood-success: #1a8c3e; - --mood-warning: #c47d0a; - --mood-error: #c23028; - --mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%); - --mood-shadow: rgba(180, 80, 20, 0.07); - --mood-input-bg: #ffffff; - --mood-input-border: #d4c0aa; - --mood-input-focus: #d44a10; + --mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */ + --mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */ + --mood-success: #1d8a4a; + --mood-warning: #b07c14; + --mood-error: #bc3d33; + --mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%); + --mood-shadow: rgba(20, 90, 120, 0.08); + --mood-input-bg: #fcfefe; + --mood-input-border: #b8ccd2; + --mood-input-focus: #0f7fa8; - --mood-status-prepa: #b8600e; - --mood-status-prepa-bg: rgba(184, 96, 14, 0.12); - --mood-status-vote: #4a5ec0; - --mood-status-vote-bg: rgba(74, 94, 192, 0.10); - --mood-status-vigueur: #1a8c3e; - --mood-status-vigueur-bg: rgba(26, 140, 62, 0.10); - --mood-status-clos: #6e5844; - --mood-status-clos-bg: rgba(110, 88, 68, 0.08); + --mood-status-prepa: #8a7040; + --mood-status-prepa-bg: rgba(138, 112, 64, 0.10); + --mood-status-fenetre: #b07c14; + --mood-status-fenetre-bg: rgba(176, 124, 20, 0.11); + --mood-status-vote: #2661b8; + --mood-status-vote-bg: rgba(38, 97, 184, 0.10); + --mood-status-fige: #58708c; + --mood-status-fige-bg: rgba(88, 112, 140, 0.12); + --mood-status-vigueur: #1d8a4a; + --mood-status-vigueur-bg: rgba(29, 138, 74, 0.10); + --mood-status-revoque: #a4553a; + --mood-status-revoque-bg: rgba(164, 85, 58, 0.10); + --mood-status-clos: #5f6f74; + --mood-status-clos-bg: rgba(95, 111, 116, 0.09); } /* -------------------------------------------------------------------------- - Zen — Vegetal lumineux, nature vivante (Light) - Palette: vert franc / terre sienne / bleu canard / bois chaud + Margelle — la pierre du puits, calcaire et bronze (Light) -------------------------------------------------------------------------- */ -.mood-zen { - --mood-bg: #f4f6f0; - --mood-surface: #fbfcf8; - --mood-surface-hover: #eaf0e4; - --mood-text: #1a2418; - --mood-text-muted: #5a7050; - --mood-accent: #2e8b48; - --mood-accent-soft: rgba(46, 139, 72, 0.09); +.mood-margelle { + --mood-bg: #f6f3ec; + --mood-surface: #fdfbf6; + --mood-surface-hover: #efe9dc; + --mood-text: #221c10; + --mood-text-muted: #77684c; + --mood-accent: #96682a; + --mood-accent-soft: rgba(150, 104, 42, 0.10); --mood-accent-text: #ffffff; - --mood-border: #b8d0a8; - --mood-secondary: #8a6030; - --mood-tertiary: #2a7888; - --mood-success: #268040; - --mood-warning: #a87818; - --mood-error: #b83838; - --mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%); - --mood-shadow: rgba(40, 100, 50, 0.07); - --mood-input-bg: #fbfcf8; - --mood-input-border: #a8c898; - --mood-input-focus: #2e8b48; + --mood-secondary: #285e88; /* le ciel dans l'eau du seau */ + --mood-tertiary: #4f7a3c; /* mousse entre les pierres */ + --mood-success: #3d7e34; + --mood-warning: #a8720e; + --mood-error: #b23f30; + --mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%); + --mood-shadow: rgba(120, 90, 40, 0.09); + --mood-input-bg: #fdfbf6; + --mood-input-border: #cfc2a6; + --mood-input-focus: #96682a; - --mood-status-prepa: #a87818; - --mood-status-prepa-bg: rgba(168, 120, 24, 0.11); - --mood-status-vote: #2a7888; - --mood-status-vote-bg: rgba(42, 120, 136, 0.10); - --mood-status-vigueur: #268040; - --mood-status-vigueur-bg: rgba(38, 128, 64, 0.10); - --mood-status-clos: #607858; - --mood-status-clos-bg: rgba(96, 120, 88, 0.08); + --mood-status-prepa: #7c6a48; + --mood-status-prepa-bg: rgba(124, 106, 72, 0.10); + --mood-status-fenetre: #a8720e; + --mood-status-fenetre-bg: rgba(168, 114, 14, 0.11); + --mood-status-vote: #285e88; + --mood-status-vote-bg: rgba(40, 94, 136, 0.10); + --mood-status-fige: #6e6a58; + --mood-status-fige-bg: rgba(110, 106, 88, 0.12); + --mood-status-vigueur: #3d7e34; + --mood-status-vigueur-bg: rgba(61, 126, 52, 0.10); + --mood-status-revoque: #9c5236; + --mood-status-revoque-bg: rgba(156, 82, 54, 0.10); + --mood-status-clos: #6f6a5c; + --mood-status-clos-bg: rgba(111, 106, 92, 0.09); } /* -------------------------------------------------------------------------- - Chagrine — Nuit profonde, reflets varies (Dark) - Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau + Nappe — l'eau profonde sous la ville (Dark) -------------------------------------------------------------------------- */ -.mood-chagrine { - --mood-bg: #0e1018; - --mood-surface: #151a28; - --mood-surface-hover: #1c2640; - --mood-text: #dce0ec; - --mood-text-muted: #8898b8; - --mood-accent: #6488d8; - --mood-accent-soft: rgba(100, 136, 216, 0.12); - --mood-accent-text: #ffffff; - --mood-border: #222e48; - --mood-secondary: #d0a040; - --mood-tertiary: #c87090; - --mood-success: #48c87a; - --mood-warning: #d8a838; - --mood-error: #d86060; - --mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%); - --mood-shadow: rgba(60, 100, 180, 0.12); - --mood-input-bg: #151a28; - --mood-input-border: #2a3858; - --mood-input-focus: #6488d8; +.mood-nappe { + --mood-bg: #0a141b; + --mood-surface: #101f2a; + --mood-surface-hover: #16293a; + --mood-text: #d6e3ea; + --mood-text-muted: #7e98a8; + --mood-accent: #3fa9cc; + --mood-accent-soft: rgba(63, 169, 204, 0.13); + --mood-accent-text: #06141c; + --mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */ + --mood-tertiary: #52b894; + --mood-success: #46bf7c; + --mood-warning: #d2a13c; + --mood-error: #d86458; + --mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%); + --mood-shadow: rgba(30, 120, 160, 0.14); + --mood-input-bg: #101f2a; + --mood-input-border: #24425a; + --mood-input-focus: #3fa9cc; - --mood-status-prepa: #d8a838; - --mood-status-prepa-bg: rgba(216, 168, 56, 0.14); - --mood-status-vote: #6488d8; - --mood-status-vote-bg: rgba(100, 136, 216, 0.14); - --mood-status-vigueur: #48c87a; - --mood-status-vigueur-bg: rgba(72, 200, 122, 0.14); - --mood-status-clos: #7888a8; - --mood-status-clos-bg: rgba(120, 136, 168, 0.10); + --mood-status-prepa: #a5987c; + --mood-status-prepa-bg: rgba(165, 152, 124, 0.12); + --mood-status-fenetre: #d2a13c; + --mood-status-fenetre-bg: rgba(210, 161, 60, 0.14); + --mood-status-vote: #5f97e0; + --mood-status-vote-bg: rgba(95, 151, 224, 0.14); + --mood-status-fige: #7d97b5; + --mood-status-fige-bg: rgba(125, 151, 181, 0.14); + --mood-status-vigueur: #46bf7c; + --mood-status-vigueur-bg: rgba(70, 191, 124, 0.13); + --mood-status-revoque: #cd7a56; + --mood-status-revoque-bg: rgba(205, 122, 86, 0.13); + --mood-status-clos: #7d8fa0; + --mood-status-clos-bg: rgba(125, 143, 160, 0.11); } /* -------------------------------------------------------------------------- - Grave — Ambre mineral, ocre lumineux (Dark) - Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse + Minuit — l'encre, et la lanterne posée sur la margelle (Dark) -------------------------------------------------------------------------- */ -.mood-grave { - --mood-bg: #131210; - --mood-surface: #1c1a16; - --mood-surface-hover: #28241c; - --mood-text: #ece4d4; - --mood-text-muted: #a89870; - --mood-accent: #d8a030; - --mood-accent-soft: rgba(216, 160, 48, 0.11); - --mood-accent-text: #131210; - --mood-border: #342e24; - --mood-secondary: #6898b8; - --mood-tertiary: #c07040; - --mood-success: #58c870; - --mood-warning: #d0a030; - --mood-error: #d05050; - --mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%); - --mood-shadow: rgba(180, 130, 40, 0.10); - --mood-input-bg: #1c1a16; - --mood-input-border: #3c3428; - --mood-input-focus: #d8a030; +.mood-minuit { + --mood-bg: #101014; + --mood-surface: #17171e; + --mood-surface-hover: #202029; + --mood-text: #e2e2e8; + --mood-text-muted: #8e8e9e; + --mood-accent: #cf9c3e; + --mood-accent-soft: rgba(207, 156, 62, 0.12); + --mood-accent-text: #131309; + --mood-secondary: #6c94d8; + --mood-tertiary: #58ae8e; + --mood-success: #52bd7e; + --mood-warning: #cfa03a; + --mood-error: #d25f55; + --mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%); + --mood-shadow: rgba(190, 140, 50, 0.11); + --mood-input-bg: #17171e; + --mood-input-border: #33333f; + --mood-input-focus: #cf9c3e; - --mood-status-prepa: #c07040; - --mood-status-prepa-bg: rgba(192, 112, 64, 0.15); - --mood-status-vote: #6898b8; - --mood-status-vote-bg: rgba(104, 152, 184, 0.14); - --mood-status-vigueur: #58c870; - --mood-status-vigueur-bg: rgba(88, 200, 112, 0.14); - --mood-status-clos: #807860; - --mood-status-clos-bg: rgba(128, 120, 96, 0.12); + --mood-status-prepa: #9e9584; + --mood-status-prepa-bg: rgba(158, 149, 132, 0.12); + --mood-status-fenetre: #cfa03a; + --mood-status-fenetre-bg: rgba(207, 160, 58, 0.14); + --mood-status-vote: #6c94d8; + --mood-status-vote-bg: rgba(108, 148, 216, 0.14); + --mood-status-fige: #8b8ba0; + --mood-status-fige-bg: rgba(139, 139, 160, 0.14); + --mood-status-vigueur: #52bd7e; + --mood-status-vigueur-bg: rgba(82, 189, 126, 0.13); + --mood-status-revoque: #c97a5c; + --mood-status-revoque-bg: rgba(201, 122, 92, 0.13); + --mood-status-clos: #85859a; + --mood-status-clos-bg: rgba(133, 133, 154, 0.11); } /* ========================================================================== - Global design tokens — Plus Jakarta Sans, rounded, borderless + Tokens dérivés — routes, tailles, ombres (identiques dans toutes les ambiances) ========================================================================== */ +.mood-source, .mood-margelle, .mood-nappe, .mood-minuit { + /* Routes du chemin — vocabulaire visuel fixe */ + --route-solo: var(--mood-accent); + --route-mandate: var(--mood-secondary); + --route-transmit: var(--mood-text-muted); + --route-advice: var(--mood-tertiary); + --route-collective: var(--mood-status-vote); + --route-record: var(--mood-status-prepa); + --route-urgent: var(--mood-error); -*, -*::before, -*::after { - border-color: transparent; + /* Rayons (consignes) */ + --r-card: 16px; + --r-input: 12px; + --r-pill: 20px; + --r-icon: 14px; + + /* Ombres borderless — deux couches, jamais de bordure */ + --shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow); + --shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow); + --shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow); } +/* ========================================================================== + Socle global — Plus Jakarta Sans, borderless, gestes + ========================================================================== */ +*, *::before, *::after { border-color: transparent; } + html { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; font-size: 16px; @@ -160,89 +198,154 @@ html { body { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; + background: var(--mood-bg); + color: var(--mood-text); transition: background-color 0.3s ease, color 0.3s ease; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -/* --- No borders anywhere --- */ button, input, select, textarea { border: none !important; outline: none; font-family: inherit; + color: inherit; } -input:focus, select:focus, textarea:focus { +input, select, textarea { + background: var(--mood-input-bg); + border-radius: var(--r-input); +} + +input:focus-visible, select:focus-visible, textarea:focus-visible, +button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: none; - box-shadow: 0 0 0 2.5px var(--mood-accent-soft); + box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent); } -/* --- Status pills --- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* --- Cartes --- */ +.ld-card { + background: var(--mood-surface); + border-radius: var(--r-card); + box-shadow: var(--shadow-card); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.ld-card--hover:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-card-hover); +} +.ld-card--hover:active { transform: translateY(0); } + +/* --- Boutons pill --- */ +.ld-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 2.25rem; + padding: 0.5rem 1.25rem; + border-radius: var(--r-pill); + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + background: var(--mood-accent); + color: var(--mood-accent-text); + transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease; + user-select: none; +} +.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); } +.ld-btn:active { transform: translateY(0); } +.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; } +.ld-btn--ghost { + background: var(--mood-accent-soft); + color: var(--mood-accent); +} +.ld-btn--quiet { + background: none; + color: var(--mood-text-muted); +} +.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; } + +/* --- Pills d'état (mapping unique état→couleur) --- */ .status-pill { display: inline-flex; align-items: center; - padding: 5px 14px; - border-radius: 20px; + gap: 0.3rem; + padding: 4px 13px; + border-radius: var(--r-pill); font-size: 0.8125rem; font-weight: 700; letter-spacing: 0.01em; - cursor: pointer; - transition: all 0.15s ease; user-select: none; - border: none !important; + transition: all 0.15s ease; } -.status-pill:hover { - filter: brightness(1.08); - transform: translateY(-1px); -} -.status-pill.active { - box-shadow: 0 0 0 2.5px var(--mood-accent); +.status-pill.is-clickable { cursor: pointer; } +.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); } +.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); } + +.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); } +.status-advice, +.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); } +.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); } +.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); } +.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); } +.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); } +.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; } +.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); } +.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); } +.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); } + +/* Suspension (frontière contestée) : fond strié */ +.status-suspended { + background: repeating-linear-gradient( + -45deg, + var(--mood-status-fenetre-bg), + var(--mood-status-fenetre-bg) 6px, + transparent 6px, + transparent 10px + ); + color: var(--mood-status-fenetre); } -.status-prepa { - background: var(--mood-status-prepa-bg); - color: var(--mood-status-prepa); -} -.status-vote { - background: var(--mood-status-vote-bg); - color: var(--mood-status-vote); -} -.status-vigueur { - background: var(--mood-status-vigueur-bg); +/* --- Le tampon 井 (signature) --- */ +.ld-stamp { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + border-radius: 10px; + transform: rotate(-10deg); + font-weight: 800; + font-size: 0.8125rem; + letter-spacing: 0.04em; color: var(--mood-status-vigueur); -} -.status-clos { - background: var(--mood-status-clos-bg); - color: var(--mood-status-clos); + box-shadow: inset 0 0 0 2px var(--mood-status-vigueur); + background: var(--mood-status-vigueur-bg); + user-select: none; } -/* ========================================================================== - Nuxt UI overrides — borderless everything - ========================================================================== */ - -:root .mood-peps, -:root .mood-zen, -:root .mood-chagrine, -:root .mood-grave { - --ui-border: transparent; - --ui-bg: var(--mood-input-bg); - --ui-text-highlighted: var(--mood-accent); +/* --- Sceau hexagramme #48 Tsing --- */ +.app-seal { + display: block; + width: 44px; + color: var(--mood-accent); + opacity: 0.28; + filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25)) + drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15)); } -:root button, -:root [class*="UButton"], -:root [class*="u-button"], -:root [data-variant] { - border: none !important; - font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important; -} - -:root input, -:root textarea, -:root select, -:root [class*="UInput"], -:root [class*="USelect"], -:root [class*="UTextarea"] { - border: none !important; - font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important; +/* --- Impression A4 (PV, affiches de session) --- */ +@media print { + body { background: #ffffff !important; color: #111111 !important; } + .app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; } + .ld-card { box-shadow: none !important; } + .print-only { display: block !important; } } +.print-only { display: none; } diff --git a/frontend/app/data/persistence.ts b/frontend/app/data/persistence.ts new file mode 100644 index 0000000..cdeb81c --- /dev/null +++ b/frontend/app/data/persistence.ts @@ -0,0 +1,179 @@ +// ───────────────────────────────────────────────────────────── +// libreDecision v2 — local-first persistence (IndexedDB via idb-keyval). +// One key per collective: `ld2:` holds a Bundle-shaped state. +// Seeds and user imports go through the SAME importBundle() path. +// Sync-ready invariants live in types/domain.ts — this layer stays dumb. +// ───────────────────────────────────────────────────────────── + +import { get, set, del, keys } from 'idb-keyval' +import type { Bundle, Collective, Id, ISODate } from '~/types/domain' + +const KEY_PREFIX = 'ld2:' +const ACTIVE_KEY = 'ld2-active' + +/** In-memory state of one collective — exactly a Bundle minus export metadata. */ +export type CollectiveState = Omit + +export function emptyState(collective: Collective): CollectiveState { + return { + collective, + people: [], + circles: [], + decisions: [], + concerns: [], + objections: [], + advices: [], + assents: [], + mandates: [], + docs: [], + clauses: [], + versions: [], + protocols: [], + sessions: [], + votes: [], + } +} + +// ── Load / save ────────────────────────────────────────────── + +export async function listCollectiveIds(): Promise { + const all = await keys() + return all + .filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX)) + .map(k => k.slice(KEY_PREFIX.length)) +} + +export async function loadState(collectiveId: Id): Promise { + return await get(KEY_PREFIX + collectiveId) +} + +const pendingSaves = new Map>() + +/** Debounced write (500 ms) — every mutation calls this; last write wins. */ +export function saveStateDebounced(state: CollectiveState): void { + const id = state.collective.id + const existing = pendingSaves.get(id) + if (existing) clearTimeout(existing) + pendingSaves.set( + id, + setTimeout(() => { + pendingSaves.delete(id) + void set(KEY_PREFIX + id, toRaw(state)) + }, 500), + ) +} + +export async function saveStateNow(state: CollectiveState): Promise { + const id = state.collective.id + const existing = pendingSaves.get(id) + if (existing) { + clearTimeout(existing) + pendingSaves.delete(id) + } + await set(KEY_PREFIX + id, toRaw(state)) +} + +export async function deleteCollective(collectiveId: Id): Promise { + await del(KEY_PREFIX + collectiveId) + const active = await getActiveCollectiveId() + if (active === collectiveId) await set(ACTIVE_KEY, null) +} + +export async function getActiveCollectiveId(): Promise { + return (await get(ACTIVE_KEY)) ?? null +} + +export async function setActiveCollectiveId(id: Id | null): Promise { + await set(ACTIVE_KEY, id) +} + +// ── Export / import — the single bundle path ───────────────── + +export function toBundle(state: CollectiveState, exportedAt: ISODate): Bundle { + return { schemaVersion: 2, exportedAt, ...toRaw(state) } +} + +export async function sha256Hex(text: string): Promise { + const data = new TextEncoder().encode(text) + const digest = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +export interface ImportIssue { + level: 'error' | 'warning' + message: string +} + +/** Structural validation — never throws, returns readable French issues. */ +export function validateBundle(raw: unknown): { bundle?: Bundle; issues: ImportIssue[] } { + const issues: ImportIssue[] = [] + if (typeof raw !== 'object' || raw === null) { + return { issues: [{ level: 'error', message: 'Ce fichier ne contient pas un collectif lisible.' }] } + } + const b = raw as Partial + if (b.schemaVersion !== 2) + issues.push({ level: 'error', message: 'Version de fichier inconnue (schemaVersion ≠ 2).' }) + if (!b.collective?.id || !b.collective?.slug || !b.collective?.name) + issues.push({ level: 'error', message: 'Le collectif du fichier est incomplet (id, slug ou nom manquant).' }) + for (const key of [ + 'people', 'circles', 'decisions', 'concerns', 'objections', 'advices', 'assents', + 'mandates', 'docs', 'clauses', 'versions', 'protocols', 'sessions', 'votes', + ] as const) { + if (!Array.isArray(b[key])) + issues.push({ level: 'error', message: `Collection manquante ou invalide : ${key}.` }) + } + // Invariant: every collective carries a Consent protocol (import validates it). + if (Array.isArray(b.protocols) && !b.protocols.some(p => p.method === 'consent')) + issues.push({ + level: 'warning', + message: 'Aucun protocole de consentement — les chemins collectifs retomberont sur « décider sur avis » jusqu\'à sa création.', + }) + if (issues.some(i => i.level === 'error')) return { issues } + return { bundle: b as Bundle, issues } +} + +export interface ImportResult { + state?: CollectiveState + issues: ImportIssue[] + collided: boolean +} + +/** + * Import a bundle as a new local collective. + * Foreign bundles (exportedAt present, not one of ours) get a lineage stamp — « essaimé de … ». + */ +export async function importBundle(json: string, opts?: { asSeed?: boolean }): Promise { + let raw: unknown + try { + raw = JSON.parse(json) + } catch { + return { issues: [{ level: 'error', message: 'Fichier illisible : ce n\'est pas du JSON valide.' }], collided: false } + } + const { bundle, issues } = validateBundle(raw) + if (!bundle) return { issues, collided: false } + + const existingIds = await listCollectiveIds() + const collided = existingIds.includes(bundle.collective.id) + + const { schemaVersion: _v, exportedAt, ...stateRest } = bundle + const state: CollectiveState = stateRest + + if (!opts?.asSeed) { + state.collective = { + ...state.collective, + lineage: { + sourceSlug: bundle.collective.slug, + exportedAt, + sha256: await sha256Hex(json), + }, + } + } + return { state, issues, collided } +} + +/** Deep-clone to plain JSON — strips Pinia/Vue reactivity proxies before IndexedDB. */ +function toRaw(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} diff --git a/frontend/app/data/seeds/atelier-du-canal.bundle.json b/frontend/app/data/seeds/atelier-du-canal.bundle.json new file mode 100644 index 0000000..c6de4cd --- /dev/null +++ b/frontend/app/data/seeds/atelier-du-canal.bundle.json @@ -0,0 +1,3841 @@ +{ + "schemaVersion": 2, + "exportedAt": "2026-08-11T12:00:00.000Z", + "collective": { + "id": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "slug": "atelier-du-canal", + "name": "L’Atelier du Canal", + "color": "#0e7490", + "icon": "i-lucide-hammer", + "template": "association", + "isTransparent": true, + "pactDocId": "931d78d6-6931-4570-a7ae-058a344dc105", + "rootCircleId": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z" + }, + "people": [ + { + "id": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Nadia Benkacem", + "isMe": false, + "attributes": { + "heures/mois": 12 + } + }, + { + "id": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Marcel Aubry", + "isMe": false, + "attributes": { + "heures/mois": 6 + } + }, + { + "id": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Chloé Ferrand", + "isMe": false + }, + { + "id": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Ibrahim Diallo", + "isMe": false + }, + { + "id": "62789d12-de5a-43bf-b7f4-111c642ec156", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Suzanne Kowalski", + "isMe": false + }, + { + "id": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Théo Lemoine", + "isMe": false, + "attributes": { + "heures/mois": 8 + } + }, + { + "id": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Awa N'Diaye", + "isMe": false, + "attributes": { + "heures/mois": 20 + } + }, + { + "id": "318cf576-aa00-4cba-be63-e62551b4693e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Pierre-Yves Guivarch", + "isMe": false + }, + { + "id": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Lucia Moreira", + "isMe": false + }, + { + "id": "4e1455b5-9697-48f9-b559-6edfe889b339", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Josette Renard", + "isMe": false + }, + { + "id": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Karim Haddad", + "isMe": false + }, + { + "id": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-10T10:00:00.000Z", + "updatedAt": "2026-01-10T10:00:00.000Z", + "displayName": "Élise Vandenberghe", + "isMe": false + } + ], + "circles": [ + { + "id": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T10:00:00.000Z", + "updatedAt": "2026-01-12T10:00:00.000Z", + "name": "Tous", + "purpose": "Toutes les membres de l'Atelier du Canal réunies — le cercle racine.", + "memberIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "4e1455b5-9697-48f9-b559-6edfe889b339", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "domains": [] + }, + { + "id": "9f399d53-6cca-4375-bee7-374ae9cdd53f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T10:05:00.000Z", + "updatedAt": "2026-01-12T10:05:00.000Z", + "name": "Bureau", + "purpose": "Faire tourner l'association au quotidien : finances, papiers, liens avec la mairie.", + "kind": "team", + "memberIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5" + ], + "parentCircleId": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "domains": [ + "gestion", + "finances" + ] + }, + { + "id": "db3def42-7c4c-4163-907e-3a818045db67", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T10:10:00.000Z", + "updatedAt": "2026-01-12T10:10:00.000Z", + "name": "Ateliers", + "purpose": "Celles et ceux qui animent les ateliers partagés : bois, textile, vélo.", + "kind": "theme", + "memberIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "parentCircleId": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "domains": [ + "ateliers", + "bois", + "textile", + "velo" + ] + }, + { + "id": "18a487ab-c0ec-43a4-bc15-e4861b949878", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T10:15:00.000Z", + "updatedAt": "2026-01-12T10:15:00.000Z", + "name": "Le local du quai", + "purpose": "Les habitué·es du local au 12 quai des Chalands — clés, rangement, voisinage.", + "kind": "place", + "memberIds": [ + "4e1455b5-9697-48f9-b559-6edfe889b339", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + ], + "parentCircleId": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "domains": [ + "local", + "quai" + ] + } + ], + "decisions": [ + { + "id": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T19:00:00.000Z", + "updatedAt": "2026-04-16T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a" + ], + "measurerIds": [ + "343a60a7-add5-46b1-b6d6-9093ed2ad559" + ], + "visibility": "collective", + "authorId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "title": "Adopter notre Pacte", + "body": "Deux soirées d’assemblée au local pour poser notre contrat social : trois boussoles, une finalité, des seuils courts et des protocoles sans camps.", + "brief": { + "context": "L’association se crée : il nous faut un cadre commun avant la première saison d’ateliers.", + "effects": [ + { + "label": "Se doter d’un cadre commun pour décider sans chef et sans vote-couperet", + "target": "12 membres signataires au 1er février" + } + ] + }, + "resources": { + "note": "Deux soirées d’assemblée et l’énergie de commencer" + }, + "tags": [ + "pacte", + "fondation" + ], + "reversibility": "costly", + "weight": "structural", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "adopted", + "decidedAt": "2026-01-15T18:00:00.000Z", + "review": { + "dueAt": "2026-04-15T18:00:00.000Z", + "verdict": "confirmed", + "note": "Le Pacte a tenu ses trois premiers mois — personne n’a demandé à le rouvrir.", + "decidedAt": "2026-04-16T09:00:00.000Z" + }, + "engraving": { + "sha256": "5f9c6ea9fb7eea6651b99ac38fe1f955478ee52e0768ee67b1135e3f2ee2ab7a", + "engravedAt": "2026-01-15T18:00:00.000Z", + "proofLevel": "local" + } + }, + { + "id": "2822b935-48db-483d-851b-eb38b7d8659d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-19T19:00:00.000Z", + "updatedAt": "2026-04-22T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "343a60a7-add5-46b1-b6d6-9093ed2ad559" + ], + "measurerIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156" + ], + "visibility": "collective", + "authorId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "title": "Adopter le Règlement intérieur", + "body": "Huit clauses de vie commune : le local, le matériel, la sécurité, l’adhésion.", + "brief": { + "context": "Le Pacte pose le cadre ; il reste à régler la vie quotidienne du local.", + "effects": [ + { + "label": "Des règles de vie claires pour le local et les ateliers", + "target": "8 clauses adoptées avant l’ouverture de février" + } + ] + }, + "resources": { + "note": "Une soirée de rédaction collective au local" + }, + "tags": [ + "reglement", + "fondation" + ], + "reversibility": "easy", + "weight": "structural", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "adopted", + "decidedAt": "2026-01-22T18:30:00.000Z", + "review": { + "dueAt": "2026-04-22T18:00:00.000Z", + "verdict": "confirmed", + "note": "Le cahier des prêts et le rangement tiennent — seule la clause d’accueil demande révision.", + "decidedAt": "2026-04-22T09:00:00.000Z" + } + }, + { + "id": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-14T19:00:00.000Z", + "updatedAt": "2026-01-29T19:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "title": "Confier la trésorerie pour un an", + "body": "Un compte à ouvrir, des cotisations à suivre, des rapports trimestriels : la trésorerie mérite un mandat borné et rendu.", + "brief": { + "effects": [ + { + "label": "Des comptes tenus et rendus tous les trimestres", + "target": "4 rapports sur l’année" + } + ] + }, + "resources": { + "note": "Quelques heures par mois et la signature du compte associatif" + }, + "tags": [ + "finances", + "mandat" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R6", + "protocolId": "d9af660a-5fea-4984-9ada-a3df18220783", + "createsMandate": { + "title": "Trésorerie", + "domainCircleIds": [ + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "domainTags": [ + "finances", + "tresorerie" + ], + "durationDays": 365, + "reportEveryDays": 90 + }, + "status": "adopted", + "decidedAt": "2026-01-29T19:00:00.000Z" + }, + { + "id": "74875264-2a7e-4bdc-a543-902d56921cb1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-20T09:00:00.000Z", + "updatedAt": "2026-07-21T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "title": "Renouveler l’assurance du local", + "body": "Même contrat que l’an dernier, prime quasi inchangée — je renouvelle avant l’échéance du 31 juillet.", + "resources": { + "note": "Prime annuelle de l’assurance du local", + "amount": 312, + "unit": "€" + }, + "tags": [ + "finances", + "local", + "contrats" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "personIds": [] + }, + "route": "mandate", + "triageRule": "R0a", + "underMandateId": "a76dfe11-db3e-4356-b540-87109ee02199", + "status": "adopted", + "windowEndsAt": "2026-07-21T09:00:00.000Z", + "decidedAt": "2026-07-21T09:00:00.000Z" + }, + { + "id": "1e46a8f1-89f6-4018-b35c-b9c9e3f28551", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "title": "Ouvrir un livret d’épargne associatif", + "body": "La caisse dort sur le compte courant ; un livret associatif rapporterait de quoi payer l’assurance.", + "resources": { + "note": "Déplacer 1 500 € de la caisse vers un livret rémunéré", + "amount": 1500, + "unit": "€" + }, + "tags": [ + "finances", + "epargne" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "personIds": [] + }, + "route": "mandate", + "triageRule": "R0a", + "underMandateId": "a76dfe11-db3e-4356-b540-87109ee02199", + "status": "objection", + "windowEndsAt": "2026-08-12T10:00:00.000Z" + }, + { + "id": "3061eba5-2c2b-4117-bedd-84a3be46d00d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T17:45:00.000Z", + "updatedAt": "2026-08-10T17:45:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "title": "Organiser la fête des voisins de septembre", + "tags": [ + "fete", + "quartier" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R4", + "status": "draft" + }, + { + "id": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "title": "Repeindre la façade du local", + "body": "Un week-end de chantier participatif, peinture microporeuse, couleur à choisir avec les habitué·es.", + "baselineNote": "Aujourd’hui : la façade s’écaille côté quai, dernière peinture en 2019.", + "resources": { + "note": "Peinture et petit matériel, un week-end de bénévolat", + "amount": 140, + "unit": "€" + }, + "tags": [ + "local", + "travaux" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "advice", + "triageRule": "R3", + "status": "advice", + "windowEndsAt": "2026-08-13T09:00:00.000Z" + }, + { + "id": "3c9829f6-196e-4029-ac60-06039ceb121f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-10T14:00:00.000Z", + "urgent": false, + "routeOverridden": true, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "title": "Poser une serrure connectée au local", + "body": "Fini les doubles de clés qui circulent : un digicode avec codes nominatifs, réversible si ça ne convient pas.", + "resources": { + "note": "Serrure connectée et pose", + "amount": 180, + "unit": "€" + }, + "tags": [ + "local", + "cles" + ], + "reversibility": "costly", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "solo", + "triageRule": "R2", + "overrideNote": "J’allège : je finance la pose et c’est démontable — fenêtre d’objection ouverte en contrepartie.", + "status": "objection", + "windowEndsAt": "2026-08-12T10:00:00.000Z", + "windowSuspendedAt": "2026-08-10T14:00:00.000Z" + }, + { + "id": "88cced14-15b9-4291-9534-59f6fa141b92", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T11:00:00.000Z", + "urgent": false, + "routeOverridden": true, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "title": "Remplacer le lave-linge partagé par un modèle d’occasion", + "body": "Celui du local fuit depuis juin ; le dépôt-vente du quai en propose un garanti six mois.", + "resources": { + "note": "Achat d’un lave-linge d’occasion garanti", + "amount": 220, + "unit": "€" + }, + "tags": [ + "local", + "materiel" + ], + "reversibility": "costly", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "solo", + "triageRule": "R2", + "overrideNote": "J’allège : l’achat est modeste et le vendeur reprend l’ancien — fenêtre d’objection ouverte en contrepartie.", + "status": "objection", + "windowEndsAt": "2026-08-12T10:00:00.000Z" + }, + { + "id": "6bae3695-3fc5-4f70-8fbe-3b8a8076b416", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-06T09:00:00.000Z", + "updatedAt": "2026-08-08T14:30:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "title": "Réviser l’accueil des nouveaux", + "body": "Trop de monde pousse la porte le samedi sans savoir par où commencer — deux pistes sont sur la table.", + "baselineNote": "Aujourd’hui : un accueil au fil de l’eau, sans référent·e, et un petit guide souvent épuisé.", + "brief": { + "context": "Depuis le printemps, chaque samedi amène trois ou quatre nouvelles têtes.", + "effects": [ + { + "label": "Chaque nouvelle personne repart accompagnée", + "target": "plus aucun samedi sans accueillant·e d’ici octobre" + } + ] + }, + "resources": { + "note": "Un binôme d’accueil par samedi d’ouverture" + }, + "tags": [ + "accueil", + "reglement" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "amendsClauseId": "8606f567-ebf8-4e61-a015-287c14a673ea", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "framing", + "windowEndsAt": "2026-08-20T09:00:00.000Z" + }, + { + "id": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T18:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "title": "Adhérer au réseau des ateliers partagés d’Île-de-France", + "body": "Quatre ateliers voisins mutualisent outillage, formations et bons plans fournisseurs.", + "baselineNote": "Aujourd’hui : nous fonctionnons seuls, sans échange avec les ateliers voisins.", + "brief": { + "effects": [ + { + "label": "Mutualiser l’outillage et les formations avec les ateliers voisins", + "target": "2 formations partagées avant décembre" + } + ] + }, + "resources": { + "note": "Cotisation annuelle au réseau", + "amount": 120, + "unit": "€" + }, + "tags": [ + "reseau", + "ateliers" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "status": "voting" + }, + { + "id": "c172cc05-571a-4d75-84b3-5bb602582534", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-20T18:00:00.000Z", + "updatedAt": "2026-05-07T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a" + ], + "measurerIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156" + ], + "visibility": "collective", + "authorId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "title": "Chauffer le local au poêle à granulés", + "body": "Les convecteurs datent du précédent locataire et chauffent mal ; un poêle à granulés couvrirait la grande salle.", + "baselineNote": "Aujourd’hui : trois convecteurs électriques, 620 € de facture l’hiver dernier.", + "brief": { + "context": "Le local est glacial les soirs d’atelier de novembre à mars.", + "symptomsVsCauses": "Le froid est le symptôme ; la cause est un chauffage inadapté au volume de la grande salle.", + "effects": [ + { + "label": "Réduire la facture de chauffage du local", + "target": "≤ 400 € pour l’hiver 2026-2027" + }, + { + "label": "Un local chauffé les soirs d’atelier" + } + ] + }, + "resources": { + "note": "Achat et pose du poêle, granulés pour l’hiver", + "amount": 2400, + "unit": "€" + }, + "tags": [ + "local", + "chauffage", + "travaux" + ], + "reversibility": "costly", + "weight": "structural", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "adopted", + "decidedAt": "2026-05-07T09:00:00.000Z", + "review": { + "dueAt": "2026-08-05T09:00:00.000Z" + } + }, + { + "id": "04ee2ece-2e75-4394-a650-3ad7bea59123", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-03-02T10:00:00.000Z", + "updatedAt": "2026-07-01T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "343a60a7-add5-46b1-b6d6-9093ed2ad559" + ], + "measurerIds": [ + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "visibility": "collective", + "authorId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "title": "Confier la communication à un prestataire", + "body": "Une lettre d’information mensuelle sous-traitée à une petite agence du quartier.", + "brief": { + "effects": [ + { + "label": "Une lettre d’information mensuelle qui sort à l’heure", + "target": "10 parutions par an", + "measured": { + "note": "Trois lettres parues en quatre mois, sans lien avec la vie du quartier.", + "at": "2026-06-20T09:00:00.000Z", + "byId": "cd7b8066-88ab-47af-9d53-acb467a5ea44" + } + } + ] + }, + "resources": { + "note": "Forfait mensuel du prestataire", + "amount": 90, + "unit": "€" + }, + "tags": [ + "communication" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R4", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "revoked", + "decidedAt": "2026-03-10T09:00:00.000Z", + "review": { + "dueAt": "2026-06-10T09:00:00.000Z", + "verdict": "revoke", + "note": "La lettre sortait, mais elle ne parlait plus de nous.", + "decidedAt": "2026-07-01T09:00:00.000Z" + } + }, + { + "id": "69bc4dc5-b866-4a47-b2f3-46fa2d484fac", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-25T10:00:00.000Z", + "updatedAt": "2026-07-01T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "781e0bb0-8006-4fe9-9501-f9c4533b216e" + ], + "measurerIds": [], + "visibility": "collective", + "authorId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "title": "Reprendre la communication en interne", + "body": "Ce qu’on en a appris : la parole du quartier ne se sous-traite pas. Élise et Karim reprennent la lettre, plus courte et plus vivante.", + "resources": { + "note": "Deux heures par mois d’Élise et de Karim" + }, + "tags": [ + "communication" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R4", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "parentDecisionId": "04ee2ece-2e75-4394-a650-3ad7bea59123", + "chainKind": "revocation", + "status": "adopted", + "decidedAt": "2026-07-01T09:00:00.000Z" + }, + { + "id": "64aea56e-93a3-43b6-8003-fe7ad9c7c86b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-02T07:40:00.000Z", + "updatedAt": "2026-08-02T07:45:00.000Z", + "urgent": true, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "title": "Couper l’eau de l’atelier bois — fuite sur le circuit", + "body": "Fuite au raccord de l’évier ce matin : vanne générale fermée en attendant le plombier.", + "tags": [ + "local", + "urgence" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "solo", + "triageRule": "R-U", + "status": "adopted", + "decidedAt": "2026-08-02T07:45:00.000Z" + }, + { + "id": "25edc8b0-36e6-444a-8982-fb426e1cf964", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-02T08:00:00.000Z", + "updatedAt": "2026-08-04T08:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "title": "Ratifier la coupure d’eau d’urgence", + "body": "Décidé en urgence le 2 août — le collectif ratifie : la vanne reste fermée jusqu’au passage du plombier.", + "tags": [ + "local", + "urgence" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R-U", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "parentDecisionId": "64aea56e-93a3-43b6-8003-fe7ad9c7c86b", + "chainKind": "ratification", + "status": "adopted", + "windowEndsAt": "2026-08-04T08:00:00.000Z", + "decidedAt": "2026-08-04T08:00:00.000Z" + }, + { + "id": "c1b50996-bdf3-4c04-8060-fef8f80f9bfa", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-08T12:30:00.000Z", + "updatedAt": "2026-08-08T12:30:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "title": "Le marché du samedi est reconduit", + "tags": [ + "marche", + "quartier" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "record", + "triageRule": "R0c", + "decidedHow": "Décidé au café du quai après le marché, à main levée — comme d’habitude.", + "status": "adopted", + "decidedAt": "2026-08-08T12:30:00.000Z" + }, + { + "id": "fd343327-1d0c-48e4-a92f-a32eeac7845c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-28T18:00:00.000Z", + "updatedAt": "2026-07-28T18:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156" + ], + "measurerIds": [], + "visibility": "collective", + "authorId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "title": "Réaménager le local du quai", + "body": "Le local déborde : électricité vieillissante, zones d’atelier qui se marchent dessus, espace commun encombré. On découpe en micro-décisions.", + "brief": { + "context": "Trois ans d’activité ont rempli le local plus vite que prévu.", + "effects": [ + { + "label": "Un local où chaque atelier a sa zone et où l’on circule", + "target": "zéro établi partagé entre deux ateliers d’ici novembre" + } + ] + }, + "resources": { + "note": "Budget travaux et week-ends de chantier", + "amount": 900, + "unit": "€" + }, + "tags": [ + "local", + "travaux", + "amenagement" + ], + "reversibility": "costly", + "weight": "structural", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "framing" + }, + { + "id": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-08-06T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "7589bff0-c5bd-4052-9144-bed92b0e7a3a" + ], + "measurerIds": [], + "visibility": "collective", + "authorId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "title": "Refaire l’électricité de l’atelier bois", + "body": "Des prises aux normes près des machines, un tableau dédié — l’électricien de la rue Neuve est disponible fin août.", + "brief": { + "effects": [ + { + "label": "Des prises aux normes près des machines", + "target": "attestation de conformité obtenue" + } + ] + }, + "resources": { + "note": "Intervention de l’électricien et fournitures", + "amount": 450, + "unit": "€" + }, + "tags": [ + "local", + "travaux", + "electricite" + ], + "reversibility": "costly", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "advice", + "triageRule": "R3", + "parentDecisionId": "fd343327-1d0c-48e4-a92f-a32eeac7845c", + "chainKind": "element", + "status": "adopted", + "windowEndsAt": "2026-07-31T09:00:00.000Z", + "decidedAt": "2026-08-06T09:00:00.000Z" + }, + { + "id": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-05T18:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "title": "Choisir l’agencement de l’espace commun", + "body": "Grande table centrale ou coin canapé près de la fenêtre : le plan est affiché au local.", + "tags": [ + "local", + "amenagement" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "18a487ab-c0ec-43a4-bc15-e4861b949878" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R4", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "parentDecisionId": "fd343327-1d0c-48e4-a92f-a32eeac7845c", + "chainKind": "element", + "status": "voting" + }, + { + "id": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-03T18:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "f790d11d-a84f-4ed1-80c9-7077b870e099" + ], + "measurerIds": [], + "visibility": "collective", + "authorId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "title": "Répartir le budget d’ateliers 2026", + "body": "Le budget annuel des ateliers se répartit au curseur : bois, textile, vélo — et une réserve calculée pour les imprévus.", + "baselineNote": "Aujourd’hui : 40 % bois, 30 % textile, 20 % vélo, 10 % de réserve.", + "brief": { + "effects": [ + { + "label": "Un budget d’ateliers réparti en connaissance de cause", + "target": "100 % ventilés avant la rentrée" + } + ] + }, + "resources": { + "note": "budget annuel ateliers", + "amount": 1200, + "unit": "€" + }, + "tags": [ + "budget", + "ateliers" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "db3def42-7c4c-4163-907e-3a818045db67", + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "paramSpec": { + "constraint": "sum100", + "impactAttrKey": "heures/mois", + "params": [ + { + "key": "bois", + "label": "Atelier bois", + "kind": "share", + "min": 10, + "max": 60, + "step": 5, + "unit": "%", + "baseline": 40 + }, + { + "key": "textile", + "label": "Atelier textile", + "kind": "share", + "min": 10, + "max": 60, + "step": 5, + "unit": "%", + "baseline": 30 + }, + { + "key": "velo", + "label": "Atelier vélo", + "kind": "share", + "min": 0, + "max": 50, + "step": 5, + "unit": "%", + "baseline": 20 + }, + { + "key": "reserve", + "label": "Réserve", + "kind": "share", + "min": 0, + "max": 30, + "step": 1, + "unit": "%", + "baseline": 10, + "derived": true + } + ] + }, + "status": "voting" + }, + { + "id": "38e46122-8147-4503-9105-f90a07055456", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-24T18:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "343a60a7-add5-46b1-b6d6-9093ed2ad559" + ], + "measurerIds": [], + "visibility": "collective", + "authorId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "title": "Fixer les tarifs d’adhésion 2027", + "body": "Un tarif plein et un tarif solidaire, réglés au curseur — les votes sont figés, la médiane attend le geste de Chloé.", + "baselineNote": "Aujourd’hui : 30 € plein tarif, 10 € solidaire.", + "brief": { + "effects": [ + { + "label": "Une adhésion accessible qui couvre les charges", + "target": "≥ 480 € de cotisations en 2027" + } + ] + }, + "resources": { + "note": "Les cotisations financent la moitié du budget annuel" + }, + "tags": [ + "budget", + "adhesion" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "paramSpec": { + "constraint": "none", + "params": [ + { + "key": "plein", + "label": "Tarif plein", + "kind": "slider", + "min": 20, + "max": 60, + "step": 5, + "unit": "€", + "baseline": 30 + }, + { + "key": "solidaire", + "label": "Tarif solidaire", + "kind": "slider", + "min": 5, + "max": 25, + "step": 1, + "unit": "€", + "baseline": 10 + } + ] + }, + "status": "voting" + }, + { + "id": "1bd63c1e-8454-4d0d-81ec-f05d15fbb31f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T11:00:00.000Z", + "updatedAt": "2026-08-09T11:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "title": "Renouveler le contrat d’électricité du local", + "body": "Le mandat Trésorerie couvre les contrats du local — je transmets à Marcel.", + "tags": [ + "local", + "contrats" + ], + "reversibility": "easy", + "weight": "light", + "scope": { + "selfOnly": false, + "circleIds": [ + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "personIds": [] + }, + "route": "transmit", + "triageRule": "R0b", + "status": "transmitted" + }, + { + "id": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-10T18:00:00.000Z", + "updatedAt": "2026-06-30T18:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [], + "measurerIds": [], + "visibility": "collective", + "authorId": "318cf576-aa00-4cba-be63-e62551b4693e", + "title": "Acheter une imprimante 3D", + "body": "Prototyper les pièces cassées plutôt que racheter — filament et formation compris.", + "brief": { + "effects": [ + { + "label": "Prototyper les pièces cassées plutôt que racheter" + } + ] + }, + "resources": { + "note": "Achat, filament et formation", + "amount": 650, + "unit": "€" + }, + "tags": [ + "materiel", + "ateliers" + ], + "reversibility": "costly", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "db3def42-7c4c-4163-907e-3a818045db67" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R5", + "protocolId": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "status": "rejected", + "decidedAt": "2026-06-30T18:00:00.000Z" + }, + { + "id": "a57378de-cefe-4361-a083-32c4aeea9863", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-03-15T10:00:00.000Z", + "updatedAt": "2026-05-12T09:00:00.000Z", + "urgent": false, + "routeOverridden": false, + "stewardIds": [ + "4e1455b5-9697-48f9-b559-6edfe889b339" + ], + "measurerIds": [ + "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + ], + "visibility": "collective", + "authorId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "title": "Organiser la braderie de printemps", + "body": "Un samedi de braderie devant le local : vide-ateliers, réparations minute, buvette à prix libre.", + "brief": { + "effects": [ + { + "label": "Financer les ateliers d’été", + "target": "≥ 300 € de recettes", + "measured": { + "note": "342 € de recettes et douze nouvelles adhésions.", + "at": "2026-05-02T09:00:00.000Z", + "byId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + } + } + ] + }, + "resources": { + "note": "Un samedi de bénévolat et les tables prêtées par la mairie" + }, + "tags": [ + "fete", + "budget", + "quartier" + ], + "reversibility": "easy", + "weight": "binding", + "scope": { + "selfOnly": false, + "circleIds": [ + "d35d01d4-4570-4e75-9c64-f00ba5118624" + ], + "personIds": [] + }, + "route": "collective", + "triageRule": "R4", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "closed", + "decidedAt": "2026-03-25T09:00:00.000Z", + "review": { + "dueAt": "2026-05-10T09:00:00.000Z", + "verdict": "confirmed", + "note": "La braderie a rempli la caisse et le trottoir — à refaire l’an prochain.", + "decidedAt": "2026-05-12T09:00:00.000Z" + } + } + ], + "concerns": [ + { + "id": "e426a925-681a-43a8-9d96-e96ccd4966ca", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "41c5fd75-0445-462e-97f6-ae3b6d5c421d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "5bf8fee5-c72e-4830-9377-9bf7dd15054b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "b5b2d066-62af-4c70-88a7-a3d5377e687c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "2a77789c-3fdc-4241-9a46-01e250f5d8b1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "5b7e6df7-bf0c-4587-b351-03867ad2ea19", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "de8c5e42-ebf9-4fea-ab40-ffe5b295c83d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "cad4a375-3493-4ff4-b3c2-3cb37ba12ac1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "af275341-aa5b-45d1-8341-da03d654af8a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "5e021df1-c0e8-46eb-b1b9-dd4e0769060d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "f8832052-e315-4a9b-af05-9223176e4ee5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "d425fa70-1d08-4f6e-8137-d3adb247c6c8", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-15T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Tous — cercle électeur du mandat", + "beforeSnapshot": true + }, + { + "id": "f5c1371a-6e0f-446f-b948-2356511ee5e0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "b7e16a51-e0f8-41b3-95f8-14bfc68749f8", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a534a140-56b7-4236-8906-cb7dd1049bd0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "0b1b5e4b-c575-4a6c-9f0d-66a181329cd7", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "00a87a19-fa11-4783-8658-ff2422acdd77", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "aeaf2d4b-ebef-40d3-9a90-f7eccca1f778", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "710908d9-4a85-48dd-ad1c-a7bdf1e33ab5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a3b0bdbc-a5ec-4a10-8ac5-e8cfbab08164", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "501a0623-baed-4b37-bc25-c7bd8cf155eb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "8939ab28-3a21-4f2a-833e-3f5c583d6b91", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "88fbaa18-b01d-4b4f-a509-d5bdec35066d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "e97af53d-1ffc-4e36-bb60-00bf6d0feb57", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a4a21a2a-fe88-4d3b-9753-47b99ebe9191", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "e14fce56-1a73-43fb-be4d-c2df3c3b7ab4", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "3245c801-92ba-4e3f-965f-4aa123b33192", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "e5e385fc-aa43-4816-8717-60770b993751", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a56cbce0-9168-487a-a8a2-bceffc394ec9", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "5daa18c1-c8f5-4964-95e2-bf90322a8bce", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "47cc8cba-9661-4709-95a8-af4011435e66", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "b49e3775-d318-49a0-928e-a47219951c6a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "cdf311e3-11b5-4db2-b635-26dff5e9996b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "e995d669-6bf2-4f17-a2f4-6150f1243a0d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "9945b15c-7e18-46f4-b991-e7ad9ea04f81", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "97642466-b734-45f4-a606-b9da09be8481", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-04-30T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "ecee9ec4-98fd-4bd1-9ae6-1b518a10757d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 3 + }, + { + "id": "cf837a65-dea1-4d9e-a6c3-2088bdc7a0a4", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 3 + }, + { + "id": "11f7e53a-fb5d-4c7f-9064-c3f154846395", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 1 + }, + { + "id": "aa57892d-a757-4c2d-a1d2-57636991ff9a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 2 + }, + { + "id": "6382fa52-7668-4816-800d-041dca8f1113", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "620c8c68-bd29-4697-a25a-383f26887ba4", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "b0ab8b2e-b267-49a2-80ee-e35e0abf2cc5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "44d4b6b3-de53-4c96-ab27-39435fb823bc", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "9b91fdf4-7687-4c66-b86a-d5edde5e5287", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "ad1b79b7-66e0-4d81-8aae-48f5aa071ad7", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "b56b0408-4f90-4895-84cd-238531a9c420", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "e88666ae-e697-4eae-b4a6-bbecda6fd288", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "3c13f70a-b391-4877-af34-590297c5fe1f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "8979c4e5-faf0-4ac6-92ee-9aa31009d429", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "f7ebb8ba-da10-494e-a83b-856e60560908", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "81df0889-01e4-41f9-bccc-165887634ee8", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "6a48b4fc-6dd3-4435-9746-b3fab8963830", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-06T12:00:00.000Z", + "updatedAt": "2026-08-06T12:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "declared", + "reason": "S’est déclarée concernée après l’arrêt de la liste", + "beforeSnapshot": false, + "declaredNote": "Le budget vélo touche aussi les sorties du samedi que j’accompagne." + }, + { + "id": "b88c9d63-e209-40cd-8c95-5329700489d7", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "380cabb4-eced-4352-a224-0a4767649acb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "da15ae05-1a69-4419-a6a8-73b1f2564cb6", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "090bf385-68fa-4237-bfb0-b9a93a7fb86d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "abc2e925-2844-4af7-ab0b-5f9e91d54e0d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "682466ae-03ff-4959-a90c-0235daf2e776", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a419d006-623b-42ff-b7d4-795283fcb771", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "ff845fbb-43fa-43e5-bd9a-d80983077a9f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "3c804960-8741-42ac-aaa3-715c54c11977", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "1ae5fd3d-1f71-44fc-baca-d4ec2c7ca09f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "a4ae055f-94f4-4c76-8722-408b8a3d6ebf", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "700064ba-f9f7-4aca-a7bf-886e4dde18e9", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-07-26T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Tous", + "beforeSnapshot": true + }, + { + "id": "95334dd5-3b8c-475d-9424-7d6532243ac5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "14588892-33b9-45e0-96ae-8be9df872a44", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "028e8fa0-1035-4d82-acdb-f7f622c349ae", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "8f42f88d-5e8e-42d1-b1aa-80003e9150a3", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "318cf576-aa00-4cba-be63-e62551b4693e", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "681b3c60-14ea-4844-b76b-cd0cb66b353e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "c77c3c80-7065-4dcd-8dd2-506f017ca29f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "f1f3ad32-f95e-438c-bf2c-c3855f544097", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-16T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "personId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "origin": "computed", + "reason": "Membre du cercle Ateliers", + "beforeSnapshot": true + }, + { + "id": "06f121a7-dfa5-4562-a272-1cff787dcaeb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "56c16805-7cb4-49b9-8cf2-4bbc1f5689e5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "6d572839-6ce1-4e1d-b0e9-7d1f1f6d885b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "1e18f870-3449-43e0-aaa5-5bcaa83e4b5f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "5979d8e2-3698-4150-83cb-190278ebdf04", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T09:00:00.000Z", + "updatedAt": "2026-08-11T09:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "653b45ac-a8eb-423b-94d5-a12f381db53a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:30:00.000Z", + "updatedAt": "2026-08-11T10:30:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "declared", + "reason": "S’est déclarée concernée", + "beforeSnapshot": true, + "declaredNote": "Je passe devant la façade tous les matins — partante pour aider au chantier." + }, + { + "id": "19b995b5-b6c6-4fd3-84ca-437bee054390", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-09T10:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "a62575e5-80ab-4d7d-9b60-c2c2100f0108", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-09T10:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "e10ef725-af68-41f0-a4f8-b480edf1a7c5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-09T10:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "0608520e-f9ec-48cb-bed1-d5d7517ca4d9", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-09T10:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "8307390f-087e-4fbe-8445-ec4f4602953d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T10:00:00.000Z", + "updatedAt": "2026-08-09T10:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "cc7203e3-2b0c-472f-8eef-ce37fcd0cb01", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "19685591-c551-4bb0-b3fb-bafab3a3144e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "85075aa2-a8fe-48aa-b057-64d6b45304da", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "aee175f9-7879-4704-bad7-f8ef8d340176", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "d9398f7e-efc3-40c1-a4ff-ddc881580421", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + }, + { + "id": "8efff575-dbfb-4bf6-a7b8-44e8531020a9", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "1e46a8f1-89f6-4018-b35c-b9c9e3f28551", + "personId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "87c48c91-7888-4bd3-8d18-86cf8448a495", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "1e46a8f1-89f6-4018-b35c-b9c9e3f28551", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "7b61037b-7f8b-47ef-a9e0-3cf9db443fd2", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "1e46a8f1-89f6-4018-b35c-b9c9e3f28551", + "personId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "aa64a7ad-123c-4478-839f-cd23cf9e87c0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "1e46a8f1-89f6-4018-b35c-b9c9e3f28551", + "personId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "origin": "computed", + "reason": "Membre du cercle Bureau", + "beforeSnapshot": true + }, + { + "id": "aa2f96f7-f039-401e-a75f-d287ec8e7fdf", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-07-29T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 1 + }, + { + "id": "6ad661f8-4501-487b-ab20-89aeb6c3192b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-07-29T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 2 + }, + { + "id": "913c5533-2e79-4279-ba28-f9c2cfee3acc", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-07-29T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 3 + }, + { + "id": "c58acec1-9f9e-4a47-add7-9dff106262a1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-07-29T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true, + "priority": 0 + }, + { + "id": "874cfea2-a603-4915-b2c8-4263a9489471", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T09:00:00.000Z", + "updatedAt": "2026-07-29T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "origin": "computed", + "reason": "Membre du cercle Le local du quai", + "beforeSnapshot": true + } + ], + "objections": [ + { + "id": "0d529a1e-1352-4036-a08d-eec479044f2c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T14:00:00.000Z", + "updatedAt": "2026-08-10T14:00:00.000Z", + "decisionId": "3c9829f6-196e-4029-ac60-06039ceb121f", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "kind": "boundary", + "argument": "Les bénévoles du marché qui ouvrent le local le samedi ne sont pas dans le périmètre — il faut les compter avant de parler du fond.", + "status": "open" + }, + { + "id": "b5e3a095-0753-475d-a109-0784c9a8956b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-05-03T10:00:00.000Z", + "updatedAt": "2026-05-05T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "kind": "content", + "argument": "Le granulé nous lie à un seul fournisseur du coin — que fait-on s’il ferme ?", + "status": "withdrawn", + "resolutionNote": "Retirée après l’ajout d’un second fournisseur au dossier." + } + ], + "advices": [ + { + "id": "0282d102-4c3e-4f6b-b5ba-4129c54aa7ce", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T10:00:00.000Z", + "updatedAt": "2026-08-11T10:00:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "position": "favorable", + "note": "Le jaune paille irait bien avec les volets — et j’ai un pistolet à peinture à prêter." + }, + { + "id": "8e920ded-3c91-4225-816b-d405d02e8285", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T11:15:00.000Z", + "updatedAt": "2026-08-11T11:15:00.000Z", + "decisionId": "06c7e749-0fe2-41bf-ab59-3c3fadc5928d", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "position": "reserved", + "note": "D’accord sur le principe, mais pas pendant les semaines de marché — l’échafaudage bloquerait le trottoir." + }, + { + "id": "159235f6-16e1-4735-be7f-9b2a1177371e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-30T09:00:00.000Z", + "updatedAt": "2026-07-30T09:00:00.000Z", + "decisionId": "00e9df0e-9975-4a4a-aa83-58fd3392c05c", + "personId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "position": "favorable", + "note": "Prends l’électricien qui a refait la boutique de la rue Neuve — sérieux et pas cher." + } + ], + "assents": [ + { + "id": "16955505-5630-4cc4-a188-1af1b394eb70", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-11T11:00:00.000Z", + "updatedAt": "2026-08-11T11:00:00.000Z", + "decisionId": "88cced14-15b9-4291-9534-59f6fa141b92", + "personId": "62789d12-de5a-43bf-b7f4-111c642ec156" + }, + { + "id": "85483c5e-0ac0-4983-aede-d3c3195a29e5", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-05-02T10:00:00.000Z", + "updatedAt": "2026-05-02T10:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099" + }, + { + "id": "3e9c97c4-892e-4caf-8eb7-7e7ce2157bb0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-05-04T16:00:00.000Z", + "updatedAt": "2026-05-04T16:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "personId": "4e1455b5-9697-48f9-b559-6edfe889b339" + }, + { + "id": "b20c904c-9c4a-4e3f-9c66-142925d95561", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T15:00:00.000Z", + "updatedAt": "2026-08-10T15:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "personId": "f790d11d-a84f-4ed1-80c9-7077b870e099" + } + ], + "mandates": [ + { + "id": "a76dfe11-db3e-4356-b540-87109ee02199", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-29T19:00:00.000Z", + "updatedAt": "2026-05-03T09:00:00.000Z", + "title": "Trésorerie", + "holderId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "originDecisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "domain": { + "circleIds": [ + "9f399d53-6cca-4375-bee7-374ae9cdd53f" + ], + "tags": [ + "finances", + "tresorerie" + ] + }, + "startsAt": "2026-02-01T00:00:00.000Z", + "endsAt": "2027-01-31T23:59:00.000Z", + "electorCircleId": "d35d01d4-4570-4e75-9c64-f00ba5118624", + "nominationMethod": "election-no-candidate", + "reports": [ + { + "dueAt": "2026-05-01T12:00:00.000Z", + "deliveredAt": "2026-05-03T09:00:00.000Z", + "content": "Trésorerie au 30 avril : 3 180 € en caisse, cotisations rentrées aux deux tiers, assurance et loyer à jour. Le poêle à granulés est provisionné." + }, + { + "dueAt": "2026-08-01T12:00:00.000Z" + } + ], + "status": "active" + } + ], + "docs": [ + { + "id": "931d78d6-6931-4570-a7ae-058a344dc105", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-12T11:00:00.000Z", + "updatedAt": "2026-01-12T11:00:00.000Z", + "slug": "pacte-atelier-du-canal", + "title": "Notre Pacte", + "role": "pact", + "description": "Notre contrat social — sacralisé, jamais immuable." + }, + { + "id": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-19T11:00:00.000Z", + "updatedAt": "2026-01-19T11:00:00.000Z", + "slug": "reglement-interieur", + "title": "Règlement intérieur", + "role": "reference", + "description": "Les règles de vie du local et des ateliers — huit clauses, amendables par décision." + } + ], + "clauses": [ + { + "id": "8d173627-9d2c-47f5-b972-9854626c416a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "Préambule", + "position": 1, + "code": "P1", + "title": "Autonomie, Équilibre, Liaison", + "inertia": "max", + "currentVersionId": "fb0b3662-73c6-4c4b-85ff-44621789c564" + }, + { + "id": "ba7b0679-61f4-404b-ade5-bc5ef73c387f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "A — Finalité", + "position": 2, + "code": "A1", + "title": "Notre finalité", + "inertia": "max", + "currentVersionId": "78b2e345-251b-4678-acba-c64b3603225f" + }, + { + "id": "9cc188ff-5006-43ac-9fc1-f232d66d4910", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 3, + "code": "B1", + "title": "Petit groupe", + "inertia": "standard", + "currentVersionId": "1001bb59-b377-4d3e-aa52-3133210ac8b1", + "settingKey": "triage.smallGroupMax" + }, + { + "id": "f26c1d66-6591-4d38-a4ad-1c0eea221a4d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 4, + "code": "B2", + "title": "Grand corps", + "inertia": "standard", + "currentVersionId": "2007ccdd-5f5b-463c-802e-cc0b34c6a7e4", + "settingKey": "triage.collectiveMin" + }, + { + "id": "20b30b90-b784-456e-a233-e3a4fa26b0a9", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 5, + "code": "B3", + "title": "Consentement direct", + "inertia": "standard", + "currentVersionId": "20e117ec-35ae-4af5-bcf1-eed3fd7f323f", + "settingKey": "triage.consentMax" + }, + { + "id": "44437252-8e01-4881-b375-1a6368a284cc", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 6, + "code": "B4", + "title": "Fenêtre d'objection", + "inertia": "standard", + "currentVersionId": "0e6703f2-e37c-44c7-b852-e48d9c25d0ca", + "settingKey": "triage.objectionWindowHours" + }, + { + "id": "1973ea50-a0d0-4610-ae74-5664f628cd67", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 7, + "code": "B5", + "title": "Fenêtre d'avis", + "inertia": "standard", + "currentVersionId": "79c079b1-c400-4eb0-a379-bee9a1645e7c", + "settingKey": "triage.adviceWindowHours" + }, + { + "id": "4d3114ef-d2ce-4dd7-b19f-ace2a39a3b60", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 8, + "code": "B6", + "title": "Temps de formulation", + "inertia": "standard", + "currentVersionId": "cf3e6996-20c0-49c9-8473-09e9bd56180e", + "settingKey": "triage.framingDays" + }, + { + "id": "356773bd-2cef-41b4-9905-b6a97f2fb197", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 9, + "code": "B7", + "title": "Affluence", + "inertia": "standard", + "currentVersionId": "8c093ca8-67cc-4d63-8814-6ff8cdb04de7", + "settingKey": "triage.concernEscalateRatio" + }, + { + "id": "c420e31e-f246-4577-b9db-3ab072d688a6", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 10, + "code": "B8", + "title": "Récurrence", + "inertia": "standard", + "currentVersionId": "d3306e55-d82a-49e2-896b-1d5bf932428c", + "settingKey": "triage.recurrenceThreshold" + }, + { + "id": "0451d643-65cf-4b0e-9c94-eca636adafa3", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 11, + "code": "B9", + "title": "L'épreuve du réel", + "inertia": "standard", + "currentVersionId": "c763c602-a22d-4fbe-8684-effff21c0161", + "settingKey": "triage.reviewDelayDays" + }, + { + "id": "1c0976d4-9c1b-4d53-8750-48d9fd175a86", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "B — Le chemin des décisions", + "position": 12, + "code": "B10", + "title": "La matière avant le vote", + "inertia": "high", + "currentVersionId": "e82588ba-8bb5-44cf-86e5-05ae2847771f", + "settingKey": "triage.requireEffects" + }, + { + "id": "8f4c903f-ca7d-4348-bef1-d7add8aa5214", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "C — Nos protocoles", + "position": 13, + "code": "C1", + "title": "Le consentement, socle commun", + "inertia": "high", + "currentVersionId": "c6deae06-a98c-4984-915c-dd582bb21e6b", + "settingKey": "protocols.consent" + }, + { + "id": "5342dea4-9789-47d2-9c25-bbafbc6679f3", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "C — Nos protocoles", + "position": 14, + "code": "C2", + "title": "Le vote nuancé", + "inertia": "high", + "currentVersionId": "f198c834-7bb8-4dc2-89fc-dca0e6283945", + "settingKey": "protocols.nuanced" + }, + { + "id": "88be8e43-3828-4919-84d4-8e1a63d9b3bc", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "C — Nos protocoles", + "position": 15, + "code": "C3", + "title": "Les grands corps décident en nuancé", + "inertia": "high", + "currentVersionId": "df82efd7-5437-4522-806b-251a2270a7db", + "settingKey": "protocols.large" + }, + { + "id": "a901241e-a583-4a3d-a04c-637497a6c7dd", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "C — Nos protocoles", + "position": 16, + "code": "C4", + "title": "Le réglage collectif", + "inertia": "high", + "currentVersionId": "b6f3df49-06a5-4087-b151-0898836c4a15", + "settingKey": "protocols.parametric" + }, + { + "id": "78327bb7-98e0-4ada-8de7-33fd6c323849", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "docId": "931d78d6-6931-4570-a7ae-058a344dc105", + "section": "C — Nos protocoles", + "position": 17, + "code": "C5", + "title": "L'élection sans candidat", + "inertia": "high", + "currentVersionId": "a5b998ac-e055-4f62-ab1b-fa7a387616e2", + "settingKey": "protocols.election" + }, + { + "id": "51ca3f57-d2fa-492c-9881-dbb78fa2360a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Vie du local", + "position": 1, + "code": "RI-1", + "title": "Ouverture du local", + "inertia": "low", + "currentVersionId": "2c1f1c62-215b-4d72-85b7-5d9df54a20c3" + }, + { + "id": "754c2984-4eca-4289-8fcd-aabb5152675b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Vie du local", + "position": 2, + "code": "RI-2", + "title": "Rangement partagé", + "inertia": "low", + "currentVersionId": "a80b60d6-d685-48a5-9cc7-286a4cb06a73" + }, + { + "id": "180b8be4-52ad-4ab9-bfa2-7dfc6ef094a1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Matériel", + "position": 3, + "code": "RI-3", + "title": "Prêt du matériel", + "inertia": "standard", + "currentVersionId": "7aa04acc-bd70-44cb-a688-9892bed5b496" + }, + { + "id": "8606f567-ebf8-4e61-a015-287c14a673ea", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Vie commune", + "position": 4, + "code": "RI-4", + "title": "Accueil des nouveaux", + "inertia": "standard", + "currentVersionId": "2e0b6dc6-eb1e-4fe0-8be3-01119049f0a6" + }, + { + "id": "1cdb3683-a76c-473b-bac2-e8a763f89b74", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Adhésion", + "position": 5, + "code": "RI-5", + "title": "Cotisation annuelle", + "inertia": "high", + "currentVersionId": "6a7b3679-dbd8-4314-8943-6095a7aaa9a3" + }, + { + "id": "d5e4b8fc-cd9d-4efd-a2ef-492752da7f97", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Sécurité", + "position": 6, + "code": "RI-6", + "title": "Usage des machines", + "inertia": "high", + "currentVersionId": "7331eb61-898f-4fc0-8f0a-d8a8922c1ca2" + }, + { + "id": "0b798f89-0b9b-4285-b3ff-e3aa4eaf9787", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Adhésion", + "position": 7, + "code": "RI-7", + "title": "Non-lucrativité", + "inertia": "max", + "currentVersionId": "f4c82cc1-898d-4178-b1e3-a51d8b8af7c4" + }, + { + "id": "2d8c6e71-61ed-49d4-99ad-0cc7d5ee9d4c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "docId": "3a7b054a-ce09-42ca-ae94-a57d99230854", + "section": "Vie commune", + "position": 8, + "code": "RI-8", + "title": "Respect et bienveillance", + "inertia": "max", + "currentVersionId": "676e1c17-b66c-401d-aa28-54d2e7a6ce69" + } + ], + "versions": [ + { + "id": "fb0b3662-73c6-4c4b-85ff-44621789c564", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "8d173627-9d2c-47f5-b972-9854626c416a", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Trois boussoles guident l'Atelier du Canal : l'autonomie de chacun·e dans ses gestes, l'équilibre entre les ateliers et les personnes, la liaison avec le quartier qui nous entoure.", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "78b2e345-251b-4678-acba-c64b3603225f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "ba7b0679-61f4-404b-ade5-bc5ef73c387f", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "L'Atelier du Canal existe pour l'entraide de quartier : des ateliers partagés — bois, textile, vélo — où l'on apprend, répare et transmet ensemble, à prix libre et à portée de tous.", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "1001bb59-b377-4d3e-aa52-3133210ac8b1", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "9cc188ff-5006-43ac-9fc1-f232d66d4910", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Jusqu'à quatre personnes concernées, on se parle et on décide ensemble, sans ouvrir de session.", + "settingValue": 4, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "2007ccdd-5f5b-463c-802e-cc0b34c6a7e4", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "f26c1d66-6591-4d38-a4ad-1c0eea221a4d", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "À partir de cinquante personnes concernées, la décision passe par le protocole des grands corps.", + "settingValue": 50, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "20e117ec-35ae-4af5-bcf1-eed3fd7f323f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "20b30b90-b784-456e-a233-e3a4fa26b0a9", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Jusqu'à sept personnes concernées, le consentement est notre chemin naturel.", + "settingValue": 7, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "0e6703f2-e37c-44c7-b852-e48d9c25d0ca", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "44437252-8e01-4881-b375-1a6368a284cc", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Une journée pour dire « ça me va » ou objecter — nos décisions ne traînent pas, elles restent révisables.", + "settingValue": 24, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "79c079b1-c400-4eb0-a379-bee9a1645e7c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "1973ea50-a0d0-4610-ae74-5664f628cd67", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Deux jours pour recueillir les avis avant de décider en écoutant.", + "settingValue": 48, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "cf3e6996-20c0-49c9-8473-09e9bd56180e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "4d3114ef-d2ce-4dd7-b19f-ace2a39a3b60", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Quatorze jours pour s'instruire et formuler des contre-propositions quand la décision structure.", + "settingValue": 14, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "8c093ca8-67cc-4d63-8814-6ff8cdb04de7", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "356773bd-2cef-41b4-9905-b6a97f2fb197", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Quand la moitié des concerné·es de second lieu se déclarent, le périmètre doit être traité : élargir, ou motiver publiquement son maintien.", + "settingValue": 0.5, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "d3306e55-d82a-49e2-896b-1d5bf932428c", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "c420e31e-f246-4577-b9db-3ab072d688a6", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Trois décisions semblables en trois mois : le Fil propose un mandat ou une règle.", + "settingValue": 3, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "c763c602-a22d-4fbe-8684-effff21c0161", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "0451d643-65cf-4b0e-9c94-eca636adafa3", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Quatre-vingt-dix jours après une décision structurante : le réel a-t-il suivi ?", + "settingValue": 90, + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "e82588ba-8bb5-44cf-86e5-05ae2847771f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "1c0976d4-9c1b-4d53-8750-48d9fd175a86", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Toute décision engageante ou structurante s'ouvre avec au moins un effet recherché — on ne vote pas sur du vide.", + "settingValue": "binding", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "c6deae06-a98c-4984-915c-dd582bb21e6b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "8f4c903f-ca7d-4348-bef1-d7add8aa5214", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Le consentement est notre protocole de base : une proposition passe quand plus personne ne s’y oppose.", + "settingValue": "0a23358c-94bd-40b1-af99-96800e946081", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "f198c834-7bb8-4dc2-89fc-dca0e6283945", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "5342dea4-9789-47d2-9c25-bbafbc6679f3", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Quand le consentement ne suffit plus, six nuances valent mieux que deux camps.", + "settingValue": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "df82efd7-5437-4522-806b-251a2270a7db", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "88be8e43-3828-4919-84d4-8e1a63d9b3bc", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Même nombreux·ses, nous refusons le pour/contre : le grand corps décide en nuancé — jamais deux camps, jamais un perdant.", + "settingValue": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "b6f3df49-06a5-4087-b151-0898836c4a15", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "a901241e-a583-4a3d-a04c-637497a6c7dd", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Quand la question est un nombre, un pourcentage ou un montant, on décide au curseur : chacun·e règle, la médiane éclaire, le garant cristallise.", + "settingValue": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "a5b998ac-e055-4f62-ab1b-fa7a387616e2", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T18:00:00.000Z", + "updatedAt": "2026-01-15T18:00:00.000Z", + "clauseId": "78327bb7-98e0-4ada-8de7-33fd6c323849", + "decisionId": "f25c44df-59e1-48ef-bffb-d06d058632b1", + "versionLabel": "v1", + "content": "Les responsabilités se confient par désignation : sans candidature, avec vote blanc, et le départage par les personnes — jamais par l'outil.", + "settingValue": "d9af660a-5fea-4984-9ada-a3df18220783", + "status": "current", + "adoptedAt": "2026-01-15T18:00:00.000Z" + }, + { + "id": "2c1f1c62-215b-4d72-85b7-5d9df54a20c3", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "51ca3f57-d2fa-492c-9881-dbb78fa2360a", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "Le local ouvre les mercredis et samedis après-midi ; toute personne détentrice d’une clé peut ouvrir en plus, en le notant sur le tableau.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "a80b60d6-d685-48a5-9cc7-286a4cb06a73", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "754c2984-4eca-4289-8fcd-aabb5152675b", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "Chaque atelier laisse l’établi propre et les outils à leur place — la personne suivante commence sans chercher.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "7aa04acc-bd70-44cb-a688-9892bed5b496", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "180b8be4-52ad-4ab9-bfa2-7dfc6ef094a1", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "Les outils s’empruntent une semaine, inscrits au cahier des prêts ; les machines fixes restent au local.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "2e0b6dc6-eb1e-4fe0-8be3-01119049f0a6", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "8606f567-ebf8-4e61-a015-287c14a673ea", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "Toute nouvelle personne est accueillie lors d’un samedi d’ouverture et repart avec le petit guide du local.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "6a7b3679-dbd8-4314-8943-6095a7aaa9a3", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "1cdb3683-a76c-473b-bac2-e8a763f89b74", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "La cotisation annuelle est affichée à l’entrée ; un tarif solidaire est proposé sans justificatif.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "7331eb61-898f-4fc0-8f0a-d8a8922c1ca2", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "d5e4b8fc-cd9d-4efd-a2ef-492752da7f97", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "Les machines dangereuses — scie, dégauchisseuse — s’utilisent à deux, jamais seul·e dans le local.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "f4c82cc1-898d-4178-b1e3-a51d8b8af7c4", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "0b798f89-0b9b-4285-b3ff-e3aa4eaf9787", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "L’Atelier ne vend rien : les participations sont libres et financent le local, jamais des personnes.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "676e1c17-b66c-401d-aa28-54d2e7a6ce69", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T18:30:00.000Z", + "updatedAt": "2026-01-22T18:30:00.000Z", + "clauseId": "2d8c6e71-61ed-49d4-99ad-0cc7d5ee9d4c", + "decisionId": "2822b935-48db-483d-851b-eb38b7d8659d", + "versionLabel": "v1", + "content": "On se parle avec respect, on répare avant de remplacer, on transmet ce qu’on sait.", + "status": "current", + "adoptedAt": "2026-01-22T18:30:00.000Z" + }, + { + "id": "ebf55919-e76f-48c5-ab6d-261678c92efe", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-07T10:00:00.000Z", + "updatedAt": "2026-08-07T10:00:00.000Z", + "clauseId": "8606f567-ebf8-4e61-a015-287c14a673ea", + "decisionId": "6bae3695-3fc5-4f70-8fbe-3b8a8076b416", + "versionLabel": "v2-a — binôme tournant", + "content": "Toute nouvelle personne est accueillie par un binôme tournant du cercle Ateliers, qui la suit sur ses trois premières visites et lui remet le petit guide du local.", + "status": "proposed" + }, + { + "id": "0b3acf19-291d-47f3-be5f-29fbfbb4cd3a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-08T14:30:00.000Z", + "updatedAt": "2026-08-08T14:30:00.000Z", + "clauseId": "8606f567-ebf8-4e61-a015-287c14a673ea", + "decisionId": "6bae3695-3fc5-4f70-8fbe-3b8a8076b416", + "versionLabel": "v2-b — permanence mensuelle", + "content": "Une permanence d’accueil se tient le premier samedi du mois : visite du local, présentation des ateliers, remise du petit guide et parrainage proposé.", + "status": "proposed" + } + ], + "protocols": [ + { + "id": "0a23358c-94bd-40b1-af99-96800e946081", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T10:00:00.000Z", + "updatedAt": "2026-01-15T10:00:00.000Z", + "name": "Consentement", + "method": "consent", + "description": "Zéro objection maintenue : la proposition passe quand plus personne ne s'y oppose — notre socle, partout.", + "durationDays": 7, + "ballot": "open", + "formula": { + "majorityPct": 100, + "baseExponent": 0.1, + "gradientExponent": 0, + "constantBase": 0 + }, + "modeParams": "D7M100B.1G0", + "pactClauseId": "8f4c903f-ca7d-4348-bef1-d7add8aa5214" + }, + { + "id": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T10:05:00.000Z", + "updatedAt": "2026-01-15T10:05:00.000Z", + "name": "Vote nuancé", + "method": "nuanced", + "description": "Six nuances plutôt que deux camps : la distribution éclaire, le seuil constate.", + "durationDays": 14, + "ballot": "open", + "formula": { + "majorityPct": 50, + "baseExponent": 0.1, + "gradientExponent": 0.2, + "constantBase": 0, + "nuancedMinParticipants": 4, + "nuancedThresholdPct": 60 + }, + "modeParams": "D14M50B.1G.2", + "pactClauseId": "5342dea4-9789-47d2-9c25-bbafbc6679f3" + }, + { + "id": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T10:10:00.000Z", + "updatedAt": "2026-01-15T10:10:00.000Z", + "name": "Réglage collectif", + "method": "parametric", + "description": "Décider au curseur : chacun·e règle, la médiane basse éclaire, le garant cristallise.", + "durationDays": 14, + "ballot": "open", + "formula": { + "majorityPct": 50, + "baseExponent": 0.1, + "gradientExponent": 0.2, + "constantBase": 0, + "parametricMinParticipants": 4 + }, + "modeParams": "D14M50B.1G.2", + "pactClauseId": "a901241e-a583-4a3d-a04c-637497a6c7dd" + }, + { + "id": "d9af660a-5fea-4984-9ada-a3df18220783", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T10:15:00.000Z", + "updatedAt": "2026-01-15T10:15:00.000Z", + "name": "Élection sans candidat", + "method": "election", + "description": "Sans candidature : chacun·e désigne, le blanc compte pour la participation ; en cas d'égalité, vous départagez — jamais l'outil.", + "durationDays": 14, + "ballot": "open", + "formula": { + "majorityPct": 50, + "baseExponent": 0.1, + "gradientExponent": 0.2, + "constantBase": 0, + "electionMinParticipants": 5, + "tieBreak": "runoff" + }, + "modeParams": "D14M50B.1G.2", + "pactClauseId": "78327bb7-98e0-4ada-8de7-33fd6c323849" + } + ], + "sessions": [ + { + "id": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-15T19:00:00.000Z", + "updatedAt": "2026-01-29T19:00:00.000Z", + "decisionId": "bb83d0b7-8eac-4a51-97fc-e6e087bd2d80", + "protocolId": "d9af660a-5fea-4984-9ada-a3df18220783", + "corpusPersonIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "4e1455b5-9697-48f9-b559-6edfe889b339", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "corpusSize": 12, + "opensAt": "2026-01-15T19:00:00.000Z", + "closesAt": "2026-01-29T19:00:00.000Z", + "status": "closed", + "outcome": "adopted" + }, + { + "id": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "9a7bc593-654a-415c-b3d1-e7fdc7a510a5", + "protocolId": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "corpusPersonIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "4e1455b5-9697-48f9-b559-6edfe889b339", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "corpusSize": 12, + "opensAt": "2026-08-09T09:00:00.000Z", + "closesAt": "2026-08-23T09:00:00.000Z", + "status": "open" + }, + { + "id": "bd6039d7-ae78-4119-80b4-3da7535c1127", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-04-30T09:00:00.000Z", + "updatedAt": "2026-05-07T09:00:00.000Z", + "decisionId": "c172cc05-571a-4d75-84b3-5bb602582534", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "corpusPersonIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "4e1455b5-9697-48f9-b559-6edfe889b339", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "corpusSize": 12, + "opensAt": "2026-04-30T09:00:00.000Z", + "closesAt": "2026-05-07T09:00:00.000Z", + "status": "closed", + "outcome": "adopted" + }, + { + "id": "9e8a7a6b-27d1-4f38-98f1-6cd471961ba6", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:00:00.000Z", + "updatedAt": "2026-08-10T09:00:00.000Z", + "decisionId": "0b07b85b-22c0-4fa1-8ce2-4b62dc518065", + "protocolId": "0a23358c-94bd-40b1-af99-96800e946081", + "corpusPersonIds": [ + "4e1455b5-9697-48f9-b559-6edfe889b339", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + ], + "corpusSize": 5, + "opensAt": "2026-08-10T09:00:00.000Z", + "closesAt": "2026-08-17T09:00:00.000Z", + "status": "open" + }, + { + "id": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T09:00:00.000Z", + "updatedAt": "2026-08-04T09:00:00.000Z", + "decisionId": "aea1c674-665a-416e-b1dc-fc015a5fe1eb", + "protocolId": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "corpusPersonIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5" + ], + "corpusSize": 11, + "opensAt": "2026-08-04T09:00:00.000Z", + "closesAt": "2026-08-18T09:00:00.000Z", + "status": "open" + }, + { + "id": "6947b008-eeec-431a-8c8d-3a41067f5da0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-26T09:00:00.000Z", + "updatedAt": "2026-08-09T09:00:00.000Z", + "decisionId": "38e46122-8147-4503-9105-f90a07055456", + "protocolId": "ddfabf00-4b0d-4ee0-98b2-bb51b6845c17", + "corpusPersonIds": [ + "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "4e1455b5-9697-48f9-b559-6edfe889b339", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "corpusSize": 12, + "opensAt": "2026-07-26T09:00:00.000Z", + "closesAt": "2026-08-09T09:00:00.000Z", + "status": "frozen" + }, + { + "id": "9be75f32-09ed-4120-be43-1f30186828c0", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-16T18:00:00.000Z", + "updatedAt": "2026-06-30T18:00:00.000Z", + "decisionId": "ed6dc706-29f9-4520-9131-b8f6a88efaea", + "protocolId": "ab3c947a-76ff-438e-87dd-24c6d053afac", + "corpusPersonIds": [ + "62789d12-de5a-43bf-b7f4-111c642ec156", + "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "f790d11d-a84f-4ed1-80c9-7077b870e099", + "318cf576-aa00-4cba-be63-e62551b4693e", + "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "cd7b8066-88ab-47af-9d53-acb467a5ea44" + ], + "corpusSize": 7, + "opensAt": "2026-06-16T18:00:00.000Z", + "closesAt": "2026-06-30T18:00:00.000Z", + "status": "closed", + "outcome": "rejected" + } + ], + "votes": [ + { + "id": "573fd12d-878a-4395-b691-339868ad3cfb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-20T10:00:00.000Z", + "updatedAt": "2026-01-20T10:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "choicePersonId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + }, + { + "id": "1f0c3ca0-2732-4d1d-be7c-9a2898f51d4e", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-20T12:00:00.000Z", + "updatedAt": "2026-01-20T12:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "343a60a7-add5-46b1-b6d6-9093ed2ad559", + "choicePersonId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + }, + { + "id": "a6384179-acc9-4410-beae-6c24d6c4bdce", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-21T09:00:00.000Z", + "updatedAt": "2026-01-21T09:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "choicePersonId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + }, + { + "id": "816b28ad-b44d-4ad7-b5a8-ba8db9f1aaec", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-22T17:00:00.000Z", + "updatedAt": "2026-01-22T17:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "choicePersonId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f" + }, + { + "id": "6f98d412-5fcd-4b89-b485-aa4fcd9f6c9a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-23T11:00:00.000Z", + "updatedAt": "2026-01-23T11:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "choicePersonId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "comment": "Marcel tient déjà les comptes du club de pétanque — les chiffres ne lui font pas peur." + }, + { + "id": "12384f05-28fc-48cb-90cc-0f429535cd19", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-24T15:00:00.000Z", + "updatedAt": "2026-01-24T15:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "choicePersonId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a" + }, + { + "id": "9222144b-1bea-4b24-a84a-cebaf9350540", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-01-25T10:00:00.000Z", + "updatedAt": "2026-01-25T10:00:00.000Z", + "sessionId": "8fe7ed8f-9b19-451b-b4ab-b5c5852e7f6f", + "voterId": "4e1455b5-9697-48f9-b559-6edfe889b339" + }, + { + "id": "d91210a4-f178-4ee3-b1f3-7fa659c4a575", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T14:00:00.000Z", + "updatedAt": "2026-08-09T14:00:00.000Z", + "sessionId": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "voterId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "value": 5 + }, + { + "id": "2db58e4d-9efe-4902-8de7-00ee2b9b2907", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-09T19:00:00.000Z", + "updatedAt": "2026-08-09T19:00:00.000Z", + "sessionId": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "voterId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "value": 4 + }, + { + "id": "f16436bc-8a2e-4d0e-bf6f-57a02e2eb027", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T09:30:00.000Z", + "updatedAt": "2026-08-10T09:30:00.000Z", + "sessionId": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "voterId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "value": 3 + }, + { + "id": "db3fdf11-02c5-433c-bf68-7f7be172888f", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T11:00:00.000Z", + "updatedAt": "2026-08-10T11:00:00.000Z", + "sessionId": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "voterId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "value": 1, + "comment": "Encore une cotisation — je préférerais un échange ponctuel d’outillage pour commencer." + }, + { + "id": "f5c3b03e-f37b-447d-bae5-09b585fb90c2", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-10T16:00:00.000Z", + "updatedAt": "2026-08-10T16:00:00.000Z", + "sessionId": "09bd70f1-61cf-4ccb-a69e-9b4ec230457b", + "voterId": "4e1455b5-9697-48f9-b559-6edfe889b339", + "value": 0, + "comment": "Je crains qu’on y perde notre autonomie de quartier — tout se décidera à Paris." + }, + { + "id": "0bb69b20-7a8b-45f8-9740-d9b2c163be76", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T14:00:00.000Z", + "updatedAt": "2026-08-04T14:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "values": [ + 40, + 30, + 20 + ] + }, + { + "id": "baa8527f-2899-40d1-bea5-7c46f463171d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-04T18:00:00.000Z", + "updatedAt": "2026-08-04T18:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "values": [ + 35, + 35, + 20 + ] + }, + { + "id": "f62a0705-3530-4646-951c-79606f6baad7", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-05T09:00:00.000Z", + "updatedAt": "2026-08-05T09:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "values": [ + 45, + 25, + 15 + ] + }, + { + "id": "c1833e5f-bd27-4256-851f-f4480310db5d", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-05T19:00:00.000Z", + "updatedAt": "2026-08-05T19:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "dfe07fca-dc13-4ebc-9e11-0dced155e13f", + "values": [ + 30, + 30, + 25 + ] + }, + { + "id": "8c0ef38b-d8c6-4b1b-bb82-d16f7ac06a7a", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-06T10:00:00.000Z", + "updatedAt": "2026-08-06T10:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "values": [ + 40, + 20, + 25 + ] + }, + { + "id": "b28a709c-1350-4804-ae38-5d296dadfcff", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-07T17:00:00.000Z", + "updatedAt": "2026-08-07T17:00:00.000Z", + "sessionId": "3acb94fb-4c4a-4c1c-9320-e714c8aee013", + "voterId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "values": [ + 50, + 20, + 10 + ], + "comment": "Le vélo tourne au ralenti depuis mars — je remets sur le bois." + }, + { + "id": "8df4b39c-84e2-430d-a08e-0a3a2da03c04", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-27T10:00:00.000Z", + "updatedAt": "2026-07-27T10:00:00.000Z", + "sessionId": "6947b008-eeec-431a-8c8d-3a41067f5da0", + "voterId": "3c5e8e6c-c66d-4796-81bd-937ce602ba1a", + "values": [ + 30, + 12 + ] + }, + { + "id": "3db61605-d927-41cb-aa06-b4b04b13b4db", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-07-29T18:00:00.000Z", + "updatedAt": "2026-07-29T18:00:00.000Z", + "sessionId": "6947b008-eeec-431a-8c8d-3a41067f5da0", + "voterId": "d0a27b00-af87-4715-9bde-20cdbe2f29c5", + "values": [ + 25, + 10 + ] + }, + { + "id": "18440391-58c8-4588-8fd5-ffc624a82abc", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-02T15:00:00.000Z", + "updatedAt": "2026-08-02T15:00:00.000Z", + "sessionId": "6947b008-eeec-431a-8c8d-3a41067f5da0", + "voterId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "values": [ + 30, + 15 + ] + }, + { + "id": "f7e96701-cfcf-4483-8e8a-a6a12e8d6129", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-08-07T20:00:00.000Z", + "updatedAt": "2026-08-07T20:00:00.000Z", + "sessionId": "6947b008-eeec-431a-8c8d-3a41067f5da0", + "voterId": "cd7b8066-88ab-47af-9d53-acb467a5ea44", + "values": [ + 35, + 15 + ] + }, + { + "id": "57f7989d-8429-4995-bf20-cbac9f3b2d1b", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-18T10:00:00.000Z", + "updatedAt": "2026-06-18T10:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "f790d11d-a84f-4ed1-80c9-7077b870e099", + "value": 1, + "comment": "Le budget partirait dans une machine que deux personnes savent faire tourner." + }, + { + "id": "b14410ef-f645-4018-a721-5dc627539fbd", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-19T14:00:00.000Z", + "updatedAt": "2026-06-19T14:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "7589bff0-c5bd-4052-9144-bed92b0e7a3a", + "value": 2 + }, + { + "id": "47c7e26c-3ad2-4154-a038-51977d993701", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-20T09:00:00.000Z", + "updatedAt": "2026-06-20T09:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "d0377c93-f2b0-4265-b5f4-4c34e0d85596", + "value": 1, + "comment": "Je préfère qu’on répare nos machines à coudre d’abord." + }, + { + "id": "f20a33fc-d9b2-4db5-a954-6043fb4480d8", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-21T18:00:00.000Z", + "updatedAt": "2026-06-21T18:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "318cf576-aa00-4cba-be63-e62551b4693e", + "value": 4 + }, + { + "id": "89c4d718-0223-42cd-9d08-21874941a922", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-24T11:00:00.000Z", + "updatedAt": "2026-06-24T11:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "62789d12-de5a-43bf-b7f4-111c642ec156", + "value": 0, + "comment": "On croule déjà sous les objets à entretenir — pas un de plus." + }, + { + "id": "28e542eb-78ef-4824-b82c-bb7b246f07bb", + "collectiveId": "c5ccb2dd-6d99-43b9-bc1a-f6c37cc67d94", + "createdAt": "2026-06-27T16:00:00.000Z", + "updatedAt": "2026-06-27T16:00:00.000Z", + "sessionId": "9be75f32-09ed-4120-be43-1f30186828c0", + "voterId": "781e0bb0-8006-4fe9-9501-f9c4533b216e", + "value": 3 + } + ] +} diff --git a/frontend/app/engine/impact.ts b/frontend/app/engine/impact.ts new file mode 100644 index 0000000..70698dc --- /dev/null +++ b/frontend/app/engine/impact.ts @@ -0,0 +1,101 @@ +/** + * Perimeter engine — who is concerned by THIS decision. + * + * Pure functions — no store, no I/O. The store assembles the inputs and + * persists the result as Concern rows (origin 'computed'). + * + * The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») : + * members of the scoped circles ∪ persons named by the author + * ∪ holders of active mandates whose domain intersects the scoped circles. + * Every inclusion carries its human-readable French reason — auditable in + * one tap, never « la machine t'a désigné ». + * + * MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per + * circle (Circle.memberIds is the ONLY membership rule). Circle nesting + * (parentCircleId, « couches d'oignon ») does NOT propagate membership: + * a parent circle does NOT automatically include the members of its child + * circles, nor the reverse. Nesting serves ONE gesture — widening the + * perimeter by one notch (« élargir d'un cran ») — which explicitly adds + * the parent circle to the scope; the computation never infers it. + */ + +import type { Circle, Decision, Id, Mandate } from '~/types/domain' + +export interface ConcernedEntry { + personId: Id + /** French reason shown on tap (becomes Concern.reason). */ + reason: string +} + +/** + * Flatten circle ids into the set of their DIRECT nominative members. + * + * NON-PROPAGATION (see module header): only Circle.memberIds of the listed + * circles count. Members of child (or parent) circles are NOT included — + * widening is a scope gesture, never an automatic inference. + * Unknown circle ids are ignored (robustness over crash). + */ +export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set { + const members = new Set() + for (const circleId of circleIds) { + const circle = circles.find(c => c.id === circleId) + if (!circle) continue + for (const personId of circle.memberIds) members.add(personId) + } + return members +} + +/** + * Compute the concerned persons of a decision scope, each with its reason. + * + * Deduplication: first reason wins, with source priority + * circle membership > named by author > mandate holder. + * The author is EXCLUDED from the list (they decide, they are not + * « concerned » by their own perimeter). + * + * Mandates count only when status 'active' and when their domain.circleIds + * INTERSECTS the scoped circles (any shared circle is enough — a mandate + * holder is concerned as soon as the decision touches their domain). + */ +export function computeConcerned( + scope: Decision['scope'], + circles: Circle[], + mandates: Mandate[], + authorId: Id, +): ConcernedEntry[] { + // Map preserves insertion order; first reason wins (priority by pass order). + const concerned = new Map() + + // 1. Members of the scoped circles (highest priority reason). + for (const circleId of scope.circleIds) { + const circle = circles.find(c => c.id === circleId) + if (!circle) continue + for (const personId of circle.memberIds) { + if (personId === authorId) continue + if (!concerned.has(personId)) { + concerned.set(personId, `membre du cercle ${circle.name}`) + } + } + } + + // 2. Persons named by the author. + for (const personId of scope.personIds) { + if (personId === authorId) continue + if (!concerned.has(personId)) { + concerned.set(personId, 'nommé·e par l\'auteur') + } + } + + // 3. Holders of active mandates whose domain intersects the scoped circles. + const scopedCircleIds = new Set(scope.circleIds) + for (const mandate of mandates) { + if (mandate.status !== 'active') continue + if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue + if (mandate.holderId === authorId) continue + if (!concerned.has(mandate.holderId)) { + concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`) + } + } + + return [...concerned].map(([personId, reason]) => ({ personId, reason })) +} diff --git a/frontend/app/engine/parametric.ts b/frontend/app/engine/parametric.ts new file mode 100644 index 0000000..4260a25 --- /dev/null +++ b/frontend/app/engine/parametric.ts @@ -0,0 +1,416 @@ +/** + * Parametric decision engine — « Réglage collectif » (collective tuning). + * + * Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3, + * Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human + * gesture (crystallization) lives in state.ts/UI, never here. + * + * LOCKED SPECS (blueprint repairs): + * - LOW median, element by element: for an even vote count, take the LOWER + * central element (index floor((n-1)/2) after ascending sort). Invariant: + * every median value is a value someone actually voted, so the step is + * honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ». + * - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true + * (the absorption variable), resolved linearly: 100 − Σ other shares. + * 'slider' params live outside the constraint and pass through untouched. + * - At vote time the resolved derived must stay within its bounds, otherwise + * the vote is rejected (validateVote). + * - At crystallization the derived is never aggregated: it is resolved from + * the median of the voted shares; if it exits its bounds ⇒ clamp to the + * violated bound + PROPORTIONAL renormalization of the non-derived shares + * (each multiplied by (100 − clampedDerived) / Σ median shares) so the + * sum-100 invariant is restored. The renormalized shares may leave the + * step grid — accepted and documented: this is the one specified exception. + * - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen. + * - computeMyImpact 'linear-share' NEVER invents a number: missing attribute + * or empty declaring corpus ⇒ null. + * - detectBimodality is a simple documented heuristic, NEVER blocking: + * it returns false on any degenerate input instead of throwing. + * + * Code and comments in English; thrown error messages in French (UI-facing). + */ + +import type { ParamDef, ParamSpec } from '../types/domain' + +/** Blueprint limit: a small manipulable space (SejeteralO lesson). */ +const MAX_PARAMS = 7 + +/** + * Absolute tolerance for floating-point comparisons (bounds and step grid). + * Vote values are human-scale (percent shares, bounded sliders), so an + * absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value. + */ +const FLOAT_EPS = 1e-6 + +/** Params that are actually voted, in spec order (derived excluded). */ +function votableParams(spec: ParamSpec): ParamDef[] { + return spec.params.filter(p => p.derived !== true) +} + +/** + * Guarded indexed access (project compiles with noUncheckedIndexedAccess). + * Every call site is protected by a prior length check or loop bound — + * this throw is an internal-invariant guard, not a reachable user error. + */ +function at(arr: number[], i: number): number { + const v = arr[i] + if (v === undefined) { + throw new Error('Incohérence interne : index hors du vecteur.') + } + return v +} + +// --------------------------------------------------------------------------- +// validateParamSpec +// --------------------------------------------------------------------------- + +/** + * Validate a ParamSpec at creation time. Throws (French message) when: + * - no param, or more than 7 params; + * - a business label is missing (never raw a, b, c); + * - bounds are inconsistent (min >= max) or step is not strictly positive; + * - a baseline lies outside its own bounds; + * - constraint 'sum100' does not have EXACTLY ONE 'share' param with + * derived:true (0 or 2+ derived, or derived on a 'slider'); + * - a derived param exists without a constraint able to resolve it. + */ +export function validateParamSpec(spec: ParamSpec): void { + if (spec.params.length === 0) { + throw new Error('Au moins un paramètre est requis.') + } + if (spec.params.length > MAX_PARAMS) { + throw new Error( + `Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`, + ) + } + + for (const p of spec.params) { + if (typeof p.label !== 'string' || p.label.trim() === '') { + throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`) + } + if (!(p.min < p.max)) { + throw new Error( + `Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`, + ) + } + if (!(p.step > 0)) { + throw new Error( + `Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`, + ) + } + if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) { + throw new Error( + `Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}–${p.max}).`, + ) + } + } + + const derived = spec.params.filter(p => p.derived === true) + + if (spec.constraint === 'sum100') { + if (derived.some(p => p.kind !== 'share')) { + throw new Error( + 'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.', + ) + } + if (derived.length === 0) { + throw new Error( + 'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).', + ) + } + if (derived.length > 1) { + throw new Error( + `Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`, + ) + } + } else if (derived.length > 0) { + throw new Error( + 'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.', + ) + } +} + +// --------------------------------------------------------------------------- +// validateVote +// --------------------------------------------------------------------------- + +/** + * Validate one vote vector against the spec. + * `values` follows the order of spec.params EXCLUDING derived params + * (Vote.values contract). Throws (French message) when: + * - the vector length does not match the number of votable params; + * - a value is not a finite number (NaN / ±Infinity sanitization); + * - a value is out of bounds or off the step grid (FLOAT_EPS tolerance); + * - constraint 'sum100': the resolved derived (100 − Σ voted shares) would + * exit its own [min, max] bounds ⇒ the vote is rejected. + */ +export function validateVote(spec: ParamSpec, values: number[]): void { + const votable = votableParams(spec) + + if (values.length !== votable.length) { + throw new Error( + `Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`, + ) + } + + votable.forEach((p, i) => { + const v = values[i] + if (typeof v !== 'number' || !Number.isFinite(v)) { + throw new Error( + `Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`, + ) + } + if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) { + throw new Error( + `Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}–${p.max}).`, + ) + } + // Step grid: v must equal min + k×step for an integer k (float tolerance). + const k = Math.round((v - p.min) / p.step) + if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) { + throw new Error( + `Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`, + ) + } + }) + + if (spec.constraint === 'sum100') { + const derivedParam = spec.params.find(p => p.derived === true) + if (derivedParam) { + let shareSum = 0 + votable.forEach((p, i) => { + if (p.kind === 'share') shareSum += at(values, i) + }) + const resolved = 100 - shareSum + if ( + resolved < derivedParam.min - FLOAT_EPS + || resolved > derivedParam.max + FLOAT_EPS + ) { + throw new Error( + `La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}–${derivedParam.max}).`, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// resolveDerived +// --------------------------------------------------------------------------- + +/** + * Expand a votable vector into the COMPLETE vector in spec.params order. + * The derived share (sum100) is resolved linearly: 100 − Σ other shares. + * 'slider' params live outside the constraint and pass through untouched. + * Throws on a length mismatch (misuse guard — same message as validateVote). + */ +export function resolveDerived(spec: ParamSpec, values: number[]): number[] { + const votable = votableParams(spec) + + if (values.length !== votable.length) { + throw new Error( + `Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`, + ) + } + + let shareSum = 0 + votable.forEach((p, i) => { + if (p.kind === 'share') shareSum += at(values, i) + }) + + let cursor = 0 + return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++))) +} + +// --------------------------------------------------------------------------- +// medianByElement +// --------------------------------------------------------------------------- + +/** + * LOW median, element by element. + * Each column is sorted ascending and the element at index floor((n-1)/2) is + * taken — for an even n this is the LOWER of the two central elements. + * Invariant: every median value is a value actually voted by someone, so the + * step grid is honored by construction (« une position que chacun aurait pu + * proposer »). + * 0 votes ⇒ [] (the caller falls back to the baseline vector). + */ +export function medianByElement(votesValues: number[][]): number[] { + const n = votesValues.length + if (n === 0) return [] + + const width = votesValues[0]?.length ?? 0 + const lowMedianIndex = Math.floor((n - 1) / 2) + const medians: number[] = [] + + for (let j = 0; j < width; j++) { + const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b) + medians.push(at(column, lowMedianIndex)) + } + return medians +} + +// --------------------------------------------------------------------------- +// crystallize +// --------------------------------------------------------------------------- + +/** + * Compute the crystallized position: LOW median of the VOTED vectors, then + * derived resolution. Returns the complete vector in spec.params order. + * + * sum100 repair (locked spec): the derived is never aggregated — it is + * resolved from the median of the voted shares. If it exits its bounds: + * - clamp it to the violated bound; + * - renormalize the non-derived shares PROPORTIONALLY to restore sum 100: + * each share is multiplied by (100 − clampedDerived) / Σ median shares. + * (Renormalized shares may leave the step grid — accepted, documented.) + * - degenerate sub-case Σ median shares = 0: proportionality is undefined, + * the remainder (100 − clampedDerived) is spread equally instead. + * 'slider' params are outside the constraint and are never renormalized. + * + * 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an + * empty screen; the crystallization GESTURE itself stays human (Δ3). + */ +export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] { + if (votesValues.length === 0) { + return spec.params.map(p => p.baseline ?? p.min) + } + + const median = medianByElement(votesValues) + const full = resolveDerived(spec, median) + + if (spec.constraint !== 'sum100') return full + + const derivedIndex = spec.params.findIndex(p => p.derived === true) + const derivedParam = spec.params[derivedIndex] + if (derivedIndex === -1 || derivedParam === undefined) { + return full // unreachable on a validated spec + } + const resolved = at(full, derivedIndex) + + const withinBounds + = resolved >= derivedParam.min - FLOAT_EPS + && resolved <= derivedParam.max + FLOAT_EPS + if (withinBounds) return full + + // Clamp to the violated bound, then restore the sum-100 invariant. + const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max) + const remainder = 100 - clamped + + let shareSum = 0 + let shareCount = 0 + spec.params.forEach((p, i) => { + if (p.derived !== true && p.kind === 'share') { + shareSum += at(full, i) + shareCount++ + } + }) + + return full.map((v, i) => { + if (i === derivedIndex) return clamped + const p = spec.params[i] + if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched + if (shareSum === 0) return remainder / shareCount // degenerate: equal spread + return v * (remainder / shareSum) // proportional renormalization + }) +} + +// --------------------------------------------------------------------------- +// computeMyImpact +// --------------------------------------------------------------------------- + +/** One line of the « Pour moi » card: my quota for one share param. */ +export interface ImpactLine { + key: string + label: string + amount: number +} + +/** « Pour moi » card content — per share param + total. */ +export interface MyImpact { + perParam: ImpactLine[] + total: number +} + +/** + * 'linear-share' personal impact (Δ15 — « Pour moi » card). + * Applies ONLY when constraint is 'sum100' AND resources.amount is set AND + * spec.impactAttrKey is set. For EACH share param p (derived included): + * amount(p) = resources.amount × value(p) / 100 + * myQuota(p) = amount(p) × myAttr / Σ corpusAttrs + * `fullValues` is the COMPLETE vector in spec.params order (resolveDerived / + * crystallize output). 'slider' params are outside the constraint: no line. + * + * Returns null — NEVER an invented number — when myAttr is undefined, + * when Σ corpusAttrs is 0 (nobody declared), or on a malformed input. + */ +export function computeMyImpact( + spec: ParamSpec, + resources: { amount?: number }, + fullValues: number[], + myAttr: number | undefined, + corpusAttrs: number[], +): MyImpact | null { + if (spec.constraint !== 'sum100') return null + if (!resources.amount || !Number.isFinite(resources.amount)) return null + if (!spec.impactAttrKey) return null + if (myAttr === undefined || !Number.isFinite(myAttr)) return null + if (fullValues.length !== spec.params.length) return null // misuse guard + + const attrSum = corpusAttrs.reduce( + (sum, a) => sum + (Number.isFinite(a) ? a : 0), + 0, + ) + if (attrSum === 0) return null + + const amount = resources.amount + const perParam: ImpactLine[] = [] + let total = 0 + + spec.params.forEach((p, i) => { + if (p.kind !== 'share') return + const paramAmount = (amount * at(fullValues, i)) / 100 + const myQuota = (paramAmount * myAttr) / attrSum + perParam.push({ key: p.key, label: p.label, amount: myQuota }) + total += myQuota + }) + + return { perParam, total } +} + +// --------------------------------------------------------------------------- +// detectBimodality +// --------------------------------------------------------------------------- + +/** + * Simple documented heuristic over ONE param's voted values — NEVER blocking + * (it only feeds the non-blocking banner and the crystallization reminder, + * Δ16): two distinct positions are detected when, after ascending sort, + * the LARGEST gap between consecutive values satisfies ALL of: + * - n >= 4 (below that, no distribution to speak of); + * - gap > 40% of the total range (max − min); + * - at least 2 values on EACH side of the gap (a single outlier is not a + * second position). + * Non-finite values are ignored; any degenerate input returns false. + */ +export function detectBimodality(values: number[]): boolean { + const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b) + const n = sorted.length + if (n < 4) return false + + const range = at(sorted, n - 1) - at(sorted, 0) + if (range <= 0) return false + + let maxGap = 0 + for (let i = 0; i < n - 1; i++) { + const gap = at(sorted, i + 1) - at(sorted, i) + if (gap > maxGap) maxGap = gap + } + if (maxGap <= 0.4 * range) return false + + // The max gap must split the values 2+ / 2+ (ties: any qualifying position). + for (let i = 1; i <= n - 3; i++) { + if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true + } + return false +} diff --git a/frontend/app/engine/settings.ts b/frontend/app/engine/settings.ts new file mode 100644 index 0000000..588234e --- /dev/null +++ b/frontend/app/engine/settings.ts @@ -0,0 +1,167 @@ +/** + * Pact settings resolution — the Pact IS the settings store (Δ10). + * + * resolveSettings() reads the Pact clauses that carry a settingKey, finds the + * 'current' ClauseVersion bearing a settingValue for each, and produces the + * resolved CollectiveSettings consumed by triage.ts and state.ts. Pure + * function — NOT a settings table: every threshold is a voted clause. + * + * Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10): + * - any absent key falls back to SETTINGS_DEFAULTS; + * - invalid values (wrong type, negative number, unknown enum member) fall + * back to the default SILENTLY — a corrupted Pact never crashes the engine; + * - any unresolved OPTIONAL protocolByRange key stays undefined: the caller + * falls back to the consent protocol; + * - a missing 'protocols.consent' (corrupted bundle) still returns a full + * settings object with protocolByRange.consent === '' — hasConsentProtocol() + * lets the triage route 'advice' with the banner « Aucun protocole — crée-le + * ou décide sur avis », NEVER a crash. + */ + +import type { + Clause, + ClauseVersion, + CollectiveSettings, + Id, + InertiaPreset, + Json, +} from '../types/domain' + +/** Default triage settings — used for every key the Pact does not resolve. */ +export const SETTINGS_DEFAULTS = { + smallGroupMax: 5, + collectiveMin: 50, + consentMax: 7, + objectionWindowHours: 48, + adviceWindowHours: 72, + framingDays: 14, + concernEscalateRatio: 0.5, + recurrenceThreshold: 3, + reviewDelayDays: 90, + requireEffects: 'binding', +} as const satisfies CollectiveSettings['triage'] + +const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max'] + +/** Most recent wins when a clause carries several 'current' versions (data anomaly). */ +function stampOf(version: ClauseVersion): string { + return version.adoptedAt ?? version.updatedAt +} + +/** The settingValue of the clause's 'current' version, if any. */ +function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined { + let best: ClauseVersion | undefined + for (const version of versions) { + if (version.clauseId !== clause.id || version.archivedAt) continue + if (version.status !== 'current' || version.settingValue === undefined) continue + if (!best || stampOf(version) > stampOf(best)) best = version + } + return best?.settingValue +} + +/** First non-archived clause holding this settingKey with a resolvable current value. */ +function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined { + for (const clause of clauses) { + if (clause.settingKey !== key || clause.archivedAt) continue + const value = currentValueOf(clause, versions) + if (value !== undefined) return value + } + return undefined +} + +/** Wrong type or negative number ⇒ default, silently. */ +function numberOrDefault(value: Json | undefined, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback +} + +/** Unknown enum member ⇒ default, silently. */ +function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' { + return value === 'none' || value === 'structural' || value === 'binding' + ? value + : SETTINGS_DEFAULTS.requireEffects +} + +/** Protocol ids must be non-empty strings — anything else counts as unresolved. */ +function idOrUndefined(value: Json | undefined): Id | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +/** + * Resolve the collective's settings from its Pact clauses. + * + * Recognized setting keys: + * triage.smallGroupMax · triage.collectiveMin · triage.consentMax · + * triage.objectionWindowHours · triage.adviceWindowHours · + * triage.framingDays · triage.concernEscalateRatio · + * triage.recurrenceThreshold · triage.reviewDelayDays · + * triage.requireEffects · protocols.consent · protocols.nuanced · + * protocols.large · protocols.parametric · protocols.election · + * protocols.clauseByInertia.{low,standard,high,max} + */ +export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings { + const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions) + + const triage: CollectiveSettings['triage'] = { + smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax), + collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin), + consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax), + objectionWindowHours: numberOrDefault( + raw('triage.objectionWindowHours'), + SETTINGS_DEFAULTS.objectionWindowHours, + ), + adviceWindowHours: numberOrDefault( + raw('triage.adviceWindowHours'), + SETTINGS_DEFAULTS.adviceWindowHours, + ), + framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays), + concernEscalateRatio: numberOrDefault( + raw('triage.concernEscalateRatio'), + SETTINGS_DEFAULTS.concernEscalateRatio, + ), + recurrenceThreshold: numberOrDefault( + raw('triage.recurrenceThreshold'), + SETTINGS_DEFAULTS.recurrenceThreshold, + ), + reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays), + requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')), + } + + // Consent is the MANDATORY invariant key. When unfindable (corrupted + // bundle), we still return a complete object with consent === '' so the + // caller can degrade gracefully (hasConsentProtocol) — never a crash. + const protocolByRange: CollectiveSettings['protocolByRange'] = { + consent: idOrUndefined(raw('protocols.consent')) ?? '', + } + + const nuanced = idOrUndefined(raw('protocols.nuanced')) + if (nuanced) protocolByRange.nuanced = nuanced + const large = idOrUndefined(raw('protocols.large')) + if (large) protocolByRange.large = large + const parametric = idOrUndefined(raw('protocols.parametric')) + if (parametric) protocolByRange.parametric = parametric + const election = idOrUndefined(raw('protocols.election')) + if (election) protocolByRange.election = election + + // The Ğ1 heritage lives here, intact: the map is exposed only when ALL four + // presets resolve (the type is a complete Record — a partial map would lie). + // Otherwise the caller falls back to consent, like any optional key. + const byInertia: Partial> = {} + for (const preset of INERTIA_PRESETS) { + const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`)) + if (id) byInertia[preset] = id + } + if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) { + protocolByRange.clauseByInertia = byInertia as Record + } + + return { triage, protocolByRange } +} + +/** + * True when the collective has a resolvable consent protocol. + * False ⇒ the triage routes 'advice' with the banner + * « Aucun protocole — crée-le ou décide sur avis » — never a crash. + */ +export function hasConsentProtocol(settings: CollectiveSettings): boolean { + return settings.protocolByRange.consent.trim().length > 0 +} diff --git a/frontend/app/engine/state.ts b/frontend/app/engine/state.ts new file mode 100644 index 0000000..6b3000a --- /dev/null +++ b/frontend/app/engine/state.ts @@ -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>> = { + 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' +} diff --git a/frontend/app/engine/threshold.ts b/frontend/app/engine/threshold.ts index 87b895f..fc2aca0 100644 --- a/frontend/app/engine/threshold.ts +++ b/frontend/app/engine/threshold.ts @@ -108,3 +108,106 @@ export function techcommThreshold(cotecSize: number, exponent: number = 0.1): nu } return Math.ceil(cotecSize ** exponent) } + +// ───────────────────────────────────────────────────────────── +// Election — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4) +// ───────────────────────────────────────────────────────────── + +import type { FormulaParams, Id, Vote } from '~/types/domain' + +/** + * Outcome of an election tally. + * + * Discriminated union on `outcome`: + * - 'elected' — a single person leads by simple plurality. + * - 'tie' — several persons share the top count. The engine NEVER + * breaks a tie (no randomness, no first-come): the closure + * flow proposes a chained runoff among `exAequoIds`, or a + * draw only if the Pact planned it (FormulaParams.tieBreak). + * - 'rejected' — reason 'quorum': participants (blanks included) below + * electionMinParticipants; `required` = that quorum. + * reason 'no-designation': quorum reached (or absent) but + * every vote is blank — nobody was designated, and an + * empty tie would be meaningless; `required` still carries + * the quorum (0 when none) for display purposes. + */ +export type ElectionOutcome = + | { + outcome: 'elected' + winnerId: Id + counts: Record + blanks: number + participants: number + } + | { + outcome: 'tie' + exAequoIds: Id[] + counts: Record + blanks: number + participants: number + } + | { + outcome: 'rejected' + reason: 'quorum' | 'no-designation' + participants: number + required: number + } + +/** + * Tally an election by simple plurality. + * + * CONTRACT — the caller passes the LAST ACTIVE votes only: one vote per + * voter, `supersedesVoteId` chains already resolved (the store filters + * superseded votes). The engine does NOT deduplicate by voterId; + * `participants` is simply `votes.length`. + * + * Rules (Δ28): + * - A vote without `choicePersonId` is a BLANK: it counts for + * participation (quorum), never for designation. + * - Quorum: when `formula.electionMinParticipants` is set and + * participants (blanks included) < quorum ⇒ rejected ('quorum'). + * - Designation by simple PLURALITY of the non-blank votes. + * - Tie at the top ⇒ 'tie' with `exAequoIds` sorted (lexicographic — + * a deterministic display order, NEVER a tie-break: the engine does + * not pick a winner among equals, no randomness, no first-come). + * - Zero designation (all blanks) ⇒ rejected ('no-designation'). + * + * @param votes - Last active votes of the session (see contract above) + * @param formula - Protocol formula params (only electionMinParticipants is read) + * @returns The election outcome — never a tie silently broken + */ +export function electionResult( + votes: Vote[], + formula: Pick, +): ElectionOutcome { + const participants = votes.length + const quorum = formula.electionMinParticipants ?? 0 + + if (formula.electionMinParticipants !== undefined && participants < formula.electionMinParticipants) { + return { outcome: 'rejected', reason: 'quorum', participants, required: formula.electionMinParticipants } + } + + const counts: Record = {} + let blanks = 0 + for (const vote of votes) { + if (vote.choicePersonId) { + counts[vote.choicePersonId] = (counts[vote.choicePersonId] ?? 0) + 1 + } else { + blanks++ // blank: participation only, never designation + } + } + + const designatedIds = Object.keys(counts) + if (designatedIds.length === 0) { + return { outcome: 'rejected', reason: 'no-designation', participants, required: quorum } + } + + const topCount = Math.max(...designatedIds.map(id => counts[id]!)) + const leaders = designatedIds.filter(id => counts[id] === topCount) + + if (leaders.length === 1) { + return { outcome: 'elected', winnerId: leaders[0]!, counts, blanks, participants } + } + + return { outcome: 'tie', exAequoIds: [...leaders].sort(), counts, blanks, participants } +} diff --git a/frontend/app/engine/triage.ts b/frontend/app/engine/triage.ts new file mode 100644 index 0000000..c965b4c --- /dev/null +++ b/frontend/app/engine/triage.ts @@ -0,0 +1,341 @@ +/** + * Routing engine — UI name « Le chemin » (BLUEPRINT-V2.md « Algorithme de triage »). + * + * Pure function triage(input, ctx, settings) → Verdict. No store, no I/O: + * the store assembles TriageContext, the engine only reasons on it. + * Rule codes (R-U, R0a…R6) NEVER reach the card: the UI shows the one-sentence + * French explanation alone; codes live in the « pourquoi ? » disclosure, + * the PV and the derogation journal. + * + * Rule order — first match wins: + * R-U (urgency overlay) · R0a already decided · R0b my mandate · + * R0c someone else's mandate · R2 self only · R3 targeted clause · + * R4 small reversible group · R5 collective default · R6 recurrence overlay. + * + * DOCUMENTED INTERPRETATION CHOICES: + * - R0b comes BEFORE R2: a selfOnly decision whose scoped circles are covered + * by my mandate still routes 'mandate' (the mandate trace duty prevails). + * - R0a yields ONLY when the author explicitly targets the clause + * (amendsClauseId set): contesting the rule IS R3, and R3 looks the clause + * up in ctx.matchingClauses — R0a fires for a bare match. + * - Specified fallback (Δ10): protocolByRange.consent unresolvable ⇒ route + * 'advice' with the « Aucun protocole » banner, prior to EVERYTHING except + * R2 (solo needs no protocol) and R0a (record needs no protocol). + * Its rule code is 'R5' — it degrades the collective default. + * - Urgency refused (urgent && irreversible): the underlying route is kept + * with its MINIMAL windows (never doubled), rule 'R-U', refusal sentence. + * - R6 is an overlay: it sets Verdict.suggestion but never changes route or + * rule; 'claim-mandate' wins over 'protocolize' when both thresholds match. + */ + +import type { + Clause, + CollectiveSettings, + Id, + Mandate, + TriageContext, + TriageInput, + Verdict, +} from '~/types/domain' +import { + BINARY_COST, + METHOD_LABELS, + NO_PROTOCOL_BANNER, + PARAMETRIC_ALT, + RECORD_ALT, + URGENT_BADGE, + URGENT_REFUSED, +} from '~/lexicon' + +// TODO-lexicon: alternative costs missing from app/lexicon.ts (frozen file) — +// move these two constants there once it thaws. +const PARAMETRIC_COST = 'formuler les curseurs avant de voter' +const RECORD_COST = 'aucune fenêtre — la décision est déjà prise' + +/** Verdict before the permanent alternatives are attached. */ +type BaseVerdict = Omit + +// ───────────────────────────────────────────────────────────── +// Parametric hint — number / % / amount (€, DU) in the capture sentence +// ───────────────────────────────────────────────────────────── + +const DIGIT_PERCENT_CURRENCY = /\d|%|€/ +// Ğ1 universal dividend — uppercase only: the French article « du » never counts. +const DU_UNIT = /\bDU\b/ + +/** Detect a number, percentage or amount (€, DU, digits) in the title. */ +export function detectParametricHint(title: string): boolean { + return DIGIT_PERCENT_CURRENCY.test(title) || DU_UNIT.test(title) +} + +// ───────────────────────────────────────────────────────────── +// Main entry +// ───────────────────────────────────────────────────────────── + +export function triage( + input: TriageInput, + ctx: TriageContext, + settings: CollectiveSettings, +): Verdict { + const hint = detectParametricHint(input.title) + + let base = routeIgnoringUrgency(input, ctx, settings) + + // R-U — urgency overlay. + if (input.urgent) { + if (input.reversibility === 'irreversible') { + // Refusal: normal route, MINIMAL windows (never doubled), no chain. + base = { ...base, rule: 'R-U', explanation: URGENT_REFUSED } + } else { + // Conservatory: the lightest legitimate route NOW, doubled window, + // chained ratification (created by the store when validating). + base = { + ...base, + rule: 'R-U', + explanation: `${URGENT_BADGE}.`, + conservatoryChain: true, + ...(base.windowHours !== undefined ? { windowHours: base.windowHours * 2 } : {}), + } + } + } + + // R6 — recurrence & maturation overlay (never blocking, never re-routing). + const suggestion = recurrenceOverlay(input, ctx, settings) + + return { + ...base, + ...(hint ? { parametricHint: true } : {}), + ...(suggestion ? { suggestion } : {}), + alternatives: buildAlternatives(hint), + } +} + +// ───────────────────────────────────────────────────────────── +// Rules R0a → R5 (urgency stripped) +// ───────────────────────────────────────────────────────────── + +function routeIgnoringUrgency( + input: TriageInput, + ctx: TriageContext, + settings: CollectiveSettings, +): BaseVerdict { + // R0a — already decided. Skipped when the author explicitly targets the + // clause (that is R3 — contesting the rule). Route 'record' proposed as + // principal: act under the standing rule, and keep the trace. + const matched = ctx.matchingClauses[0] + if (matched && !input.amendsClauseId) { + return { + route: 'record', + rule: 'R0a', + explanation: `C'est déjà décidé (${matched.code}, ${matched.title}) — agis, ou conteste la règle.`, + reviewRequired: false, + engravingSuggested: false, + } + } + + // Specified fallback (Δ10): no consent protocol resolvable — prior to + // everything except R2/record. Rule code 'R5': the collective default, + // degraded to advice because the collective has no protocol yet. + if (!settings.protocolByRange.consent) { + if (input.scope.selfOnly) return r2SelfOnly(input) + return { + route: 'advice', + rule: 'R5', + explanation: `${NO_PROTOCOL_BANNER}.`, + windowHours: settings.triage.adviceWindowHours, + reviewRequired: false, + engravingSuggested: false, + } + } + + // R0b — my mandate covers. Before R2: even selfOnly, the mandate trace + // duty prevails. Empty scope.circleIds never counts as covered (a trivial + // ⊆ match would hand every uncircled decision to the first mandate). + const myMandate = coveringMandate(input, ctx.myActiveMandates) + if (myMandate) { + return { + route: 'mandate', + rule: 'R0b', + explanation: `Ton mandat ${myMandate.title} couvre — décide, c'est tracé.`, + windowHours: settings.triage.objectionWindowHours, + reviewRequired: false, + engravingSuggested: false, + } + } + + // R0c — someone else's mandate covers. TriageContext carries no person + // names: the mandate TITLE names the power, the store resolves the holder. + const otherMandate = coveringMandate(input, ctx.otherActiveMandates) + if (otherMandate) { + return { + route: 'transmit', + rule: 'R0c', + explanation: `Le mandat ${otherMandate.title} couvre — transmets à sa ou son titulaire.`, + reviewRequired: false, + engravingSuggested: false, + } + } + + // R2 — self only. + if (input.scope.selfOnly) return r2SelfOnly(input) + + // R3 — targeted clause: its inertia applies, vote of those it governs. + if (input.amendsClauseId) return r3TargetedClause(input, ctx, settings) + + const n = ctx.computedConcernedIds.length + + // R4 — small reversible group: ask for advice, then decide. + if (input.reversibility === 'easy' && n <= settings.triage.smallGroupMax) { + const plural = n > 1 ? 's' : '' + return { + route: 'advice', + rule: 'R4', + explanation: `Réversible et ${n} personne${plural} concernée${plural} — demande leur avis puis décide.`, + windowHours: settings.triage.adviceWindowHours, + reviewRequired: false, + engravingSuggested: false, + } + } + + // R5 — collective default, modality by perimeter size. + return r5Collective(input, n, settings) +} + +/** R2 — « Personne d'autre n'est concerné ». Zero window. On irreversible the + * engine SUGGESTS a review (reviewRequired) — the UI shows it pre-checked but + * REMOVABLE: on oneself the tool never interposes; the pre-check is a UI fact, + * the engine only recommends. */ +function r2SelfOnly(input: TriageInput): BaseVerdict { + return { + route: 'solo', + rule: 'R2', + explanation: 'Personne d\'autre n\'est concerné — décide.', + reviewRequired: input.reversibility === 'irreversible', + engravingSuggested: false, + } +} + +/** R3 — amendsClauseId set. Protocol resolved by the clause's inertia + * (clauseByInertia), specified fallback → consent. The clause is looked up in + * ctx.matchingClauses (the Q0 index); if absent, consent applies and the + * sentence names « cette règle » instead of a code. */ +function r3TargetedClause( + input: TriageInput, + ctx: TriageContext, + settings: CollectiveSettings, +): BaseVerdict { + const clause: Clause | undefined = ctx.matchingClauses.find( + c => c.id === input.amendsClauseId, + ) + const protocolId: Id + = (clause && settings.protocolByRange.clauseByInertia?.[clause.inertia]) + ?? settings.protocolByRange.consent + const name = clause ? clause.code : 'cette règle' + return { + route: 'collective', + rule: 'R3', + explanation: `Tu proposes une version de ${name} — son inertie s'applique : vote de ceux qu'elle gouverne.`, + protocolId, + ...(input.weight === 'structural' ? { framingDays: settings.triage.framingDays } : {}), + reviewRequired: false, + engravingSuggested: false, + } +} + +/** R5 — modality by computed perimeter size, protocols resolved by the Pact + * with the specified fallback → consent. */ +function r5Collective( + input: TriageInput, + n: number, + settings: CollectiveSettings, +): BaseVerdict { + const range = settings.protocolByRange + let protocolId: Id + let explanation: string + + if (n <= settings.triage.consentMax) { + protocolId = range.consent + explanation = `Vous êtes ${n} — un tour d'accord suffit : sans objection, c'est adopté.` + } else if (n <= settings.triage.collectiveMin) { + protocolId = range.nuanced ?? range.consent + explanation = `Vous êtes ${n} — vote nuancé : chacun se prononce en nuances, pas en camps.` + } else { + protocolId = range.large ?? range.consent + explanation = `Vous êtes ${n} — la modalité que votre Pacte a choisie s'applique.` + } + + const structural = input.weight === 'structural' + return { + route: 'collective', + rule: 'R5', + explanation, + protocolId, + ...(structural ? { framingDays: settings.triage.framingDays } : {}), + reviewRequired: structural || input.reversibility === 'irreversible', + engravingSuggested: structural, + } +} + +// ───────────────────────────────────────────────────────────── +// R6 overlay & permanent alternatives +// ───────────────────────────────────────────────────────────── + +/** R6 — recurrence & maturation. Overlay only: a suggestion, never a route. + * 'claim-mandate' (recent adopted look-alikes) wins over 'protocolize' + * (recorded look-alikes) when both thresholds are reached. */ +function recurrenceOverlay( + input: TriageInput, + ctx: TriageContext, + settings: CollectiveSettings, +): Verdict['suggestion'] | undefined { + const threshold = settings.triage.recurrenceThreshold + if (ctx.similarRecentCount >= threshold) { + return { + kind: 'claim-mandate', + prefill: { + title: input.title, + domainTags: input.tags, + domainCircleIds: input.scope.circleIds, + }, + } + } + if (ctx.similarRecordedCount >= threshold) { + return { + kind: 'protocolize', + prefill: { title: input.title, tags: input.tags }, + } + } + return undefined +} + +/** The permanent « Je choisis autrement » alternatives — ALWAYS present. + * Parametric comes FIRST when a number/%/amount was detected (parametricHint); + * otherwise record leads (the observatory gesture stays one tap away). */ +function buildAlternatives(parametricFirst: boolean): Verdict['alternatives'] { + const parametric = { + route: 'collective' as const, + label: PARAMETRIC_ALT, + cost: PARAMETRIC_COST, + } + const record = { route: 'record' as const, label: RECORD_ALT, cost: RECORD_COST } + const binary = { + route: 'collective' as const, + label: METHOD_LABELS.binary, + cost: BINARY_COST, + } + return parametricFirst ? [parametric, record, binary] : [record, parametric, binary] +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +/** A mandate covers when scope.circleIds is a NON-EMPTY subset of its + * domain.circleIds. Defensive status filter: only 'active' mandates count. */ +function coveringMandate(input: TriageInput, mandates: Mandate[]): Mandate | undefined { + const { circleIds } = input.scope + if (circleIds.length === 0) return undefined + return mandates.find( + m => m.status === 'active' && circleIds.every(id => m.domain.circleIds.includes(id)), + ) +} diff --git a/frontend/tests/engine/election.spec.ts b/frontend/tests/engine/election.spec.ts new file mode 100644 index 0000000..5f0bb18 --- /dev/null +++ b/frontend/tests/engine/election.spec.ts @@ -0,0 +1,168 @@ +/** + * electionResult — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4). + * + * Contract under test: the caller passes the LAST ACTIVE votes only + * (supersedesVoteId chains resolved by the store) — one vote per voter. + * Blank vote = no choicePersonId: counts for participation, never for + * designation. The engine NEVER breaks a tie. + */ +import { describe, expect, it } from 'vitest' +import type { Vote } from '../../app/types/domain' +import { electionResult } from '../../app/engine/threshold' + +let seq = 0 + +/** Build a minimal last-active Vote; omit choicePersonId for a blank. */ +function vote(choicePersonId?: string): Vote { + seq++ + return { + id: `vote-${seq}`, + collectiveId: 'col-1', + createdAt: '2026-08-11T00:00:00Z', + updatedAt: '2026-08-11T00:00:00Z', + sessionId: 'session-1', + voterId: `voter-${seq}`, + choicePersonId, + } +} + +/** n votes for the same person (or n blanks when personId is undefined). */ +function votes(n: number, personId?: string): Vote[] { + return Array.from({ length: n }, () => vote(personId)) +} + +describe('electionResult — clear plurality', () => { + it('elects the most designated person; blanks count in participation only', () => { + const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(1)] + const result = electionResult(ballot, { electionMinParticipants: 5 }) + + expect(result).toEqual({ + outcome: 'elected', + winnerId: 'alice', + counts: { alice: 3, bob: 2 }, + blanks: 1, + participants: 6, + }) + }) + + it('a tie below the top does not prevent election', () => { + const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(2, 'carol')] + const result = electionResult(ballot, {}) + + expect(result.outcome).toBe('elected') + if (result.outcome === 'elected') { + expect(result.winnerId).toBe('alice') + expect(result.counts).toEqual({ alice: 3, bob: 2, carol: 2 }) + } + }) + + it('works without any quorum configured (electionMinParticipants undefined)', () => { + const result = electionResult([vote('alice')], {}) + expect(result.outcome).toBe('elected') + }) +}) + +describe('electionResult — tie: the engine NEVER breaks it', () => { + it('two-way tie at the top => tie with sorted exAequoIds', () => { + const ballot = [...votes(2, 'bob'), ...votes(2, 'alice'), ...votes(1, 'carol')] + const result = electionResult(ballot, { electionMinParticipants: 3 }) + + expect(result).toEqual({ + outcome: 'tie', + exAequoIds: ['alice', 'bob'], // sorted — deterministic order, not a tie-break + counts: { alice: 2, bob: 2, carol: 1 }, + blanks: 0, + participants: 5, + }) + }) + + it('three-way tie => all three ex aequo, sorted regardless of arrival order', () => { + const ballot = [...votes(2, 'carol'), ...votes(2, 'alice'), ...votes(2, 'bob'), ...votes(1)] + const result = electionResult(ballot, { electionMinParticipants: 4 }) + + expect(result.outcome).toBe('tie') + if (result.outcome === 'tie') { + expect(result.exAequoIds).toEqual(['alice', 'bob', 'carol']) + expect(result.blanks).toBe(1) + expect(result.participants).toBe(7) + } + }) + + it('never designates a winner among equals (no random, no first-come)', () => { + // Same ballot tallied twice must yield the exact same tie. + const ballot = [...votes(1, 'bob'), ...votes(1, 'alice')] + const first = electionResult(ballot, {}) + const second = electionResult(ballot, {}) + expect(first).toEqual(second) + expect(first.outcome).toBe('tie') + }) +}) + +describe('electionResult — rejections', () => { + it('quorum not reached => rejected with reason quorum and required', () => { + const ballot = [...votes(2, 'alice'), ...votes(1)] + const result = electionResult(ballot, { electionMinParticipants: 5 }) + + expect(result).toEqual({ + outcome: 'rejected', + reason: 'quorum', + participants: 3, + required: 5, + }) + }) + + it('quorum reached but all blanks => rejected with reason no-designation', () => { + const ballot = votes(4) + const result = electionResult(ballot, { electionMinParticipants: 3 }) + + expect(result).toEqual({ + outcome: 'rejected', + reason: 'no-designation', + participants: 4, + required: 3, + }) + }) + + it('zero votes without quorum => rejected no-designation (required 0)', () => { + const result = electionResult([], {}) + expect(result).toEqual({ + outcome: 'rejected', + reason: 'no-designation', + participants: 0, + required: 0, + }) + }) +}) + +describe('electionResult — blanks and participation', () => { + it('quorum reached THANKS to blanks: blanks count for participation', () => { + // 3 designations alone would miss the quorum of 5; 2 blanks complete it. + const ballot = [...votes(3, 'alice'), ...votes(2)] + const result = electionResult(ballot, { electionMinParticipants: 5 }) + + expect(result).toEqual({ + outcome: 'elected', + winnerId: 'alice', + counts: { alice: 3 }, + blanks: 2, + participants: 5, + }) + }) + + it('exact quorum boundary: participants === required passes', () => { + const ballot = [...votes(1, 'alice'), ...votes(1)] + const result = electionResult(ballot, { electionMinParticipants: 2 }) + expect(result.outcome).toBe('elected') + }) + + it('blanks never appear in counts', () => { + const ballot = [...votes(2, 'alice'), ...votes(3)] + const result = electionResult(ballot, {}) + if (result.outcome === 'elected') { + expect(Object.keys(result.counts)).toEqual(['alice']) + expect(result.blanks).toBe(3) + } else { + throw new Error(`expected elected, got ${result.outcome}`) + } + }) +}) diff --git a/frontend/tests/engine/impact.spec.ts b/frontend/tests/engine/impact.spec.ts new file mode 100644 index 0000000..1d97c5a --- /dev/null +++ b/frontend/tests/engine/impact.spec.ts @@ -0,0 +1,158 @@ +/** + * Tests du moteur de périmètre — computeConcerned / expandCircleMembers. + * Fonctions pures : fixtures assemblées à la main, aucun store. + */ +import { describe, expect, it } from 'vitest' +import { computeConcerned, expandCircleMembers } from '../../app/engine/impact' +import type { Circle, Decision, Mandate } from '../../app/types/domain' + +const T0 = '2026-01-01T00:00:00Z' +const T9 = '2027-01-01T00:00:00Z' +const AUTHOR = 'p-author' + +function mkCircle(over: Partial = {}): Circle { + return { + id: 'c1', + collectiveId: 'col', + createdAt: T0, + updatedAt: T0, + name: 'Forgerons', + purpose: '', + memberIds: [], + domains: [], + ...over, + } +} + +function mkMandate(over: Partial = {}): Mandate { + return { + id: 'm1', + collectiveId: 'col', + createdAt: T0, + updatedAt: T0, + title: 'Modération', + holderId: 'p-holder', + originDecisionId: 'd0', + domain: { circleIds: ['c1'], tags: [] }, + startsAt: T0, + endsAt: T9, + electorCircleId: 'c1', + nominationMethod: 'consent', + reports: [], + status: 'active', + ...over, + } +} + +function mkScope(over: Partial = {}): Decision['scope'] { + return { selfOnly: false, circleIds: [], personIds: [], ...over } +} + +describe('computeConcerned — l\'union calculée, chaque entrée avec sa raison', () => { + it('unit les trois sources : cercles, personnes nommées, titulaires de mandats', () => { + const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1', 'p2'] })] + const mandates = [mkMandate({ holderId: 'p4', title: 'Modération' })] + const scope = mkScope({ circleIds: ['c1'], personIds: ['p3'] }) + + const result = computeConcerned(scope, circles, mandates, AUTHOR) + + expect(result).toHaveLength(4) + expect(result).toContainEqual({ personId: 'p1', reason: 'membre du cercle Forgerons' }) + expect(result).toContainEqual({ personId: 'p2', reason: 'membre du cercle Forgerons' }) + expect(result).toContainEqual({ personId: 'p3', reason: 'nommé·e par l\'auteur' }) + expect(result).toContainEqual({ personId: 'p4', reason: 'titulaire du mandat Modération' }) + }) + + it('dédoublonne — la première raison gagne, priorité cercle > nommé > mandat', () => { + const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1'] })] + // p1 est membre du cercle ET nommé ET titulaire d'un mandat intersectant. + const mandates = [mkMandate({ holderId: 'p1' })] + const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] }) + + const result = computeConcerned(scope, circles, mandates, AUTHOR) + + expect(result).toEqual([{ personId: 'p1', reason: 'membre du cercle Forgerons' }]) + }) + + it('dédoublonne — nommé·e gagne sur titulaire de mandat', () => { + const mandates = [mkMandate({ holderId: 'p1', domain: { circleIds: ['c1'], tags: [] } })] + const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] }) + + const result = computeConcerned(scope, [], mandates, AUTHOR) + + expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }]) + }) + + it('exclut l\'auteur de toutes les sources', () => { + const circles = [mkCircle({ id: 'c1', memberIds: [AUTHOR, 'p1'] })] + const mandates = [mkMandate({ holderId: AUTHOR })] + const scope = mkScope({ circleIds: ['c1'], personIds: [AUTHOR] }) + + const result = computeConcerned(scope, circles, mandates, AUTHOR) + + expect(result.map(e => e.personId)).toEqual(['p1']) + }) + + it('n\'inclut un titulaire que si le domaine du mandat intersecte les cercles du périmètre', () => { + const mandates = [ + mkMandate({ id: 'm1', holderId: 'p4', domain: { circleIds: ['c9'], tags: [] } }), + mkMandate({ id: 'm2', holderId: 'p5', title: 'Trésorerie', domain: { circleIds: ['c2', 'c1'], tags: [] } }), + ] + const scope = mkScope({ circleIds: ['c1'] }) + + const result = computeConcerned(scope, [], mandates, AUTHOR) + + expect(result).toEqual([{ personId: 'p5', reason: 'titulaire du mandat Trésorerie' }]) + }) + + it('ignore les mandats non actifs', () => { + const mandates = [mkMandate({ holderId: 'p4', status: 'expired' })] + const scope = mkScope({ circleIds: ['c1'] }) + + expect(computeConcerned(scope, [], mandates, AUTHOR)).toEqual([]) + }) + + it('ignore sans erreur un cercle inconnu dans le périmètre', () => { + const scope = mkScope({ circleIds: ['c-fantome'], personIds: ['p1'] }) + + const result = computeConcerned(scope, [], [], AUTHOR) + + expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }]) + }) +}) + +describe('expandCircleMembers — appartenance nominative, sans propagation', () => { + it('réunit les membres directs de plusieurs cercles, dédoublonnés', () => { + const circles = [ + mkCircle({ id: 'c1', memberIds: ['p1', 'p2'] }), + mkCircle({ id: 'c2', name: 'Cuisine', memberIds: ['p2', 'p3'] }), + ] + + const members = expandCircleMembers(['c1', 'c2'], circles) + + expect(members).toEqual(new Set(['p1', 'p2', 'p3'])) + }) + + it('ne propage PAS l\'appartenance des cercles enfants au parent (emboîtement ≠ appartenance)', () => { + // v2 : l'appartenance est nominative par cercle ; l'emboîtement + // (parentCircleId) sert l'élargissement d'un cran, jamais le calcul. + const parent = mkCircle({ id: 'c-parent', name: 'Village', memberIds: ['p1'] }) + const child = mkCircle({ + id: 'c-child', + name: 'Hameau', + memberIds: ['p2'], + parentCircleId: 'c-parent', + }) + + expect(expandCircleMembers(['c-parent'], [parent, child])).toEqual(new Set(['p1'])) + // Ni dans l'autre sens : l'enfant n'hérite pas des membres du parent. + expect(expandCircleMembers(['c-child'], [parent, child])).toEqual(new Set(['p2'])) + // L'élargissement est un geste explicite : ajouter le cercle au périmètre. + expect(expandCircleMembers(['c-parent', 'c-child'], [parent, child])) + .toEqual(new Set(['p1', 'p2'])) + }) + + it('ignore un identifiant de cercle inconnu', () => { + expect(expandCircleMembers(['c-fantome'], [mkCircle()])).toEqual(new Set()) + }) +}) diff --git a/frontend/tests/engine/parametric.spec.ts b/frontend/tests/engine/parametric.spec.ts new file mode 100644 index 0000000..4c91af8 --- /dev/null +++ b/frontend/tests/engine/parametric.spec.ts @@ -0,0 +1,480 @@ +/** + * Suite du moteur paramétrique — « Réglage collectif » (BLUEPRINT-V2.md + * Δ2, Δ3, Δ15, Δ16). Specs verrouillées testées ici : + * médiane BASSE élément par élément (pair ⇒ élément central inférieur, + * step respecté par construction), sum100 avec EXACTEMENT une part dérivée, + * clamp + renormalisation proportionnelle à la cristallisation, + * « Pour moi » linear-share (jamais de chiffre inventé), bimodalité + * heuristique jamais bloquante, dégénérescences 0 vote. + */ +import type { ParamSpec } from '../../app/types/domain' +import { describe, expect, it } from 'vitest' +import { + computeMyImpact, + crystallize, + detectBimodality, + medianByElement, + resolveDerived, + validateParamSpec, + validateVote, +} from '../../app/engine/parametric' + +// --------------------------------------------------------------------------- +// Specs de référence +// --------------------------------------------------------------------------- + +/** Spec sum100 : 2 parts votées + 1 part dérivée large (bornes 0–100). */ +function sum100Spec(overrides: Partial = {}): ParamSpec { + return { + constraint: 'sum100', + params: [ + { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + ], + ...overrides, + } +} + +/** Spec sans contrainte : 2 curseurs bornés. */ +function sliderSpec(): ParamSpec { + return { + constraint: 'none', + params: [ + { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1, baseline: 12 }, + { key: 'taux', label: 'Taux (%)', kind: 'slider', min: 0, max: 10, step: 0.5 }, + ], + } +} + +// --------------------------------------------------------------------------- +// medianByElement — médiane basse élément par élément +// --------------------------------------------------------------------------- + +describe('medianByElement — médiane basse élément par élément', () => { + it('nombre impair de votes : élément central classique', () => { + expect(medianByElement([[10], [30], [20]])).toEqual([20]) + }) + + it('nombre pair de votes : élément central INFÉRIEUR (médiane basse)', () => { + // Colonne triée [10, 20, 30, 40] : index floor((4-1)/2) = 1 ⇒ 20, pas 25. + expect(medianByElement([[40], [10], [30], [20]])).toEqual([20]) + }) + + it('la médiane paire est la valeur votée inférieure des deux centrales', () => { + const votes = [[15], [25], [40], [60]] + const [median] = medianByElement(votes) + expect(median).toBe(25) // jamais 32.5 (moyenne des centrales) + expect(votes.flat()).toContain(median) // une position que quelqu'un a votée + }) + + it('step respecté par construction : la médiane appartient à la grille votée', () => { + // Votes conformes au pas 5 : la médiane basse est l'un d'eux, donc conforme. + const votes = [[15], [25], [40], [60]] + const [median] = medianByElement(votes) + expect((median - 0) % 5).toBe(0) + }) + + it('élément par élément : chaque colonne a sa propre médiane', () => { + const votes = [ + [10, 90], + [20, 80], + [30, 70], + ] + expect(medianByElement(votes)).toEqual([20, 80]) + }) + + it('deux votes : médiane basse = le plus petit des deux', () => { + expect(medianByElement([[50, 10], [40, 20]])).toEqual([40, 10]) + }) + + it('0 vote ⇒ [] (l’appelant gère la baseline)', () => { + expect(medianByElement([])).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// validateParamSpec +// --------------------------------------------------------------------------- + +describe('validateParamSpec — validation de la spec à la création', () => { + it('accepte une spec sum100 valide (exactement une part dérivée)', () => { + expect(() => validateParamSpec(sum100Spec())).not.toThrow() + }) + + it('accepte une spec de curseurs sans contrainte', () => { + expect(() => validateParamSpec(sliderSpec())).not.toThrow() + }) + + it('rejette plus de 7 paramètres', () => { + const spec: ParamSpec = { + constraint: 'none', + params: Array.from({ length: 8 }, (_, i) => ({ + key: `p${i}`, + label: `Paramètre ${i}`, + kind: 'slider' as const, + min: 0, + max: 10, + step: 1, + })), + } + expect(() => validateParamSpec(spec)).toThrow(/maximum 7/) + }) + + it('rejette une spec sans aucun paramètre', () => { + expect(() => validateParamSpec({ constraint: 'none', params: [] })).toThrow(/Au moins un paramètre/) + }) + + it('rejette un libellé métier manquant', () => { + const spec = sliderSpec() + spec.params[0].label = ' ' + expect(() => validateParamSpec(spec)).toThrow(/Libellé métier manquant/) + }) + + it('rejette sum100 sans part dérivée (0 derived)', () => { + const spec = sum100Spec() + delete spec.params[2].derived + expect(() => validateParamSpec(spec)).toThrow(/exactement une part dérivée/) + }) + + it('rejette sum100 avec deux parts dérivées (2 derived)', () => { + const spec = sum100Spec() + spec.params[1].derived = true + expect(() => validateParamSpec(spec)).toThrow(/2 trouvées/) + }) + + it('rejette un derived posé sur un curseur (kind slider)', () => { + const spec = sum100Spec() + spec.params[2].kind = 'slider' + expect(() => validateParamSpec(spec)).toThrow(/part/) + }) + + it('rejette un derived sans contrainte pour le résoudre', () => { + const spec = sliderSpec() + spec.params[1].derived = true + expect(() => validateParamSpec(spec)).toThrow(/sans contrainte/) + }) + + it('rejette des bornes incohérentes (min >= max)', () => { + const spec = sliderSpec() + spec.params[0].min = 24 + spec.params[0].max = 24 + expect(() => validateParamSpec(spec)).toThrow(/Bornes incohérentes/) + }) + + it('rejette un pas nul ou négatif (step <= 0)', () => { + const spec = sliderSpec() + spec.params[1].step = 0 + expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/) + spec.params[1].step = -1 + expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/) + }) + + it('rejette une baseline hors bornes', () => { + const spec = sliderSpec() + spec.params[0].baseline = 25 // max 24 + expect(() => validateParamSpec(spec)).toThrow(/hors bornes/) + }) +}) + +// --------------------------------------------------------------------------- +// validateVote +// --------------------------------------------------------------------------- + +describe('validateVote — validation d’un vote', () => { + it('accepte un vote valide (ordre des params hors derived)', () => { + expect(() => validateVote(sum100Spec(), [60, 30])).not.toThrow() + expect(() => validateVote(sliderSpec(), [12, 2.5])).not.toThrow() + }) + + it('rejette une longueur fausse (le derived ne se vote pas)', () => { + expect(() => validateVote(sum100Spec(), [60, 30, 10])).toThrow(/Nombre de valeurs invalide/) + expect(() => validateVote(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/) + }) + + it('rejette NaN et Infinity (sanitize)', () => { + expect(() => validateVote(sum100Spec(), [Number.NaN, 30])).toThrow(/nombre fini/) + expect(() => validateVote(sum100Spec(), [60, Number.POSITIVE_INFINITY])).toThrow(/nombre fini/) + expect(() => validateVote(sliderSpec(), [Number.NEGATIVE_INFINITY, 2])).toThrow(/nombre fini/) + }) + + it('rejette une valeur hors bornes', () => { + expect(() => validateVote(sliderSpec(), [0, 2])).toThrow(/hors bornes/) // min 1 + expect(() => validateVote(sliderSpec(), [12, 10.5])).toThrow(/hors bornes/) // max 10 + }) + + it('rejette une valeur non alignée sur le pas', () => { + expect(() => validateVote(sum100Spec(), [62, 30])).toThrow(/pas/) // pas de 5 + expect(() => validateVote(sliderSpec(), [12, 2.3])).toThrow(/pas/) // pas de 0.5 + }) + + it('tolérance flottante : 0.1 + 0.2 est accepté sur une grille de 0.1', () => { + const spec: ParamSpec = { + constraint: 'none', + params: [{ key: 't', label: 'Taux', kind: 'slider', min: 0, max: 1, step: 0.1 }], + } + expect(() => validateVote(spec, [0.1 + 0.2])).not.toThrow() // 0.30000000000000004 + }) + + it('sum100 : rejette le vote si le derived résolu sort de ses bornes', () => { + const spec = sum100Spec() + spec.params[2].max = 15 // réserve bornée [0, 15] + // 60 + 30 = 90 ⇒ derived 10 : OK ; 50 + 30 = 80 ⇒ derived 20 : rejet. + expect(() => validateVote(spec, [60, 30])).not.toThrow() + expect(() => validateVote(spec, [50, 30])).toThrow(/sortirait de ses bornes/) + // 105 votés ⇒ derived −5 < min 0 : rejet aussi. + expect(() => validateVote(spec, [70, 35])).toThrow(/sortirait de ses bornes/) + }) + + it('sum100 : le derived exactement sur sa borne est accepté', () => { + const spec = sum100Spec() + spec.params[2].max = 5 + expect(() => validateVote(spec, [65, 30])).not.toThrow() // derived = 5 pile + }) + + it('sum100 : un curseur hors contrainte ne compte pas dans la somme', () => { + const spec: ParamSpec = { + constraint: 'sum100', + params: [ + { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, + { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + ], + } + // Le curseur vaut 24 : s'il comptait, derived = 100 − 24 − 60 = 16 ; + // il ne compte pas ⇒ derived = 40, dans [0, 100]. + expect(() => validateVote(spec, [24, 60])).not.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// resolveDerived +// --------------------------------------------------------------------------- + +describe('resolveDerived — résolution linéaire du derived', () => { + it('insère le derived à sa position dans l’ordre de spec.params', () => { + expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10]) + }) + + it('derived en position centrale : l’ordre est préservé', () => { + const spec: ParamSpec = { + constraint: 'sum100', + params: [ + { key: 'a', label: 'Part A', kind: 'share', min: 0, max: 100, step: 1 }, + { key: 'r', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + { key: 'b', label: 'Part B', kind: 'share', min: 0, max: 100, step: 1 }, + ], + } + expect(resolveDerived(spec, [40, 35])).toEqual([40, 25, 35]) + }) + + it('les curseurs hors contrainte passent tels quels', () => { + expect(resolveDerived(sliderSpec(), [12, 2.5])).toEqual([12, 2.5]) + }) + + it('sum100 avec curseur : seul les parts entrent dans le calcul du derived', () => { + const spec: ParamSpec = { + constraint: 'sum100', + params: [ + { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, + { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + ], + } + expect(resolveDerived(spec, [24, 60])).toEqual([24, 60, 40]) + }) + + it('rejette une longueur fausse', () => { + expect(() => resolveDerived(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/) + }) +}) + +// --------------------------------------------------------------------------- +// crystallize +// --------------------------------------------------------------------------- + +describe('crystallize — médiane basse des parts votées puis résolution', () => { + it('cas nominal : médiane basse puis derived résolu, somme 100', () => { + const votes = [ + [50, 20], + [60, 30], + [70, 40], + ] + const out = crystallize(sum100Spec(), votes) + expect(out).toEqual([60, 30, 10]) + expect(out[0] + out[1] + out[2]).toBe(100) + }) + + it('derived dans ses bornes : aucun clamp, valeurs médianes intactes', () => { + const spec = sum100Spec() + spec.params[2].max = 15 + const out = crystallize(spec, [[60, 30], [65, 30], [55, 35]]) + expect(out).toEqual([60, 30, 10]) // derived 10 ≤ 15 : rien à clamper + }) + + it('cas chiffré exact : derived clampé à 5, parts renormalisées ×95/90', () => { + // Médiane des parts votées = {60, 30} ⇒ derived résolu 10 > max 5. + // Spec verrouillée : clamp à 5, puis parts × (100 − 5)/(60 + 30). + const spec = sum100Spec() + spec.params[2].max = 5 + const votes = [ + [50, 20], + [60, 30], + [70, 40], + ] + const out = crystallize(spec, votes) + expect(out[2]).toBe(5) + expect(out[0]).toBeCloseTo((60 * 95) / 90, 10) + expect(out[1]).toBeCloseTo((30 * 95) / 90, 10) + expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10) + }) + + it('clamp à la borne min : même renormalisation proportionnelle', () => { + // Médiane {60, 30} ⇒ derived 10 < min 20 : clamp à 20, parts × 80/90. + const spec = sum100Spec() + spec.params[2].min = 20 + const out = crystallize(spec, [[60, 30]]) + expect(out[2]).toBe(20) + expect(out[0]).toBeCloseTo((60 * 80) / 90, 10) + expect(out[1]).toBeCloseTo((30 * 80) / 90, 10) + expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10) + }) + + it('les curseurs hors contrainte ne sont jamais renormalisés', () => { + const spec: ParamSpec = { + constraint: 'sum100', + params: [ + { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, + { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 5, step: 1, derived: true }, + ], + } + const out = crystallize(spec, [[12, 60, 30]]) + expect(out[0]).toBe(12) // le curseur passe tel quel + expect(out[3]).toBe(5) + expect(out[1] + out[2] + out[3]).toBeCloseTo(100, 10) + }) + + it('0 vote ⇒ vecteur des baselines (baseline ?? min)', () => { + const spec = sum100Spec() + spec.params[0].baseline = 50 + spec.params[2].baseline = 20 + // params[1] sans baseline ⇒ min (0). + expect(crystallize(spec, [])).toEqual([50, 0, 20]) + }) + + it('0 vote sans aucune baseline ⇒ vecteur des min', () => { + expect(crystallize(sliderSpec(), [])).toEqual([12, 0]) // duree a baseline 12, taux min 0 + }) + + it('sans contrainte : médiane basse simple, aucun derived', () => { + const out = crystallize(sliderSpec(), [[6, 2], [12, 4], [18, 3], [24, 5]]) + expect(out).toEqual([12, 3]) // médiane basse de chaque colonne + }) +}) + +// --------------------------------------------------------------------------- +// computeMyImpact +// --------------------------------------------------------------------------- + +describe('computeMyImpact — carte « Pour moi » (linear-share)', () => { + function impactSpec(): ParamSpec { + const spec = sum100Spec() + spec.impactAttrKey = 'heures/mois' + return spec + } + + it('cas nominal chiffré : quote-parts au prorata de l’attribut déclaré', () => { + // 1 200 € ; valeurs [50, 30, 20] ; moi 10 h, corpus [10, 20, 10] ⇒ Σ 40. + // montants : 600, 360, 240 ; ma quote-part = montant × 10/40. + const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10]) + expect(result).not.toBeNull() + expect(result!.perParam).toEqual([ + { key: 'bois', label: 'Atelier bois', amount: 150 }, + { key: 'metal', label: 'Atelier métal', amount: 90 }, + { key: 'reserve', label: 'Réserve (calculé)', amount: 60 }, + ]) + expect(result!.total).toBeCloseTo(300, 10) + }) + + it('le derived est inclus dans les quote-parts (chaque part compte)', () => { + const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10]) + expect(result!.perParam.map(l => l.key)).toContain('reserve') + }) + + it('null si mon attribut est absent — jamais de chiffre inventé', () => { + expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], undefined, [10, 20])).toBeNull() + }) + + it('null si Σ des attributs du corpus vaut 0', () => { + expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [0, 0, 0])).toBeNull() + expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [])).toBeNull() + }) + + it('null hors sum100, sans montant ou sans impactAttrKey', () => { + const noConstraint = sliderSpec() + noConstraint.impactAttrKey = 'heures/mois' + expect(computeMyImpact(noConstraint, { amount: 1200 }, [12, 2.5], 10, [10, 20])).toBeNull() + + expect(computeMyImpact(impactSpec(), {}, [50, 30, 20], 10, [10, 20])).toBeNull() + + const noKey = sum100Spec() // pas d'impactAttrKey + expect(computeMyImpact(noKey, { amount: 1200 }, [50, 30, 20], 10, [10, 20])).toBeNull() + }) + + it('les curseurs hors contrainte n’ont pas de quote-part', () => { + const spec: ParamSpec = { + constraint: 'sum100', + impactAttrKey: 'heures/mois', + params: [ + { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, + { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, + { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + ], + } + const result = computeMyImpact(spec, { amount: 1000 }, [12, 60, 40], 5, [5, 5]) + expect(result!.perParam.map(l => l.key)).toEqual(['bois', 'reserve']) + expect(result!.total).toBeCloseTo(500, 10) // (600 + 400) × 5/10 + }) +}) + +// --------------------------------------------------------------------------- +// detectBimodality +// --------------------------------------------------------------------------- + +describe('detectBimodality — heuristique jamais bloquante', () => { + it('bimodal net : deux paquets séparés par un grand écart ⇒ vrai', () => { + // Triées : [10, 11, 12, 60, 61, 62] — étendue 52, gap max 48 > 40 %. + expect(detectBimodality([10, 60, 11, 61, 12, 62])).toBe(true) + }) + + it('distribution uniforme ⇒ faux', () => { + // Gaps réguliers de 10 sur une étendue de 40 : 10 < 16. + expect(detectBimodality([10, 20, 30, 40, 50])).toBe(false) + }) + + it('moins de 4 valeurs ⇒ toujours faux', () => { + expect(detectBimodality([0, 100])).toBe(false) + expect(detectBimodality([0, 50, 100])).toBe(false) + expect(detectBimodality([])).toBe(false) + }) + + it('un simple outlier (1 seule valeur d’un côté du gap) ⇒ faux', () => { + // Gap max 50 entre 0 et 50, mais une seule valeur à gauche. + expect(detectBimodality([0, 50, 51, 52, 53])).toBe(false) + }) + + it('valeurs toutes identiques (étendue nulle) ⇒ faux', () => { + expect(detectBimodality([5, 5, 5, 5])).toBe(false) + }) + + it('deux paires exactement de part et d’autre du gap ⇒ vrai (cas limite n=4)', () => { + expect(detectBimodality([0, 1, 99, 100])).toBe(true) + }) + + it('jamais bloquante : les valeurs non finies sont ignorées, pas d’exception', () => { + expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow() + expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true) + }) +}) diff --git a/frontend/tests/engine/settings.spec.ts b/frontend/tests/engine/settings.spec.ts new file mode 100644 index 0000000..df1b110 --- /dev/null +++ b/frontend/tests/engine/settings.spec.ts @@ -0,0 +1,226 @@ +/** + * resolveSettings — the Pact IS the settings store (Δ10). + * Defaults, voted overrides, specified fallbacks, corrupted-bundle resilience. + */ +import { describe, expect, it } from 'vitest' +import { SETTINGS_DEFAULTS, hasConsentProtocol, resolveSettings } from '../../app/engine/settings' +import type { Clause, ClauseVersion, Json } from '../../app/types/domain' + +const T0 = '2026-08-01T00:00:00.000Z' +const T1 = '2026-08-05T00:00:00.000Z' + +let seq = 0 + +function makeClause(settingKey: string, overrides: Partial = {}): Clause { + seq += 1 + return { + id: `clause-${seq}`, + collectiveId: 'col-1', + createdAt: T0, + updatedAt: T0, + docId: 'doc-pact', + section: 'Réglages', + position: seq, + code: `S${seq}`, + title: settingKey, + inertia: 'standard', + settingKey, + ...overrides, + } +} + +function makeVersion( + clauseId: string, + settingValue: Json, + overrides: Partial = {}, +): ClauseVersion { + seq += 1 + return { + id: `version-${seq}`, + collectiveId: 'col-1', + createdAt: T0, + updatedAt: T0, + clauseId, + decisionId: 'dec-founding', + versionLabel: 'v1', + content: 'clause adoptée', + settingValue, + status: 'current', + adoptedAt: T0, + ...overrides, + } +} + +/** Build a Pact from [settingKey, settingValue] pairs and resolve it. */ +function resolveFrom(entries: [string, Json][]) { + const clauses = entries.map(([key]) => makeClause(key)) + const versions = clauses.map((clause, index) => makeVersion(clause.id, entries[index]![1])) + return resolveSettings(clauses, versions) +} + +describe('resolveSettings — les défauts complets', () => { + it('sans aucune clause, tous les seuils de triage prennent leur défaut', () => { + const settings = resolveSettings([], []) + expect(settings.triage).toEqual({ + smallGroupMax: 5, + collectiveMin: 50, + consentMax: 7, + objectionWindowHours: 48, + adviceWindowHours: 72, + framingDays: 14, + concernEscalateRatio: 0.5, + recurrenceThreshold: 3, + reviewDelayDays: 90, + requireEffects: 'binding', + }) + expect(settings.triage).toEqual(SETTINGS_DEFAULTS) + }) + + it('sans aucune clause, les protocoles optionnels restent indéfinis (repli → consent chez l’appelant)', () => { + const settings = resolveSettings([], []) + expect(settings.protocolByRange.nuanced).toBeUndefined() + expect(settings.protocolByRange.large).toBeUndefined() + expect(settings.protocolByRange.parametric).toBeUndefined() + expect(settings.protocolByRange.election).toBeUndefined() + expect(settings.protocolByRange.clauseByInertia).toBeUndefined() + }) +}) + +describe('resolveSettings — une clause adoptée change une valeur', () => { + it('la version courante d’une clause remplace le défaut', () => { + const settings = resolveFrom([['triage.smallGroupMax', 8]]) + expect(settings.triage.smallGroupMax).toBe(8) + expect(settings.triage.collectiveMin).toBe(50) // les autres clés gardent leur défaut + }) + + it('une version seulement proposée ne change rien', () => { + const clause = makeClause('triage.framingDays') + const version = makeVersion(clause.id, 21, { status: 'proposed', adoptedAt: undefined }) + expect(resolveSettings([clause], [version]).triage.framingDays).toBe(14) + }) + + it('une version remplacée ne change rien — seule la courante compte', () => { + const clause = makeClause('triage.consentMax') + const superseded = makeVersion(clause.id, 3, { status: 'superseded' }) + const current = makeVersion(clause.id, 9, { adoptedAt: T1 }) + expect(resolveSettings([clause], [superseded, current]).triage.consentMax).toBe(9) + }) + + it('deux versions courantes (anomalie) — la plus récemment adoptée gagne', () => { + const clause = makeClause('triage.objectionWindowHours') + const older = makeVersion(clause.id, 24, { adoptedAt: T0 }) + const newer = makeVersion(clause.id, 96, { adoptedAt: T1 }) + expect(resolveSettings([clause], [older, newer]).triage.objectionWindowHours).toBe(96) + }) + + it('une clause archivée est ignorée', () => { + const clause = makeClause('triage.smallGroupMax', { archivedAt: T1 }) + const version = makeVersion(clause.id, 12) + expect(resolveSettings([clause], [version]).triage.smallGroupMax).toBe(5) + }) +}) + +describe('resolveSettings — requireEffects résolu', () => { + it('« none » voté est appliqué', () => { + expect(resolveFrom([['triage.requireEffects', 'none']]).triage.requireEffects).toBe('none') + }) + + it('« structural » voté est appliqué', () => { + expect(resolveFrom([['triage.requireEffects', 'structural']]).triage.requireEffects).toBe( + 'structural', + ) + }) + + it('une valeur inconnue retombe sur « binding »', () => { + expect(resolveFrom([['triage.requireEffects', 'everything']]).triage.requireEffects).toBe( + 'binding', + ) + }) +}) + +describe('resolveSettings — le protocole de consentement', () => { + it('la clause protocols.consent résout l’identifiant', () => { + const settings = resolveFrom([['protocols.consent', 'proto-consent-1']]) + expect(settings.protocolByRange.consent).toBe('proto-consent-1') + expect(hasConsentProtocol(settings)).toBe(true) + }) + + it('consent introuvable (bundle corrompu) — objet complet, consent vide, jamais de crash', () => { + expect(() => resolveSettings([], [])).not.toThrow() + const settings = resolveSettings([], []) + expect(settings.protocolByRange.consent).toBe('') + expect(hasConsentProtocol(settings)).toBe(false) + expect(settings.triage.smallGroupMax).toBe(5) // le reste de l’objet est utilisable + }) + + it('consent d’un mauvais type compte comme introuvable', () => { + const settings = resolveFrom([['protocols.consent', 42]]) + expect(settings.protocolByRange.consent).toBe('') + expect(hasConsentProtocol(settings)).toBe(false) + }) +}) + +describe('resolveSettings — les protocoles optionnels et l’héritage Ğ1', () => { + it('nuanced, large, parametric et election se résolvent chacun', () => { + const settings = resolveFrom([ + ['protocols.consent', 'p-consent'], + ['protocols.nuanced', 'p-nuanced'], + ['protocols.large', 'p-large'], + ['protocols.parametric', 'p-parametric'], + ['protocols.election', 'p-election'], + ]) + expect(settings.protocolByRange.nuanced).toBe('p-nuanced') + expect(settings.protocolByRange.large).toBe('p-large') + expect(settings.protocolByRange.parametric).toBe('p-parametric') + expect(settings.protocolByRange.election).toBe('p-election') + }) + + it('clauseByInertia complet (4 presets) est exposé', () => { + const settings = resolveFrom([ + ['protocols.clauseByInertia.low', 'p-low'], + ['protocols.clauseByInertia.standard', 'p-standard'], + ['protocols.clauseByInertia.high', 'p-high'], + ['protocols.clauseByInertia.max', 'p-max'], + ]) + expect(settings.protocolByRange.clauseByInertia).toEqual({ + low: 'p-low', + standard: 'p-standard', + high: 'p-high', + max: 'p-max', + }) + }) + + it('clauseByInertia partiel reste indéfini — l’appelant retombe sur consent', () => { + const settings = resolveFrom([ + ['protocols.clauseByInertia.low', 'p-low'], + ['protocols.clauseByInertia.standard', 'p-standard'], + ['protocols.clauseByInertia.high', 'p-high'], + ]) + expect(settings.protocolByRange.clauseByInertia).toBeUndefined() + }) +}) + +describe('resolveSettings — les valeurs invalides retombent sur les défauts, en silence', () => { + it('un mauvais type sur une clé numérique est ignoré', () => { + expect(resolveFrom([['triage.smallGroupMax', 'huit']]).triage.smallGroupMax).toBe(5) + }) + + it('un nombre négatif est ignoré', () => { + expect(resolveFrom([['triage.framingDays', -3]]).triage.framingDays).toBe(14) + }) + + it('un ratio négatif est ignoré', () => { + expect(resolveFrom([['triage.concernEscalateRatio', -0.5]]).triage.concernEscalateRatio).toBe( + 0.5, + ) + }) + + it('null est ignoré', () => { + expect(resolveFrom([['triage.reviewDelayDays', null]]).triage.reviewDelayDays).toBe(90) + }) + + it('un identifiant de protocole vide compte comme non résolu', () => { + const settings = resolveFrom([['protocols.nuanced', ' ']]) + expect(settings.protocolByRange.nuanced).toBeUndefined() + }) +}) diff --git a/frontend/tests/engine/state.spec.ts b/frontend/tests/engine/state.spec.ts new file mode 100644 index 0000000..a6ad591 --- /dev/null +++ b/frontend/tests/engine/state.spec.ts @@ -0,0 +1,623 @@ +/** + * canTransition — the ONE state machine of the decision (Δ29). + * Every guard (a–i) tested with positive AND negative cases + windowOutcome. + */ +import { describe, expect, it } from 'vitest' +import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state' +import type { TransitionContext } from '../../app/engine/state' +import type { + Assent, + CollectiveSettings, + Concern, + Decision, + Objection, + ParamSpec, + VoteSession, +} from '../../app/types/domain' + +const T0 = '2026-08-01T00:00:00.000Z' +const NOW = '2026-08-11T12:00:00.000Z' + +let seq = 0 + +function makeEntity() { + seq += 1 + return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 } +} + +function makeSettings(overrides: Partial = {}): CollectiveSettings { + return { + triage: { + smallGroupMax: 5, + collectiveMin: 50, + consentMax: 7, + objectionWindowHours: 48, + adviceWindowHours: 72, + framingDays: 14, + concernEscalateRatio: 0.5, + recurrenceThreshold: 3, + reviewDelayDays: 90, + requireEffects: 'binding', + ...overrides, + }, + protocolByRange: { consent: 'proto-consent' }, + } +} + +function makeDecision(overrides: Partial = {}): Decision { + return { + ...makeEntity(), + id: 'dec-1', + authorId: 'p-author', + title: 'Décision de test', + tags: [], + reversibility: 'easy', + weight: 'light', + urgent: false, + scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] }, + route: 'collective', + triageRule: 'R5', + routeOverridden: false, + status: 'draft', + stewardIds: [], + measurerIds: [], + visibility: 'scope', + ...overrides, + } +} + +function makeConcern(origin: Concern['origin'], overrides: Partial = {}): Concern { + const entity = makeEntity() + return { + ...entity, + decisionId: 'dec-1', + personId: `p-${entity.id}`, + origin, + reason: 'membre du cercle', + beforeSnapshot: true, + ...overrides, + } +} + +function makeAssent(personId: string, overrides: Partial = {}): Assent { + return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides } +} + +function makeObjection(status: Objection['status'], overrides: Partial = {}): Objection { + return { + ...makeEntity(), + decisionId: 'dec-1', + personId: 'p-objector', + kind: 'content', + argument: 'Je maintiens mon désaccord.', + status, + ...overrides, + } +} + +function makeSession(overrides: Partial = {}): VoteSession { + return { + ...makeEntity(), + decisionId: 'dec-1', + protocolId: 'proto-1', + corpusPersonIds: ['p-1', 'p-2', 'p-3'], + corpusSize: 3, + opensAt: T0, + closesAt: NOW, + status: 'open', + ...overrides, + } +} + +function makeCtx(overrides: Partial = {}): TransitionContext { + return { + concerns: [], + settings: makeSettings(), + assents: [], + objections: [], + now: NOW, + ...overrides, + } +} + +const PARAM_SPEC: ParamSpec = { + params: [ + { key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 }, + { key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true }, + ], + constraint: 'sum100', +} + +/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */ +function influxConcerns(): Concern[] { + return [ + makeConcern('computed'), + makeConcern('computed'), + makeConcern('computed'), + makeConcern('computed'), + makeConcern('declared'), + makeConcern('declared'), + ] +} + +describe('La table des transitions', () => { + it('draft ouvre les sept chemins', () => { + expect(TRANSITIONS.draft).toEqual([ + 'advice', + 'objection', + 'framing', + 'voting', + 'adopted', + 'transmitted', + 'rejected', + ]) + }) + + it('chaque état de travail connaît ses sorties', () => { + expect(TRANSITIONS.advice).toEqual(['adopted', 'voting']) + expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting']) + expect(TRANSITIONS.framing).toEqual(['voting', 'closed']) + expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected']) + expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed']) + }) + + it('les états terminaux n’ont aucune sortie', () => { + expect(TRANSITIONS.rejected).toBeUndefined() + expect(TRANSITIONS.revoked).toBeUndefined() + expect(TRANSITIONS.closed).toBeUndefined() + expect(TRANSITIONS.transmitted).toBeUndefined() + }) +}) + +describe('Garde a — la transition inconnue est refusée', () => { + it('adopted → voting n’existe pas', () => { + const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason.length).toBeGreaterThan(0) + }) + + it('rejected est terminal — aucune sortie', () => { + expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false) + }) + + it('draft → advice existe', () => { + const decision = makeDecision({ status: 'draft', route: 'advice' }) + expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true) + }) + + it('le retour explicite d’une fenêtre à son propre état est permis', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate' }) + expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true) + }) + + it('un état hors fenêtre ne boucle pas sur lui-même', () => { + expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false) + }) +}) + +describe('Garde b — la frontière contestée suspend toute sortie', () => { + it('une fenêtre d’objection suspendue ne peut pas adopter', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW }) + const result = canTransition(decision, 'adopted', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('frontière') + }) + + it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => { + const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false) + }) + + it('le retour explicite au même état reste possible pendant la suspension', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW }) + expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true) + }) + + it('sans suspension, la fenêtre s’adopte normalement', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate' }) + expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true) + }) + + it('la frontière prime sur l’affluence (ordre des gardes)', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW }) + const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('frontière') + }) +}) + +describe('Garde c — l’affluence exige un traitement du périmètre', () => { + it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => { + const decision = makeDecision({ status: 'voting' }) + const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe( + 'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.', + ) + } + }) + + it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => { + const decision = makeDecision({ + status: 'voting', + scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.', + }) + expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true) + }) + + it('sous le ratio, la clôture passe', () => { + const concerns = [ + makeConcern('computed'), + makeConcern('computed'), + makeConcern('computed'), + makeConcern('computed'), + makeConcern('declared'), + ] + const decision = makeDecision({ status: 'voting' }) + expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true) + }) + + it('sans concerné calculé, la garde ne se déclenche jamais', () => { + const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')] + const decision = makeDecision({ status: 'voting' }) + expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true) + }) + + it('la garde vaut aussi pour la fenêtre d’avis', () => { + const decision = makeDecision({ status: 'advice', route: 'advice' }) + expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false) + }) + + it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => { + const decision = makeDecision({ status: 'voting' }) + expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false) + }) + + it('les concernés d’une autre décision ne comptent pas', () => { + const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' })) + const decision = makeDecision({ status: 'voting' }) + expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true) + }) + + it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate' }) + expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true) + }) +}) + +describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => { + const resources = { note: 'Deux heures par semaine pendant un mois' } + + it('« binding » + poids binding sans effet recherché — refus', () => { + const decision = makeDecision({ status: 'draft', weight: 'binding', resources }) + const result = canTransition(decision, 'voting', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('effet recherché') + }) + + it('« binding » + poids binding avec un effet recherché — ouverture', () => { + const decision = makeDecision({ + status: 'draft', + weight: 'binding', + resources, + brief: { effects: [{ label: 'Réduire le temps de réunion' }] }, + }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true) + }) + + it('« binding » + poids structural sans cible mesurable — refus', () => { + const decision = makeDecision({ + status: 'draft', + weight: 'structural', + resources, + brief: { effects: [{ label: 'Assainir le budget' }] }, + }) + const result = canTransition(decision, 'voting', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('cible') + }) + + it('« binding » + poids structural avec une cible — ouverture', () => { + const decision = makeDecision({ + status: 'draft', + weight: 'structural', + resources, + brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] }, + }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true) + }) + + it('« structural » : le poids binding n’est pas sous garde', () => { + const decision = makeDecision({ status: 'draft', weight: 'binding', resources }) + const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) }) + expect(canTransition(decision, 'voting', ctx).ok).toBe(true) + }) + + it('« structural » : le poids structural sans effet est refusé', () => { + const decision = makeDecision({ status: 'draft', weight: 'structural', resources }) + const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) }) + expect(canTransition(decision, 'voting', ctx).ok).toBe(false) + }) + + it('« none » : aucune exigence de matière, même structural', () => { + const decision = makeDecision({ status: 'draft', weight: 'structural', resources }) + const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) }) + expect(canTransition(decision, 'voting', ctx).ok).toBe(true) + }) + + it('le poids light n’est jamais soumis à la matière', () => { + const decision = makeDecision({ status: 'draft', weight: 'light' }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true) + }) + + it('la garde est réservée à la route collective', () => { + const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true) + }) + + it('framing → voting est gardé de la même façon', () => { + const decision = makeDecision({ status: 'framing', weight: 'structural', resources }) + expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false) + }) +}) + +describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => { + it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => { + const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' }) + const result = canTransition(decision, 'objection', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('engage') + }) + + it('avec la note, la fenêtre s’ouvre', () => { + const decision = makeDecision({ + status: 'draft', + route: 'mandate', + weight: 'binding', + resources: { note: 'Une demi-journée de l’équipe accueil' }, + }) + expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true) + }) + + it('une note blanche ne compte pas', () => { + const decision = makeDecision({ + status: 'draft', + route: 'mandate', + weight: 'binding', + resources: { note: ' ' }, + }) + expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false) + }) + + it('le poids light ouvre sans note', () => { + const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' }) + expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true) + }) + + it('la route solo est exemptée', () => { + const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' }) + expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true) + }) + + it('la garde vit aussi à l’ouverture de session — après la matière', () => { + const decision = makeDecision({ + status: 'draft', + weight: 'binding', + brief: { effects: [{ label: 'Un effet recherché' }] }, + }) + const result = canTransition(decision, 'voting', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('engage') + }) +}) + +describe('Garde f — l’accord explicite (Assent) hors du réversible', () => { + it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' }) + expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true) + }) + + it('costly sans Assent — la fenêtre se prolonge', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' }) + const result = canTransition(decision, 'adopted', makeCtx()) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.') + } + }) + + it('costly : l’accord de l’auteur seul ne suffit pas', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' }) + const ctx = makeCtx({ assents: [makeAssent('p-author')] }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(false) + }) + + it('costly : un accord d’un tiers concerné débloque l’adoption', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' }) + const ctx = makeCtx({ assents: [makeAssent('p-other')] }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(true) + }) + + it('irreversible sans Assent — refus aussi', () => { + const decision = makeDecision({ + status: 'objection', + route: 'mandate', + reversibility: 'irreversible', + }) + expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false) + }) + + it('l’Assent d’une autre décision ne compte pas', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' }) + const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(false) + }) +}) + +describe('Garde g — jamais d’adoption sur une objection ouverte', () => { + it('une objection ouverte bloque l’adoption, même en easy', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' }) + const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('objection') + }) + + it('une objection retirée ne bloque plus', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' }) + const ctx = makeCtx({ objections: [makeObjection('withdrawn')] }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(true) + }) + + it('une objection intégrée ne bloque plus', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' }) + const ctx = makeCtx({ objections: [makeObjection('integrated')] }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(true) + }) + + it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => { + const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' }) + const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] }) + const result = canTransition(decision, 'adopted', ctx) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('objection') + }) +}) + +describe('Garde h — la clôture du dossier découpé', () => { + const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' }) + + function element(status: Decision['status'], overrides: Partial = {}): Decision { + const entity = makeEntity() + return makeDecision({ + id: entity.id, + parentDecisionId: 'dossier-1', + chainKind: 'element', + status, + ...overrides, + }) + } + + it('sans élément, le dossier ne se clôt pas', () => { + const result = canTransition(parent(), 'closed', makeCtx({ children: [] })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('dossier') + }) + + it('tous les éléments terminaux — le garant peut clore', () => { + const children = [ + element('adopted'), + element('rejected'), + element('revoked'), + element('closed'), + element('transmitted'), + ] + expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true) + }) + + it('un élément encore en vote retient le dossier', () => { + const children = [element('adopted'), element('voting')] + const result = canTransition(parent(), 'closed', makeCtx({ children })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toContain('en cours') + }) + + it('des enfants non-éléments ne font pas un dossier', () => { + const children = [element('adopted', { chainKind: 'revision' })] + expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false) + }) + + it('l’élément d’un autre dossier ne compte pas', () => { + const children = [element('adopted', { parentDecisionId: 'dossier-2' })] + expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false) + }) + + it('framing → voting du parent reste possible pendant que le dossier vit', () => { + const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' }) + expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true) + }) +}) + +describe('Garde i — la cristallisation attend le geste du garant', () => { + const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC }) + + it('session figée — pas d’adoption sans le geste', () => { + const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) }) + const result = canTransition(parametricDecision(), 'adopted', ctx) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.') + } + }) + + it('session close sans crystallizedById — le moteur ne cristallise jamais', () => { + const ctx = makeCtx({ session: makeSession({ status: 'closed' }) }) + expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false) + }) + + it('session close et geste daté — l’adoption passe', () => { + const ctx = makeCtx({ + session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }), + }) + expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true) + }) + + it('le rejet (quorum non atteint) exige le même geste', () => { + const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) }) + expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false) + }) + + it('le rejet constaté au geste passe', () => { + const ctx = makeCtx({ + session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }), + }) + expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true) + }) + + it('une session figée trahit le paramétrique même sans paramSpec', () => { + const decision = makeDecision({ status: 'voting' }) + const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(false) + }) + + it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => { + const decision = makeDecision({ status: 'voting' }) + const ctx = makeCtx({ session: makeSession({ status: 'closed' }) }) + expect(canTransition(decision, 'adopted', ctx).ok).toBe(true) + }) + + it('sans session dans le contexte, la garde ne s’applique pas', () => { + expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true) + }) +}) + +describe('windowOutcome — l’échéance des fenêtres d’objection', () => { + it('easy — le silence vaut accord, la fenêtre s’adopte', () => { + const decision = makeDecision({ status: 'objection', reversibility: 'easy' }) + expect(windowOutcome(decision, makeCtx())).toBe('adopt') + }) + + it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => { + const decision = makeDecision({ status: 'objection', reversibility: 'costly' }) + expect(windowOutcome(decision, makeCtx())).toBe('extend') + }) + + it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => { + const decision = makeDecision({ status: 'objection', reversibility: 'costly' }) + expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt') + }) + + it('l’accord de l’auteur seul ne compte pas', () => { + const decision = makeDecision({ status: 'objection', reversibility: 'costly' }) + expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend') + }) + + it('frontière suspendue — la fenêtre attend, même en easy', () => { + const decision = makeDecision({ + status: 'objection', + reversibility: 'easy', + windowSuspendedAt: NOW, + }) + expect(windowOutcome(decision, makeCtx())).toBe('wait') + }) +}) diff --git a/frontend/tests/engine/triage.spec.ts b/frontend/tests/engine/triage.spec.ts new file mode 100644 index 0000000..f447705 --- /dev/null +++ b/frontend/tests/engine/triage.spec.ts @@ -0,0 +1,608 @@ +/** + * Tests du moteur de routage — « Le chemin ». + * Ordre des règles vérifié : R-U, R0a, R0b, R0c, R2, R3, R4, R5, R6. + * Fonctions pures : fixtures assemblées à la main, aucun store. + */ +import { describe, expect, it } from 'vitest' +import { detectParametricHint, triage } from '../../app/engine/triage' +import { + BINARY_COST, + PARAMETRIC_ALT, + RECORD_ALT, + URGENT_REFUSED, +} from '../../app/lexicon' +import type { + Clause, + CollectiveSettings, + Mandate, + TriageContext, + TriageInput, +} from '../../app/types/domain' + +const T0 = '2026-01-01T00:00:00Z' +const T9 = '2027-01-01T00:00:00Z' + +function mkSettings(over: { + triage?: Partial + protocolByRange?: Partial +} = {}): CollectiveSettings { + return { + triage: { + smallGroupMax: 5, + collectiveMin: 50, + consentMax: 7, + objectionWindowHours: 48, + adviceWindowHours: 72, + framingDays: 14, + concernEscalateRatio: 0.5, + recurrenceThreshold: 3, + reviewDelayDays: 90, + requireEffects: 'binding', + ...over.triage, + }, + protocolByRange: { consent: 'proto-consent', ...over.protocolByRange }, + } +} + +function mkInput(over: Partial = {}): TriageInput { + return { + title: 'Repeindre la salle', + tags: [], + scope: { selfOnly: false, circleIds: [], personIds: [] }, + reversibility: 'costly', + weight: 'light', + urgent: false, + ...over, + } +} + +function mkCtx(over: Partial = {}): TriageContext { + return { + myActiveMandates: [], + otherActiveMandates: [], + matchingClauses: [], + similarRecentCount: 0, + similarRecordedCount: 0, + computedConcernedIds: ['p1', 'p2', 'p3'], + ...over, + } +} + +function mkMandate(over: Partial = {}): Mandate { + return { + id: 'm1', + collectiveId: 'col', + createdAt: T0, + updatedAt: T0, + title: 'Modération', + holderId: 'p-holder', + originDecisionId: 'd0', + domain: { circleIds: ['c1', 'c2'], tags: [] }, + startsAt: T0, + endsAt: T9, + electorCircleId: 'c1', + nominationMethod: 'consent', + reports: [], + status: 'active', + ...over, + } +} + +function mkClause(over: Partial = {}): Clause { + return { + id: 'cl1', + collectiveId: 'col', + createdAt: T0, + updatedAt: T0, + docId: 'doc1', + section: 'E', + position: 9, + code: 'E9', + title: 'Acte Forgeron', + inertia: 'high', + ...over, + } +} + +const scoped = (circleIds: string[]): TriageInput['scope'] => + ({ selfOnly: false, circleIds, personIds: [] }) + +describe('detectParametricHint — nombre, %, montant dans la phrase', () => { + it('détecte un montant en euros', () => { + expect(detectParametricHint('Fixer la cotisation à 25 €')).toBe(true) + }) + + it('ne détecte rien dans une phrase sans chiffre', () => { + expect(detectParametricHint('Repeindre la salle')).toBe(false) + }) + + it('détecte un pourcentage', () => { + expect(detectParametricHint('Porter la part commune à douze %')).toBe(true) + }) + + it('détecte l\'unité DU en majuscules', () => { + expect(detectParametricHint('Verser un DU aux nouveaux venus')).toBe(true) + }) + + it('ne confond pas l\'article « du » avec l\'unité DU', () => { + expect(detectParametricHint('Changer du mobilier dans la salle')).toBe(false) + }) +}) + +describe('R-U — urgence', () => { + it('conservatoire : route sous-jacente calculée sans urgence, fenêtre doublée, chaîne posée', () => { + // Sans urgence, ce cas serait R4 (easy, 3 ≤ 5, fenêtre 72 h). + const verdict = triage( + mkInput({ urgent: true, reversibility: 'easy' }), + mkCtx(), + mkSettings(), + ) + + expect(verdict.rule).toBe('R-U') + expect(verdict.route).toBe('advice') + expect(verdict.conservatoryChain).toBe(true) + expect(verdict.windowHours).toBe(144) + expect(verdict.explanation).toBe('Décidé en urgence — le collectif ratifie.') + }) + + it('urgence × irréversible : refus — route normale, fenêtres minimales (jamais doublées)', () => { + // Sans urgence, ce cas serait R0b (mandat couvrant, fenêtre 48 h). + const verdict = triage( + mkInput({ urgent: true, reversibility: 'irreversible', scope: scoped(['c1']) }), + mkCtx({ myActiveMandates: [mkMandate()] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R-U') + expect(verdict.route).toBe('mandate') + expect(verdict.explanation).toBe(URGENT_REFUSED) + expect(verdict.conservatoryChain).toBeUndefined() + expect(verdict.windowHours).toBe(48) + }) +}) + +describe('R0a — déjà décidé', () => { + it('clause en vigueur trouvée : consigner en principal, agir ou contester', () => { + const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings()) + + expect(verdict.rule).toBe('R0a') + expect(verdict.route).toBe('record') + expect(verdict.explanation).toBe( + 'C\'est déjà décidé (E9, Acte Forgeron) — agis, ou conteste la règle.', + ) + expect(verdict.windowHours).toBeUndefined() + }) + + it('limite : viser explicitement la clause (amendsClauseId) passe à R3, pas R0a', () => { + // Choix documenté : contester la règle EST R3 — R0a ne bloque pas l'amendement. + const verdict = triage( + mkInput({ amendsClauseId: 'cl1' }), + mkCtx({ matchingClauses: [mkClause()] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R3') + expect(verdict.route).toBe('collective') + }) +}) + +describe('R0b — mon mandat couvre', () => { + it('périmètre inclus dans le domaine du mandat : décide, c\'est tracé', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1']) }), + mkCtx({ myActiveMandates: [mkMandate()] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R0b') + expect(verdict.route).toBe('mandate') + expect(verdict.windowHours).toBe(48) + expect(verdict.explanation).toBe('Ton mandat Modération couvre — décide, c\'est tracé.') + }) + + it('limite : couverture partielle du périmètre ne suffit pas', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1', 'c3']) }), + mkCtx({ myActiveMandates: [mkMandate({ domain: { circleIds: ['c1', 'c2'], tags: [] } })] }), + mkSettings(), + ) + + expect(verdict.rule).not.toBe('R0b') + }) + + it('ordre : selfOnly avec mandat couvrant ⇒ R0b gagne, car R0b précède R2', () => { + // Choix documenté : le devoir de trace du mandat prime sur le solo. + const verdict = triage( + mkInput({ scope: { selfOnly: true, circleIds: ['c1'], personIds: [] } }), + mkCtx({ myActiveMandates: [mkMandate()] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R0b') + expect(verdict.route).toBe('mandate') + }) + + it('limite : périmètre sans cercle ⇒ jamais de couverture triviale', () => { + const verdict = triage( + mkInput({ scope: scoped([]) }), + mkCtx({ myActiveMandates: [mkMandate()] }), + mkSettings(), + ) + + expect(verdict.rule).not.toBe('R0b') + }) +}) + +describe('R0c — le mandat d\'un autre couvre', () => { + it('transmets à sa ou son titulaire — le titre du mandat nomme le pouvoir', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1']) }), + mkCtx({ otherActiveMandates: [mkMandate({ holderId: 'p-other' })] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R0c') + expect(verdict.route).toBe('transmit') + expect(verdict.explanation).toBe( + 'Le mandat Modération couvre — transmets à sa ou son titulaire.', + ) + }) + + it('ordre : mon mandat gagne sur celui d\'un autre (R0b avant R0c)', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1']) }), + mkCtx({ + myActiveMandates: [mkMandate({ id: 'm-me', title: 'Trésorerie' })], + otherActiveMandates: [mkMandate({ id: 'm-other', holderId: 'p-other' })], + }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R0b') + }) + + it('limite : un mandat expiré ne couvre pas', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1']) }), + mkCtx({ otherActiveMandates: [mkMandate({ status: 'expired' })] }), + mkSettings(), + ) + + expect(verdict.rule).not.toBe('R0c') + }) +}) + +describe('R2 — moi seul', () => { + it('selfOnly : décide, zéro fenêtre', () => { + const verdict = triage( + mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }), + mkCtx({ computedConcernedIds: [] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R2') + expect(verdict.route).toBe('solo') + expect(verdict.explanation).toBe('Personne d\'autre n\'est concerné — décide.') + expect(verdict.windowHours).toBeUndefined() + expect(verdict.reviewRequired).toBe(false) + }) + + it('limite : irréversible sur soi ⇒ revoyure suggérée (pré-coche retirable côté UI)', () => { + // Le moteur suggère (reviewRequired) ; la pré-coche retirable est un fait + // d'UI — sur soi, l'outil ne s'interpose jamais. + const verdict = triage( + mkInput({ + scope: { selfOnly: true, circleIds: [], personIds: [] }, + reversibility: 'irreversible', + }), + mkCtx({ computedConcernedIds: [] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R2') + expect(verdict.reviewRequired).toBe(true) + }) +}) + +describe('R3 — clause visée', () => { + const clauseSettings = mkSettings({ + protocolByRange: { + clauseByInertia: { + low: 'proto-low', + standard: 'proto-standard', + high: 'proto-high', + max: 'proto-max', + }, + }, + }) + + it('l\'inertie de la clause choisit le protocole', () => { + const verdict = triage( + mkInput({ amendsClauseId: 'cl1' }), + mkCtx({ matchingClauses: [mkClause({ inertia: 'high' })] }), + clauseSettings, + ) + + expect(verdict.rule).toBe('R3') + expect(verdict.route).toBe('collective') + expect(verdict.protocolId).toBe('proto-high') + expect(verdict.explanation).toBe( + 'Tu proposes une version de E9 — son inertie s\'applique : vote de ceux qu\'elle gouverne.', + ) + }) + + it('repli : sans clauseByInertia, le consentement s\'applique', () => { + const verdict = triage( + mkInput({ amendsClauseId: 'cl1' }), + mkCtx({ matchingClauses: [mkClause()] }), + mkSettings(), + ) + + expect(verdict.protocolId).toBe('proto-consent') + }) + + it('poids structurel ⇒ fenêtre de formulation posée', () => { + const verdict = triage( + mkInput({ amendsClauseId: 'cl1', weight: 'structural' }), + mkCtx({ matchingClauses: [mkClause()] }), + clauseSettings, + ) + + expect(verdict.framingDays).toBe(14) + }) + + it('limite : clause introuvable dans l\'index ⇒ consentement et phrase générique', () => { + const verdict = triage( + mkInput({ amendsClauseId: 'cl-inconnue' }), + mkCtx({ matchingClauses: [] }), + clauseSettings, + ) + + expect(verdict.rule).toBe('R3') + expect(verdict.protocolId).toBe('proto-consent') + expect(verdict.explanation).toBe( + 'Tu proposes une version de cette règle — son inertie s\'applique : vote de ceux qu\'elle gouverne.', + ) + }) +}) + +describe('R4 — petit cercle réversible', () => { + it('réversible et 3 personnes : demande leur avis puis décide (72 h)', () => { + const verdict = triage(mkInput({ reversibility: 'easy' }), mkCtx(), mkSettings()) + + expect(verdict.rule).toBe('R4') + expect(verdict.route).toBe('advice') + expect(verdict.windowHours).toBe(72) + expect(verdict.explanation).toBe( + 'Réversible et 3 personnes concernées — demande leur avis puis décide.', + ) + }) + + it('limite : accord du singulier pour 1 personne', () => { + const verdict = triage( + mkInput({ reversibility: 'easy' }), + mkCtx({ computedConcernedIds: ['p1'] }), + mkSettings(), + ) + + expect(verdict.explanation).toBe( + 'Réversible et 1 personne concernée — demande leur avis puis décide.', + ) + }) + + it('limite : au-delà de smallGroupMax, l\'escalade collective s\'applique', () => { + const verdict = triage( + mkInput({ reversibility: 'easy' }), + mkCtx({ computedConcernedIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R5') + }) + + it('limite : coûteux à défaire ⇒ jamais R4, même à 3 personnes', () => { + const verdict = triage(mkInput({ reversibility: 'costly' }), mkCtx(), mkSettings()) + + expect(verdict.rule).toBe('R5') + }) +}) + +describe('R5 — collectif, modalité par taille du périmètre', () => { + const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `p${i}`) + const fullSettings = mkSettings({ + protocolByRange: { nuanced: 'proto-nuanced', large: 'proto-large' }, + }) + + it('n ≤ consentMax : un tour d\'accord suffit', () => { + const verdict = triage( + mkInput(), + mkCtx({ computedConcernedIds: ids(6) }), + fullSettings, + ) + + expect(verdict.rule).toBe('R5') + expect(verdict.route).toBe('collective') + expect(verdict.protocolId).toBe('proto-consent') + expect(verdict.explanation).toBe( + 'Vous êtes 6 — un tour d\'accord suffit : sans objection, c\'est adopté.', + ) + }) + + it('n ≤ collectiveMin : vote nuancé', () => { + const verdict = triage( + mkInput(), + mkCtx({ computedConcernedIds: ids(23) }), + fullSettings, + ) + + expect(verdict.protocolId).toBe('proto-nuanced') + expect(verdict.explanation).toBe( + 'Vous êtes 23 — vote nuancé : chacun se prononce en nuances, pas en camps.', + ) + }) + + it('repli : sans protocole nuancé, le consentement s\'applique', () => { + const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(23) }), mkSettings()) + + expect(verdict.protocolId).toBe('proto-consent') + }) + + it('n > collectiveMin : la modalité choisie par le Pacte', () => { + const verdict = triage( + mkInput(), + mkCtx({ computedConcernedIds: ids(60) }), + fullSettings, + ) + + expect(verdict.protocolId).toBe('proto-large') + expect(verdict.explanation).toBe( + 'Vous êtes 60 — la modalité que votre Pacte a choisie s\'applique.', + ) + }) + + it('repli : sans protocole large, le consentement s\'applique', () => { + const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(60) }), mkSettings()) + + expect(verdict.protocolId).toBe('proto-consent') + }) + + it('poids structurel ⇒ formulation + revoyure + gravure suggérée', () => { + const verdict = triage( + mkInput({ weight: 'structural' }), + mkCtx({ computedConcernedIds: ids(6) }), + fullSettings, + ) + + expect(verdict.framingDays).toBe(14) + expect(verdict.reviewRequired).toBe(true) + expect(verdict.engravingSuggested).toBe(true) + }) + + it('irréversible ⇒ revoyure exigée, sans gravure automatique', () => { + const verdict = triage( + mkInput({ reversibility: 'irreversible' }), + mkCtx({ computedConcernedIds: ids(6) }), + fullSettings, + ) + + expect(verdict.reviewRequired).toBe(true) + expect(verdict.engravingSuggested).toBe(false) + }) +}) + +describe('R6 — récurrence et maturation (surcouche, jamais bloquante)', () => { + it('3 décisions similaires récentes ⇒ suggère de réclamer un mandat, sans changer la route', () => { + const verdict = triage( + mkInput({ tags: ['cuisine', 'achats'] }), + mkCtx({ similarRecentCount: 3 }), + mkSettings(), + ) + + expect(verdict.rule).toBe('R5') + expect(verdict.suggestion?.kind).toBe('claim-mandate') + }) + + it('3 consignations similaires ⇒ suggère de protocoliser la pratique', () => { + const verdict = triage(mkInput(), mkCtx({ similarRecordedCount: 3 }), mkSettings()) + + expect(verdict.suggestion?.kind).toBe('protocolize') + }) + + it('les deux seuils atteints ⇒ réclamer un mandat passe en premier', () => { + const verdict = triage( + mkInput(), + mkCtx({ similarRecentCount: 3, similarRecordedCount: 3 }), + mkSettings(), + ) + + expect(verdict.suggestion?.kind).toBe('claim-mandate') + }) + + it('sous le seuil ⇒ aucune suggestion', () => { + const verdict = triage( + mkInput(), + mkCtx({ similarRecentCount: 2, similarRecordedCount: 2 }), + mkSettings(), + ) + + expect(verdict.suggestion).toBeUndefined() + }) +}) + +describe('Repli sans protocole de consentement (bundle corrompu)', () => { + const broken = mkSettings({ protocolByRange: { consent: '' } }) + + it('route sur avis avec la bannière — prioritaire même sur un mandat couvrant', () => { + const verdict = triage( + mkInput({ scope: scoped(['c1']) }), + mkCtx({ myActiveMandates: [mkMandate()] }), + broken, + ) + + expect(verdict.route).toBe('advice') + expect(verdict.explanation).toBe('Aucun protocole — crée-le ou décide sur avis.') + expect(verdict.windowHours).toBe(72) + }) + + it('sauf R2 : moi seul décide sans protocole', () => { + const verdict = triage( + mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }), + mkCtx(), + broken, + ) + + expect(verdict.rule).toBe('R2') + expect(verdict.route).toBe('solo') + }) + + it('sauf consigner : une clause en vigueur reste consignable sans protocole', () => { + const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), broken) + + expect(verdict.rule).toBe('R0a') + expect(verdict.route).toBe('record') + }) +}) + +describe('Alternatives permanentes — « Je choisis autrement »', () => { + it('toujours présentes : réglage, consigner, binaire avec son coût', () => { + const verdict = triage(mkInput(), mkCtx(), mkSettings()) + + expect(verdict.alternatives).toHaveLength(3) + const labels = verdict.alternatives.map(a => a.label) + expect(labels).toContain(PARAMETRIC_ALT) + expect(labels).toContain(RECORD_ALT) + const binary = verdict.alternatives.find(a => a.cost === BINARY_COST) + expect(binary).toBeDefined() + expect(binary?.route).toBe('collective') + }) + + it('présentes sur toutes les routes, solo et consigner comprises', () => { + const solo = triage( + mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }), + mkCtx(), + mkSettings(), + ) + const record = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings()) + + expect(solo.alternatives).toHaveLength(3) + expect(record.alternatives).toHaveLength(3) + }) + + it('montant détecté ⇒ le réglage passe en premier et parametricHint est posé', () => { + const verdict = triage( + mkInput({ title: 'Fixer la cotisation à 25 €' }), + mkCtx(), + mkSettings(), + ) + + expect(verdict.parametricHint).toBe(true) + expect(verdict.alternatives[0]?.label).toBe(PARAMETRIC_ALT) + }) + + it('sans montant ⇒ pas de mise en avant du réglage', () => { + const verdict = triage(mkInput(), mkCtx(), mkSettings()) + + expect(verdict.parametricHint).toBeUndefined() + expect(verdict.alternatives[0]?.label).not.toBe(PARAMETRIC_ALT) + }) +}) diff --git a/frontend/tests/lexicon.spec.ts b/frontend/tests/lexicon.spec.ts new file mode 100644 index 0000000..63dc3ba --- /dev/null +++ b/frontend/tests/lexicon.spec.ts @@ -0,0 +1,142 @@ +/** + * Anti-lexicon guard (BLUEPRINT-V2.md Δ22). + * + * Two sweeps, both case- and accent-insensitive (NFD normalization): + * 1. Every UI-visible VALUE exported by app/lexicon.ts — strings, Record + * values, array items, and the return of template functions called with + * dummy params. Export NAMES and Record KEYS are code identifiers and + * are NOT tested (e.g. REVIEW_VERDICTS is a legal identifier even + * though « verdict » is forbidden in UI text). + * 2. The