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() { diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue index 046135f..81ecdc2 100644 --- a/frontend/app/pages/index.vue +++ b/frontend/app/pages/index.vue @@ -21,17 +21,6 @@ onMounted(async () => { }) const entryCards = computed(() => [ - { - key: 'documents', - title: 'Documents', - icon: 'i-lucide-book-open', - to: '/documents', - count: documents.activeDocuments.length, - countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`, - totalLabel: `${documents.list.length} au total`, - description: 'Textes fondateurs sous vote permanent', - color: 'var(--mood-accent)', - }, { key: 'decisions', title: 'Decisions', @@ -44,15 +33,15 @@ const entryCards = computed(() => [ color: 'var(--mood-secondary, var(--mood-accent))', }, { - key: 'protocoles', - title: 'Protocoles', - icon: 'i-lucide-settings', - to: '/protocols', - count: protocols.protocols.length, - countLabel: `${protocols.protocols.length} modalite${protocols.protocols.length > 1 ? 's' : ''}`, - totalLabel: 'Boite a outils de vote + workflows', - description: 'Modalites de vote, formules, workflows n8n', - color: 'var(--mood-tertiary, var(--mood-accent))', + key: 'documents', + title: 'Documents', + icon: 'i-lucide-book-open', + to: '/documents', + count: documents.activeDocuments.length, + countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`, + totalLabel: `${documents.list.length} au total`, + description: 'Textes fondateurs sous vote permanent', + color: 'var(--mood-accent)', }, { key: 'mandats', @@ -65,6 +54,17 @@ const entryCards = computed(() => [ description: 'Missions deleguees avec nomination en binome', color: 'var(--mood-success)', }, + { + key: 'protocoles', + title: 'Protocoles', + icon: 'i-lucide-settings', + to: '/protocols', + count: protocols.protocols.length, + countLabel: `${protocols.protocols.length} modalite${protocols.protocols.length > 1 ? 's' : ''}`, + totalLabel: 'Boite a outils de vote + workflows', + description: 'Modalites de vote, formules, workflows', + color: 'var(--mood-tertiary, var(--mood-accent))', + }, ]) const recentDecisions = computed(() => { @@ -151,35 +151,27 @@ function formatDate(dateStr: string): string { - -
-
- -

Boite a outils

- {{ protocols.protocols.length }} + + +
+
+ +
+
+

Boite a outils

+

+ Simulateur de formules, modules de vote, workflows +

+
+ Vote WoT + Inertie + Smith + Nuance +
+
+
-
- - -
- - Voir la boite a outils - - -
+
@@ -292,7 +284,7 @@ function formatDate(dateStr: string): string { @media (min-width: 640px) { .dash__entries { - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + grid-template-columns: 1fr 1fr; gap: 1rem; } } @@ -460,73 +452,91 @@ function formatDate(dateStr: string): string { transform: translateY(0); } -/* --- Toolbox teaser --- */ -.dash__toolbox { - background: var(--mood-surface); +/* --- Toolbox card (5th block, distinct) --- */ +.dash__toolbox-card { + display: block; + text-decoration: none; + background: var(--mood-accent-soft); border-radius: 16px; - padding: 1rem; + padding: 1.25rem; + transition: transform 0.15s ease, box-shadow 0.15s ease; + border-left: 4px solid var(--mood-accent); } -@media (min-width: 640px) { - .dash__toolbox { - padding: 1.25rem; - } +.dash__toolbox-card:hover { + transform: translateY(-3px); + box-shadow: 0 8px 24px var(--mood-shadow); } -.dash__toolbox-head { +.dash__toolbox-card-inner { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.dash__toolbox-card-icon { + width: 2.75rem; + height: 2.75rem; + flex-shrink: 0; display: flex; align-items: center; - gap: 0.5rem; - color: var(--mood-accent); - font-weight: 800; - font-size: 1.0625rem; -} -.dash__toolbox-head h3 { margin: 0; } - -.dash__toolbox-count { - font-size: 0.75rem; - font-weight: 800; - background: var(--mood-accent-soft); - color: var(--mood-accent); - padding: 2px 8px; - border-radius: 20px; + justify-content: center; + border-radius: 14px; + background: var(--mood-accent); + color: var(--mood-accent-text); } -.dash__toolbox-tags { +.dash__toolbox-card-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.dash__toolbox-card-title { + font-size: 1.125rem; + font-weight: 800; + color: var(--mood-text); + margin: 0; +} + +.dash__toolbox-card-desc { + font-size: 0.8125rem; + color: var(--mood-text-muted); + margin: 0; + line-height: 1.4; +} + +.dash__toolbox-card-tags { display: flex; flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.dash__tag { - display: inline-flex; - align-items: center; - padding: 0.375rem 0.875rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--mood-accent); - background: var(--mood-accent-soft); - border-radius: 20px; - text-decoration: none; - transition: transform 0.1s ease; -} -.dash__tag:hover { - transform: translateY(-1px); -} - -.dash__toolbox-link { - display: inline-flex; - align-items: center; gap: 0.375rem; - margin-top: 0.75rem; - font-size: 0.875rem; + margin-top: 0.25rem; +} + +.dash__toolbox-card-tag { + display: inline-flex; + padding: 0.25rem 0.625rem; + font-size: 0.6875rem; font-weight: 700; color: var(--mood-accent); - text-decoration: none; + background: var(--mood-surface); + border-radius: 20px; } -.dash__toolbox-link:hover { - text-decoration: underline; + +.dash__toolbox-card-arrow { + flex-shrink: 0; + color: var(--mood-text-muted); + opacity: 0.3; + margin-top: 0.375rem; + transition: all 0.15s; +} + +.dash__toolbox-card:hover .dash__toolbox-card-arrow { + opacity: 1; + color: var(--mood-accent); + transform: translateX(3px); } /* --- Activity --- */ diff --git a/frontend/app/pages/mandates/index.vue b/frontend/app/pages/mandates/index.vue index 654bba7..2d6e7f0 100644 --- a/frontend/app/pages/mandates/index.vue +++ b/frontend/app/pages/mandates/index.vue @@ -272,23 +272,34 @@ async function handleCreate() { diff --git a/frontend/app/pages/protocols/index.vue b/frontend/app/pages/protocols/index.vue index c349466..5bd35c4 100644 --- a/frontend/app/pages/protocols/index.vue +++ b/frontend/app/pages/protocols/index.vue @@ -109,6 +109,35 @@ async function createProtocol() { } } +/** Operational protocols (workflow templates). */ +interface WorkflowStep { + label: string + actor: string + icon: string + type: string +} + +const operationalProtocols = [ + { + slug: 'embarquement-forgeron', + name: 'Embarquement Forgeron', + description: 'Processus complet d\'intégration d\'un nouveau forgeron dans le réseau Duniter.', + category: 'onboarding', + icon: 'i-lucide-hammer', + instancesLabel: '~10-50 / an', + steps: [ + { label: 'Invitation on-chain', actor: 'Smith existant', icon: 'i-lucide-send', type: 'on_chain' }, + { label: 'Acceptation', actor: 'Candidat', icon: 'i-lucide-check', type: 'on_chain' }, + { label: 'Session keys', actor: 'Candidat', icon: 'i-lucide-key', type: 'on_chain' }, + { label: 'Checklist aspirant', actor: 'Candidat', icon: 'i-lucide-clipboard-check', type: 'checklist' }, + { label: 'Certification 1', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' }, + { label: 'Certification 2', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' }, + { label: 'Certification 3', actor: 'Certificateur', icon: 'i-lucide-stamp', type: 'certification' }, + { label: 'Go online', actor: 'Candidat', icon: 'i-lucide-wifi', type: 'on_chain' }, + ] as WorkflowStep[], + }, +] + /** n8n workflow demo items. */ const n8nWorkflows = [ { @@ -229,6 +258,50 @@ const n8nWorkflows = [
+ +
+

+ + Protocoles operationnels + {{ operationalProtocols.length }} +

+ +
+
+
+ +
+
+

{{ op.name }}

+

{{ op.description }}

+ {{ op.instancesLabel }} +
+
+ + +
+
+
+ +
+
+ {{ step.label }} + {{ step.actor }} +
+
+
+
+
+
+

@@ -802,6 +875,152 @@ const n8nWorkflows = [ margin: 0; } +/* --- Operational protocols --- */ +.proto-ops { + margin-top: 1.5rem; +} + +.proto-ops__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9375rem; + font-weight: 700; + color: var(--mood-text); + margin: 0 0 0.75rem; +} + +.proto-ops__count { + font-size: 0.6875rem; + font-weight: 700; + background: var(--mood-accent-soft); + color: var(--mood-accent); + padding: 2px 8px; + border-radius: 20px; +} + +.proto-ops__card { + background: var(--mood-surface); + border-radius: 16px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.proto-ops__card-head { + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +.proto-ops__card-icon { + width: 2.75rem; + height: 2.75rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--mood-accent-soft); + color: var(--mood-accent); +} + +.proto-ops__card-info { + flex: 1; + min-width: 0; +} + +.proto-ops__card-name { + font-size: 1.0625rem; + font-weight: 800; + color: var(--mood-text); + margin: 0; +} + +.proto-ops__card-desc { + font-size: 0.8125rem; + color: var(--mood-text-muted); + line-height: 1.4; + margin: 0.125rem 0 0; +} + +.proto-ops__card-meta { + font-size: 0.6875rem; + font-weight: 600; + color: var(--mood-accent); + opacity: 0.7; +} + +/* Timeline */ +.proto-ops__timeline { + display: flex; + flex-direction: column; + gap: 0; + padding-left: 0.25rem; +} + +.proto-ops__step { + display: flex; + align-items: center; + gap: 0.625rem; + position: relative; + padding: 0.375rem 0; +} + +.proto-ops__step-dot { + width: 1.75rem; + height: 1.75rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--mood-accent-soft); + color: var(--mood-accent); + z-index: 1; +} + +.proto-ops__step-dot--on_chain { + background: color-mix(in srgb, var(--mood-success) 15%, transparent); + color: var(--mood-success); +} + +.proto-ops__step-dot--checklist { + background: color-mix(in srgb, var(--mood-warning) 15%, transparent); + color: var(--mood-warning); +} + +.proto-ops__step-dot--certification { + background: color-mix(in srgb, var(--mood-secondary) 15%, transparent); + color: var(--mood-secondary, var(--mood-accent)); +} + +.proto-ops__step-body { + display: flex; + flex-direction: column; +} + +.proto-ops__step-label { + font-size: 0.8125rem; + font-weight: 700; + color: var(--mood-text); +} + +.proto-ops__step-actor { + font-size: 0.6875rem; + color: var(--mood-text-muted); +} + +.proto-ops__step-line { + position: absolute; + left: calc(0.875rem - 1px); + top: calc(0.375rem + 1.75rem); + width: 2px; + height: calc(100% - 1.75rem + 0.375rem); + background: color-mix(in srgb, var(--mood-accent) 15%, transparent); +} + /* --- Modal --- */ .proto-modal { padding: 1.25rem; diff --git a/frontend/app/pages/tools.vue b/frontend/app/pages/tools.vue new file mode 100644 index 0000000..f788a18 --- /dev/null +++ b/frontend/app/pages/tools.vue @@ -0,0 +1,332 @@ + + + + +