diff --git a/backend/app/tests/test_doc_protocol_integration.py b/backend/app/tests/test_doc_protocol_integration.py
new file mode 100644
index 0000000..9c65bbf
--- /dev/null
+++ b/backend/app/tests/test_doc_protocol_integration.py
@@ -0,0 +1,856 @@
+"""TDD: Document ↔ Protocol ↔ Vote integration tests.
+
+Tests the interrelation between:
+- DocumentItem ←→ VotingProtocol (via voting_protocol_id)
+- VotingProtocol ←→ FormulaConfig (formula parameters)
+- VoteSession creation from DocumentItem context
+- Threshold computation using item's protocol (inertia presets)
+- Smith vs WoT standard protocol behavior
+- ItemVersion lifecycle: propose → vote → accept/reject
+- Multi-criteria adoption (WoT + Smith + TechComm)
+
+All tests are pure unit tests exercising engine functions + service logic
+without a real database (mocks only).
+"""
+
+from __future__ import annotations
+
+import math
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from app.engine.mode_params import parse_mode_params
+from app.engine.nuanced_vote import evaluate_nuanced
+from app.engine.smith_threshold import smith_threshold
+from app.engine.techcomm_threshold import techcomm_threshold
+from app.engine.threshold import wot_threshold
+
+
+# ===========================================================================
+# 1. DOCUMENT-PROTOCOL INTERRELATION
+# ===========================================================================
+
+
+class TestInertiaPresetsThresholds:
+ """Verify that different inertia presets produce different thresholds.
+
+ Inertia presets map to gradient_exponent values:
+ low → G=0.1 (easy replacement)
+ standard → G=0.2 (balanced)
+ high → G=0.4 (hard replacement)
+ very_high → G=0.6 (very hard replacement)
+ """
+
+ WOT_SIZE = 7224
+ TOTAL_VOTES = 120 # ~1.66% participation
+
+ INERTIA_MAP = {
+ "low": {"gradient_exponent": 0.1, "majority_pct": 50},
+ "standard": {"gradient_exponent": 0.2, "majority_pct": 50},
+ "high": {"gradient_exponent": 0.4, "majority_pct": 60},
+ "very_high": {"gradient_exponent": 0.6, "majority_pct": 66},
+ }
+
+ def _threshold_for_preset(self, preset: str) -> int:
+ params = self.INERTIA_MAP[preset]
+ return wot_threshold(
+ wot_size=self.WOT_SIZE,
+ total_votes=self.TOTAL_VOTES,
+ majority_pct=params["majority_pct"],
+ gradient_exponent=params["gradient_exponent"],
+ )
+
+ def test_low_inertia_easiest(self):
+ """Low inertia should produce the lowest threshold."""
+ t_low = self._threshold_for_preset("low")
+ t_std = self._threshold_for_preset("standard")
+ assert t_low < t_std, f"Low ({t_low}) should be < standard ({t_std})"
+
+ def test_standard_below_high(self):
+ """Standard inertia should be below high."""
+ t_std = self._threshold_for_preset("standard")
+ t_high = self._threshold_for_preset("high")
+ assert t_std < t_high, f"Standard ({t_std}) should be < high ({t_high})"
+
+ def test_high_below_very_high(self):
+ """High inertia should be below very_high."""
+ t_high = self._threshold_for_preset("high")
+ t_vh = self._threshold_for_preset("very_high")
+ assert t_high < t_vh, f"High ({t_high}) should be < very_high ({t_vh})"
+
+ def test_monotonic_ordering(self):
+ """All 4 presets must be strictly ordered: low < standard < high < very_high."""
+ thresholds = {p: self._threshold_for_preset(p) for p in self.INERTIA_MAP}
+ assert thresholds["low"] < thresholds["standard"]
+ assert thresholds["standard"] < thresholds["high"]
+ assert thresholds["high"] < thresholds["very_high"]
+
+ def test_low_inertia_near_majority(self):
+ """With low inertia (G=0.1), even at 1.66% participation,
+ threshold shouldn't be too far from total votes."""
+ t = self._threshold_for_preset("low")
+ # With G=0.1, inertia is mild even at low participation
+ assert t <= self.TOTAL_VOTES, f"Low inertia threshold ({t}) should be <= total ({self.TOTAL_VOTES})"
+
+ def test_very_high_inertia_near_unanimity(self):
+ """With very_high inertia (G=0.6, M=66%), at 1.66% participation,
+ threshold should be very close to total votes (near unanimity)."""
+ t = self._threshold_for_preset("very_high")
+ # At very low participation with high G and high M, threshold ≈ total
+ ratio = t / self.TOTAL_VOTES
+ assert ratio > 0.85, f"Very high inertia ratio ({ratio:.2f}) should demand near-unanimity"
+
+
+class TestSmithProtocolVsStandard:
+ """Compare the behavior of Smith protocol (D30M50B.1G.2S.1) vs
+ WoT standard (D30M50B.1G.2) — the only difference is the Smith criterion."""
+
+ WOT_SIZE = 7224
+ SMITH_SIZE = 20
+ TOTAL = 120
+ VOTES_FOR = 97
+
+ def test_same_wot_threshold(self):
+ """Both protocols have M50B.1G.2 → same WoT threshold."""
+ smith_params = parse_mode_params("D30M50B.1G.2S.1")
+ std_params = parse_mode_params("D30M50B.1G.2")
+
+ t_smith = wot_threshold(
+ wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
+ majority_pct=smith_params["majority_pct"],
+ gradient_exponent=smith_params["gradient_exponent"],
+ )
+ t_std = wot_threshold(
+ wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
+ majority_pct=std_params["majority_pct"],
+ gradient_exponent=std_params["gradient_exponent"],
+ )
+
+ assert t_smith == t_std
+
+ def test_smith_criterion_present_vs_absent(self):
+ """Smith protocol has smith_exponent=0.1, standard has None."""
+ smith_params = parse_mode_params("D30M50B.1G.2S.1")
+ std_params = parse_mode_params("D30M50B.1G.2")
+
+ assert smith_params["smith_exponent"] == 0.1
+ assert std_params["smith_exponent"] is None
+
+ def test_smith_protocol_can_be_blocked_by_smiths(self):
+ """Smith protocol adoption requires both WoT AND Smith criteria."""
+ wot_thresh = wot_threshold(
+ wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
+ majority_pct=50, gradient_exponent=0.2,
+ )
+ wot_pass = self.VOTES_FOR >= wot_thresh
+
+ smith_thresh = smith_threshold(self.SMITH_SIZE, 0.1) # = 2
+ smith_votes_for = 1 # Only 1 smith voted → fails
+
+ adopted = wot_pass and (smith_votes_for >= smith_thresh)
+ assert wot_pass is True
+ assert smith_votes_for < smith_thresh
+ assert adopted is False
+
+ def test_standard_protocol_ignores_smith(self):
+ """Standard protocol with no smith_exponent always passes smith criterion."""
+ params = parse_mode_params("D30M50B.1G.2")
+ smith_ok = True # Default when smith_exponent is None
+ if params["smith_exponent"] is not None:
+ smith_ok = False # Would need smith votes
+
+ assert smith_ok is True
+
+ def test_smith_threshold_scales_with_smith_size(self):
+ """Smith threshold ceil(N^0.1) grows slowly with smith WoT size."""
+ sizes_and_expected = [
+ (1, 1), # ceil(1^0.1) = 1
+ (5, 2), # ceil(5^0.1) = ceil(1.175) = 2
+ (20, 2), # ceil(20^0.1) = ceil(1.35) = 2
+ (100, 2), # ceil(100^0.1) = ceil(1.585) = 2
+ (1000, 2), # ceil(1000^0.1) = ceil(1.995) = 2
+ (1024, 2), # ceil(1024^0.1) = ceil(2.0) = 2
+ (1025, 3), # ceil(1025^0.1) > 2
+ ]
+ for size, expected in sizes_and_expected:
+ result = smith_threshold(size, 0.1)
+ assert result == expected, f"smith_threshold({size}, 0.1) = {result}, expected {expected}"
+
+
+class TestModeParamsRoundtrip:
+ """Verify mode_params parsing produces correct formula parameters."""
+
+ def test_smith_protocol_params(self):
+ """D30M50B.1G.2S.1 — the Forgeron Smith protocol."""
+ params = parse_mode_params("D30M50B.1G.2S.1")
+ assert params["duration_days"] == 30
+ assert params["majority_pct"] == 50
+ assert params["base_exponent"] == 0.1
+ assert params["gradient_exponent"] == 0.2
+ assert params["smith_exponent"] == 0.1
+ assert params["techcomm_exponent"] is None
+
+ def test_techcomm_protocol_params(self):
+ """D30M50B.1G.2T.1 — a TechComm protocol."""
+ params = parse_mode_params("D30M50B.1G.2T.1")
+ assert params["techcomm_exponent"] == 0.1
+ assert params["smith_exponent"] is None
+
+ def test_full_protocol_params(self):
+ """D30M50B.1G.2S.1T.1 — both Smith AND TechComm."""
+ params = parse_mode_params("D30M50B.1G.2S.1T.1")
+ assert params["smith_exponent"] == 0.1
+ assert params["techcomm_exponent"] == 0.1
+
+ def test_high_inertia_params(self):
+ """D30M60B.1G.4 — high inertia preset."""
+ params = parse_mode_params("D30M60B.1G.4")
+ assert params["majority_pct"] == 60
+ assert params["gradient_exponent"] == 0.4
+
+ def test_very_high_inertia_params(self):
+ """D30M66B.1G.6 — very high inertia preset."""
+ params = parse_mode_params("D30M66B.1G.6")
+ assert params["majority_pct"] == 66
+ assert params["gradient_exponent"] == 0.6
+
+ def test_params_used_in_threshold_match(self):
+ """Threshold computed from parsed params must match direct computation."""
+ params = parse_mode_params("D30M50B.1G.2S.1")
+ computed = wot_threshold(
+ wot_size=7224, total_votes=120,
+ majority_pct=params["majority_pct"],
+ base_exponent=params["base_exponent"],
+ gradient_exponent=params["gradient_exponent"],
+ )
+ direct = wot_threshold(
+ wot_size=7224, total_votes=120,
+ majority_pct=50, base_exponent=0.1, gradient_exponent=0.2,
+ )
+ assert computed == direct
+
+
+# ===========================================================================
+# 2. VOTE BEHAVIOR — ADVANCED SCENARIOS
+# ===========================================================================
+
+
+class TestInertiaFormulaBehavior:
+ """Deep tests on the inertia formula behavior across participation levels."""
+
+ def test_participation_curve_is_monotonically_decreasing(self):
+ """As participation increases, required threshold ratio decreases.
+ This is the fundamental property of inertia-based democracy."""
+ W = 7224
+ M = 0.5
+ G = 0.2
+
+ prev_ratio = float("inf")
+ for t in range(10, W + 1, 100):
+ participation = t / W
+ inertia = 1.0 - participation ** G
+ ratio = M + (1.0 - M) * inertia
+ assert ratio <= prev_ratio, (
+ f"Ratio must decrease: at T={t}, ratio={ratio:.4f} > prev={prev_ratio:.4f}"
+ )
+ prev_ratio = ratio
+
+ def test_at_full_participation_ratio_equals_majority(self):
+ """At T=W (100% participation), ratio should equal M exactly."""
+ W = 7224
+ M_pct = 50
+ M = M_pct / 100
+
+ threshold = wot_threshold(wot_size=W, total_votes=W, majority_pct=M_pct)
+ expected = math.ceil(0.1 ** W + M * W)
+ assert threshold == expected
+
+ def test_at_1_percent_participation_near_unanimity(self):
+ """At ~1% participation, threshold should be near total votes."""
+ W = 7224
+ T = 72 # ~1%
+
+ threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50)
+ ratio = threshold / T
+ assert ratio > 0.75, f"At 1% participation, ratio={ratio:.2f} should be > 0.75"
+
+ def test_at_50_percent_participation(self):
+ """At 50% participation with G=0.2, threshold is well above simple majority."""
+ W = 1000
+ T = 500
+
+ threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50,
+ gradient_exponent=0.2)
+ # (500/1000)^0.2 ≈ 0.87, inertia ≈ 0.13, ratio ≈ 0.565
+ assert threshold > 250, "Should be above simple majority"
+ assert threshold < 400, "Should not be near unanimity"
+
+ def test_gradient_zero_means_always_majority(self):
+ """With G=0, (T/W)^0 = 1, inertia = 0, ratio = M always.
+ This effectively disables inertia."""
+ W = 7224
+ M_pct = 50
+
+ for T in [10, 100, 1000, 7224]:
+ threshold = wot_threshold(wot_size=W, total_votes=T,
+ majority_pct=M_pct, gradient_exponent=0.0001)
+ expected_approx = M_pct / 100 * T
+ # With very small G, threshold should be close to M*T
+ assert abs(threshold - math.ceil(expected_approx)) <= 2, (
+ f"At T={T}, threshold={threshold}, expected≈{expected_approx:.0f}"
+ )
+
+
+class TestMultiCriteriaAdoption:
+ """Test that adoption requires ALL applicable criteria to pass."""
+
+ WOT = 7224
+ TOTAL = 120
+ VOTES_FOR = 97
+ SMITH_SIZE = 20
+ TECHCOMM_SIZE = 5
+
+ def _wot_threshold(self):
+ return wot_threshold(wot_size=self.WOT, total_votes=self.TOTAL,
+ majority_pct=50, gradient_exponent=0.2)
+
+ def test_all_pass(self):
+ """WoT pass + Smith pass + TechComm pass → adopted."""
+ wot_ok = self.VOTES_FOR >= self._wot_threshold()
+ smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1) # 3 >= 2
+ tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 2 >= 2
+
+ assert wot_ok and smith_ok and tc_ok
+
+ def test_wot_fails(self):
+ """WoT fail + Smith pass + TechComm pass → rejected."""
+ wot_ok = 50 >= self._wot_threshold() # 50 < 94
+ smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
+ tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
+
+ assert not wot_ok
+ assert not (wot_ok and smith_ok and tc_ok)
+
+ def test_smith_fails(self):
+ """WoT pass + Smith fail + TechComm pass → rejected."""
+ wot_ok = self.VOTES_FOR >= self._wot_threshold()
+ smith_ok = 1 >= smith_threshold(self.SMITH_SIZE, 0.1) # 1 < 2
+ tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
+
+ assert wot_ok and tc_ok
+ assert not smith_ok
+ assert not (wot_ok and smith_ok and tc_ok)
+
+ def test_techcomm_fails(self):
+ """WoT pass + Smith pass + TechComm fail → rejected."""
+ wot_ok = self.VOTES_FOR >= self._wot_threshold()
+ smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
+ tc_ok = 1 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 1 < 2
+
+ assert wot_ok and smith_ok
+ assert not tc_ok
+ assert not (wot_ok and smith_ok and tc_ok)
+
+ def test_all_fail(self):
+ """All three fail → rejected."""
+ wot_ok = 10 >= self._wot_threshold()
+ smith_ok = 0 >= smith_threshold(self.SMITH_SIZE, 0.1)
+ tc_ok = 0 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
+
+ assert not (wot_ok or smith_ok or tc_ok)
+
+ def test_no_smith_no_techcomm(self):
+ """When protocol has no Smith/TechComm, only WoT matters."""
+ params = parse_mode_params("D30M50B.1G.2")
+ wot_ok = self.VOTES_FOR >= self._wot_threshold()
+
+ # smith_exponent and techcomm_exponent are None
+ smith_ok = True # default when not configured
+ tc_ok = True
+ if params["smith_exponent"] is not None:
+ smith_ok = False
+ if params["techcomm_exponent"] is not None:
+ tc_ok = False
+
+ assert wot_ok and smith_ok and tc_ok
+
+
+class TestEdgeCasesVotes:
+ """Edge cases in vote behavior."""
+
+ def test_single_vote_small_wot(self):
+ """1 vote out of 5 WoT members → threshold near 1 (almost unanimity)."""
+ threshold = wot_threshold(wot_size=5, total_votes=1, majority_pct=50)
+ # With 1/5 = 20% participation, inertia is high → threshold ≈ 1
+ assert threshold == 1
+
+ def test_single_vote_large_wot(self):
+ """1 vote out of 7224 WoT → threshold = 1 (need that 1 vote to be for)."""
+ threshold = wot_threshold(wot_size=7224, total_votes=1, majority_pct=50)
+ assert threshold == 1
+
+ def test_two_votes_disagree(self):
+ """2 votes: 1 for + 1 against. At low participation → need near-unanimity."""
+ threshold = wot_threshold(wot_size=7224, total_votes=2, majority_pct=50)
+ # Threshold should be close to 2 (near unanimity)
+ assert threshold == 2
+
+ def test_exact_threshold_boundary(self):
+ """votes_for == threshold exactly → adopted (>= comparison)."""
+ threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
+ assert threshold >= threshold # votes_for == threshold → adopted
+
+ def test_one_below_threshold_boundary(self):
+ """votes_for == threshold - 1 → rejected."""
+ threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
+ assert (threshold - 1) < threshold
+
+ def test_constant_base_raises_minimum(self):
+ """With C=5, threshold is at least 5 even with no participation effect."""
+ threshold = wot_threshold(wot_size=100, total_votes=0,
+ majority_pct=50, constant_base=5.0)
+ assert threshold >= 5
+
+ def test_wot_size_1_minimal(self):
+ """WoT of 1 member, 1 vote → threshold = 1."""
+ threshold = wot_threshold(wot_size=1, total_votes=1, majority_pct=50)
+ assert threshold == 1
+
+
+class TestNuancedVoteAdvanced:
+ """Advanced nuanced vote scenarios."""
+
+ def test_all_level_5_adopted(self):
+ """60 voters all at level 5 (TOUT A FAIT) → adopted."""
+ result = evaluate_nuanced([5] * 60)
+ assert result["adopted"] is True
+ assert result["positive_pct"] == 100.0
+
+ def test_all_level_0_rejected(self):
+ """60 voters all at level 0 (CONTRE) → rejected."""
+ result = evaluate_nuanced([0] * 60)
+ assert result["adopted"] is False
+ assert result["positive_pct"] == 0.0
+
+ def test_exactly_80_pct_positive(self):
+ """Exactly 80% positive (48/60) → threshold_met = True."""
+ # 48 positive (levels 3-5) + 12 negative (levels 0-2) = 60
+ votes = [4] * 48 + [1] * 12
+ result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
+ assert result["positive_pct"] == 80.0
+ assert result["threshold_met"] is True
+ assert result["adopted"] is True
+
+ def test_just_below_80_pct(self):
+ """79.67% positive (47.8/60 ≈ 47/59) → threshold_met = False."""
+ # 47 positive + 13 negative = 60
+ votes = [4] * 47 + [1] * 13
+ result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
+ assert result["positive_pct"] < 80.0
+ assert result["threshold_met"] is False
+ assert result["adopted"] is False
+
+ def test_min_participants_exactly_met(self):
+ """59 participants exactly → min_participants_met = True."""
+ votes = [5] * 59
+ result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
+ assert result["min_participants_met"] is True
+ assert result["adopted"] is True
+
+ def test_min_participants_not_met(self):
+ """58 participants → min_participants_met = False, even if 100% positive."""
+ votes = [5] * 58
+ result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
+ assert result["min_participants_met"] is False
+ assert result["threshold_met"] is True # 100% > 80%
+ assert result["adopted"] is False # but quorum not met
+
+ def test_neutre_counts_as_positive(self):
+ """Level 3 (NEUTRE) counts as positive in the formula."""
+ votes = [3] * 60
+ result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
+ assert result["positive_count"] == 60
+ assert result["positive_pct"] == 100.0
+ assert result["adopted"] is True
+
+ def test_invalid_level_raises(self):
+ """Vote level 6 should raise ValueError."""
+ with pytest.raises(ValueError, match="invalide"):
+ evaluate_nuanced([6])
+
+ def test_negative_level_raises(self):
+ """Vote level -1 should raise ValueError."""
+ with pytest.raises(ValueError, match="invalide"):
+ evaluate_nuanced([-1])
+
+
+# ===========================================================================
+# 3. ITEM MODIFICATION / DELETION / ADDITION WORKFLOW
+# ===========================================================================
+
+
+class TestItemVersionWorkflow:
+ """Test the ItemVersion status lifecycle and apply/reject logic
+ using the document_service functions with mock database."""
+
+ def _make_item(self, item_id=None, text="Original text"):
+ item = MagicMock()
+ item.id = item_id or uuid.uuid4()
+ item.current_text = text
+ return item
+
+ def _make_version(self, version_id=None, item_id=None, proposed_text="New text",
+ status="proposed"):
+ version = MagicMock()
+ version.id = version_id or uuid.uuid4()
+ version.item_id = item_id or uuid.uuid4()
+ version.proposed_text = proposed_text
+ version.status = status
+ version.rationale = "Test rationale"
+ return version
+
+ @pytest.mark.asyncio
+ async def test_apply_version_updates_current_text(self):
+ """When a version is accepted, item.current_text is updated."""
+ from app.services.document_service import apply_version
+
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = self._make_item(item_id, "Old text")
+ version = self._make_version(version_id, item_id, "New improved text")
+
+ db = AsyncMock()
+ # Mock item query
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ # Mock version query
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = version
+ # Mock other versions query (no other pending versions)
+ other_result = MagicMock()
+ other_result.scalars.return_value = iter([])
+
+ db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
+ db.commit = AsyncMock()
+ db.refresh = AsyncMock()
+
+ result = await apply_version(item_id, version_id, db)
+
+ assert item.current_text == "New improved text"
+ assert version.status == "accepted"
+
+ @pytest.mark.asyncio
+ async def test_apply_version_rejects_competing_versions(self):
+ """When a version is accepted, all other pending versions are rejected."""
+ from app.services.document_service import apply_version
+
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = self._make_item(item_id)
+ version = self._make_version(version_id, item_id, "Winning text")
+ other1 = self._make_version(status="proposed")
+ other2 = self._make_version(status="voting")
+
+ db = AsyncMock()
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = version
+ other_result = MagicMock()
+ other_result.scalars.return_value = iter([other1, other2])
+
+ db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
+ db.commit = AsyncMock()
+ db.refresh = AsyncMock()
+
+ await apply_version(item_id, version_id, db)
+
+ assert other1.status == "rejected"
+ assert other2.status == "rejected"
+
+ @pytest.mark.asyncio
+ async def test_apply_version_wrong_item_raises(self):
+ """Applying a version that belongs to a different item should raise."""
+ from app.services.document_service import apply_version
+
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = self._make_item(item_id)
+ version = self._make_version(version_id, uuid.uuid4(), "Text") # different item_id
+
+ db = AsyncMock()
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = version
+
+ db.execute = AsyncMock(side_effect=[item_result, version_result])
+
+ with pytest.raises(ValueError, match="n'appartient pas"):
+ await apply_version(item_id, version_id, db)
+
+ @pytest.mark.asyncio
+ async def test_reject_version_sets_status(self):
+ """Rejecting a version sets its status to 'rejected'."""
+ from app.services.document_service import reject_version
+
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = self._make_item(item_id)
+ version = self._make_version(version_id, item_id, status="proposed")
+
+ db = AsyncMock()
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = version
+
+ db.execute = AsyncMock(side_effect=[item_result, version_result])
+ db.commit = AsyncMock()
+ db.refresh = AsyncMock()
+
+ result = await reject_version(item_id, version_id, db)
+
+ assert version.status == "rejected"
+
+ @pytest.mark.asyncio
+ async def test_apply_nonexistent_item_raises(self):
+ """Applying a version to a nonexistent item should raise."""
+ from app.services.document_service import apply_version
+
+ db = AsyncMock()
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = None
+
+ db.execute = AsyncMock(return_value=item_result)
+
+ with pytest.raises(ValueError, match="introuvable"):
+ await apply_version(uuid.uuid4(), uuid.uuid4(), db)
+
+ @pytest.mark.asyncio
+ async def test_apply_nonexistent_version_raises(self):
+ """Applying a nonexistent version should raise."""
+ from app.services.document_service import apply_version
+
+ item_id = uuid.uuid4()
+ item = self._make_item(item_id)
+
+ db = AsyncMock()
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = None
+
+ db.execute = AsyncMock(side_effect=[item_result, version_result])
+
+ with pytest.raises(ValueError, match="introuvable"):
+ await apply_version(item_id, uuid.uuid4(), db)
+
+
+class TestDocumentSerialization:
+ """Test document serialization for IPFS archival with new fields."""
+
+ def _make_doc(self, items=None):
+ from app.services.document_service import serialize_document_to_text
+
+ doc = MagicMock()
+ doc.title = "Acte d'engagement Certification"
+ doc.version = "1.0.0"
+ doc.doc_type = "engagement"
+ doc.status = "active"
+ doc.description = "Test document"
+ doc.items = items or []
+ return doc
+
+ def _make_item(self, position, sort_order, title, text, section_tag=None, item_type="clause"):
+ item = MagicMock()
+ item.position = position
+ item.sort_order = sort_order
+ item.title = title
+ item.current_text = text
+ item.item_type = item_type
+ item.section_tag = section_tag
+ return item
+
+ def test_serialization_includes_section_items(self):
+ """Serialization should include items from all sections."""
+ from app.services.document_service import serialize_document_to_text
+
+ items = [
+ self._make_item("I1", 0, "Preambule", "Texte preambule", "introduction"),
+ self._make_item("E1", 1, "Clause 1", "Texte clause", "fondamental"),
+ self._make_item("X1", 2, "Annexe 1", "Texte annexe", "annexe"),
+ ]
+ doc = self._make_doc(items)
+ result = serialize_document_to_text(doc)
+
+ assert "Preambule" in result
+ assert "Clause 1" in result
+ assert "Annexe 1" in result
+ assert "Texte preambule" in result
+
+ def test_serialization_preserves_order(self):
+ """Items should appear in sort_order, not insertion order."""
+ from app.services.document_service import serialize_document_to_text
+
+ items = [
+ self._make_item("X1", 2, "Third", "Text 3"),
+ self._make_item("I1", 0, "First", "Text 1"),
+ self._make_item("E1", 1, "Second", "Text 2"),
+ ]
+ doc = self._make_doc(items)
+ result = serialize_document_to_text(doc)
+
+ first_pos = result.index("First")
+ second_pos = result.index("Second")
+ third_pos = result.index("Third")
+ assert first_pos < second_pos < third_pos
+
+ def test_serialization_deterministic(self):
+ """Same document serialized twice must produce identical output."""
+ from app.services.document_service import serialize_document_to_text
+
+ items = [self._make_item("E1", 0, "Clause 1", "Text 1")]
+ doc = self._make_doc(items)
+
+ result1 = serialize_document_to_text(doc)
+ result2 = serialize_document_to_text(doc)
+ assert result1 == result2
+
+
+class TestVoteSessionCreationFromItem:
+ """Test the logic for creating a VoteSession from a DocumentItem's protocol context.
+ This simulates what the votes router does when creating a session."""
+
+ def test_session_inherits_protocol_params(self):
+ """A vote session created for an item should use the item's protocol."""
+ # Simulate: DocumentItem has voting_protocol_id → VotingProtocol has mode_params
+ protocol_params = parse_mode_params("D30M50B.1G.2S.1")
+
+ # These params should be used in threshold computation
+ threshold = wot_threshold(
+ wot_size=7224,
+ total_votes=120,
+ majority_pct=protocol_params["majority_pct"],
+ gradient_exponent=protocol_params["gradient_exponent"],
+ base_exponent=protocol_params["base_exponent"],
+ )
+
+ assert threshold == 94
+
+ def test_different_protocols_different_thresholds(self):
+ """Items with different protocols should produce different thresholds."""
+ # Certification item (standard WoT)
+ std_params = parse_mode_params("D30M50B.1G.2")
+ # Forgeron item (Smith protocol)
+ smith_params = parse_mode_params("D30M50B.1G.2S.1")
+ # TechComm item
+ tc_params = parse_mode_params("D30M50B.1G.2T.1")
+
+ # WoT thresholds are the same (same M/B/G)
+ t_std = wot_threshold(wot_size=7224, total_votes=120,
+ majority_pct=std_params["majority_pct"],
+ gradient_exponent=std_params["gradient_exponent"])
+ t_smith = wot_threshold(wot_size=7224, total_votes=120,
+ majority_pct=smith_params["majority_pct"],
+ gradient_exponent=smith_params["gradient_exponent"])
+ assert t_std == t_smith
+
+ # But Smith protocol requires additional smith votes
+ smith_req = smith_threshold(20, smith_params["smith_exponent"])
+ assert smith_req == 2
+
+ # TechComm protocol requires additional techcomm votes
+ tc_req = techcomm_threshold(5, tc_params["techcomm_exponent"])
+ assert tc_req == 2
+
+ def test_session_duration_from_protocol(self):
+ """Session duration should come from protocol's duration_days."""
+ params = parse_mode_params("D30M50B.1G.2")
+ assert params["duration_days"] == 30
+
+ params_short = parse_mode_params("D7M50B.1G.2")
+ assert params_short["duration_days"] == 7
+
+
+class TestRealWorldScenarios:
+ """Real-world voting scenarios from Duniter community."""
+
+ def test_forgeron_vote_feb_2026(self):
+ """Engagement Forgeron v2.0.0 — Feb 2026.
+ 97 pour / 23 contre, WoT 7224, Smith 20.
+ Mode: D30M50B.1G.2S.1 → threshold=94 → adopted."""
+ params = parse_mode_params("D30M50B.1G.2S.1")
+
+ threshold = wot_threshold(
+ wot_size=7224, total_votes=120,
+ majority_pct=params["majority_pct"],
+ gradient_exponent=params["gradient_exponent"],
+ )
+ assert threshold == 94
+
+ adopted = 97 >= threshold
+ assert adopted is True
+
+ # Smith criterion
+ smith_req = smith_threshold(20, params["smith_exponent"])
+ assert smith_req == 2
+ # Assume at least 2 smiths voted for → passes
+ smith_ok = 5 >= smith_req
+ assert smith_ok is True
+
+ def test_forgeron_vote_barely_passes(self):
+ """Same scenario but with exactly 94 votes for → still passes."""
+ threshold = wot_threshold(wot_size=7224, total_votes=117,
+ majority_pct=50, gradient_exponent=0.2)
+ # Slightly different total (94+23=117)
+ assert 94 >= threshold
+
+ def test_forgeron_vote_would_fail_at_93(self):
+ """93 votes for out of 116 → fails (threshold likely ~93-94)."""
+ threshold = wot_threshold(wot_size=7224, total_votes=116,
+ majority_pct=50, gradient_exponent=0.2)
+ # With 116 total, threshold is still high
+ # 93 may or may not pass depending on exact computation
+ if threshold > 93:
+ assert 93 < threshold
+
+ def test_certification_item_low_inertia(self):
+ """A certification document item with low inertia (G=0.1) is easier to replace."""
+ threshold_low = wot_threshold(wot_size=7224, total_votes=120,
+ majority_pct=50, gradient_exponent=0.1)
+ threshold_std = wot_threshold(wot_size=7224, total_votes=120,
+ majority_pct=50, gradient_exponent=0.2)
+ assert threshold_low < threshold_std
+
+ def test_very_high_inertia_item(self):
+ """A formule/ordonnancement item with very_high inertia (G=0.6, M=66%)."""
+ threshold = wot_threshold(wot_size=7224, total_votes=120,
+ majority_pct=66, gradient_exponent=0.6)
+ # Should be very close to 120 (near unanimity at such low participation)
+ assert threshold >= 110, f"Very high inertia should demand near-unanimity, got {threshold}"
+
+ def test_full_wot_participation_simple_majority_wins(self):
+ """If entire WoT of 7224 votes, simple majority (3613) suffices with standard params."""
+ threshold = wot_threshold(wot_size=7224, total_votes=7224,
+ majority_pct=50, gradient_exponent=0.2)
+ # At full participation: threshold ≈ 0.5 * 7224 = 3612
+ assert threshold == math.ceil(0.1 ** 7224 + 0.5 * 7224)
+ assert threshold == 3612
+
+ def test_techcomm_vote_cotec_5_members(self):
+ """TechComm criterion with 5 members, exponent 0.1 → need 2 votes."""
+ tc_threshold = techcomm_threshold(5, 0.1)
+ assert tc_threshold == 2
+
+ # 1 TC vote → fails
+ assert 1 < tc_threshold
+ # 2 TC votes → passes
+ assert 2 >= tc_threshold
diff --git a/backend/app/tests/test_public_api.py b/backend/app/tests/test_public_api.py
index f85cb5b..ff5052a 100644
--- a/backend/app/tests/test_public_api.py
+++ b/backend/app/tests/test_public_api.py
@@ -43,6 +43,7 @@ def _make_document_mock(
doc.description = description
doc.ipfs_cid = None
doc.chain_anchor = None
+ doc.genesis_json = None
doc.created_at = datetime.now(timezone.utc)
doc.updated_at = datetime.now(timezone.utc)
doc.items = []
@@ -68,6 +69,9 @@ def _make_item_mock(
item.current_text = current_text
item.voting_protocol_id = None
item.sort_order = sort_order
+ item.section_tag = None
+ item.inertia_preset = "standard"
+ item.is_permanent_vote = True
item.created_at = datetime.now(timezone.utc)
item.updated_at = datetime.now(timezone.utc)
return item
@@ -135,8 +139,8 @@ class TestDocumentOutSchema:
expected_fields = {
"id", "slug", "title", "doc_type", "version", "status",
- "description", "ipfs_cid", "chain_anchor", "created_at",
- "updated_at", "items_count",
+ "description", "ipfs_cid", "chain_anchor", "genesis_json",
+ "created_at", "updated_at", "items_count",
}
assert expected_fields.issubset(set(data.keys()))
diff --git a/backend/seed.py b/backend/seed.py
index 9312608..6341fe7 100644
--- a/backend/seed.py
+++ b/backend/seed.py
@@ -213,7 +213,7 @@ async def seed_voting_protocols(
# ---------------------------------------------------------------------------
-# Seed: Engagement Certification (Acte d'engagement certification)
+# Seed: Engagement Certification (Acte d'engagement Certification)
#
# Full structured document built from:
# - Licence G1 v0.3.0 (in force)
@@ -298,7 +298,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"Le présent acte d'engagement définit les obligations réciproques "
"des membres de la toile de confiance de la monnaie libre G1. "
"Cet acte est de fait l'unique relation contractuelle de notre "
- "toile fiduciaire. Toute certification doit s'accompagner de la "
+ "toile fiduciaire. Toute Certification doit s'accompagner de la"
"transmission de ce document, dont le certificateur doit s'assurer "
"qu'il a été étudié, compris et accepté par le certifié."
),
@@ -311,12 +311,12 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "introduction",
"inertia_preset": "standard",
"current_text": (
- "La certification repose sur deux garanties réciproques :\n\n"
+ "La Certification repose sur deux garanties réciproques :\n\n"
"**1.** Derrière une clé publique créatrice de monnaie "
"se trouve un **être humain vivant**.\n\n"
"**2.** Derrière cet être humain se trouve **une seule et unique** "
"clé publique créatrice de monnaie.\n\n"
- "La certification est un acte technique et fiduciaire, "
+ "La Certification est un acte technique et fiduciaire, "
"pas un acte d'adhésion morale ou de sympathie."
),
},
@@ -482,7 +482,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "technique",
"inertia_preset": "standard",
"current_text": (
- "Avant toute certification, vérifiez si le compte a déjà "
+ "Avant toute Certification, vérifiez si le compte a déjà "
"reçu des certifications et de qui elles proviennent. "
"Contactez les certifieurs existants en cas de doute. "
"Si un certifieurs existant ne connaît pas la personne, "
@@ -512,7 +512,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"current_text": (
"Vérifiez que vos contacts ont bien étudié et compris "
"le présent acte d'engagement dans sa version à jour "
- "avant de procéder à la certification."
+ "avant de procéder à la Certification."
),
},
# ===================================================================
@@ -569,7 +569,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "Les obligations pour les logiciels implémentant la certification G1."
+ "Les obligations pour les logiciels implémentant la Certification G1."
),
},
{
@@ -580,11 +580,11 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "Le texte de référence de l'acte d'engagement certification est "
+ "Le texte de référence de l'acte d'engagement Certification est "
"hébergé dans le dépôt git officiel : "
"https://git.duniter.org/documents/g1_monetary_license\n\n"
"Les applications Cesium, Gecko et toute application de "
- "certification doivent pointer vers ce dépôt pour afficher "
+ "Certification doivent pointer vers ce dépôt pour afficher "
"la version en vigueur."
),
},
@@ -596,7 +596,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "Tout logiciel G1 permettant la certification doit transmettre "
+ "Tout logiciel G1 permettant la Certification doit transmettre "
"le présent acte d'engagement au certifié et en afficher les "
"paramètres du bloc 0 de la blockchain Duniter."
),
@@ -609,7 +609,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "Tout logiciel utilisé pour la certification ou la création "
+ "Tout logiciel utilisé pour la Certification ou la création "
"monétaire doit être publié sous licence libre, afin de "
"permettre son audit par la communauté."
),
@@ -620,12 +620,12 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
{
"position": "X2",
"item_type": "section",
- "title": "Annexe 2 : Questions à la certification",
+ "title": "Annexe 2 : Questions à la Certification",
"sort_order": 23,
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "Liste de questions à présenter dans les logiciels de certification. "
+ "Liste de questions à présenter dans les logiciels de Certification."
"Inspirée de la checklist de la Charte 1.0 et des discussions "
"forum (topic 32412)."
),
@@ -638,8 +638,8 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "**Si OUI à l'une de ces questions, la certification doit être refusée.**\n\n"
- "- La personne m'a contacté uniquement pour obtenir ma certification\n"
+ "**Si OUI à l'une de ces questions, la Certification doit être refusée.**\n\n"
+ "- La personne m'a contacté uniquement pour obtenir ma Certification\n"
"- Je certifie sous la pression ou pour faire plaisir\n"
"- Je n'ai aucun moyen de vérifier l'identité de la personne\n"
"- La personne refuse de me fournir plusieurs moyens de contact\n"
@@ -657,7 +657,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "**Si NON à l'une de ces questions, la certification doit être refusée.**\n\n"
+ "**Si NON à l'une de ces questions, la Certification doit être refusée.**\n\n"
"- Je connais cette personne suffisamment pour la recontacter\n"
"- J'ai vérifié personnellement le lien personne / clé publique\n"
"- La personne est une personne physique vivante (pas une entité morale)\n"
@@ -673,7 +673,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
"section_tag": "annexe",
"inertia_preset": "low",
"current_text": (
- "**Si NON, la certification est déconseillée.**\n\n"
+ "**Si NON, la Certification est déconseillée.**\n\n"
"- La personne a généré son document de révocation\n"
"- La personne maîtrise effectivement son compte "
"(test de transfert effectué)\n"
@@ -836,7 +836,7 @@ async def seed_document_engagement_certification(
Document,
"slug",
"engagement-certification",
- title="Acte d'engagement certification",
+ title="Acte d'engagement Certification",
doc_type="engagement",
version="1.0.0",
status="active",
@@ -849,7 +849,7 @@ async def seed_document_engagement_certification(
),
genesis_json=genesis,
)
- print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
+ print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created:
# Map inertia presets to voting protocols
@@ -877,147 +877,528 @@ async def seed_document_engagement_certification(
# ---------------------------------------------------------------------------
# Seed: Engagement Forgeron v2.0.0 (real content from forum topic 33165)
+#
+# Full structured document built from:
+# - Engagement Forgeron v2.0.0-fr, adopted 2026-02-06
+# - Original text by 1000i100: https://forum.monnaie-libre.fr/t/33165/3
+# - Vote results: 97 pour / 23 contre, WoT 7224, Smith 8/3
+# - Git repo: https://git.duniter.org/documents/g1_monetary_license
+# - Working group: ML#32603 (39 posts)
# ---------------------------------------------------------------------------
+GENESIS_FORGERON = {
+ "source_document": {
+ "title": "Engagement forgeron Ğ1 v2.0.0-fr",
+ "url": "https://forum.monnaie-libre.fr/t/vote-engagement-forgeron-v2-0-0/33165/3",
+ "repo": "https://git.duniter.org/documents/g1_monetary_license/-/tree/master/smith_commitment",
+ "date": "2026-01-07",
+ "version": "2.0.0-fr",
+ },
+ "vote_record": {
+ "topic_url": "https://forum.monnaie-libre.fr/t/vote-engagement-forgeron-v2-0-0/33165",
+ "g1vote_url": "https://g1vote-view-237903.pages.duniter.org/#/vote/33165",
+ "mode_params": "D30M50B.1G.2S.1",
+ "period": "2026-01-07 → 2026-02-06",
+ "result": {
+ "pour": 97,
+ "contre": 23,
+ "nuls_invalides": 19,
+ "smith_pour": 8,
+ "smith_contre": 3,
+ "wot_size": 7224,
+ "threshold_required": 97,
+ "status": "adopté",
+ },
+ "addresses": {
+ "pour": "5UkkPtV7TEbVL11ztGHQkYKeobnMhh4k3FJXsdSaX84S:7G1",
+ "contre": "Cwpmov6D2XZsiRya5ZVsYFVWy94sjatcgwAntQndzLz:786",
+ },
+ },
+ "référence_tools": {
+ "g1vote_repo": "https://git.duniter.org/tools/g1vote-view",
+ "g1vote_live": "https://g1vote-view-237903.pages.duniter.org/",
+ "pad_source": "https://pad.p2p.legal/g1-v2-charte-forgeron",
+ },
+ "forum_synthesis": [
+ {
+ "title": "Vote : Engagement Forgeron v2.0.0",
+ "url": "https://forum.monnaie-libre.fr/t/vote-engagement-forgeron-v2-0-0/33165",
+ "status": "adopté",
+ "posts": 15,
+ },
+ {
+ "title": "Vers un engagement forgeron : Groupe de réflexion",
+ "url": "https://forum.monnaie-libre.fr/t/vers-un-engagement-forgeron-groupe-de-reflexion/32603",
+ "status": "référence",
+ "posts": 39,
+ },
+ {
+ "title": "Vers une charte forgeron : Sondage formulation du sujet",
+ "url": "https://forum.monnaie-libre.fr/t/vers-une-charte-forgeron-sondage-formulation-du-sujet/32318",
+ "status": "référence",
+ },
+ {
+ "title": "License forgeron (proposition initiale 2022)",
+ "url": "https://forum.duniter.org/t/license-forgeron/9997",
+ "status": "référence",
+ "posts": 48,
+ },
+ {
+ "title": "Traduction espagnole",
+ "url": "https://foro.moneda-libre.org/t/votacion-compromiso-de-forjadores-v2-0-0/3608",
+ "status": "référence",
+ },
+ ],
+ "formula_trigger": (
+ "Quand un item atteint le seuil d'adoption (formule WoT + critère Smith), "
+ "le texte de remplacement est intégré au document officiel. "
+ "Le hash IPFS du document mis à jour est ancré on-chain via system.remark. "
+ "Le dépôt git officiel est synchronisé."
+ ),
+ "implementation_actions": [
+ "Ajouter l'engagement forgeron au dépôt charte/licence (MR avec 1 validation min)",
+ "Ajouter les checklist smith et certSmith dans g1cli",
+ "Ajouter les checklist smith et certSmith dans duniter-vue",
+ "Ajouter les checklist smith et certSmith dans cesium2",
+ "Développer l'UI de checklist lors des certif smith",
+ ],
+ "contributors": [
+ {"name": "1000i100", "role": "Pilote principal, rédacteur"},
+ {"name": "sarahmagicienne", "role": "Animation vote, résultats officiels"},
+ {"name": "BuLmA", "role": "Groupe de réflexion"},
+ {"name": "Moul", "role": "Review technique, retours"},
+ {"name": "kapis", "role": "Traduction espagnole"},
+ ],
+}
+
+
ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
- # --- Aspirant Forgeron : Sécurité et conformité ---
+ # ===================================================================
+ # INTRODUCTION — Intention, enjeux, compétences requises
+ # ===================================================================
+ {
+ "position": "P1",
+ "item_type": "preamble",
+ "title": "Préambule",
+ "sort_order": 1,
+ "section_tag": "introduction",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Le présent acte d'engagement définit les obligations réciproques "
+ "des forgerons (validateurs de blocs) de la blockchain Duniter V2. "
+ "L'engagement forgeron est une certification de qualification "
+ "et d'aptitudes techniques, à distinguer de l'acte d'engagement "
+ "Certification qui porte sur l'identité unique des membres. "
+ "Toute certification forgeron doit s'accompagner de la "
+ "vérification de chaque clause, présentée dans un ordre aléatoire."
+ ),
+ },
+ {
+ "position": "P2",
+ "item_type": "preamble",
+ "title": "Intention et enjeux",
+ "sort_order": 2,
+ "section_tag": "introduction",
+ "inertia_preset": "standard",
+ "current_text": (
+ "S'assurer en tant que communauté que les forgerons qui gèrent "
+ "l'écosystème technique le font avec : compétence, rigueur, "
+ "sécurité et réactivité.\n\n"
+ "La majorité des nœuds forgeron déclarés « online » doivent "
+ "être effectivement en bon état de fonctionnement et accessibles "
+ "en ligne.\n\n"
+ "Pour résister aux attaques, les nœuds doivent éviter au maximum "
+ "les dépendances centralisées, qu'elles soient techniques ou "
+ "humaines. Les dépendances centralisées tolérées devraient l'être "
+ "uniquement si leur altération nuit suffisamment à l'attaquant "
+ "pour le dissuader d'attaquer par ces dépendances."
+ ),
+ },
+ # ===================================================================
+ # ENGAGEMENTS FONDAMENTAUX — Protocoles concrets, pas de morale
+ # Reframé depuis les "savoirs-être" : rigueur, réactivité,
+ # responsabilité deviennent des engagements évaluables.
+ # Cf. Yvv, ML#32603 post #31 : "Remplacer savoir-être par
+ # engagements changerait tout, y compris la façon de les formuler."
+ # ===================================================================
+ {
+ "position": "EF0",
+ "item_type": "section",
+ "title": "Engagements fondamentaux",
+ "sort_order": 3,
+ "section_tag": "fondamental",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Engagements de conduite et de rigueur auxquels chaque forgeron "
+ "s'engage concrètement. Chaque engagement est formulé comme un "
+ "protocole évaluable, non comme un trait de personnalité."
+ ),
+ },
+ {
+ "position": "EF1",
+ "item_type": "clause",
+ "title": "Rigueur : protocole de doute",
+ "sort_order": 4,
+ "section_tag": "fondamental",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Au moindre doute sur la sécurité ou le fonctionnement "
+ "de mon nœud, je m'engage au protocole suivant :\n\n"
+ "1. **Documenter** le problème (logs, contexte, symptômes)\n"
+ "2. **Poster sur le forum** Duniter (catégorie technique) "
+ "pour bénéficier de l'expertise collective\n"
+ "3. **Me déclarer offline** si le doute persiste après 24h\n\n"
+ "Je ne lance pas mon nœud sans comprendre sa configuration. "
+ "Une fausse sécurité érode la confiance et met le réseau en danger."
+ ),
+ },
+ {
+ "position": "EF2",
+ "item_type": "clause",
+ "title": "Réactivité : délais d'intervention",
+ "sort_order": 5,
+ "section_tag": "fondamental",
+ "inertia_preset": "standard",
+ "current_text": (
+ "En cas d'interruption de service ou de problème signalé, "
+ "je m'engage aux délais suivants :\n\n"
+ "- **J1** : Alerte — je préviens les forgerons concernés "
+ "(forum, Telegram, contact direct)\n"
+ "- **J2 à J5** : Rétablissement de service ou escalade "
+ "(appel à l'aide d'autres forgerons)\n"
+ "- Si je ne suis pas en mesure de tenir ces délais, "
+ "je me déclare offline."
+ ),
+ },
+ {
+ "position": "EF3",
+ "item_type": "clause",
+ "title": "Certification : garantie de compétences",
+ "sort_order": 6,
+ "section_tag": "fondamental",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Certifier un forgeron, c'est garantir ses compétences "
+ "vérifiées. Je m'engage à :\n\n"
+ "- **Évaluer** les savoir-faire du candidat avant de certifier\n"
+ "- **Ne pas laisser sans réponse** ses questions techniques\n"
+ "- **Vérifier** que le certifié respecte les protocoles en vigueur\n"
+ "- **Compenser les lacunes** ou ne pas certifier\n\n"
+ "Il ne s'agit pas d'être « responsable » au sens moral, mais de "
+ "s'engager sur des vérifications concrètes et documentées."
+ ),
+ },
+ # ===================================================================
+ # ENGAGEMENTS TECHNIQUES — Compétences évaluables
+ # Reframé depuis les "savoirs-faire" : chaque compétence doit être
+ # testable, pas décrite avec des termes vagues.
+ # Cf. Yvv, ML#32603 post #31 : "Je m'attends à ce que chaque
+ # savoir-faire formulé soit évaluable."
+ # ===================================================================
+ {
+ "position": "ET0",
+ "item_type": "section",
+ "title": "Engagements techniques",
+ "sort_order": 7,
+ "section_tag": "technique",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Compétences techniques requises pour opérer un nœud forgeron. "
+ "Chaque compétence est formulée de manière évaluable lors du "
+ "parcours de qualification (Embarquement Forgeron)."
+ ),
+ },
+ {
+ "position": "ET1",
+ "item_type": "clause",
+ "title": "Administration système Linux",
+ "sort_order": 8,
+ "section_tag": "technique",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Je suis capable d'administrer un serveur Linux en ligne de "
+ "commande :\n\n"
+ "- Installer et configurer un service (paquet ou Docker)\n"
+ "- Configurer réseau et pare-feu, exposer un nœud en ligne\n"
+ "- Diagnostiquer un problème à partir des logs système\n\n"
+ "**Évaluation** : démontrer lors de l'embarquement la capacité "
+ "à déployer et maintenir un nœud miroir fonctionnel."
+ ),
+ },
+ {
+ "position": "ET2",
+ "item_type": "clause",
+ "title": "Sécurité et gestion de clés",
+ "sort_order": 9,
+ "section_tag": "technique",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Je maîtrise les pratiques de sécurité nécessaires :\n\n"
+ "- Génération et stockage sécurisé de phrases de récupération\n"
+ "- Modèle de menaces : identifier ma surface d'attaque\n"
+ "- Ne pas exposer d'API unsafe publiquement\n"
+ "- Séparer compte membre et compte de transactions\n\n"
+ "**Évaluation** : décrire son modèle de menaces et démontrer "
+ "ses pratiques de gestion de clés au certificateur."
+ ),
+ },
+ {
+ "position": "ET3",
+ "item_type": "clause",
+ "title": "Mécanismes de consensus Duniter",
+ "sort_order": 10,
+ "section_tag": "technique",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Je comprends les mécanismes de consensus de la blockchain "
+ "Duniter V2 :\n\n"
+ "- Toile de confiance Smith (sous-toile des forgerons)\n"
+ "- Autorités online, epochs, production de blocs (BABE)\n"
+ "- Finalisation (GRANDPA) et tolérance aux fautes byzantines\n"
+ "- Offenses et leurs conséquences (slash, exclusion)\n\n"
+ "**Évaluation** : poser 2 questions pertinentes sur le forum "
+ "Duniter, démontrant la compréhension des enjeux de consensus."
+ ),
+ },
+ # ===================================================================
+ # QUALIFICATION — Lien explicite avec le protocole Embarquement
+ # Cf. Yvv, ML#32603 post #3 : "compétence sécu et réactivité sont
+ # davantage les sujets de l'enrôlement et accompagnement (onboarding)"
+ # Cf. Yvv, ML#32960 post #12 : "compétences requises et protocoles
+ # à respecter à chaque jalon du onboarding"
+ # ===================================================================
+ {
+ "position": "Q0",
+ "item_type": "section",
+ "title": "Qualification : Embarquement forgeron",
+ "sort_order": 11,
+ "section_tag": "qualification",
+ "inertia_preset": "standard",
+ "current_text": (
+ "La qualification d'un forgeron repose sur un parcours "
+ "d'embarquement structuré en jalons progressifs. Chaque "
+ "jalon introduit de nouvelles obligations dans l'acte "
+ "d'engagement. Ce parcours est défini par le protocole "
+ "« Embarquement Forgeron »."
+ ),
+ },
+ {
+ "position": "Q1",
+ "item_type": "clause",
+ "title": "Jalons du parcours de qualification",
+ "sort_order": 12,
+ "section_tag": "qualification",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Le parcours d'embarquement forgeron comprend les jalons "
+ "suivants, chacun conditionnant le passage au suivant :\n\n"
+ "1. **Candidature** — Déclaration d'intention, premier contact "
+ "avec un forgeron référent\n"
+ "2. **Nœud miroir** — Déployer et maintenir un nœud miroir "
+ "synchronisé pendant une période probatoire\n"
+ "3. **Évaluation technique** — Vérification des compétences "
+ "techniques par un forgeron certificateur (checklist aléatoire)\n"
+ "4. **Certification Smith** — Réception de 3 certifications "
+ "forgeron après validation de l'ensemble des engagements\n"
+ "5. **Mise en ligne** — Passage du nœud validateur en mode "
+ "online, début de la forge effective\n\n"
+ "→ Protocole détaillé : **Embarquement Forgeron** "
+ "(voir section Protocoles)"
+ ),
+ },
+ # ===================================================================
+ # ASPIRANT FORGERON — Clauses d'engagement (texte intégral v2.0.0-fr)
+ # Checklist présentée dans un ordre aléatoire lors de la certification
+ # ===================================================================
+ {
+ "position": "T1",
+ "item_type": "section",
+ "title": "Clauses d'engagement de l'aspirant forgeron",
+ "sort_order": 13,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Les clauses ci-dessous constituent le parcours d'onboarding "
+ "du futur forgeron. Chaque clause est présentée dans un ordre "
+ "aléatoire lors de la certification ; l'aspirant doit répondre "
+ "correctement à chacune d'elles."
+ ),
+ },
+ # --- Aspirant : Sécurité et conformité (11 clauses) ---
{
"position": "A1",
"item_type": "clause",
"title": "Intention et motivation",
- "sort_order": 1,
+ "sort_order": 14,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai clarifié ce qui me motive à devenir forgeron, "
- "j'en assume les raisons."
+ "J'ai clarifié ce qui me motive à devenir forgeron, j'en "
+ "assume les raisons et les indiquerai à qui me les demande."
),
},
{
"position": "A2",
"item_type": "clause",
"title": "Veille sécurité",
- "sort_order": 2,
+ "sort_order": 15,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je fais de la veille pour maintenir mes pratiques de sécurité "
- "système et réseau à jour."
+ "système et réseau à la hauteur des enjeux Duniter actuels."
),
},
{
"position": "A3",
"item_type": "clause",
"title": "Notifications forum",
- "sort_order": 3,
+ "sort_order": 16,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"J'ai activé les notifications sur forum.duniter.org pour être "
- "alerté des discussions importantes concernant le réseau."
+ "alerté des sollicitations perso et smiths."
),
},
{
"position": "A4",
"item_type": "verification",
"title": "Phrase de récupération aléatoire",
- "sort_order": 4,
+ "sort_order": 17,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je confirme que ma phrase de récupération a été générée "
- "aléatoirement et n'est pas une phrase choisie par moi."
+ "aléatoirement et que je l'utilise uniquement pour mon "
+ "compte membre."
),
},
{
"position": "A5",
"item_type": "verification",
- "title": "Compte séparé",
- "sort_order": 5,
+ "title": "Compte séparé pour les transactions",
+ "sort_order": 18,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "J'utilise un autre compte pour mes transactions courantes ; "
- "le compte forgeron est strictement réservé à la validation."
+ "J'utilise un autre compte pour mes transactions courantes. "
+ "Je m'authentifie sur mon compte membre seulement pour les "
+ "opérations nécessitant les droits non délégables, et depuis "
+ "mon propre matériel."
),
},
{
"position": "A6",
"item_type": "verification",
"title": "Sauvegarde phrase de récupération",
- "sort_order": 6,
+ "sort_order": 19,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"J'ai stocké ma phrase de récupération sur plusieurs supports "
- "physiques distincts et sécurisés."
+ "physiques (numérique ou non), récupérables indépendamment. "
+ "En cas de vol, incendie, panne, oubli… j'aurai accès à au "
+ "moins une des copies pour reprendre la main."
),
},
{
"position": "A7",
"item_type": "verification",
- "title": "Noeud à jour et synchronisé",
- "sort_order": 7,
+ "title": "Nœud à jour et synchronisé",
+ "sort_order": 20,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "Je gère déjà un noeud à jour, correctement synchronisé et "
- "joignable par les autres noeuds du réseau."
+ "Je gère déjà un nœud à jour (version logiciel), correctement "
+ "synchronisé (état blockchain) et joignable (peer réseau)."
),
},
{
"position": "A8",
"item_type": "verification",
"title": "API unsafe non exposée",
- "sort_order": 8,
+ "sort_order": 21,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"J'ai veillé à ne pas exposer publiquement l'api unsafe "
- "de mon noeud validateur."
+ "de mon nœud smith."
),
},
{
"position": "A9",
"item_type": "clause",
"title": "Transparence technique",
- "sort_order": 9,
+ "sort_order": 22,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je fournis à la demande d'un autre forgeron, mes choix "
- "techniques (matériel, OS, configuration réseau)."
+ "techniques et si je change d'approche, je préviens les "
+ "forgerons qui m'ont certifié (pour être alerté des "
+ "éventuels soucis associés)."
),
},
{
"position": "A10",
"item_type": "clause",
"title": "Déclaration offline en cas de doute",
- "sort_order": 10,
+ "sort_order": 23,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je me déclare offline en cas de doute sur la sécurité "
- "de mon noeud ou de mon infrastructure."
+ "ou la continuité de service de mon nœud."
),
},
{
"position": "A11",
"item_type": "clause",
"title": "Réactivité 24h",
- "sort_order": 11,
+ "sort_order": 24,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je m'engage à répondre en moins de 24h aux forgerons "
- "quand je suis déclaré online."
+ "quand je suis déclaré online. Sinon, je me déclare offline."
),
},
- # --- Aspirant Forgeron : Contact ---
+ # --- Aspirant : Contact (1 clause) ---
{
"position": "A12",
"item_type": "clause",
"title": "Contact multi-canal",
- "sort_order": 12,
+ "sort_order": 25,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"Je sais joindre efficacement et rapidement 3 des forgerons "
- "par au moins 2 canaux de communication différents."
+ "qui comptent me certifier (cela par au moins 2 canaux : "
+ "tél/sms + email, xmpp, matrix…)."
),
},
- # --- Aspirant Forgeron : Connaissances ---
+ # --- Aspirant : Connaissances (3 clauses) ---
{
"position": "A13",
"item_type": "clause",
"title": "Acceptation des engagements",
- "sort_order": 13,
+ "sort_order": 26,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"J'ai lu et j'accepte de respecter l'ensemble des "
- "engagements forgerons en vigueur."
+ "engagements forgerons et j'en comprends les implications "
+ "et raisons d'être."
),
},
{
"position": "A14",
"item_type": "clause",
"title": "Règles de la TdC forgeron",
- "sort_order": 14,
+ "sort_order": 27,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
"J'ai pris connaissance des règles et délais associés au "
"fonctionnement de la TdC forgeron."
@@ -1026,31 +1407,39 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
{
"position": "A15",
"item_type": "clause",
- "title": "Fonctionnement blockchain",
- "sort_order": 15,
+ "title": "Fonctionnement blockchain Duniter",
+ "sort_order": 28,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai bien compris le fonctionnement d'un réseau blockchain "
- "Duniter et le rôle du validateur."
+ "J'ai bien compris le fonctionnement d'un réseau blockchain, "
+ "en particulier les enjeux de sécurité liés aux mécanismes "
+ "de consensus de la blockchain Duniter."
),
},
- # --- Aspirant Forgeron : Pièges (expected: NON) ---
+ # --- Aspirant : Pièges (3 clauses — réponse attendue : NON) ---
{
"position": "A16",
"item_type": "rule",
"title": "Piège : harcèlement",
- "sort_order": 16,
+ "sort_order": 29,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "[Piège - réponse attendue : NON] "
- "J'insiste, harcèle ou fais pression pour être certifié forgeron."
+ "[Piège — réponse attendue : NON]\n"
+ "J'insiste, harcèle ou fais pression d'une autre manière "
+ "pour être certifié."
),
},
{
"position": "A17",
"item_type": "rule",
"title": "Piège : gloire et pouvoir",
- "sort_order": 17,
+ "sort_order": 30,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "[Piège - réponse attendue : NON] "
+ "[Piège — réponse attendue : NON]\n"
"Je veux être forgeron pour la gloire et le pouvoir."
),
},
@@ -1058,161 +1447,219 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
"position": "A18",
"item_type": "rule",
"title": "Piège : nuisance",
- "sort_order": 18,
+ "sort_order": 31,
+ "section_tag": "aspirant",
+ "inertia_preset": "standard",
"current_text": (
- "[Piège - réponse attendue : NON] "
- "Je cherche à nuire à l'écosystème G1."
+ "[Piège — réponse attendue : NON]\n"
+ "Je cherche à nuire à l'écosystème Ğ1 en m'infiltrant "
+ "parmi les forgerons."
),
},
- # --- Certificateur Forgeron : Sécurité et conformité ---
+ # ===================================================================
+ # CERTIFICATEUR FORGERON — Clauses d'engagement (texte intégral)
+ # ===================================================================
+ {
+ "position": "T2",
+ "item_type": "section",
+ "title": "Clauses d'engagement du forgeron certificateur",
+ "sort_order": 32,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
+ "current_text": (
+ "Les clauses ci-dessous s'adressent au forgeron qui certifie "
+ "un aspirant. Elles engagent sa responsabilité de garant des "
+ "compétences et pratiques du certifié."
+ ),
+ },
+ # --- Certificateur : Sécurité et conformité (8 clauses) ---
{
"position": "C1",
"item_type": "clause",
"title": "Intention du certifié questionnée",
- "sort_order": 19,
+ "sort_order": 33,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai questionné l'intention du certifié à rejoindre "
- "les forgerons et vérifié sa motivation."
+ "J'ai questionné l'intention du certifié à rejoindre les "
+ "forgerons et je reste prêt à le certifier."
),
},
{
"position": "C2",
"item_type": "verification",
"title": "Pratiques de sécurité du certifié",
- "sort_order": 20,
+ "sort_order": 34,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
"J'ai demandé au certifié quelles étaient ses pratiques de "
- "sécurité système et réseau."
+ "sécurité et je les estime suffisantes pour le réseau actuel."
),
},
{
"position": "C3",
"item_type": "verification",
"title": "Phrase aléatoire du certifié",
- "sort_order": 21,
+ "sort_order": 35,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "Le certifié m'assure que son compte forgeron est issu d'une "
- "phrase générée aléatoirement."
+ "Le certifié m'assure que son compte est issu d'une phrase "
+ "de récupération générée aléatoirement."
),
},
{
"position": "C4",
"item_type": "verification",
"title": "Sauvegarde du certifié",
- "sort_order": 22,
+ "sort_order": 36,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
"Le certifié m'assure avoir stocké sa phrase de récupération "
- "sur plusieurs supports physiques."
+ "sur plusieurs supports physiques (numérique ou non), "
+ "récupérables indépendamment. En cas de vol, incendie, oubli "
+ "de mot de passe… au moins une des versions lui est accessible."
),
},
{
"position": "C5",
"item_type": "verification",
- "title": "Noeud du certifié vérifié",
- "sort_order": 23,
+ "title": "Nœud du certifié vérifié",
+ "sort_order": 37,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai vérifié que le certifié gère déjà un noeud à jour, "
- "correctement synchronisé et joignable."
+ "J'ai vérifié que le certifié gère déjà un nœud à jour "
+ "qui est correctement synchronisé."
),
},
{
"position": "C6",
"item_type": "clause",
"title": "Configuration du certifié notée",
- "sort_order": 24,
+ "sort_order": 38,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai noté le style de configuration du noeud du certifié "
- "(matériel, OS, hébergement)."
+ "J'ai noté le style de configuration (paquet debian, "
+ "docker-compose…) que le certifié utilise pour son nœud "
+ "(cette configuration n'a pas de faille connue, notamment "
+ "n'expose pas l'api unsafe publiquement)."
),
},
{
"position": "C7",
"item_type": "clause",
"title": "Engagement d'information du certifié",
- "sort_order": 25,
+ "sort_order": 39,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "Le certifié s'est engagé à m'informer de tout changement "
- "significatif de sa configuration."
+ "Le certifié s'est engagé auprès de moi à m'informer de "
+ "tout changement significatif de sa config (pour pouvoir "
+ "transmettre les infos de manière ciblée en cas de problème)."
),
},
{
"position": "C8",
"item_type": "verification",
"title": "Risques offline connus du certifié",
- "sort_order": 26,
+ "sort_order": 40,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
"J'ai vérifié avec le certifié qu'il connaît les risques "
- "d'être déclaré offline et les conséquences."
+ "pour lui et le réseau d'être offline sans l'avoir annoncé, "
+ "et qu'il se considère en mesure d'assurer un uptime suffisant."
),
},
- # --- Certificateur Forgeron : Contact ---
+ # --- Certificateur : Contact (3 clauses) ---
{
"position": "C9",
"item_type": "clause",
"title": "Joindre les certifiés",
- "sort_order": 27,
+ "sort_order": 41,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "Je sais joindre efficacement les forgerons que j'ai certifiés."
+ "Je sais joindre efficacement et rapidement les forgerons "
+ "que j'ai certifiés (téléphone généralement)."
),
},
{
"position": "C10",
"item_type": "clause",
"title": "Deux canaux de contact",
- "sort_order": 28,
+ "sort_order": 42,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "Je peux les joindre par au moins 2 canaux différents."
+ "Je peux les joindre par au moins 2 canaux "
+ "(sms, email, xmpp, matrix…)."
),
},
{
"position": "C11",
"item_type": "clause",
"title": "Contact sous 24h en cas de défaut",
- "sort_order": 29,
+ "sort_order": 43,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "Je m'engage à contacter sous 24h ce forgeron si un défaut "
- "concerne son noeud."
+ "Je m'engage à contacter sous 24h max ce forgeron si "
+ "j'apprends qu'un défaut concerne son nœud validateur "
+ "(désynchronisé, pas à jour, inaccessible…)."
),
},
- # --- Certificateur Forgeron : Connaissances ---
+ # --- Certificateur : Connaissances (3 clauses) ---
{
"position": "C12",
"item_type": "verification",
"title": "Engagements acceptés par le certifié",
- "sort_order": 30,
+ "sort_order": 44,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
"J'ai vérifié que le certifié a accepté les engagements "
- "forgerons intégralement."
+ "forgerons dans son intégralité."
),
},
{
"position": "C13",
"item_type": "verification",
"title": "Règles consultables par le certifié",
- "sort_order": 31,
+ "sort_order": 45,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "J'ai vérifié que le certifié sait où consulter les règles "
- "détaillées de la TdC forgeron."
+ "J'ai vérifié que le certifié savait où consulter les "
+ "règles détaillées de la TdC forgeron."
),
},
{
"position": "C14",
"item_type": "verification",
"title": "Délais connus du certifié",
- "sort_order": 32,
+ "sort_order": 46,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
"J'ai vérifié que le certifié connaît les délais de passage "
- "en ligne et hors ligne."
+ "en ligne/hors ligne de son nœud validateur."
),
},
- # --- Certificateur Forgeron : Pièges (expected: NON) ---
+ # --- Certificateur : Pièges (2 clauses — réponse attendue : NON) ---
{
"position": "C15",
"item_type": "rule",
"title": "Piège : certification sous pression",
- "sort_order": 33,
+ "sort_order": 47,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "[Piège - réponse attendue : NON] "
+ "[Piège — réponse attendue : NON]\n"
"Je certifie sous la menace ou autre forme de pression."
),
},
@@ -1220,16 +1667,234 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
"position": "C16",
"item_type": "rule",
"title": "Piège : avantage personnel",
- "sort_order": 34,
+ "sort_order": 48,
+ "section_tag": "certificateur",
+ "inertia_preset": "standard",
"current_text": (
- "[Piège - réponse attendue : NON] "
+ "[Piège — réponse attendue : NON]\n"
"Je tire un avantage personnel en échange de ma certification."
),
},
+ # ===================================================================
+ # ANNEXES — Non contractuels, modifiables sans vote
+ # ===================================================================
+ {
+ "position": "X1",
+ "item_type": "section",
+ "title": "Annexes",
+ "sort_order": 49,
+ "section_tag": "annexe",
+ "inertia_preset": "low",
+ "current_text": (
+ "Non contractuels, modifiables sans vote pour clarifier "
+ "le contenu de l'engagement."
+ ),
+ },
+ {
+ "position": "X1.1",
+ "item_type": "preamble",
+ "title": "Lexique",
+ "sort_order": 50,
+ "section_tag": "annexe",
+ "inertia_preset": "low",
+ "current_text": (
+ "- **Phrase de récupération** : séquence de mots générée "
+ "aléatoirement (mnemonic) dont est dérivée la graine "
+ "cryptographique (seed, clef privée)\n\n"
+ "- **Nœud validateur, smith node** : nœud autorisé et "
+ "configuré pour écrire des blocs dans la blockchain. "
+ "Chaque compte forgeron peut autoriser un seul nœud "
+ "simultanément à être validateur.\n\n"
+ "- **Seuil unani-majoritaire** : détermine combien de votes "
+ "POUR sont nécessaires pour qu'une proposition soit adoptée."
+ ),
+ },
+ {
+ "position": "X1.2",
+ "item_type": "rule",
+ "title": "Règles de la TdC forgeron",
+ "sort_order": 51,
+ "section_tag": "annexe",
+ "inertia_preset": "low",
+ "current_text": (
+ "Pour devenir forgeron, outre respecter les engagements "
+ "du présent document, il faut :\n\n"
+ "- Être membre de la toile de confiance principale\n"
+ "- Être invité par un forgeron\n"
+ "- Accepter l'invitation\n"
+ "- Recevoir 3 certifications forgeron\n\n"
+ "Le rôle forgeron (comme les certifications associées) "
+ "n'a pas de péremption directe mais se perd dans les "
+ "cas suivants :\n\n"
+ "- Perte du statut de membre de la TdC principale\n"
+ "- Nœud validateur (forgeron) hors ligne pendant 6 mois"
+ ),
+ },
+ {
+ "position": "X1.3",
+ "item_type": "rule",
+ "title": "Notice d'implémentation",
+ "sort_order": 52,
+ "section_tag": "annexe",
+ "inertia_preset": "low",
+ "current_text": (
+ "À destination des développeurs de logiciels permettant "
+ "de certifier :\n\n"
+ "À chaque certification, l'ensemble des clauses d'engagements "
+ "forgeron sont présentées une à une au certificateur, dans "
+ "un **ordre aléatoire**.\n\n"
+ "- Il peut répondre *← non* ou *oui →* à chaque clause\n"
+ "- Le document de certification n'est émis qu'après avoir "
+ "répondu correctement à chaque clause\n"
+ "- Les clauses cochées [x] doivent recevoir pour réponse "
+ "*oui →* pour que le document de certification soit émis\n"
+ "- Les clauses non cochées [ ] doivent recevoir pour réponse "
+ "*← non* pour que le document de certification soit émis\n"
+ "- Toute réponse invalide interrompt la procédure et présente "
+ "le message : « Attention vous n'avez pas répondu correctement "
+ "à la question. Nous vous invitons à relire l'ensemble des "
+ "engagements forgerons et à en discuter avec d'autres forgerons "
+ "avant de retenter de certifier. »"
+ ),
+ },
+ # ===================================================================
+ # FORMULE DE VOTE — Double critère WoT + Smith
+ # ===================================================================
+ {
+ "position": "F1",
+ "item_type": "section",
+ "title": "Conditions d'adoption et gouvernance",
+ "sort_order": 53,
+ "section_tag": "formule",
+ "inertia_preset": "high",
+ "current_text": (
+ "Les conditions qui régissent l'adoption ou le rejet de chaque "
+ "modification du présent document. Vote à seuil unani-majoritaire "
+ "avec double critère : WoT complète + forgerons."
+ ),
+ },
+ {
+ "position": "F1.1",
+ "item_type": "rule",
+ "title": "Formule du seuil WoT + Smith",
+ "sort_order": 54,
+ "section_tag": "formule",
+ "inertia_preset": "high",
+ "current_text": (
+ "**Double critère d'adoption :**\n\n"
+ "```\n"
+ "votesPour >= ceil(WotSize^0.1 + (0.5 + (1 - 0.5)\n"
+ " × (1 - (TotalVotes/WotSize)^0.2)) × TotalVotes)\n"
+ "```\n"
+ "**ET**\n"
+ "```\n"
+ "votesSmithPour >= ceil(SmithWotSize^0.1)\n"
+ "```\n\n"
+ "Le premier critère (WoT) assure un soutien communautaire large. "
+ "Le second critère (Smith) garantit que les opérateurs du réseau "
+ "soutiennent la décision. Les deux doivent être satisfaits."
+ ),
+ },
+ {
+ "position": "F1.2",
+ "item_type": "rule",
+ "title": "Paramètres adoptés",
+ "sort_order": 55,
+ "section_tag": "formule",
+ "inertia_preset": "high",
+ "current_text": (
+ "**Mode compact : D30M50B.1G.2S.1**\n\n"
+ "- **D30** : durée de vote 30 jours (vote permanent)\n"
+ "- **M50** : majorité cible 50%\n"
+ "- **B.1** : exposant de base 0.1 (plancher WotSize^0.1)\n"
+ "- **G.2** : gradient d'inertie 0.2\n"
+ "- **S.1** : critère Smith (SmithWotSize^0.1)\n\n"
+ "**Comportement :** Faible participation → quasi-unanimité requise. "
+ "Forte participation → majorité simple 50% suffit. "
+ "Dans tous les cas, un minimum de forgerons doit voter POUR."
+ ),
+ },
+ {
+ "position": "F1.3",
+ "item_type": "rule",
+ "title": "Processus de dépôt officiel",
+ "sort_order": 56,
+ "section_tag": "formule",
+ "inertia_preset": "high",
+ "current_text": (
+ "Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n"
+ "1. Le texte de remplacement est intégré au document officiel\n"
+ "2. Le hash IPFS du document mis à jour est calculé\n"
+ "3. Le hash est ancré on-chain via `system.remark`\n"
+ "4. Le dépôt git officiel est synchronisé\n"
+ "5. Les applications implémentant la checklist mettent à jour"
+ ),
+ },
+ # ===================================================================
+ # RÉGLAGE DE L'INERTIE — Méta-protection
+ # ===================================================================
+ {
+ "position": "N1",
+ "item_type": "section",
+ "title": "Réglage de l'inertie",
+ "sort_order": 57,
+ "section_tag": "inertie",
+ "inertia_preset": "very_high",
+ "current_text": (
+ "Le réglage de l'inertie définit la difficulté de remplacement "
+ "de chaque section du document. Ce réglage est lui-même soumis "
+ "à l'inertie la plus élevée, pour empêcher la modification "
+ "des règles de modification."
+ ),
+ },
+ {
+ "position": "N1.1",
+ "item_type": "rule",
+ "title": "Niveaux d'inertie",
+ "sort_order": 58,
+ "section_tag": "inertie",
+ "inertia_preset": "very_high",
+ "current_text": (
+ "**Basse** — G=0.1, M=50%\n"
+ "- Descend vite vers 50%. Dès 10% de participation, "
+ "seuil proche de la majorité simple\n"
+ "- *Usage : annexes (non contractuelles)*\n\n"
+ "**Standard** — G=0.2, M=50%\n"
+ "- Descente progressive, équilibre stabilité/réactivité\n"
+ "- *Usage : clauses d'engagement (aspirant + certificateur)*\n\n"
+ "**Haute** — G=0.4, M=60%\n"
+ "- Résistante, super-majorité même à pleine participation\n"
+ "- *Usage : formule de vote et conditions d'adoption*\n\n"
+ "**Très haute** — G=0.6, M=66%\n"
+ "- Quasi-unanimité requise à tout niveau\n"
+ "- *Usage : réglage de l'inertie (méta-protection)*"
+ ),
+ },
+ # ===================================================================
+ # ORDONNANCEMENT
+ # ===================================================================
+ {
+ "position": "O1",
+ "item_type": "section",
+ "title": "Ordonnancement du document",
+ "sort_order": 59,
+ "section_tag": "ordonnancement",
+ "inertia_preset": "standard",
+ "current_text": (
+ "L'ordre de présentation des items dans le document est "
+ "lui-même soumis au vote. Toute proposition de réorganisation "
+ "doit atteindre le seuil d'adoption avec l'inertie standard."
+ ),
+ },
]
-async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
+async def seed_document_engagement_forgeron(
+ session: AsyncSession,
+ protocols: dict[str, VotingProtocol],
+) -> Document:
+ genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
+
doc, created = await get_or_create(
session,
Document,
@@ -1241,15 +1906,27 @@ async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
status="active",
description=(
"Acte d'engagement des forgerons (validateurs) Duniter V2. "
- "Adopté en février 2026 (97 pour / 23 contre). "
- "34 clauses : aspirant (18) + certificateur (16)."
+ "Certification de qualification et d'aptitudes techniques. "
+ "Adopté en février 2026 (97 pour / 23 contre, WoT 7224). "
+ "Document modulaire sous vote permanent avec critère Smith : "
+ "59 items — introduction (2) + fondamentaux (4) + techniques (4) "
+ "+ qualification (2) + aspirant (19) + certificateur (17) "
+ "+ annexes (4) + formule (4) + inertie (2) + ordonnancement (1)."
),
+ genesis_json=genesis,
)
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
+ # All items use the Smith protocol (double threshold: WoT + Smith)
+ smith_protocol = protocols.get("Vote forgeron (Smith)")
+
for item_data in ENGAGEMENT_FORGERON_ITEMS:
- item = DocumentItem(document_id=doc.id, **item_data)
+ item = DocumentItem(
+ document_id=doc.id,
+ voting_protocol_id=smith_protocol.id if smith_protocol else None,
+ **item_data,
+ )
session.add(item)
await session.flush()
print(f" -> {len(ENGAGEMENT_FORGERON_ITEMS)} items created")
@@ -1336,6 +2013,109 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
return decision
+# ---------------------------------------------------------------------------
+# Seed: Decision - Embarquement Forgeron (protocole de qualification)
+#
+# Processus d'onboarding des forgerons en jalons progressifs.
+# Lié explicitement à la section "Qualification" de l'Acte d'engagement.
+# Cf. Yvv, ML#32603 post #3 : "compétence sécu et réactivité sont
+# davantage les sujets de l'enrôlement et accompagnement (onboarding)"
+# Cf. elois, Duniter#9047 : séquence miroir → sync → membership → certifs
+# ---------------------------------------------------------------------------
+
+EMBARQUEMENT_FORGERON_STEPS: list[dict] = [
+ {
+ "step_order": 1,
+ "step_type": "candidature",
+ "title": "Candidature",
+ "description": (
+ "Déclaration d'intention auprès de la communauté forgeron. "
+ "Premier contact avec un forgeron référent qui accepte "
+ "d'accompagner le parcours. Vérification du statut membre "
+ "de la toile de confiance principale."
+ ),
+ },
+ {
+ "step_order": 2,
+ "step_type": "mirror",
+ "title": "Nœud miroir",
+ "description": (
+ "Déployer un nœud miroir Duniter V2 et le maintenir "
+ "synchronisé pendant une période probatoire. Démontrer "
+ "les compétences d'administration système Linux : "
+ "installation, configuration réseau, monitoring. "
+ "Le nœud doit être joignable et à jour."
+ ),
+ },
+ {
+ "step_order": 3,
+ "step_type": "evaluation",
+ "title": "Évaluation technique",
+ "description": (
+ "Vérification des compétences techniques par un forgeron "
+ "certificateur. Parcours de la checklist d'engagement dans "
+ "un ordre aléatoire. Le candidat doit démontrer :\n"
+ "- Gestion de clés et sécurité\n"
+ "- Compréhension du consensus Duniter\n"
+ "- Capacité de diagnostic et réactivité\n"
+ "Toute réponse incorrecte interrompt la procédure."
+ ),
+ },
+ {
+ "step_order": 4,
+ "step_type": "certification",
+ "title": "Certification Smith",
+ "description": (
+ "Invitation par un forgeron existant, acceptation de "
+ "l'invitation, puis réception de 3 certifications forgeron. "
+ "Chaque certificateur a vérifié les compétences du candidat "
+ "via la checklist. La certification engage la responsabilité "
+ "du certificateur (section Certificateur de l'engagement)."
+ ),
+ },
+ {
+ "step_order": 5,
+ "step_type": "online",
+ "title": "Mise en ligne",
+ "description": (
+ "Passage du nœud validateur en mode online (goOnline). "
+ "Le forgeron commence à produire des blocs. "
+ "Surveillance active pendant les premières semaines : "
+ "uptime, synchronisation, réactivité aux alertes. "
+ "Le forgeron est pleinement soumis à l'ensemble des "
+ "engagements de l'acte."
+ ),
+ },
+]
+
+
+async def seed_decision_embarquement_forgeron(session: AsyncSession) -> Decision:
+ decision, created = await get_or_create(
+ session,
+ Decision,
+ "title",
+ "Embarquement Forgeron",
+ description=(
+ "Protocole d'embarquement (onboarding) des forgerons Duniter V2. "
+ "Parcours en 5 jalons progressifs de la candidature à la mise en "
+ "ligne du nœud validateur. Lié à la section Qualification de "
+ "l'Acte d'engagement forgeron."
+ ),
+ decision_type="onboarding",
+ status="active",
+ )
+ print(f" Decision 'Embarquement Forgeron': {'created' if created else 'exists'}")
+
+ if created:
+ for step_data in EMBARQUEMENT_FORGERON_STEPS:
+ step = DecisionStep(decision_id=decision.id, **step_data)
+ session.add(step)
+ await session.flush()
+ print(f" -> {len(EMBARQUEMENT_FORGERON_STEPS)} steps created")
+
+ return decision
+
+
# ---------------------------------------------------------------------------
# Seed: Simulated voters + votes on first 3 engagement items
# ---------------------------------------------------------------------------
@@ -1469,33 +2249,36 @@ async def run_seed():
# Ensure tables exist
await init_db()
- print("[0/7] Tables created.\n")
+ print("[0/8] Tables created.\n")
async with async_session() as session:
async with session.begin():
- print("\n[1/7] Formula Configs...")
+ print("\n[1/8] Formula Configs...")
formulas = await seed_formula_configs(session)
- print("\n[2/7] Voting Protocols...")
+ print("\n[2/8] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas)
- print("\n[3/7] Document: Acte d'engagement certification...")
+ print("\n[3/8] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols)
- print("\n[4/7] Document: Acte d'engagement forgeron v2.0.0...")
- doc_forgeron = await seed_document_engagement_forgeron(session)
+ print("\n[4/8] Document: Acte d'engagement forgeron v2.0.0...")
+ doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
- print("\n[5/7] Decision: Runtime Upgrade...")
+ print("\n[5/8] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
- print("\n[6/7] Simulated voters...")
+ print("\n[6/8] Decision: Embarquement Forgeron...")
+ await seed_decision_embarquement_forgeron(session)
+
+ print("\n[7/8] Simulated voters...")
voters = await seed_voters(session)
- print("\n[7/7] Votes on first 3 engagements forgeron...")
+ print("\n[8/8] Votes on first 3 engagements forgeron...")
await seed_votes_on_items(
session,
doc_forgeron,
- protocols["Vote WoT standard"],
+ protocols["Vote forgeron (Smith)"],
voters,
)
diff --git a/docs/content/dev/10.spike-workflow-engine.md b/docs/content/dev/10.spike-workflow-engine.md
new file mode 100644
index 0000000..3658d02
--- /dev/null
+++ b/docs/content/dev/10.spike-workflow-engine.md
@@ -0,0 +1,239 @@
+# Spike : Moteur de workflow — Protocole Embarquement Forgerons
+
+**Date** : 2026-03-02
+**Statut** : Spike / Pré-étude
+**Auteur** : Yvv + Claude
+
+---
+
+## Contexte : deux objets distincts
+
+L'app a déjà deux concepts qui se côtoient dans "Protocoles" :
+
+| Objet | Ce que c'est | Exemple |
+|-------|-------------|---------|
+| **VotingProtocol** | Règle de vote (formule, seuils, critères) | "Vote forgeron (Smith)" — D30M50B.1G.2S.1 |
+| **Decision + Steps** | Processus multi-étapes (one-shot) | "Runtime Upgrade" — 5 étapes séquentielles |
+
+Il manque un **troisième** objet : le **Protocole opérationnel réutilisable** — un template de workflow qui s'instancie pour chaque candidat/cas.
+
+### Exemple : Embarquement Forgerons
+
+Ce n'est pas une décision ponctuelle. C'est un **processus répétable** :
+
+```
+[Candidat] ──invite──▶ [Invitation on-chain]
+ │
+ ◀──accept──
+ │
+ ──setSessionKeys──▶ [Preuve technique]
+ │
+ ┌──checklist aspirant (aléatoire, avec pièges)
+ │
+ ├──certif smith 1 (checklist certificateur)
+ ├──certif smith 2 (checklist certificateur)
+ └──certif smith 3 (checklist certificateur)
+ │
+ ──goOnline──▶ [Autorité active]
+```
+
+Chaque étape a :
+- Un **acteur** (candidat, certificateur, système)
+- Des **prérequis** (étapes précédentes complétées)
+- Une **preuve** (on-chain tx, checklist complétée, vote)
+- Un **délai** (optionnel)
+
+---
+
+## Volume croissant prévisible
+
+| Protocole opérationnel | Acteurs | Instances/an estimées |
+|----------------------|---------|----------------------|
+| Embarquement Forgerons | candidat + 3 certifieurs | ~10-50 |
+| Embarquement Membre (Certification) | certifié + 5 certifieurs | ~500-2000 |
+| Runtime Upgrade | CoTec + forgerons + communauté | ~4-12 |
+| Modification Document | proposeur + communauté | ~10-50 |
+| Mandat (élection/révocation) | candidat + communauté | ~5-20 |
+| Engagement CoTec | candidat + CoTec | ~2-5 |
+
+**Observation clé** : l'Embarquement Membre est le plus massif et partage la même structure que l'Embarquement Forgeron (checklist + certifications multiples). L'architecture doit être pensée pour ce volume.
+
+---
+
+## Options évaluées
+
+### Option A : n8n (workflow automation)
+
+**n8n** est un outil d'automatisation visuel (self-hosted, open source).
+
+| Pour | Contre |
+|------|--------|
+| Éditeur visuel de workflows | Dépendance externe lourde (~500 MB Docker) |
+| Webhooks, triggers, crons intégrés | Latence réseau (appels HTTP entre services) |
+| 400+ intégrations (email, matrix, etc.) | Pas de MCP server configuré actuellement |
+| Pas de code à écrire pour l'orchestration | Pas de concept natif de "checklist aléatoire" |
+| | Les preuves on-chain nécessitent du dev custom de toute façon |
+| | La communauté Duniter refuse les dépendances centralisées |
+
+**Verdict** : n8n est excellent pour les **automations périphériques** (notifications, alertes, reporting), pas pour le **cœur du workflow**. Le cœur doit rester dans l'app.
+
+**Usage recommandé de n8n** : connecteur optionnel pour les triggers de notification (webhook quand une étape change de statut → email/matrix/telegram). Ne pas en faire le moteur.
+
+### Option B : Dev maison — étendre Decision/DecisionStep
+
+Étendre le modèle `Decision` existant avec un concept de **template**.
+
+```python
+class ProcessTemplate(Base):
+ """Reusable workflow template (e.g. "Embarquement Forgeron")."""
+ id: UUID
+ slug: str # "embarquement-forgeron"
+ name: str # "Embarquement Forgerons"
+ description: str
+ category: str # "onboarding", "governance", "upgrade"
+ step_templates: JSON # Ordered list of step definitions
+ checklist_document_id: UUID # FK to Document (engagement forgeron)
+ voting_protocol_id: UUID # FK to VotingProtocol
+ is_active: bool
+
+class ProcessInstance(Base):
+ """One execution of a template (e.g. "Embarquement de Matograine")."""
+ id: UUID
+ template_id: UUID # FK to ProcessTemplate
+ candidate_id: UUID # FK to DuniterIdentity (le candidat)
+ status: str # invited, accepted, keys_set, checklist, certifying, online, failed
+ current_step: int
+ started_at: datetime
+ completed_at: datetime | None
+ metadata: JSON # on-chain tx hashes, certifier IDs, etc.
+
+class ProcessStepExecution(Base):
+ """One step within an instance."""
+ id: UUID
+ instance_id: UUID
+ step_order: int
+ step_type: str # "on_chain", "checklist", "certification", "manual"
+ actor_id: UUID | None # Who must act
+ status: str # pending, active, completed, failed, skipped
+ proof: JSON | None # tx_hash, checklist_result, vote_session_id
+ started_at: datetime | None
+ completed_at: datetime | None
+```
+
+| Pour | Contre |
+|------|--------|
+| Zéro dépendance externe | Plus de code à écrire |
+| Contrôle total sur la checklist (ordre aléatoire, pièges) | Faut designer le moteur de transitions |
+| Les preuves on-chain sont natives (substrate-interface) | Le workflow avancé (timeouts, escalation) sera simpliste |
+| S'intègre avec le vote engine existant | |
+| La DB track tout (audit trail complet) | |
+| Volume OK avec PostgreSQL (100k instances/an = rien) | |
+
+**Verdict** : c'est la voie naturelle. Le modèle actuel `Decision + Steps` est une version simplifiée de ça. On l'étend proprement.
+
+### Option C : Temporal.io / autre moteur de workflow distribué
+
+| Pour | Contre |
+|------|--------|
+| Garanties transactionnelles fortes | Énorme pour le use case (~GB de RAM) |
+| Retry/timeout/escalation natifs | Cluster Temporal = infra supplémentaire |
+| Bon pour les longs workflows (jours/semaines) | Surcharge conceptuelle |
+| | Aucune intégration native blockchain |
+
+**Verdict** : overkill. À considérer uniquement si on dépasse 10 protocoles actifs avec des centaines d'instances simultanées. Pas avant 2028.
+
+---
+
+## Recommandation
+
+### Sprint 2 : Option B — Dev maison, progressif
+
+**Phase 1** (Sprint 2) — Fondations :
+1. Créer `ProcessTemplate` + `ProcessInstance` + `ProcessStepExecution`
+2. Seed : template "Embarquement Forgerons" avec ses 7 étapes
+3. Frontend : page `/protocols/embarquement-forgerons` avec timeline visuelle
+4. API : `POST /processes/{template_slug}/start` → crée une instance
+5. API : `POST /processes/instances/{id}/advance` → passe à l'étape suivante
+
+**Phase 2** (Sprint 3) — Checklist interactive :
+1. UI de checklist avec ordre aléatoire + détection pièges
+2. Liaison avec le Document (engagement forgeron) pour les clauses
+3. Signature Ed25519 du résultat de checklist (preuve cryptographique)
+
+**Phase 3** (Sprint 4+) — On-chain :
+1. Trigger on-chain via substrate-interface (invite, accept, certify, goOnline)
+2. Listener d'événements blockchain pour compléter automatiquement les étapes on-chain
+3. Optionnel : webhook n8n pour notifications matrix/telegram
+
+### Architecture cible
+
+```
+┌─────────────────────────────────────────────────┐
+│ Frontend │
+│ /protocols/embarquement-forgerons │
+│ ├── Vue template (timeline, étapes) │
+│ ├── Checklist interactive (aléatoire + pièges) │
+│ └── Instance dashboard (candidats en cours) │
+└──────────────────┬──────────────────────────────┘
+ │ API REST
+┌──────────────────▼──────────────────────────────┐
+│ Backend │
+│ ProcessService │
+│ ├── create_instance(template, candidate) │
+│ ├── advance_step(instance, proof) │
+│ ├── evaluate_checklist(instance, answers) │
+│ └── on_chain_trigger(instance, extrinsic) │
+│ │
+│ SubstrateService (substrate-interface) │
+│ ├── smithsMembership.invite() │
+│ ├── smithsMembership.acceptInvitation() │
+│ ├── smithsMembership.setSessionKeys() │
+│ └── authorityMembers.goOnline() │
+└──────────────────┬──────────────────────────────┘
+ │ Events
+┌──────────────────▼──────────────────────────────┐
+│ n8n (optionnel) │
+│ Webhook → Notification matrix/telegram/email │
+│ Cron → Relance candidats inactifs │
+└─────────────────────────────────────────────────┘
+```
+
+### Ce que n8n ne fait PAS (et qu'on doit coder) :
+- Checklist aléatoire avec clause piège et interruption
+- Signature Ed25519 du résultat
+- Appels substrate-interface (invite, certify, goOnline)
+- Calcul de seuil unani-majoritaire
+- Intégrité du workflow (preuve on-chain de chaque étape)
+
+### Ce que n8n PEUT faire (optionnel, sprint 4+) :
+- Webhook → notification email quand un candidat arrive à l'étape "certification"
+- Cron → rappel hebdo aux certificateurs qui n'ont pas agi
+- Webhook → post forum automatique quand un forgeron est accepté
+- Dashboard monitoring (combien de candidats en cours, taux de completion)
+
+---
+
+## Nomenclature proposée dans l'UI
+
+| Menu | Sous-section | Contenu |
+|------|-------------|---------|
+| **Protocoles** | Protocoles de vote | VotingProtocol (binaire, nuancé, smith, techcomm) |
+| | Simulateur de formules | FormulaConfig interactif |
+| | **Protocoles opérationnels** | ProcessTemplate (embarquement, upgrade, etc.) |
+| **Décisions** | (inchangé) | Decision + Steps (instances one-shot) |
+
+Les protocoles opérationnels ont leur propre section dans `/protocols` avec :
+- Carte par template (nom, description, nb d'instances actives)
+- Page détail : timeline template + liste d'instances en cours
+- Page instance : suivi temps réel d'un candidat spécifique
+
+---
+
+## Prochaine étape
+
+Valider cette orientation avec Yvv, puis :
+1. Créer les 3 tables (ProcessTemplate, ProcessInstance, ProcessStepExecution)
+2. Migration Alembic
+3. Seed le template "Embarquement Forgerons" (7 étapes)
+4. Router + service backend
+5. Frontend : page template + page instance
diff --git a/frontend/app/components/common/MarkdownRenderer.vue b/frontend/app/components/common/MarkdownRenderer.vue
index 7ed8ce0..5db82d4 100644
--- a/frontend/app/components/common/MarkdownRenderer.vue
+++ b/frontend/app/components/common/MarkdownRenderer.vue
@@ -72,22 +72,22 @@ const renderedHtml = computed(() => {
diff --git a/frontend/app/pages/documents/index.vue b/frontend/app/pages/documents/index.vue
index 337b8d0..428f5bb 100644
--- a/frontend/app/pages/documents/index.vue
+++ b/frontend/app/pages/documents/index.vue
@@ -87,7 +87,6 @@ const filteredDocuments = computed(() => {
})
/** Toolbox vignettes from protocols. */
-const toolboxTitle = 'Modalites de vote'
const typeLabel = (docType: string): string => {
switch (docType) {
@@ -252,23 +251,27 @@ async function createDocument() {
-
- Aucun protocole configure
-
+ Simulateur de formules, modules de vote, workflows +
+ +- Aucun protocole configure -
+{{ op.description }}
+ ++ Tous les outils de decision collective, organises par section +
+{{ tool.description }}
+{{ tool.description }}
+