Compare commits

...
13 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 3f10a0e860 v3 : F1.2, N1, N1.1 et O1 réécrits selon les règles appliquées (Yvv, 02/11)
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful
- Le seed porte les nouveaux textes (fr, en, es), ceux du brouillon relu
  (textes-regles-seed.md), pour les bases neuves.
- L'amendement de seed vise aussi une proposition ouverte du seed, par son
  étiquette (Version::Open) ; mêmes gardes : rien d'une personne, 0 vote,
  révision 1, empreinte attendue. Quatre amendements, de l'empreinte du seed du
  21/10 (inchangée depuis 67ddee2) à celle d'aujourd'hui.
- Tests vus rouges : remplacement une fois, un vote garde le texte, republication
  de Monnaie libre seule, base neuve « present ».

Boucle : 237 tests Rust, svelte-check 0, 55 vitest, e2e 35 / 35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:56:34 +01:00
YvvandClaude Opus 5.5 0919b467c9 Fusion du lot G : sur une version retenue par une objection, « contre » et retrait oui, « pour » non (Yvv, 01/11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:29:35 +01:00
YvvandClaude Opus 5.5 f47a685b5f Docs : la règle d'Yvv du 01/11 sur une version retenue (ADR 0010, CHANTIER, CONTROLES, CLAUDE.md)
ADR 0010 révisée : « Retirer ou "contre" » remplace la ligne qui gelait tout ;
la levée fait entrer la version avec les voix encore là, et le coût le dit.
CHANTIER et l'invariant de CLAUDE.md suivent. CONTROLES : C28 ajusté (chez Vera,
pas de « Pour », mais « Contre » et le retrait ; le panier), passage du lot G :
C1–C33 et M1, 35 tests verts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:21:30 +01:00
YvvandClaude Opus 5.5 70aa9ba874 Brouillon : F1.2, N1, N1.1 et O1 réécrits selon les règles appliquées (fr, en, es), pour relecture d'Yvv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:01:00 +01:00
YvvandClaude Opus 5.5 adb64ddb0e Tableau : lot G, réécriture des articles seed, licence posée ; ce qu'Yvv doit fournir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:52:55 +01:00
YvvandClaude Opus 5.5 5d1f8652fc v3 : interface d'une version retenue — « Contre » et « Retirer mon vote », jamais « Pour »
Sous l'article, une version retenue montre « Contre » et le retrait, avec la
phrase qui dit pourquoi. Dans le panier, elle se choisit en « contre » ou en
retrait ; « Tout pour » la saute et le dit, « Tout contre » la prend ; un « pour »
gardé d'avant l'objection quitte le panier (basket.kept). Les phrases qui disaient
« ne se vote plus » disent la règle d'Yvv, en fr, en et es. C28 ajusté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:41:50 +01:00
YvvandClaude Opus 5.5 76b8e9f7be Licence : AGPL-3.0 (choix d'Yvv du 02/11)
LICENSE est le texte actuel de gnu.org (661 lignes, vérifié mot à mot contre le
texte SPDX) ; license = "AGPL-3.0-only" dans le workspace Cargo et package.json ;
README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:40:47 +01:00
YvvandClaude Opus 5.5 238c7db93f v3 : sur une version retenue, « contre » et retrait oui, « pour » non (moteur, serveur, chaîne)
Décision d'Yvv du 01/11/2026 : « Retirer ou "contre" ». Le moteur dit la règle
(objection::may_cast) ; le vote direct, le vote groupé et le bulletin de la chaîne
l'appliquent par DocState::admits : « pour » 409 objection, « contre » et retrait
acceptés, une ligne refusée refuse tout le vote. Un bulletin scellé avant
l'objection compte tel qu'il est signé ; une version retenue n'entre jamais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:03:45 +01:00
YvvandClaude Opus 5.5 61547c11c0 Acte M4 exécuté : 4d4adcc en ligne depuis le 01/11 à 23 h 06 ; tableau : Q5 à Q8 tranchées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:38 +01:00
YvvandClaude Opus 5.5 2514804d38 HANDOFF : L9 à L13 fusionnés et relus, acte M4 figé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:49:01 +01:00
YvvandClaude Opus 5.5 e16c0bd3e0 Acte M4 figé sur 4d4adcc (relectures ok, retour arrière décrit) ; tableau : liste d'attente de la relecture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:48:17 +01:00
YvvandClaude Opus 5.5 875a3541df Acte M4 : l'image de production se construit sur 4d4adcc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:10:48 +01:00
YvvandClaude Opus 5.5 3b6e171489 Dossier d'acte M4 préparé sur 4d4adcc (remplace M2 et M3a) ; tableau : D et F fusionnés, répétition faite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:07:36 +01:00
41 changed files with 2259 additions and 176 deletions
+3 -2
View File
@@ -117,8 +117,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; - **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend une coquille ne demande pas de vote, un changement de fond si ; la coquille d'un autre attend
son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée son auteur (un administrateur pour le seed) ou 3 jours, et en vigueur seule une coquille validée
change le texte ; une objection rend la dernière arrivée non votable jusqu'à sa levée (jamais un change le texte ; une objection retient la dernière arrivée jusqu'à sa levée (jamais un texte en
texte en vigueur ; ADR 0010) ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ; vigueur ; ADR 0010) : elle n'entre pas en vigueur, et on peut y retirer son vote ou passer à
« contre », jamais ajouter un « pour » (Yvv, 01/11/2026 : « Retirer ou "contre" ») ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test** dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv). (chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
+1
View File
@@ -8,6 +8,7 @@ edition = "2024"
rust-version = "1.90" rust-version = "1.90"
repository = "https://git.open.us.org/LPK/decision" repository = "https://git.open.us.org/LPK/decision"
publish = false publish = false
license = "AGPL-3.0-only"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
+13 -3
View File
@@ -1,6 +1,6 @@
# HANDOFF — libreDecision # HANDOFF — libreDecision
> Dernière mise à jour : 2026-10-30, session orch du chantier 1 (revue d'Yvv du 30/10). > Dernière mise à jour : 2026-10-31 à 10 h 50, session orch du chantier 1 (revue d'Yvv du 30/10).
## Où on en est ## Où on en est
@@ -13,9 +13,19 @@
- Décisions ; - Décisions ;
- Raison d'être, publiée dans le dépôt des textes, commit `35864eb` ; - Raison d'être, publiée dans le dépôt des textes, commit `35864eb` ;
- versions groupées. - versions groupées.
- En cours : lot C, l'inertie à cinq crans. - **L9 à L13 fusionnés et relus** sur `chantier/acte-certification` :
- Le vote par transaction de 0,1 Ğ1 (lot B) attend trois réponses d'Yvv (Q5 à Q7 du tableau ; - le vote déplié et groupé, et le vote par la chaîne Ğ1, corrigé mais livré éteint (B, B2) ;
- l'inertie à cinq crans (C) ;
- l'ordre des articles et le titre du document en modules (D) ;
- les objections, les coquilles validées et la boîte « Messages » (F).
Boucle verte (230 tests Rust, 55 vitest, e2e 35 / 35), image de production construite.
- **Acte M4 figé** sur `4d4adcc` (`actes/2026-10-31-m4-vote-modules-objections.md`). Il remplace M2
et M3a. Il attend « déploie » ; ses prémisses sont à revérifier juste avant, dont 0 signalement en
production.
- Le vote par la chaîne reste éteint tant qu'Yvv n'a pas tranché Q5 à Q8 (tableau ;
`docs/recherche/vote-g1-chaine.md`). `docs/recherche/vote-g1-chaine.md`).
- Question de la relecture pour Yvv : sur une version retenue par une objection, peut-on retirer
son vote ou voter « contre » ? Défaut : non, comme le dit l'ADR 0010.
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org, **Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org,
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+3 -2
View File
@@ -111,5 +111,6 @@ maquette/ # la v2 Nuxt + FastAPI, telle quelle
## Licence ## Licence
Logiciel libre — la licence reste à choisir (question ouverte à Yvv) ; le fichier LICENSE sera Logiciel libre sous **GNU Affero General Public License, version 3** (`AGPL-3.0-only`, choix d'Yvv
posé avant toute publication du code. du 2026-11-02) : quiconque fait tourner une version modifiée en ligne doit en publier le code.
Le texte est dans `LICENSE`.
@@ -0,0 +1,240 @@
# Acte — Mise en ligne M4 (le vote, l'inertie à cinq crans, les modules d'ordre et de titre, les objections, les coquilles validées)
Classe : déploie
Projet : libreDecision Date : 2026-10-31
**État : EXÉCUTÉ** le 01/11/2026 (armé par Yvv : « déploie. », le 01/11 vers 22 h 50) — figé le 2026-10-31 à 10 h 47 sur `4d4adcc`
`74f96be`, `9c8f4cc`, `cd7e683`, `924a40d`, `033b331`, `4d4adcc`
## Intention
Mettre en ligne en une fois le reste de la revue d'Yvv du 30/10, L9 à L13 :
- L9 : le vote déplié et groupé ; le vote par la chaîne, corrigé (B2) mais livré **éteint** ;
- L10 : l'inertie à cinq crans ;
- L11 : l'ordre des articles en fin de section (cran 2) et le titre du document en module (cran 4) ;
- L12 : les objections « redit » et « contredit » ;
- L13 : les coquilles validées par leur auteur, la boîte « Messages », le délai de 3 jours.
Cet acte **remplace** les dossiers M2 (`actes/2026-10-31-m2-vote.md`, figé sur `74f96be`) et M3a
(`actes/2026-10-30-m3-inertie-cinq-crans.md`, figé sur `71819f9`). `4d4adcc` contient l'un et
l'autre, et ce que M2 et M3a écrivent en base est repris ici. Si M2 ou M3a est armé avant celui-ci,
cet acte reste valable : seules les migrations manquantes s'appliquent (prémisse 1).
**Le vote par la chaîne reste éteint** : `LD_VOTE_ACCOUNT` n'est pas posé. Yvv doit encore trancher :
- le secret du vote face aux totaux en direct (Q8) ;
- le commentaire scellé (Q5) ;
- l'extension (Q6) ;
- le compte de vote (Q7).
Dans Monnaie libre, l'écran dit pourquoi on ne vote pas encore. Dans le groupe d'essai, tout marche
côté serveur.
## Ce qui part
Version : `4d4adcc`, qui contient la version en production `8727315`. Lots et relectures :
| Lot | Commits | Relecture |
|---|---|---|
| C (cinq crans) | `a576b68`, `71819f9` | « ok » ×2 |
| B (vote) | `74f96be` | « ok » |
| B2 (corrections du vote) | `7ffa4fd` | « à reprendre », repris en `9c8f4cc` |
| F (objections, coquilles, messages) | `cd7e683` | « ok », avec `9c8f4cc` et `924a40d` |
| D (modules) et sa fusion | `033b331` | « ok », avec `4d4adcc` |
| Corrections M1–M4 de la relecture de F | `4d4adcc` | « ok », avec `033b331` |
Ce qui change hors du code (vérifié le 31/10 à 10 h 10) :
- `docker/app-compose.yml` reçoit **trois lignes de commentaire** (où poser `LD_VOTE_ACCOUNT`), et
rien d'autre ;
- `seeds/acte-engagement-certification.json` passe aux crans et décrit les modules. Il n'est lu que pour une base neuve :
un document qui existe en base n'est jamais réinstancié ;
- aucun fichier `.env`, `.db`, clé ou secret.
Nouvelles dépendances (celles de M2) : `polkadot-api`, chargé seulement au moment de signer ; `uqr` ;
la crate `blake2`.
## Ce que l'acte écrit dans la base de production (donnée runtime)
- **Migrations 0005 à 0008**, additives :
- 0005 : les crans, avec les anciens mots gardés ;
- 0006 : les tables du vote, vides ;
- 0007 : `units.kind` et le journal `module_amendments` ;
- 0008 : les objections, les coquilles et les messages, et index ; en production, aucun
signalement à convertir (0 le 31/10 à 06 h 58).
- **Ajouts gardés, journalisés, une seule fois** :
- E1 et E2 au cran 4, dans les deux groupes (4 lignes) ;
- un module titre (cran 4) et un module d'ordre par section (cran 2, 8 sections) par document,
soit 18 modules, chacun avec sa version en vigueur, l'état présent.
- **La mesure de participation** dans `settings`.
- **La republication de Monnaie libre** : trois publications en file (crans E1 et E2, modules) ;
rien pour le groupe d'essai.
- **Aucun article, aucun vote, aucune proposition existante n'est touché.**
**Répétition faite le 31/10 de 10 h 02 à 10 h 06**, avec le serveur de `4d4adcc` (`target/debug`),
sur une copie fraîche de la base de production (la santé disait `87273156`), sans publication
configurée. Avant et après la montée :
| | Avant | Après |
|---|---|---|
| Migrations | 1–4 | 1–8 |
| Unités | 66 articles `standard` | 66 articles (62 au cran 3, 4 au cran 4), 16 ordres au cran 2, 2 titres au cran 4 |
| Propositions d'article | 6 en vigueur, 66 ouvertes | 6 en vigueur, 64 textes ouverts et 2 propositions d'inertie ouvertes (les mêmes 72) |
| Modules | — | 18, en vigueur |
| Révisions | 74 | 92 |
| Traductions | 140 | 144 |
| Votes, adoptions, signalements | 0, 0, 0 | 0, 0, 0 |
| Identités | 1 | 1 |
Ce que montrent les journaux :
- 4 lignes « inertia amendment applied » et 2 lignes « modules added » ;
- dans la file : 3 publications, toutes pour le document de Monnaie libre ;
- `integrity_check` ok, `foreign_key_check` vide.
Un second démarrage ne rejoue rien : mêmes comptes, journaux 4 / 2, file à 6.
## Avant — prémisses vivantes
1. La production sert `8727315` : `curl -s https://decision.librodrome.org/api/health` →
`"build":"87273156"`. Si elle sert `71819f9` (M3a) ou `74f96be` (M2), l'acte reste valable.
2. `git fetch prod && git fetch origin && git rev-parse --short prod/main && git rev-parse --short origin/main`
→ la même version que la santé, deux fois.
3. Dans `~/harness/etat/relectures.json`, ces relectures sont inscrites « ok » :
`a576b68`, `71819f9`, `74f96be`, `9c8f4cc`, `cd7e683`, `924a40d`, `033b331`, `4d4adcc`.
Aucune autre relecture de ce code n'est en cours.
4. `git diff 8727315 4d4adcc -- docker/ .woodpecker/ .env data/` ne montre que les trois lignes de
commentaire sur `LD_VOTE_ACCOUNT`. Sur sonic, `grep -c LD_VOTE_ACCOUNT /opt/libredecision-app/secrets.env`
→ `0`, ou fichier absent.
5. La boucle est verte sur `4d4adcc` : faite le 31/10 vers 10 h (230 tests Rust, svelte-check sans
avertissement, 55 vitest, e2e 35 / 35). L'image de production se construit :
`docker build -f docker/app.Dockerfile --target production .`, sortie 0 le 31/10 à 10 h 09 (tests
rejoués dans l'image : Rust verts, 55 vitest).
6. Sur une copie fraîche de la base de production, **juste avant la poussée** :
- `SELECT group_concat(version) FROM _sqlx_migrations` → `1,2,3,4` (ou plus si M2 / M3a est en ligne) ;
- `SELECT COUNT(*) FROM votes` → `0` ;
- `SELECT COUNT(*) FROM signals` → `0`. Cette prémisse compte : deux signalements debout d'une même
personne sur une même paire feraient échouer la migration 0008 (index `signals_one_standing`,
relecture de `033b331+4d4adcc`). Le serveur ne démarrerait pas, et Woodpecker relancerait
`8727315` sans aucun module, sans dégât.
## Commandes exactes
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod 4d4adcc:main
git push origin 4d4adcc:main
git push origin chantier/acte-certification
```
## Effet attendu
Les visiteurs voient :
- l'inertie en crans et les réglages de la formule ;
- « Le vote » qui se déplie sous l'article, « Voter en lot », « Voter cette section », et le panier ;
- l'ordre des articles en fin de section, et le titre du document qui se vote ;
- « Faire une objection » (redit, contredit), la boîte « Messages », les coquilles proposées ;
- dans Monnaie libre, la raison pour laquelle on ne vote pas encore.
E1 et E2 passent au cran 4. Le dépôt des textes reçoit un `document.json` qui porte les crans et les
modules.
Irréversibilité : **partielle**. Le code se remet en arrière. Ce qui reste :
- les crans en base, avec les mots d'origine à côté ;
- les 18 modules ;
- les tables nouvelles, vides ou presque ;
- les commits publiés dans le dépôt des textes.
## Vérifications après
- [ ] `curl -s https://decision.librodrome.org/api/health` → `"build":"4d4adcc…"`, les huit premiers
caractères de l'empreinte.
- [ ] `ssh sonic 'docker logs lpk-libredecision-app 2>&1 | grep -E "inertia amendment|modules added|participation measured|published|ERROR"'`
→ quatre « inertia amendment applied », deux « modules added », les mesures, des
publications, aucune erreur.
- [ ] Redémarrage à froid (`cd /opt/libredecision-app && docker compose restart`), puis les mêmes
journaux : aucun ajout rejoué.
- [ ] À l'écran (Yvv) :
- Monnaie libre → Acte d'engagement : en fin de section, l'ordre des articles ; « Le vote » sous
la Raison d'être dit que le vote par la chaîne n'est pas encore ouvert ;
- la boîte « Messages » s'ouvre depuis le menu ;
- dans le groupe d'essai : voter « Pour » sous un article ; le panier groupe une section ; « Faire
une objection » dans « Détails » d'une proposition.
## Retour
- **Le code**, au plus court, sur sonic : `docker tag libredecision-app:previous libredecision-app:latest`,
puis `cd /opt/libredecision-app && docker compose up -d --force-recreate`.
- **Revenir à `8727315` une fois les modules créés abîme l'affichage et la publication** (relecture
de `033b331+4d4adcc`, prouvé sur une base faite par `8727315`). `open_db` tolère les migrations
inconnues, mais l'image ancienne lit toute la table `units` :
- le titre et les ordres apparaissent comme 9 faux articles par document (un titre au corps vide,
des listes d'identifiants), comptés « en vigueur » et « à voter » ;
- à son démarrage, elle republie Monnaie libre avec eux (`publish::resync`) ;
- elle lit les crans « 3 » et « 4 » comme `standard`, ce qui est sans effet tant qu'il n'y a aucun vote.
Ce retour peut aussi venir seul : l'étape `retablir` de `.woodpecker/app.yml` relance
`libredecision-app:previous` si la nouvelle version ne répond pas dans les 10 minutes.
**Conduite retenue par défaut** (Yvv peut la corriger) :
- on ne revient pas à la main vers `8727315` : on corrige en avant, par un nouveau déploiement ;
- si `retablir` joue, on laisse `8727315` servir le temps de corriger. La publication fausse ne
perd rien : elle part dans le dépôt des textes provisoire, et le déploiement suivant republie le bon
texte par-dessus. Le contexte : 1 identité et 0 vote en production.
- **Les crans, les modules, les tables nouvelles** : pas de retour automatique. Ils sont additifs, ou
gardent l'ancien mot à côté.
- **Les commits publiés** dans le dépôt des textes : on ne les retire pas sans réécrire l'historique
de ce dépôt, ce qui serait un acte « supprime ». On publierait par-dessus.
## Exécution
Acte exécuté le 01/11/2026, 22h52-23h40, par orch (session libreDecision), armé par Yvv dans cette
session (« déploie. »).
**Écart au dossier** : seule `git push prod 4d4adcc:main` est partie. Les deux poussées vers
`origin` (`4d4adcc:main` et `chantier/acte-certification`) relèvent de « pousse », absent du message
d'armement ; elles attendent ce mot. Leçon : un dossier « déploie » ne liste que la poussée qui met en
ligne. Une poussée vers `origin` est un acte « pousse » à part.
Prémisses revérifiées de 22 h 52 à 22 h 53 :
1. santé `87273156` ;
2. `prod/main` = `origin/main` = `8727315` ;
3. les huit relectures inscrites « ok », aucune autre en cours ;
4. trois lignes de commentaire seulement dans `docker/`, `.woodpecker/`, `.env` et `data/` ;
`secrets.env` absent sur sonic ;
5. boucle et image du 31/10, inchangées depuis ;
6. sur une copie fraîche : migrations `1,2,3,4`, 0 vote, 0 signalement, 1 identité, 72 propositions.
La copie a été retirée aussitôt.
Mise en ligne :
- 22 h 53 : `git push prod 4d4adcc:main` → `8727315..4d4adcc 4d4adcc -> main` ;
- 23 h 06 : la santé répond `{"build":"4d4adcc2","ok":true}` ; elle répondait `87273156` jusqu'à 22 h 54.
Journaux du démarrage (22 h 06 UTC) :
- 4 lignes « inertia amendment applied » (E1 et E2, essai et Monnaie libre) ;
- 2 lignes « modules added » ;
- les mesures de participation (médiane absente, échantillon 0) ;
- une publication, `published … commit="12134cd49eccadbf25a157bffa8b627e869099e9"`, qui regroupe les
trois mises en file ;
- aucune ligne ERROR ni WARN.
Image de retour : `libredecision-app:previous` = `sha256:625646c7e73d…`, construite le 30/10 à 15 h 44
(`8727315`).
Redémarrage à froid (`docker compose restart`, 23 h 35) :
- le serveur écoute en `4d4adcc2`, `RestartCount` 0 ;
- aucun ajout rejoué ;
- la publication de reprise dit `commit="unchanged"`.
La sonde de santé du script, faite de l'intérieur du conteneur, a échoué : l'image n'a ni `wget` ni
`curl`. Le script s'est donc arrêté avant de se retirer ; je l'ai retiré à la main. La santé a été
vérifiée de l'extérieur : `4d4adcc2`.
Invariants sur une copie fraîche, après le redémarrage, identiques à la répétition du 31/10 à la ligne
près :
- migrations `1,…,8` ;
- 62 articles au cran 3, 4 au cran 4, 16 ordres au cran 2, 2 titres au cran 4 ;
- 90 propositions, dont les mêmes 72 d'article ;
- 92 révisions, 144 traductions, 0 vote, 1 identité ;
- journaux 4 et 2 ;
- `integrity_check` ok, `foreign_key_check` vide.
Retirés, parce qu'ils portaient des données réelles : la copie de la prémisse 6, la copie d'après, la
répétition `/tmp/ld-repetition-m4` et le journal de construction.
Reste à l'écran pour Yvv : les vérifications « À l'écran » ci-dessus.
+1
View File
@@ -5,6 +5,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true publish.workspace = true
license.workspace = true
[dependencies] [dependencies]
blake2 = "0.10" blake2 = "0.10"
+26
View File
@@ -17,6 +17,10 @@
//! is changed by a proposal. The target is fixed when the objection is raised; //! is changed by a proposal. The target is fixed when the objection is raised;
//! - the votes already cast on it stay, but it cannot enter into force //! - the votes already cast on it stay, but it cannot enter into force
//! (`votable` and the adoption: a non-votable version is blocked); //! (`votable` and the adoption: a non-votable version is blocked);
//! - while it is held, one may still **vote against it or take one's vote
//! back**, never add a *for* (`may_cast`, Yvv 2026-11-01: « Retirer ou
//! "contre" »): it can only lose votes, and once lifted it enters with the
//! votes still there;
//! - its author **answers**: a substantive revision of it, or a proposal linked //! - its author **answers**: a substantive revision of it, or a proposal linked
//! to the objection (on the article it repeats, for a « redit »; a reworded //! to the objection (on the article it repeats, for a « redit »; a reworded
//! version, for a version without author). A substantive revision of **the //! version, for a version without author). A substantive revision of **the
@@ -31,6 +35,7 @@
//! lift too (never reiterate: the objection is not theirs); withdrawing either //! lift too (never reiterate: the objection is not theirs); withdrawing either
//! version ends it. //! version ends it.
use crate::ballot::Choice;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::Ordering; use std::cmp::Ordering;
@@ -296,6 +301,16 @@ pub fn votable(id: &str, in_force: bool, held: &[&str]) -> bool {
in_force || !held.contains(&id) in_force || !held.contains(&id)
} }
/// May this choice be cast on a version? `held` = a standing objection holds it
/// back now (never a version in force, `votable`). Yvv, 2026-11-01, to « pendant
/// qu'une objection retient une version, que permet-on ? »: « Retirer ou
/// "contre" ». On a held version one may take one's vote back or vote against,
/// never add a *for*: it can only lose votes while it is held, and once lifted it
/// enters with the votes still there. A version not held takes any choice.
pub fn may_cast(held: bool, choice: Choice) -> bool {
!held || choice != Choice::For
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -357,6 +372,17 @@ mod tests {
assert!(votable("x", true, &["x"]), "a version in force is never made non-votable"); assert!(votable("x", true, &["x"]), "a version in force is never made non-votable");
} }
#[test]
fn a_held_version_takes_against_and_withdrawal_never_for() {
// Yvv, 2026-11-01: « Retirer ou "contre" » — it can only lose votes while held.
assert!(!may_cast(true, Choice::For), "never a new *for* while held");
assert!(may_cast(true, Choice::Against), "against, yes");
assert!(may_cast(true, Choice::None), "taking one's vote back, yes");
for c in [Choice::For, Choice::Against, Choice::None] {
assert!(may_cast(false, c), "a version not held takes any choice: {c:?}");
}
}
#[test] #[test]
fn the_full_cycle_answer_reiterate_answer_lift() { fn the_full_cycle_answer_reiterate_answer_lift() {
let start = Phase::Author { cycle: 1 }; let start = Phase::Author { cycle: 1 };
+1
View File
@@ -5,6 +5,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true publish.workspace = true
license.workspace = true
[dependencies] [dependencies]
ld-engine = { path = "../ld-engine" } ld-engine = { path = "../ld-engine" }
+12 -6
View File
@@ -90,7 +90,7 @@ pub struct BallotBody {
author: Option<String>, author: Option<String>,
} }
/// A line checked against the document: a votable version, its revision now. /// A line checked against the document: a version open to its choice, its revision now.
pub(crate) struct Cast { pub(crate) struct Cast {
pub proposal_id: String, pub proposal_id: String,
pub revision: i64, pub revision: i64,
@@ -105,7 +105,9 @@ fn parse_choice(raw: Option<&str>) -> AppResult<Choice> {
} }
/// The lines of a vote, read against the document: at least one, each version /// The lines of a vote, read against the document: at least one, each version
/// once, each one of this document and votable. /// once, each one of this document and open to its choice — a version held back
/// by an objection takes against or a withdrawal, never a *for* (`DocState::admits`).
/// One line refused refuses them all.
fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> { fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if lines.is_empty() || lines.len() > MAX_LINES { if lines.is_empty() || lines.len() > MAX_LINES {
return Err(AppError::Invalid("lines")); return Err(AppError::Invalid("lines"));
@@ -120,11 +122,13 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if !matches!(p.status.as_str(), "open" | "in_force") { if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable")); return Err(AppError::Conflict("not_votable"));
} }
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010). let choice = parse_choice(l.choice.as_deref())?;
if !st.votable(p) { // Held back by an objection: no new *for*, on the server or on the chain
// (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
if !st.admits(p, choice) {
return Err(AppError::Conflict("objection")); return Err(AppError::Conflict("objection"));
} }
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? }); out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice });
} }
Ok(out) Ok(out)
} }
@@ -600,7 +604,9 @@ async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str)
} }
/// The ballot counts: written for the identity of the signer, line by line, /// The ballot counts: written for the identity of the signer, line by line,
/// where no more recent ballot of that identity decided already. /// where no more recent ballot of that identity decided already. A ballot sealed
/// before an objection counts as it was signed — a *for* too, as a vote already
/// cast —, and the version held back still cannot enter (ADR 0010).
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> { async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
let voter = format!("idty:{index}"); let voter = format!("idty:{index}");
let position = found.comment.position; let position = found.comment.position;
+12 -2
View File
@@ -12,6 +12,7 @@ use crate::participation;
use crate::util::{self, LangMap}; use crate::util::{self, LangMap};
use axum::Json; use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use ld_engine::ballot::Choice;
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::inertia::{InertiaLevel, Participation, Scale}; use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally}; use ld_engine::threshold::{Evaluation, Tally};
@@ -265,7 +266,8 @@ pub struct DocState {
pub module_kinds: HashMap<String, ModuleKind>, pub module_kinds: HashMap<String, ModuleKind>,
pub proposals: Vec<ProposalRow>, pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>, pub tallies: HashMap<String, Tally>,
/// Versions held back by a standing objection: not votable, never entering into force. /// Versions held back by a standing objection: against or a withdrawal only, never
/// a new *for* (`admits`), and never entering into force.
pub blocked: HashSet<String>, pub blocked: HashSet<String>,
/// Standing objections touching each version, either side. /// Standing objections touching each version, either side.
pub signal_counts: HashMap<String, i64>, pub signal_counts: HashMap<String, i64>,
@@ -353,7 +355,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
} }
// A standing objection holds back the latest arrival of its pair (never a version // A standing objection holds back the latest arrival of its pair (never a version
// in force): not votable, and it cannot enter into force (ADR 0010). // in force): no new *for*, and it cannot enter into force (ADR 0010).
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
let (electorate, electorate_source) = electorate(state, &group).await?; let (electorate, electorate_source) = electorate(state, &group).await?;
let measure = participation::stored(state, &group.slug).await?; let measure = participation::stored(state, &group.slug).await?;
@@ -414,6 +416,14 @@ impl DocState {
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id) matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
} }
/// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the
/// chain? Open or in force; held back by an objection, against or a withdrawal
/// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01).
pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool {
matches!(p.status.as_str(), "open" | "in_force")
&& ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice)
}
/// The module a unit is, if it is one. /// The module a unit is, if it is one.
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> { pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
self.module_kinds.get(unit_id).copied() self.module_kinds.get(unit_id).copied()
+3 -2
View File
@@ -1,8 +1,9 @@
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010). //! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
//! //!
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of //! The rules are the engine's (`ld_engine::objection`): the latest arrival of
//! the pair, never a version in force, is no longer votable while the objection //! the pair, never a version in force, takes no new *for* while the objection
//! stands; its author answers (a substantive revision, or a proposal linked to //! stands — against or a withdrawal only (`may_cast`, Yvv 2026-11-01) — and
//! cannot enter into force; its author answers (a substantive revision, or a proposal linked to
//! the objection — a revision of the other side answers too); the objector is //! the objection — a revision of the other side answers too); the objector is
//! then asked, by a message, to lift or reiterate; without an answer within 3 //! then asked, by a message, to lift or reiterate; without an answer within 3
//! days the objection is lifted (`due`, run by the sweeper and at start). //! days the objection is lifted (`due`, run by the sweeper and at start).
+2 -1
View File
@@ -138,7 +138,8 @@ pub async fn decisions(
/// or a seed a minima), the open versions, and whether `me` holds a valid vote /// or a seed a minima), the open versions, and whether `me` holds a valid vote
/// on one of them. A change of inertia is not a version of the text; the order /// on one of them. A change of inertia is not a version of the text; the order
/// of a section of fewer than two articles has nothing to decide; a version held /// of a section of fewer than two articles has nothing to decide; a version held
/// by an objection is nobody's to vote while it is held (ADR 0010). /// by an objection is not counted as one to vote while it is held: it takes no
/// new *for*, only against or a withdrawal (ADR 0010).
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> { async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct VersionRow { struct VersionRow {
+8 -7
View File
@@ -648,19 +648,20 @@ pub async fn vote(
if !matches!(p.status.as_str(), "open" | "in_force") { if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable")); return Err(AppError::Conflict("not_votable"));
} }
// Held back by an objection: its votes stay, no new one (ADR 0010).
if !st.votable(&p) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
let choice = match b.choice.as_deref() { let choice = match b.choice.as_deref() {
Some("for") => Choice::For, Some("for") => Choice::For,
Some("against") => Choice::Against, Some("against") => Choice::Against,
None => Choice::None, None => Choice::None,
Some(_) => return Err(AppError::Invalid("choice")), Some(_) => return Err(AppError::Invalid("choice")),
}; };
// Held back by an objection: its votes stay; against or a withdrawal, never a
// new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
if !st.admits(&p, choice) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice }; let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
ballots::write_server_votes(&state, &user.id, &[cast]).await?; ballots::write_server_votes(&state, &user.id, &[cast]).await?;
let entered = adoption::run(&state, &st.doc.id).await?; let entered = adoption::run(&state, &st.doc.id).await?;
+117 -21
View File
@@ -1,5 +1,10 @@
//! Seed amendments — a starting text replaced by a newer text of the seed. //! Seed amendments — a starting text replaced by a newer text of the seed.
//! //!
//! The text replaced is an article's version in force (a seed a minima), or, since
//! 2026-11-02, one of its open proposals from the seed, named by its label: the
//! rule articles F1.2, N1, N1.1 and O1 rewritten after the rules applied (Yvv).
//! The guards are the same.
//!
//! A seeded document is never instantiated again: once in the database, it is //! A seeded document is never instantiated again: once in the database, it is
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30: //! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
//! the Préambule becomes « Raison d'être »), new bases get it from the seed; //! the Préambule becomes « Raison d'être »), new bases get it from the seed;
@@ -25,6 +30,24 @@ use crate::seed::{SeedDoc, SeedVersion};
use crate::util; use crate::util;
use sqlx::SqliteConnection; use sqlx::SqliteConnection;
/// Which version of the article an amendment replaces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Version {
/// Its text in force, a seed a minima.
InForce,
/// An open proposal of the seed, at zero votes, by its label.
Open(&'static str),
}
impl Version {
fn status(self) -> &'static str {
match self {
Version::InForce => "in_force",
Version::Open(_) => "open",
}
}
}
/// One starting text to replace, in every document instantiated from a seed. /// One starting text to replace, in every document instantiated from a seed.
pub struct Amendment { pub struct Amendment {
/// Stable identifier: each document's decision is kept under it. /// Stable identifier: each document's decision is kept under it.
@@ -33,6 +56,8 @@ pub struct Amendment {
pub doc: &'static str, pub doc: &'static str,
/// Code of the article. /// Code of the article.
pub unit: &'static str, pub unit: &'static str,
/// The version replaced.
pub version: Version,
/// Fingerprint of the starting text expected in force (source language). /// Fingerprint of the starting text expected in force (source language).
pub from: &'static str, pub from: &'static str,
/// Fingerprint of the new text: the version in force of the seed today, which /// Fingerprint of the new text: the version in force of the seed today, which
@@ -42,14 +67,54 @@ pub struct Amendment {
pub reason: &'static str, pub reason: &'static str,
} }
pub const AMENDMENTS: &[Amendment] = &[Amendment { pub const AMENDMENTS: &[Amendment] = &[
id: "2026-10-30-i1-raison-d-etre", Amendment {
doc: "acte-engagement-certification", id: "2026-10-30-i1-raison-d-etre",
unit: "I1", doc: "acte-engagement-certification",
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375", unit: "I1",
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a", version: Version::InForce,
reason: "texte de départ remplacé : I1", from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
}]; to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
reason: "texte de départ remplacé : I1",
},
// The rule articles, open proposals of the seed, after the rules applied (Yvv, 2026-11-02).
Amendment {
id: "2026-11-02-f1-2-regles",
doc: "acte-engagement-certification",
unit: "F1.2",
version: Version::Open("1.0.0"),
from: "044c65d6b97e4278184cb1acfe6fa2227ddfe91465c717d8a81f52092314fae6",
to: "b24ff4d1c95cfbaae0231885009ee3d600957a3ce498bede80b29bceca651a60",
reason: "proposition de départ réécrite : F1.2",
},
Amendment {
id: "2026-11-02-n1-regles",
doc: "acte-engagement-certification",
unit: "N1",
version: Version::Open("1.0.0"),
from: "eed13441afae8d33eba37545163037ccf89b0801d84072ef55290575a1410142",
to: "c1facdfd0c0f3aaa94b74192e146557558fe88f66304dc6d21279714d7fb727e",
reason: "proposition de départ réécrite : N1",
},
Amendment {
id: "2026-11-02-n1-1-regles",
doc: "acte-engagement-certification",
unit: "N1.1",
version: Version::Open("1.0.0"),
from: "d5d06c5ae5f3a1c1b08e3c3721b2b09c3dcfd528c11d16d3368d6e14d8942bee",
to: "865ed24d5eb18745a8c35dc727247219c449e078d39b6a18ec2fdcfd4f8289a0",
reason: "proposition de départ réécrite : N1.1",
},
Amendment {
id: "2026-11-02-o1-regles",
doc: "acte-engagement-certification",
unit: "O1",
version: Version::Open("1.0.0"),
from: "40ab4c06a9e374bded6023b9dffbee5736fc4000d422c5a819f35cdb00b02642",
to: "4173738055af10fceb74bb6e954bdfefd7ab364480d49afbc92ea5f68825c537",
reason: "proposition de départ réécrite : O1",
},
];
/// The fingerprint of a text: the hash translations are pinned to. /// The fingerprint of a text: the hash translations are pinned to.
pub fn fingerprint(title: &str, body: &str) -> String { pub fn fingerprint(title: &str, body: &str) -> String {
@@ -88,7 +153,12 @@ pub enum Verdict {
/// The rule, apart from the database: any trace of a person keeps the text. /// The rule, apart from the database: any trace of a person keeps the text.
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict { pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
let Some(f) = facts else { return Verdict::Refuse("no_text_in_force") }; let Some(f) = facts else {
return Verdict::Refuse(match a.version {
Version::InForce => "no_text_in_force",
Version::Open(_) => "no_open_version",
});
};
if f.fingerprint == a.to { if f.fingerprint == a.to {
return Verdict::Present; return Verdict::Present;
} }
@@ -160,8 +230,12 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided"); tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
return None; return None;
}; };
let version = let version = seed.units.iter().find(|u| u.code == a.unit).and_then(|u| {
seed.units.iter().find(|u| u.code == a.unit).and_then(|u| u.versions.iter().find(|v| v.status == "in_force")); u.versions.iter().find(|v| match a.version {
Version::InForce => v.status == "in_force",
Version::Open(label) => v.status == "open" && v.label.as_deref() == Some(label),
})
});
let src = seed.source_lang.as_str(); let src = seed.source_lang.as_str();
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?))); let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
match (version, text) { match (version, text) {
@@ -178,7 +252,7 @@ fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'
} }
} }
async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result<Option<Facts>> { async fn facts(conn: &mut SqliteConnection, proposal_id: &str, status: &str) -> anyhow::Result<Option<Facts>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
origin: String, origin: String,
@@ -189,9 +263,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
body: String, body: String,
} }
let Some(p) = sqlx::query_as::<_, Row>( let Some(p) = sqlx::query_as::<_, Row>(
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = 'in_force'", "SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = ?",
) )
.bind(proposal_id) .bind(proposal_id)
.bind(status)
.fetch_optional(&mut *conn) .fetch_optional(&mut *conn)
.await? .await?
else { else {
@@ -252,33 +327,49 @@ async fn decide(
if decided > 0 { if decided > 0 {
return Ok(None); return Ok(None);
} }
let in_force: Option<String> = let target_id: Option<String> = match a.version {
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?") Version::InForce => {
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?
.flatten()
}
Version::Open(label) => {
sqlx::query_scalar(
"SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = ?
ORDER BY p.rowid LIMIT 1",
)
.bind(doc_id) .bind(doc_id)
.bind(a.unit) .bind(a.unit)
.bind(label)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await? .await?
.flatten(); }
let found = match &in_force { };
Some(id) => facts(&mut tx, id).await?, let found = match &target_id {
Some(id) => facts(&mut tx, id, a.version.status()).await?,
None => None, None => None,
}; };
let mut v = verdict(found.as_ref(), a); let mut v = verdict(found.as_ref(), a);
let now = util::now(); let now = util::now();
if let (Verdict::Apply, Some(pid)) = (v, &in_force) { if let (Verdict::Apply, Some(pid)) = (v, &target_id) {
let src = seed.source_lang.as_str(); let src = seed.source_lang.as_str();
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default(); let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
let (title, body) = (pick(&target.title), pick(&target.body)); let (title, body) = (pick(&target.title), pick(&target.body));
// Written only over the text that was judged, still untouched. // Written only over the text that was judged, still untouched.
let done = sqlx::query( let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ? "UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL WHERE id = ? AND status = ? AND origin = 'seed' AND entered_at IS NULL
AND confirmed_at IS NULL AND revision = 1", AND confirmed_at IS NULL AND revision = 1",
) )
.bind(&title) .bind(&title)
.bind(&body) .bind(&body)
.bind(&now) .bind(&now)
.bind(pid) .bind(pid)
.bind(a.version.status())
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
if done.rows_affected() == 1 { if done.rows_affected() == 1 {
@@ -335,7 +426,7 @@ async fn decide(
.bind(a.id) .bind(a.id)
.bind(doc_id) .bind(doc_id)
.bind(a.unit) .bind(a.unit)
.bind(&in_force) .bind(&target_id)
.bind(outcome) .bind(outcome)
.bind(reason) .bind(reason)
.bind(&now) .bind(&now)
@@ -353,6 +444,7 @@ mod tests {
id: "test", id: "test",
doc: "doc", doc: "doc",
unit: "I1", unit: "I1",
version: Version::InForce,
from: "old", from: "old",
to: "new", to: "new",
reason: "texte de départ remplacé : I1", reason: "texte de départ remplacé : I1",
@@ -398,5 +490,9 @@ mod tests {
for (facts, reason) in cases { for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}"); assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
} }
// An open proposal of the seed that is no longer there (withdrawn, never seeded).
let open = Amendment { version: Version::Open("1.0.0"), ..A };
assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version"));
assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply);
} }
} }
+2 -2
View File
@@ -936,11 +936,11 @@ async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state) (Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
} }
/// Each document's decision on the amendment: (group, outcome, reason, at). /// Each document's decision on the amendment of I1: (group, outcome, reason, at).
async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> { async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> {
sqlx::query_as( sqlx::query_as(
"SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id "SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
ORDER BY d.group_slug", WHERE s.amendment_id = '2026-10-30-i1-raison-d-etre' ORDER BY d.group_slug",
) )
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
+46
View File
@@ -776,6 +776,52 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_an_objection_holds_it()
assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back"); assert!(unit(&d, "E5")["inForce"].is_null(), "an objection holds it back");
} }
/// Yvv, 2026-11-01: « Retirer ou "contre" ». On the chain too, a held version takes a
/// ballot against it or a withdrawal, never a *for* — alone or among free lines.
#[tokio::test]
async fn on_the_chain_a_held_version_takes_against_and_withdrawal_never_for() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e1, e4, e5) = (version(&doc, "E1", "inForce"), version(&doc, "E4", "0"), version(&doc, "E5", "0"));
let o = c
.ok(
"POST",
&format!("/api/proposals/{e5}/objections"),
Some(json!({ "otherId": e1, "kind": "contredit", "reason": "Ces deux textes disent le contraire." })),
)
.await;
assert_eq!(o["heldId"], e5.as_str());
let uri = format!("/api/docs/{doc_id}/ballots");
let refused = (StatusCode::CONFLICT, Some("objection"));
for lines in [
json!([{ "proposalId": e5, "choice": "for" }]),
json!([{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "for" }]),
] {
let (s, v) = c.call("POST", &uri, Some(json!({ "lines": lines }))).await;
assert_eq!((s, v["error"].as_str()), refused, "{lines}");
}
// Against the held one, for a free one: sealed, then confirmed by the chain.
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("against"))], None).await;
indexer.pay(200, comment_of(&b), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!((tally(&d, &e4), tally(&d, &e5)), ((1, 0), (0, 1)));
assert_eq!(find(&d, &e5)["myVote"], "against");
// A withdrawal: the last ballot stands.
let w = c.ballot(&doc, &[(&e5, None)], None).await;
indexer.pay(201, comment_of(&w), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&w).await["status"], "confirmed");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e5), (0, 0));
assert!(find(&d, &e5)["myVote"].is_null());
assert!(unit(&d, "E5")["inForce"].is_null(), "held, it never enters");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// After the independent review of 74f96be (lot B2): its probes, kept as tests // After the independent review of 74f96be (lot B2): its probes, kept as tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+57
View File
@@ -0,0 +1,57 @@
{
"_source": "seeds/acte-engagement-certification.json au commit 70aa9ba : les quatre articles des règles tels que la production les tient (seed du 21/10, inchangé)",
"units": {
"F1.2": {
"label": "1.0.0",
"title": {
"fr": "Paramètres par défaut",
"en": "Default parameters",
"es": "Parámetros por defecto"
},
"body": {
"fr": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes exprimés (pour + contre)\n- **W** = taille de la toile de confiance\n- **M** = majorité cible (50% par défaut)\n- **G** = gradient d'inertie (0.2 par défaut)\n\n**Valeurs par défaut (inertie standard) :**\n- Durée : 30 jours (vote permanent)\n- Majorité cible M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lecture du curseur d'inertie :**\nLe curseur affiché sur chaque section représente le niveau G. Plus le curseur est à droite, plus le remplacement est difficile. La courbe associée montre le seuil requis en fonction de la participation.\n\nMode compact : **D30M50B.1G.2**",
"en": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes cast (for + against)\n- **W** = size of the web of trust\n- **M** = target majority (50% by default)\n- **G** = inertia gradient (0.2 by default)\n\n**Default values (standard inertia):**\n- Duration: 30 days (permanent vote)\n- Target majority M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constant C = 0\n\n**Reading the inertia slider:**\nThe slider shown on each section represents the level G. The further to the right the slider, the harder the replacement. The associated curve shows the required threshold as a function of participation.\n\nCompact mode: **D30M50B.1G.2**",
"es": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votos emitidos (a favor + en contra)\n- **W** = tamaño de la red de confianza\n- **M** = mayoría objetivo (50% por defecto)\n- **G** = gradiente de inercia (0.2 por defecto)\n\n**Valores por defecto (inercia estándar):**\n- Duración: 30 días (votación permanente)\n- Mayoría objetivo M = 50%\n- Gradiente G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lectura del cursor de inercia:**\nEl cursor que se muestra en cada sección representa el nivel G. Cuanto más a la derecha está el cursor, más difícil es la sustitución. La curva asociada muestra el umbral requerido en función de la participación.\n\nModo compacto: **D30M50B.1G.2**"
}
},
"N1": {
"label": "1.0.0",
"title": {
"fr": "Réglage de l'inertie",
"en": "Inertia setting",
"es": "Ajuste de la inercia"
},
"body": {
"fr": "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.",
"en": "The inertia setting defines how difficult it is to replace each section of the document. This setting is itself subject to the highest inertia, to prevent the rules of modification from being modified.",
"es": "El ajuste de la inercia define la dificultad de sustitución de cada sección del documento. Este ajuste está a su vez sometido a la inercia más elevada, para impedir la modificación de las reglas de modificación."
}
},
"N1.1": {
"label": "1.0.0",
"title": {
"fr": "Niveaux d'inertie",
"en": "Inertia levels",
"es": "Niveles de inercia"
},
"body": {
"fr": "**Formule simplifiée :** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nLe **gradient G** contrôle la courbe d'inertie : plus G est élevé, plus le seuil reste haut même avec une participation croissante.\n\n**Basse** — G=0.1, M=50%\n- La courbe descend très vite vers 50%\n- Dès 10% de participation, le seuil est proche de la majorité simple\n- *Usage : annexes, recommandations, conseils*\n\n**Standard** — G=0.2, M=50%\n- Descente progressive, équilibre entre stabilité et réactivité\n- À 25% de participation, le seuil avoisine 65%\n- *Usage : engagements fondamentaux et techniques*\n\n**Haute** — G=0.4, M=60%\n- Courbe résistante, le seuil reste élevé longtemps\n- Super-majorité de 60% même à pleine participation\n- *Usage : formule de vote elle-même*\n\n**Très haute** — G=0.6, M=66%\n- Quasi-unanimité requise à tout niveau de participation\n- Seul un consensus massif (66%+) peut modifier ce réglage\n- *Usage : réglage de l'inertie (méta-protection)*\n\nLe curseur d'inertie associé à chaque section du document reflète visuellement ces niveaux.",
"en": "**Simplified formula:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nThe **gradient G** controls the inertia curve: the higher G is, the higher the threshold remains, even as participation increases.\n\n**Low** — G=0.1, M=50%\n- The curve drops very quickly towards 50%\n- From 10% participation, the threshold is close to a simple majority\n- *Use: annexes, recommendations, advice*\n\n**Standard** — G=0.2, M=50%\n- Gradual descent, balance between stability and responsiveness\n- At 25% participation, the threshold is around 65%\n- *Use: fundamental and technical commitments*\n\n**High** — G=0.4, M=60%\n- Resistant curve, the threshold stays high for a long time\n- Supermajority of 60% even at full participation\n- *Use: the voting formula itself*\n\n**Very high** — G=0.6, M=66%\n- Near-unanimity required at every level of participation\n- Only a massive consensus (66%+) can modify this setting\n- *Use: the inertia setting (meta-protection)*\n\nThe inertia slider associated with each section of the document visually reflects these levels.",
"es": "**Fórmula simplificada:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nEl **gradiente G** controla la curva de inercia: cuanto más alto es G, más alto se mantiene el umbral, incluso con una participación creciente.\n\n**Baja** — G=0.1, M=50%\n- La curva desciende muy rápido hacia el 50%\n- A partir del 10% de participación, el umbral se acerca a la mayoría simple\n- *Uso: anexos, recomendaciones, consejos*\n\n**Estándar** — G=0.2, M=50%\n- Descenso progresivo, equilibrio entre estabilidad y reactividad\n- Con un 25% de participación, el umbral ronda el 65%\n- *Uso: compromisos fundamentales y técnicos*\n\n**Alta** — G=0.4, M=60%\n- Curva resistente, el umbral se mantiene elevado durante mucho tiempo\n- Supermayoría del 60% incluso con participación plena\n- *Uso: la propia fórmula de votación*\n\n**Muy alta** — G=0.6, M=66%\n- Se requiere casi unanimidad con cualquier nivel de participación\n- Solo un consenso masivo (66%+) puede modificar este ajuste\n- *Uso: el ajuste de la inercia (metaprotección)*\n\nEl cursor de inercia asociado a cada sección del documento refleja visualmente estos niveles."
}
},
"O1": {
"label": "1.0.0",
"title": {
"fr": "Ordonnancement du document",
"en": "Ordering of the document",
"es": "Ordenación del documento"
},
"body": {
"fr": "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.",
"en": "The order in which items are presented in the document is itself subject to vote. Any reorganisation proposal must reach the adoption threshold with standard inertia.",
"es": "El orden de presentación de los elementos en el documento está a su vez sometido a votación. Toda propuesta de reorganización debe alcanzar el umbral de adopción con la inercia estándar."
}
}
}
}
+113 -1
View File
@@ -142,7 +142,8 @@ async fn a_redit_holds_the_latest_back_until_its_author_answers_and_the_objector
assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable"); assert_eq!(find(&d, &e1_id)["votable"], true, "a version in force is never made non-votable");
assert_eq!(find(&d, &e1_id)["objections"], 1); assert_eq!(find(&d, &e1_id)["objections"], 1);
// Nobody votes on it any more: neither one vote nor a grouped one. // Nobody votes *for* it any more: neither one vote nor a grouped one (against and a
// withdrawal still pass: `a_held_version_takes_against_and_withdrawal_never_for`).
let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await; let one = yvv.err("POST", &format!("/api/proposals/{b}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(one, (StatusCode::CONFLICT, "objection".into())); assert_eq!(one, (StatusCode::CONFLICT, "objection".into()));
let many = yvv let many = yvv
@@ -476,6 +477,117 @@ async fn votes_already_cast_stay_but_a_held_version_cannot_enter() {
assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters"); assert_eq!(lifted["entered"][0]["proposalId"], b.as_str(), "lifted: it enters");
} }
/// Yvv, 2026-11-01, « pendant qu'une objection retient une version, que permet-on ? » —
/// « Retirer ou "contre" »: on a held version, against and a withdrawal pass, by one vote
/// or a grouped one; a *for* never does, alone or among free lines (all or none).
#[tokio::test]
async fn a_held_version_takes_against_and_withdrawal_never_for() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut zoe = chiara.other();
zoe.login("Zoé", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
zoe.object(&b, &e1, "redit", REPEATS).await;
// Yvv voted for it before the objection (written as it would have been then).
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at)
VALUES (?, 'dev:yvv', 'for', 1, '2026-10-31T00:00:00Z')",
)
.bind(&b)
.execute(&state.db)
.await
.expect("vote");
let tally = |d: &Value| (find(d, &b)["tally"]["for"].as_u64(), find(d, &b)["tally"]["against"].as_u64());
let vote = |c: Option<&str>| Some(json!({ "choice": c }));
let uri = format!("/api/proposals/{b}/vote");
let refused = (StatusCode::CONFLICT, "objection".to_owned());
// One vote: never for — not even the one he already holds.
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
assert_eq!(chiara.err("POST", &uri, vote(Some("for"))).await, refused);
// Against: his for becomes against.
assert_eq!(yvv.ok("POST", &uri, vote(Some("against"))).await["entered"], json!([]));
let d = yvv.doc("essai").await;
assert_eq!(tally(&d), (Some(0), Some(1)));
assert_eq!(find(&d, &b)["myVote"], "against");
assert_eq!((find(&d, &b)["votable"].as_bool(), find(&d, &b)["blocked"].as_bool()), (Some(false), Some(true)));
// And back to for: still not.
assert_eq!(yvv.err("POST", &uri, vote(Some("for"))).await, refused);
// A withdrawal.
yvv.ok("POST", &uri, vote(None)).await;
let d = yvv.doc("essai").await;
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
// Grouped: a for on the held version refuses the whole vote, the free line too.
let votes = format!("/api/docs/{doc_id}/votes");
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "for" }] });
assert_eq!(chiara.err("POST", &votes, Some(mixed)).await, refused);
let d = chiara.doc("essai").await;
assert!(find(&d, &e2)["myVote"].is_null(), "all or none: nothing written");
// Against the held one, for the free one: both written.
let mixed = json!({ "lines": [{ "proposalId": e2, "choice": "for" }, { "proposalId": b, "choice": "against" }] });
assert_eq!(chiara.ok("POST", &votes, Some(mixed)).await["count"], 2);
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &e2)["myVote"].as_str(), find(&d, &b)["myVote"].as_str()), (Some("for"), Some("against")));
assert_eq!(tally(&d), (Some(0), Some(1)));
// A grouped withdrawal on the held one.
chiara.ok("POST", &votes, Some(json!({ "lines": [{ "proposalId": b, "choice": null }] }))).await;
let d = chiara.doc("essai").await;
assert_eq!((tally(&d), find(&d, &b)["myVote"].is_null()), ((Some(0), Some(0)), true));
}
/// While held, a version can only lose votes; lifted, it enters with the votes still
/// there — not with those it had when the objection was raised.
#[tokio::test]
async fn lifted_a_version_enters_only_with_the_votes_still_there() {
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
let mut yvv = chiara.other();
yvv.login("Yvv", false).await;
let mut zoe = chiara.other();
zoe.login("Zoé", false).await;
let doc = chiara.doc("essai").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let b = chiara
.propose(&doc, json!({ "title": "Un compte", "body": "Un seul compte membre par personne.", "submit": true }))
.await;
let oid = zoe.object(&b, &e1, "redit", REPEATS).await["id"].as_str().expect("id").to_owned();
// Every member of the group for it, as before the objection: enough to enter.
let members: Vec<String> = sqlx::query_scalar("SELECT identity_id FROM memberships WHERE group_slug = 'essai'")
.fetch_all(&state.db)
.await
.expect("members");
assert_eq!(members.len(), 3);
for m in &members {
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, 'for', 1, '2026-10-31T00:00:00Z')")
.bind(&b)
.bind(m)
.execute(&state.db)
.await
.expect("vote");
}
assert!(adoption::run(&state, &doc_id).await.expect("pass").is_empty(), "held back, whatever its votes");
// While it is held, each one takes back or turns against.
let uri = format!("/api/proposals/{b}/vote");
chiara.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
yvv.ok("POST", &uri, Some(json!({ "choice": null }))).await;
zoe.ok("POST", &uri, Some(json!({ "choice": "against" }))).await;
let lifted = zoe.ok("POST", &format!("/api/objections/{oid}/lift"), None).await;
assert_eq!(lifted["entered"], json!([]), "lifted: no for left, it does not enter");
let d = chiara.doc("essai").await;
assert_eq!((find(&d, &b)["status"].as_str(), find(&d, &b)["votable"].as_bool()), (Some("open"), Some(true)));
assert_eq!(find(&d, &b)["tally"], json!({ "for": 0, "against": 2 }));
}
/// Withdrawing the other side frees the held version at once: with its votes, it /// Withdrawing the other side frees the held version at once: with its votes, it
/// enters then, not at the next pass of the sweeper (relecture of cd7e683). /// enters then, not at the next pass of the sweeper (relecture of cd7e683).
#[tokio::test] #[tokio::test]
+207
View File
@@ -0,0 +1,207 @@
//! The four rule articles of the seed rewritten after the rules applied (Yvv,
//! 2026-11-02): F1.2, N1, N1.1 and O1 are open proposals at zero votes, not texts
//! in force; a guarded seed amendment replaces them where nobody touched them.
use ld_server::seed_amend::{self, AMENDMENTS, Version};
use ld_server::{AppState, Config, open_db, seed};
use serde_json::Value;
const CODES: [&str; 4] = ["F1.2", "N1", "N1.1", "O1"];
fn read(path: std::path::PathBuf) -> Value {
serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json")
}
/// The four articles as production holds them (the seed of 21/10, unchanged until 02/11).
fn before() -> Value {
read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-regles-avant.json"))
}
fn today_seed() -> Value {
read(Config::for_tests().seed_dir.join("acte-engagement-certification.json"))
}
fn version<'a>(seed: &'a Value, code: &str) -> &'a Value {
let u = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit");
&u["versions"][0]
}
fn fr(v: &Value) -> (&str, &str) {
(v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"))
}
/// Today's seed with the four rule articles of before.
fn seed_dir_before(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ld-seed-rules-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let mut seed = today_seed();
let old = before();
for u in seed["units"].as_array_mut().expect("units") {
let code = u["code"].as_str().expect("code").to_owned();
if let Some(o) = old["units"].get(&code) {
u["versions"][0]["title"] = o["title"].clone();
u["versions"][0]["body"] = o["body"].clone();
}
}
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
dir
}
/// A base seeded with the rule articles of before, as production was, by the code of
/// the time (no amendment).
async fn base_before(tag: &str) -> AppState {
let mut cfg = Config::for_tests();
cfg.seed_dir = seed_dir_before(tag);
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::documents(&state).await.expect("seed of before");
state
}
/// The same base, started again with today's seed and its amendments.
async fn restart(before: &AppState) -> AppState {
let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
async fn proposal(state: &AppState, group: &str, code: &str) -> (String, String, String, i64) {
sqlx::query_as(
"SELECT p.id, p.title, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id
JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND u.code = ? AND p.status = 'open' AND p.origin = 'seed' AND p.label = '1.0.0'",
)
.bind(group)
.bind(code)
.fetch_one(&state.db)
.await
.expect("seed proposal")
}
/// Each decision on the four rule amendments: (group, unit, outcome, reason).
async fn decisions(state: &AppState) -> Vec<(String, String, String, Option<String>)> {
sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s
JOIN documents d ON d.id = s.doc_id
WHERE s.amendment_id LIKE '2026-11-02-%' ORDER BY d.group_slug, s.unit_code",
)
.fetch_all(&state.db)
.await
.expect("decisions")
}
#[test]
fn the_shipped_amendments_go_from_the_texts_of_before_to_the_seed_of_today() {
let (old, today) = (before(), today_seed());
for code in CODES {
let a = AMENDMENTS.iter().find(|a| a.unit == code).unwrap_or_else(|| panic!("no amendment for {code}"));
assert_eq!(a.doc, "acte-engagement-certification");
assert_eq!(a.version, Version::Open("1.0.0"), "{code}: the open proposal of the seed");
let (t, b) = fr(&old["units"][code]);
assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds");
let v = version(&today, code);
assert_eq!((v["status"].as_str(), v["label"].as_str()), (Some("open"), Some("1.0.0")), "{code}");
let (t, b) = fr(v);
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today");
assert_ne!(a.from, a.to, "{code}");
}
}
#[tokio::test]
async fn untouched_rule_articles_are_replaced_once_and_a_vote_keeps_its_text() {
let base = base_before("replace").await;
// In the test group someone voted on N1: there it keeps its text.
let (n1, ..) = proposal(&base, "essai", "N1").await;
sqlx::query("INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:vera', 'dev', 'Vera', '2026-11-01T00:00:00Z')")
.execute(&base.db)
.await
.expect("identity");
sqlx::query("INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, 'dev:vera', 'against', 1, '2026-11-01T00:00:00Z')")
.bind(&n1)
.execute(&base.db)
.await
.expect("vote");
let state = restart(&base).await;
let (old, today) = (before(), today_seed());
for group in ["essai", "monnaie-libre"] {
for code in CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
let kept = group == "essai" && code == "N1";
let expected = if kept { fr(&old["units"][code]) } else { fr(version(&today, code)) };
assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}");
assert_eq!(revision, if kept { 1 } else { 2 }, "{group} {code}");
if kept {
continue;
}
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
.bind(&id)
.fetch_all(&state.db)
.await
.expect("revisions");
assert_eq!(kinds, ["initial", "amendment"], "{group} {code}");
for lang in ["en", "es"] {
let (t, b, hash): (String, String, String) = sqlx::query_as(
"SELECT title, body, source_hash FROM translations WHERE proposal_id = ? AND lang = ?",
)
.bind(&id)
.bind(lang)
.fetch_one(&state.db)
.await
.expect("translation");
let v = version(&today, code);
assert_eq!(
(t.as_str(), b.as_str()),
(v["title"][lang].as_str().expect("t"), v["body"][lang].as_str().expect("b"))
);
assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date");
}
}
}
let expected: Vec<(String, String, String, Option<String>)> = ["essai", "monnaie-libre"]
.iter()
.flat_map(|g| {
CODES.iter().map(move |c| {
let kept = *g == "essai" && *c == "N1";
(
(*g).to_owned(),
(*c).to_owned(),
if kept { "refused" } else { "applied" }.to_owned(),
kept.then(|| "votes".to_owned()),
)
})
})
.collect();
assert_eq!(decisions(&state).await, expected);
// Monnaie libre republishes, once per article; the test group never.
let queued: Vec<(String, String)> = sqlx::query_as(
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
WHERE o.reason LIKE '%F1.2%' OR o.reason LIKE '%N1%' OR o.reason LIKE '%O1%' ORDER BY o.id",
)
.fetch_all(&state.db)
.await
.expect("outbox");
assert_eq!(queued.len(), 4, "{queued:?}");
assert!(queued.iter().all(|(g, _)| g == "monnaie-libre"), "{queued:?}");
// Decided once: a second start changes nothing.
let again = restart(&state).await;
assert_eq!(decisions(&again).await, expected);
let (_, _, _, revision) = proposal(&again, "monnaie-libre", "N1.1").await;
assert_eq!(revision, 2);
}
#[tokio::test]
async fn a_new_base_is_born_with_the_rules_of_today() {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
let today = today_seed();
for group in ["essai", "monnaie-libre"] {
for code in CODES {
let (_, title, body, revision) = proposal(&state, group, code).await;
assert_eq!(((title.as_str(), body.as_str()), revision), (fr(version(&today, code)), 1), "{group} {code}");
}
}
assert!(decisions(&state).await.iter().all(|(_, _, o, _)| o == "present"));
assert_eq!(decisions(&state).await.len(), 8);
}
+1
View File
@@ -5,6 +5,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish.workspace = true publish.workspace = true
license.workspace = true
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+4 -2
View File
@@ -147,5 +147,7 @@ les lettres d'aucun cran.
fois par jour, un texte peut passer ou ne plus passer sans nouveau vote sur lui (comme quand W fois par jour, un texte peut passer ou ne plus passer sans nouveau vote sur lui (comme quand W
change). Chaque entrée en vigueur garde la médiane qui l'a jugée. change). Chaque entrée en vigueur garde la médiane qui l'a jugée.
- Le document publié (`document.json`) porte l'inertie en nombre (1 à 5), et non plus en mot. - Le document publié (`document.json`) porte l'inertie en nombre (1 à 5), et non plus en mot.
- Le texte N1.1 du seed (« Niveaux d'inertie », quatre niveaux) est une proposition de la - Le texte N1.1 du seed (« Niveaux d'inertie », quatre niveaux) décrivait autre chose que la règle
communauté : il n'est pas modifié et décrit désormais autre chose que la règle en place. en place. Le 2026-11-02, Yvv a fait réécrire F1.2, N1, N1.1 et O1 selon les règles appliquées
(`docs/chantiers/acte-certification/textes-regles-seed.md`) ; un amendement de seed gardé remplace
ces propositions ouvertes là où personne n'y a touché (`seed_amend`, `Version::Open`).
@@ -4,7 +4,8 @@
> [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille > [0003](0003-corriger-n-est-pas-voter.md) (corriger n'est pas voter), dont la règle de la coquille
> ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la > ne change pas. Décisions d'Yvv du 2026-10-30 (revue) ; la messagerie dans l'application et la
> coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch. > coquille d'un texte du seed validée par un administrateur sont les choix par défaut d'orch.
> Lot F, 2026-10-31. > Lot F, 2026-10-31. **Révisée le 2026-11-01** (décision d'Yvv, lot G) : sur une version
> retenue, on peut encore retirer son vote ou voter « contre », jamais ajouter un « pour ».
**Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à **Contexte.** Le signalement de contradiction (0004) bloquait l'entrée en vigueur sans dire à
qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte qui revenait le geste suivant, et une coquille corrigée par n'importe qui changeait le texte
@@ -29,9 +30,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 :
- Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis - Elle retient **la dernière arrivée** des deux (date de soumission, à défaut de création, puis
l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur, l'ordre d'écriture) ; **jamais un texte en vigueur** : si la dernière arrivée est en vigueur,
c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite. c'est l'autre qui est retenue. La version retenue est fixée quand l'objection est faite.
- Tant que l'objection tient, la version retenue **ne se vote plus** (vote direct, vote groupé, - Tant que l'objection tient, la version retenue **ne prend plus de « pour »** et **n'entre pas
bulletin de la chaîne : `409 objection`) et **n'entre pas en vigueur**. Les votes déjà faits en vigueur**, quelles que soient ses voix. Yvv, 2026-11-01, à la question « pendant qu'une
restent ; un bulletin scellé avant l'objection et confirmé après compte, sans la faire entrer. objection retient une version, que permet-on ? » : **« Retirer ou "contre" »**. Chacun peut
**retirer son vote** ou **passer à « contre »** ; personne ne peut **ajouter un « pour »** (vote
direct, vote groupé, bulletin de la chaîne : `409 objection`, et une ligne refusée refuse tout le
vote). La version ne peut que perdre des voix tant qu'elle est retenue ; à la levée, elle
n'entre qu'avec les voix encore là. Les votes déjà faits restent ; un bulletin scellé avant
l'objection et confirmé après compte tel qu'il est signé, sans la faire entrer. Règle :
`ld_engine::objection::may_cast`, appliquée par `DocState::admits`.
- Une personne n'a qu'une objection debout sur une même paire (`objection_exists`). - Une personne n'a qu'une objection debout sur une même paire (`objection_exists`).
- **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé** - **L'auteur de la version retenue répond** : pour une redite, il **modifie l'article visé**
(une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ; (une proposition liée à l'objection, sur l'article de l'autre version, partie de son texte) ;
@@ -45,14 +52,15 @@ d'un autre sans lui demander. Yvv, 2026-10-30 :
message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui). message ni l'échéance. Réitérer rend la main à l'auteur (tour suivant, sans échéance pour lui).
- Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de - Lever : la personne qui a fait l'objection, à tout moment, ou un administrateur de
trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection. trustinWallet. Réitérer : elle seule. Retirer une des deux versions met fin à l'objection.
Une levée relance l'adoption : la version libérée entre en vigueur si ses voix suffisent. Une levée relance l'adoption : la version libérée entre en vigueur si les voix encore là
suffisent.
- Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`. - Règles d'accès : `trustin_access::lifts_objection`, `reiterates_objection`.
| Qui | Quoi | Effet | Échéance | | Qui | Quoi | Effet | Échéance |
|---|---|---|---| |---|---|---|---|
| tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | | tout membre du groupe | faire une objection « redit » ou « contredit » | la dernière arrivée (jamais un texte en vigueur) ne prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; message à son auteur | — |
| l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle | | l'auteur de la version retenue (tout membre pour une version du seed) | modifier l'article visé (redit) ou reformuler (contredit) | la main passe à la personne qui objecte ; message « lever ou réitérer » | 3 jours pour elle |
| la personne qui objecte | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | | la personne qui objecte | lever | la version reprend des « pour », et entre en vigueur si les voix encore là suffisent | — |
| la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur | | la personne qui objecte | réitérer | la main revient à l'auteur ; message « réponds à nouveau » | aucune pour l'auteur |
| personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — | | personne | (3 jours sans geste après une réponse) | levée « 3 jours sans suite » | — |
| un administrateur | lever | levée « par un administrateur » | — | | un administrateur | lever | levée « par un administrateur » | — |
@@ -119,6 +127,8 @@ corrections des autres) et `messages`, et sur les révisions la correction d'ori
validée. validée.
**Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la **Coût.** Une version retenue attend son auteur aussi longtemps qu'il ne répond pas : c'est la
dernière arrivée, c'est à elle de s'expliquer. Une personne qui réitère sans fin garde une dernière arrivée, c'est à elle de s'expliquer. Pendant ce temps, ceux qui la soutiennent ne
peuvent pas la renforcer : un nouveau « pour » attend la levée, alors qu'un « contre » ou un
retrait compte tout de suite. Une personne qui réitère sans fin garde une
version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois version hors du vote : un administrateur peut lever. Une coquille d'un autre met jusqu'à trois
jours à paraître, et sur un texte du seed elle attend un administrateur. jours à paraître, et sur un texte du seed elle attend un administrateur.
+14 -11
View File
@@ -231,20 +231,23 @@ la laisse entrer) ; écriture : `crates/ld-server/src/proposals.rs` et `crates/l
### Deux articles qui se répètent ou se contredisent ### Deux articles qui se répètent ou se contredisent
Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le Tout membre peut faire une **objection** à une version : elle **redit** un autre article (elle le
répète) ou elle le **contredit**, motif à l'appui. **Une objection rend la dernière arrivée des répète) ou elle le **contredit**, motif à l'appui. **Une objection retient la dernière arrivée
deux versions non votable jusqu'à sa levée** — jamais un texte en vigueur : si la dernière des deux versions jusqu'à sa levée** — jamais un texte en vigueur : si la dernière arrivée est
arrivée est en vigueur, c'est l'autre qui est retenue. Les votes déjà faits restent ; la version en vigueur, c'est l'autre qui est retenue. La version retenue n'entre pas en vigueur, quelles que
retenue ne reçoit plus de vote (ni sous l'article, ni dans le panier, ni par la chaîne) et soient ses voix. Pendant l'objection, **on peut retirer son vote ou passer à « contre », jamais
n'entre pas en vigueur. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite, ajouter un « pour »** (Yvv, 01/11/2026 : « Retirer ou "contre" ») — sous l'article, dans le
panier comme par la chaîne : « Tout pour » la saute et le dit, « Tout contre » la prend. Elle ne
peut que perdre des voix tant qu'elle est retenue ; à la levée, elle n'entre qu'avec les voix
encore là. Pour qu'elle se vote de nouveau, **son auteur répond** : pour une redite,
il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait il **modifie l'article visé** ; pour une contradiction, il **reformule**. La personne qui a fait
l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous l'objection reçoit alors un message : **lever** ou **réitérer** ; sans réponse de sa part sous
**3 jours**, l'objection est levée. **3 jours**, l'objection est levée.
| Qui | Quoi | Effet | Échéance | | Qui | Quoi | Effet | Échéance |
|---|---|---|---| |---|---|---|---|
| tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne se vote plus ; message à son auteur | — | | tout membre du groupe | faire une objection « redit » ou « contredit », motif à l'appui | la dernière arrivée des deux versions (jamais un texte en vigueur) ne prend plus de « pour » — « contre » et retrait seulement — et n'entre pas en vigueur ; message à son auteur | — |
| l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle | | l'auteur de la version retenue (tout membre pour une version du seed) | « Modifier l'article visé » (redit) : une proposition liée, sur l'autre article, partie de son texte ; « Reformuler » (contredit) : une révision de fond de sa version | la main passe à la personne qui objecte : message « lever ou réitérer » | 3 jours pour elle |
| la personne qui a fait l'objection | lever | la version se vote de nouveau, et entre en vigueur si ses voix suffisent | — | | la personne qui a fait l'objection | lever | la version reprend des « pour », et entre en vigueur si les voix encore là suffisent | — |
| la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur | | la personne qui a fait l'objection | réitérer | la main revient à l'auteur : message « réponds à nouveau » | aucune pour l'auteur |
| personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — | | personne | (3 jours sans geste après une réponse) | levée : « 3 jours sans suite » | — |
| un administrateur de trustinWallet | lever | levée « par un administrateur » | — | | un administrateur de trustinWallet | lever | levée « par un administrateur » | — |
@@ -366,10 +369,10 @@ Les raisons longues sont dans `docs/adr/`.
## Questions ouvertes (à Yvv) ## Questions ouvertes (à Yvv)
- **Licence** du code — non choisie. - ~~Licence du code~~ — AGPL-3.0 (Yvv, 2026-11-02).
- **Texte « Paramètres par défaut » du seed** (F1.2) : il annonce C = 0 et une durée de 30 jours, - ~~Texte « Paramètres par défaut » du seed (F1.2)~~ — réécrit avec N1, N1.1 et O1 selon les règles
alors que le vote est permanent avec C = 50. À reformuler par proposition (c'est leur rôle), appliquées (Yvv, 2026-11-02) ; un amendement de seed gardé remplace ces propositions ouvertes à
ou à corriger dans le seed avant la première publication si c'est une erreur de transcription. 0 vote, et Yvv relit les textes en ligne.
- Anglais : l'interface est en anglais américain, le seed en anglais britannique. - Anglais : l'interface est en anglais américain, le seed en anglais britannique.
## Ce qui reste après la livraison ## Ce qui reste après la livraison
@@ -40,7 +40,7 @@ pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| C25 | L'ordre des articles | groupe d'essai : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles · <section> » (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px | | C25 | L'ordre des articles | groupe d'essai : en fin de « Conclusion », « Proposer un autre ordre » ; cliquer « Monnaie G1 » puis la place 1 ; au clavier, flèche bas puis haut ; « Proposer cet ordre » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas (dès 16 membres, une voix suffit dans le groupe d'essai : ADR 0007) ; puis, dans Monnaie libre, sur un téléphone, avec un portefeuille : « Voter en lot », « Pour » sur « Ordre des articles · Engagements fondamentaux », « Signer et envoyer (1) » | chaque section finit par « Ordre des articles · <section> » (7, aucun pour l'Ordonnancement, un seul article), ses articles numérotés, « 2 articles · inertie 2 / 5 · basse », « seed a minima · 0 vote » ; le vote d'un ordre en vigueur déplié montre la liste entière ; la liste où l'on clique : l'article choisi marqué, puis à sa place, annoncé (« « Monnaie G1 » en place 1 sur 2. »), « Proposer cet ordre » éteint tant que c'est l'ordre en vigueur ; le vote de l'ordre proposé se déplie dans la liste des propositions, l'ordre proposé en clair ; il est dans le panier, à la fin de sa section ; à la voix qui suffit : « Le nouvel ordre de « Conclusion » est entré en vigueur. », la section se lit dans le nouvel ordre ; par la chaîne : « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « seed a minima · 1 vote » ; sur un téléphone, rien ne déborde, chaque ligne fait au moins 44 px |
| C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » | | C26 | Le titre du document | groupe d'essai : sous le titre, « Proposer un autre titre » ; « Acte d'engagement de la certification » ; « Pour » ; une deuxième personne vote « Pour » si cette voix ne suffit pas ; lire en anglais | sous le titre : « Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote » et les trois gestes ; le titre proposé se vote dans la liste des propositions (« Titre du document ») ; à la voix qui suffit : « Le titre « … » est entré en vigueur. », le document change de titre, son vote reste déplié sous le titre ; en anglais, l'ancien titre traduit, marqué « translation needs review » |
| C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » | | C27 | La participation ramenée dans ses bornes | un groupe de 4 dont la médiane est 2,5 votants, ramenée à 50 % (l'automate fait répondre ces chiffres au serveur) : « Réglages de la formule » | « 3 votants sur 4 (62,5 %) : la médiane des 12 propositions votées depuis un an. » — la part de la mesure elle-même, jamais 3 sur 4 arrondi d'abord ; puis « Ramenée entre 5 % et 50 % de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient (50 %). » ; « Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude. » |
| C28 | Objection « redit » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera recharge | « Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée. » ; chez Oscar, « « … » redit « Unicité du compte » », « cette version ne se vote plus », « Lever l'objection » ; chez Vera, « Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent. », « Elle redit « Unicité du compte ». », pas de « Pour », la ligne marquée « non votable : objection » ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter | | C28 | Objection « redit » ; sur la version retenue, « contre » et retrait, jamais « pour » | groupe d'essai, à trois (Aude, Oscar, Vera) : Aude propose un nouvel article qui répète « Unicité du compte » ; Oscar, dans « Détails » de cette proposition → « Faire une objection » → « Redit », « En vigueur — Unicité du compte », un motif → « Envoyer l'objection » ; Vera déplie son vote → « Contre », puis « Retirer mon vote » ; Vera ouvre « Voter en lot » → « Tout le document » : « Tout pour », puis « Tout contre », puis « Vider » ; Aude ouvre « Messages » → « Modifier l'article visé », complète le texte et soumet ; Oscar ouvre « Messages » → « Lever l'objection » ; Vera déplie de nouveau son vote | « Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions. » ; chez Oscar, « « … » redit « Unicité du compte » », « personne ne peut plus voter pour cette version », « Lever l'objection » ; chez Vera, « Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là. », « Elle redit « Unicité du compte ». », pas de « Pour », mais « Contre », la ligne marquée « retenue par une objection » ; après « Contre » : « Vote enregistré. », « Ton vote : contre », « 0 pour · 1 contre », et « Retirer mon vote » ; après le retrait : « Vote retiré. », « 0 pour · 0 contre », toujours pas de « Pour » ; dans le panier, la version retenue montre « Contre » sans « Pour », avec « une objection la retient : contre ou retrait, pas de « pour » » ; « Tout pour » la laisse de côté et le dit (« Tu as tout mis pour, sauf 1 version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote. », le nombre suivant les versions retenues du document) ; « Tout contre » la prend (« Contre » enfoncé) ; chez Aude, « Messages, 1 non lu », le message « Oscar fait une objection : ta version « … » redit « Unicité du compte »… Pour répondre, modifie l'article visé. », puis le panneau « Proposer » sur Unicité du compte, partant de son texte, avec « Cette proposition répond à une objection… » ; chez Oscar, « Ton objection sur « … » a reçu une réponse. », une échéance, « Lever l'objection » et « Réitérer l'objection » ; levée : « Objection levée. », le message dit « objection levée » sans plus aucun geste ; Vera peut de nouveau voter, « Pour » compris |
| C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste | | C29 | Objection « contredit », levée à l'échéance | groupe d'essai : Aude propose un article qui contredit « Réciprocité » ; Oscar fait une objection « Contredit » ; Aude déplie le vote de sa proposition → « Reformuler », change le fond, « Enregistrer la correction » ; Oscar ne fait rien ; trois jours passent (l'automate avance l'horloge du serveur : `POST /api/dev/clock`, connexion de développement seulement) | chez Aude : « Elle contredit « Réciprocité ». », « Pour qu'elle se vote à nouveau, reformule-la… », « Reformuler » ouvre « Détails » avec le formulaire de modification ; « Nouvelle version enregistrée : les votants devront reconfirmer. », puis « Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le … » ; trois jours après : l'objection dit « levée : 3 jours sans suite », la proposition se vote de nouveau, le message d'Oscar dit « objection levée : 3 jours sans suite », sans geste |
| C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » | | C30 | La coquille d'un autre, validée par l'auteur | groupe d'essai : Aude propose « Clé unique et vivante » ; Vera, dans « Détails » → « Corriger une coquille », apostrophes typographiques → « Enregistrer la correction » ; Aude ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le … » ; le texte ne change pas ; « Coquilles proposées » montre le diff, « proposée par Vera », « en attente : s'applique d'elle-même le … » ; chez Aude : « Vera propose une correction de coquille dans ta version « Clé unique et vivante ». », le diff, une échéance, « Valider » / « Refuser » ; « Correction appliquée. », le message dit « correction appliquée » ; le texte porte la correction, l'historique « coquille corrigée · par Vera · validée par Aude » |
| C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » | | C31 | Coquille sur un texte en vigueur | groupe d'essai : une administratrice (Ada) ouvre le groupe ; Vera, sur « Raison d'être » → « Le vote » → « Coquilles, traductions, objections, historique » → « Corriger une coquille », « C'est le seul » → « C’est le seul » ; Ada ouvre « Messages » → « Valider » | chez Vera : « Ta correction attend un administrateur, ce texte venant du seed… » ; le texte en vigueur ne change pas (« C'est le seul ») ; la correction en attente se lit dans « Détails » ; chez Ada : « Vera propose une correction de coquille dans « Raison d'être », un texte du seed : un administrateur la valide. » ; après « Valider », le texte en vigueur porte « C’est le seul » et l'historique « validée par Ada » |
@@ -81,3 +81,5 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts | | 2026-10-31 | automate | local, lot D (l'ordre des articles, le titre du document, la participation ramenée) | C1–C27, M1 | 29 / 29 verts |
| 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-10-31 | automate | local, fusion de la tête du chantier (`924a40d` : B2, F) dans le lot D ; C25 et C26 supportent un groupe d'essai où une voix suffit | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) | | 2026-10-31 | automate | local, `033b331` et la relecture de `cd7e683` (coquille sur un texte qui vient d'entrer, objection face à un texte retiré, objection en double, retrait qui relance l'adoption) | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-11-02 | automate | local, lot G sur `5d1f865` (décision d'Yvv du 01/11 : sur une version retenue, « contre » et retrait, jamais « pour » ; C28 ajusté : « Contre » et le retrait sous l'article, le panier) | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-11-02 | automate | local, lot G fusionné (`0919b46`) et les articles des règles réécrits (amendement de seed sur une proposition ouverte) | C1–C33, M1 | 34 / 34 verts (35 tests) |
+35 -13
View File
@@ -7,11 +7,22 @@
Orch seul sur le dépôt (sous-agents en copies de travail). **M1 (L1–L8) en ligne** depuis le Orch seul sur le dépôt (sous-agents en copies de travail). **M1 (L1–L8) en ligne** depuis le
2026-10-30 à 15 h 45 (`8727315`, acte `actes/2026-10-30-m1-mise-en-ligne.md`) ; Raison d'être 2026-10-30 à 15 h 45 (`8727315`, acte `actes/2026-10-30-m1-mise-en-ligne.md`) ; Raison d'être
publiée (commit `35864eb` du dépôt des textes). Actes M3a et M2 figés, ils attendent « déploie ». publiée (commit `35864eb` du dépôt des textes). Sur `chantier/acte-certification` : tous les lots
Sur `chantier/acte-certification` : B2 (`7ffa4fd`, repris en `9c8f4cc`), F (`cd7e683`) et `924a40d` fusionnés (B2, F, D), puis `4d4adcc` (mineurs de la relecture de F) ; boucle verte, e2e 35 / 35.
(C22) ; boucle verte, e2e 31 / 31 ; relecture indépendante en cours. Le lot D, fini sur sa branche, **M4 en ligne** depuis le 01/11 à 23 h 06 (`4d4adcc`) : L9 à L13, le vote par la chaîne éteint. Les
fusionne chantier dans `lot/d-modules` (17 fichiers en conflit). Puis : relecture de la fusion D, poussées vers `origin` attendent « pousse ». En cours (02/11) :
répétition des migrations 0007 et 0008 sur une copie de la base de production, dossier d'acte. - **lot G** : retirer son vote ou voter « contre » sur une version retenue (réponse d'Yvv du 01/11),
en copie de travail ;
- **réécriture des articles seed** F1.2, N1, N1.1, O1 selon les règles appliquées : brouillon en
rédaction (`textes-regles-seed.md`), puis relecture d'Yvv, puis un amendement gardé (acte
« déploie ») ;
- **licence** AGPL-3.0 posée (`76b8e9f`).
Ce qu'Yvv doit fournir :
- l'identifiant et le courriel de Chiara, pour l'acte « accorde » qui crée son compte sur la forge
(l'inscription y est fermée) ;
- l'adresse publique du compte de vote, quand il l'aura créé dans Ğecko. L'ouverture du vote par la
chaîne sera alors un acte « déploie », et le premier vote réel un acte « paye ».
## Critère de livraison (liste fermée) ## Critère de livraison (liste fermée)
@@ -41,10 +52,10 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag
| A | L1–L6 : entrée, DA, menu, listes, Décisions | high | **en ligne** 30/10 15 h 45 | `lot/a-entree` | | A | L1–L6 : entrée, DA, menu, listes, Décisions | high | **en ligne** 30/10 15 h 45 | `lot/a-entree` |
| R | L7–L8 : Raison d'être (amendement de seed gardé), versions groupées | high | **en ligne** 30/10 15 h 45 | `lot/r-texte` | | R | L7–L8 : Raison d'être (amendement de seed gardé), versions groupées | high | **en ligne** 30/10 15 h 45 | `lot/r-texte` |
| B | L9 : vote déplié, vote groupé, vote par transaction de 0,1 Ğ1 | xhigh | fusionné et relu (`74f96be`) ; acte M2 figé (vote par la chaîne éteint) | `lot/b-vote` | | B | L9 : vote déplié, vote groupé, vote par transaction de 0,1 Ğ1 | xhigh | fusionné et relu (`74f96be`) ; acte M2 figé (vote par la chaîne éteint) | `lot/b-vote` |
| B2 | Corrections du vote avant de l'ouvrir (relecture de `74f96be`, points 2 à 8) | xhigh | fusionné (`7ffa4fd`) ; relu « à reprendre », repris en `9c8f4cc`, en relecture | `lot/b2-vote-sur` | | B2 | Corrections du vote avant de l'ouvrir (relecture de `74f96be`, points 2 à 8) | xhigh | fusionné (`7ffa4fd`) ; relu « à reprendre », repris en `9c8f4cc`, relu « ok » | `lot/b2-vote-sur` |
| C | L10 : cinq crans, échelle, réglages | xhigh | fusionné et relu (`a576b68`, `71819f9`) ; acte M3a figé | `lot/c-inertie` | | C | L10 : cinq crans, échelle, réglages | xhigh | fusionné et relu (`a576b68`, `71819f9`) ; acte M3a figé | `lot/c-inertie` |
| D | L11 + titre du doc en module (4) + restes mineurs des relectures | high | fini sur sa branche (e2e 29 / 29) ; fusionne chantier dans sa branche | `lot/d-modules` | | D | L11 + titre du doc en module (4) + restes mineurs des relectures | high | fusionné (`033b331`), relu « ok » | `lot/d-modules` |
| F | L12–L13 : objections, coquilles validées, messages, délai de 3 jours | xhigh | fusionné (`cd7e683`), boucle verte ; en relecture | `lot/f-objections` | | F | L12–L13 : objections, coquilles validées, messages, délai de 3 jours | xhigh | fusionné (`cd7e683`), relu « ok » ; mineurs repris en `4d4adcc`, relu « ok » | `lot/f-objections` |
## Questions (réponse par défaut appliquée tant qu'Yvv n'a pas tranché) ## Questions (réponse par défaut appliquée tant qu'Yvv n'a pas tranché)
@@ -54,10 +65,10 @@ chaîne (question stratégique, `docs/recherche/vote-g1-chaine.md`) ; l'affichag
| Q2 | Les votes se voient-ils avec les noms ? | **tranché** : totaux seulement | | Q2 | Les votes se voient-ils avec les noms ? | **tranché** : totaux seulement |
| Q3 | Échelle des cinq crans | **tranché** : calée sur la participation médiane | | Q3 | Échelle des cinq crans | **tranché** : calée sur la participation médiane |
| Q4 | Le titre d'un article se vote-t-il à part ? | **tranché** : non ; seul le titre du doc devient un module (4) | | Q4 | Le titre d'un article se vote-t-il à part ? | **tranché** : non ; seul le titre du doc devient un module (4) |
| Q5 | Secret du vote (Q2) et transaction publique (Q1) : le commentaire porte le choix en clair, ou une empreinte scellée ? | défaut : empreinte scellée (la chaîne montre qui a voté et quand, pas quoi) | | Q5 | Secret du vote (Q2) et transaction publique (Q1) : le commentaire porte le choix en clair, ou une empreinte scellée ? | **tranché** (01/11, avec Q8) : empreinte scellée, inchangée |
| Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | défaut : duniter-connect maintenant (Ğ1Companion et trustinWallet se brancheront sur la même interface), lien et QR Ğecko sur mobile | | Q6 | Ğ1Companion ne signe pas encore une transaction construite par une page : quelle extension pour le geste unique ? | **tranché** (01/11) : duniter-connect maintenant ; Ğ1Companion et trustinWallet sur la même interface ; Ğecko par lien et QR |
| Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | défaut : un compte libreDecision créé par Yvv dans Ğecko, les sommes restent au collectif | | Q7 | Le compte qui reçoit les 0,1 Ğ1 : à qui, et que deviennent les sommes ? | **tranché** (01/11) : un compte « libreDecision » créé par Yvv dans Ğecko, les sommes au collectif ; attend son adresse publique |
| Q8 | Le secret du vote tombe par recoupement : la chaîne montre qui vient de voter, et les totaux bougent aussitôt (relecture de `74f96be`) | à trancher avant d'ouvrir le vote ; pas de défaut posé | | Q8 | Le secret du vote tombe par recoupement : la chaîne montre qui vient de voter, et les totaux bougent aussitôt (relecture de `74f96be`) | **tranché** (01/11) : on ne dit rien ; totaux et son propre vote en direct, inchangés |
Défauts posés par orch, sans question (Yvv peut les corriger) : Défauts posés par orch, sans question (Yvv peut les corriger) :
- Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote - Décisions : « modules restant à voter » = les modules du doc pas encore adoptés par un vote
@@ -75,12 +86,23 @@ Défauts posés par orch, sans question (Yvv peut les corriger) :
- M1 : **exécuté** le 30/10/2026, 15 h 29 – 15 h 52 (`actes/2026-10-30-m1-mise-en-ligne.md`). - M1 : **exécuté** le 30/10/2026, 15 h 29 – 15 h 52 (`actes/2026-10-30-m1-mise-en-ligne.md`).
- M3a (L10) : **figé** sur `71819f9` (`actes/2026-10-30-m3-inertie-cinq-crans.md`), attend « déploie ». - M3a (L10) : **figé** sur `71819f9` (`actes/2026-10-30-m3-inertie-cinq-crans.md`), attend « déploie ».
- M2 + M3a (L9 et L10) : **figé** sur `74f96be` (`actes/2026-10-31-m2-vote.md`), le vote par la chaîne éteint ; remplace M3a s'il est armé. - M2 + M3a (L9 et L10) : **figé** sur `74f96be` (`actes/2026-10-31-m2-vote.md`), le vote par la chaîne éteint ; remplace M3a s'il est armé.
- M4 (L9 à L13) : **exécuté** le 01/11/2026, 22 h 52 – 23 h 40 (`actes/2026-10-31-m4-vote-modules-objections.md`) ; M2 et M3a n'ont plus d'objet.
Restes mineurs de `71819f9` et du lot B : faits dans le lot D. Restes mineurs de `71819f9` et du lot B : faits dans le lot D.
Liste d'attente (relecture de `033b331+4d4adcc`, après M4) :
- 0008 : lever les doublons debout avant de créer `signals_one_standing` (sans objet en production : 0 signalement) ;
- un ordre ne départage pas deux articles à la même position (deux créations simultanées) ;
- `place_at_end` coupe toujours le même intervalle : au 52e ajout dans une section, les positions se confondent ;
- `publish.rs` : articles sur `tally`, modules sur `shown_tally` (identiques aujourd'hui).
À dire à Yvv : dans le groupe d'essai (C = 1), dès 16 membres une voix seule fait passer un texte, À dire à Yvv : dans le groupe d'essai (C = 1), dès 16 membres une voix seule fait passer un texte,
la garde B^W passant sous la précision du calcul (ADR 0007). Monnaie libre (C = 50) n'est pas la garde B^W passant sous la précision du calcul (ADR 0007). Monnaie libre (C = 50) n'est pas
concernée. concernée. Pour deux voix dans l'essai quel que soit W : C = 2.
À demander à Yvv (relecture de `cd7e683`) : sur une version retenue par une objection, ni retirer
son vote ni passer à « contre » ; à la levée, l'adoption tourne aussitôt. Le garder, ou permettre de
retirer son vote et de voter « contre » sur une version retenue ? Défaut : tel quel (ADR 0010).
Ensuite : Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture Ensuite : Chaque mise en ligne (M1 à M4) attend « déploie » d'Yvv et une relecture
indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne indépendante inscrite. La migration « Raison d'être » touche la base de production : elle ne
@@ -0,0 +1,418 @@
# Textes du seed : les règles appliquées (F1.2, N1, N1.1, O1)
> Préparé le 2026-11-02 pour ta relecture, Yvv, d'après tes décisions du 02/11/2026 : vote
> permanent sans durée ; C = 50 dans Monnaie libre ; inertie à cinq crans calée sur la
> participation médiane ; cran 3 par défaut, cran 4 pour Unicité, Réciprocité et le titre du
> document ; l'inertie de l'inertie hérite du cran de l'article ; l'ordre des articles de chaque
> section au cran 2. Rien n'est encore collé dans `seeds/acte-engagement-certification.json`.
## Avant de coller
- **Le rendu.** Les textes passent par un sous-ensemble de Markdown (`web/src/lib/markdown.ts:1-3`) :
paragraphes, retours à la ligne, listes, gras, italique, code. **Ni tableau ni intertitre** : les
cinq crans sont donc une liste. Une liste ne se lit comme liste que si la ligne qui l'annonce en
est séparée par une ligne vide (`markdown.ts:22-27`) ; sinon les tirets s'affichent tels quels.
C'est le cas aujourd'hui dans F1.2 et N1.1 ; les textes ci-dessous ont la ligne vide. Chacun a
été passé par `render()` : toutes les listes sortent en `<ul>`.
- **La production.** Ces quatre articles y sont déjà, comme propositions ouvertes. Changer le JSON
ne touche que les bases neuves (local, e2e) : un document seedé n'est jamais réinstancié, et
l'amendement de seed ne remplace qu'un texte **en vigueur** (`crates/ld-server/src/seed_amend.rs:9-10`,
`:91`, `:192` ; il refuserait ici avec `no_text_in_force`). Pour la production, il faut choisir
une voie : étendre l'amendement aux propositions ouvertes du seed que personne n'a touchées, ou
proposer ces textes par l'application.
- **Les renvois.** Les articles du seed ne se citent jamais par leur code ; ces textes renvoient aux
autres par leur titre (« Formule du seuil WoT », « Niveaux d'inertie »).
- **La forme.** Comme le reste du seed : apostrophes droites et « » espacés en français, “ ” en
anglais (britannique : *favour*), «» serrés en espagnol ; espaces ordinaires ; formules en code,
avec × et −, jamais d'astérisque (le rendu le lirait comme de l'italique).
- **Longueur.** fr : F1.2 1600 · N1 543 · N1.1 1849 · O1 802 signes. Limite d'une proposition : 4 000 signes
(`crates/ld-server/src/proposals.rs:32`) ; au-delà de 700, l'assistant dit « texte long »
(`crates/ld-engine/src/assist.rs:25`), comme il le dirait des textes actuels de F1.2 et N1.1.
## F1.2 — Paramètres par défaut
**Aujourd'hui** (`seeds/acte-engagement-certification.json:695`) : « Valeurs par défaut (inertie standard) : Durée : 30 jours (vote permanent) · M = 50% · G = 0.2 · B = 0.1, C = 0 », un « curseur d'inertie » affiché « sur chaque section » qui « représente le niveau G », le « Mode compact : D30M50B.1G.2 », et en tête `Seuil = M + (1-M) × (1 - (T/W)^G)`.
**Ce qui est faux** : le vote n'a pas de durée ; C vaut 50 dans Monnaie libre ; M et G viennent du cran (au cran 3 par défaut : M = 55 %, et G n'est jamais fixe, il se déduit de la participation habituelle) ; il n'y a pas de curseur et l'inertie se règle par article ; la formule de tête est la part exigée, pas le seuil, et elle oublie C. Le mode compact est retiré : la notation g1vote (D durée, M, B, G) ne sait dire ni l'absence de durée ni un G qui suit la participation.
**Titre** gardé : l'article donne toujours les valeurs par défaut (le cran 3, et les crans de départ), en plus des lettres fixes.
### F1.2 · Français (fait foi)
Titre : **Paramètres par défaut**
```markdown
Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.
**Un vote permanent, sans durée**
- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.
- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.
- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.
**Les lettres**
- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.
- **T** : les votes exprimés sur la version, pour et contre.
- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.
- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.
- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.
**Par défaut**
- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.
- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.
```
### F1.2 · English
Titre : **Default parameters**
```markdown
The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.
**A permanent vote, with no duration**
- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.
- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.
- If several proposals get there, the one with the widest margin comes into force.
**The letters**
- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.
- **T**: the votes cast on the version, for and against.
- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.
- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.
- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.
**By default**
- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.
- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.
```
### F1.2 · Español
Titre : **Parámetros por defecto**
```markdown
Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.
**Una votación permanente, sin duración**
- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.
- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.
- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.
**Las letras**
- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.
- **T**: los votos emitidos sobre la versión, a favor y en contra.
- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.
- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.
- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.
**Por defecto**
- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.
- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3.
```
## N1 — Réglage de l'inertie
**Aujourd'hui** (`seeds/acte-engagement-certification.json:739`) : « 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. »
**Ce qui est faux** : un changement d'inertie se vote au cran actuel de l'article, ni au plus élevé, ni au cran au-dessus (règle du 21/10, remplacée le 30/10) ; l'inertie se règle par article et par module, pas par section.
**Titre** gardé.
### N1 · Français (fait foi)
Titre : **Réglage de l'inertie**
```markdown
Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.
Tout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.
```
### N1 · English
Titre : **Inertia setting**
```markdown
Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.
Any member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.
```
### N1 · Español
Titre : **Ajuste de la inercia**
```markdown
Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.
Cualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4.
```
## N1.1 — Niveaux d'inertie
**Aujourd'hui** (`seeds/acte-engagement-certification.json:761`) : quatre niveaux à G fixe : Basse G=0.1 M=50% (annexes, recommandations, conseils), Standard G=0.2 M=50% (engagements fondamentaux et techniques), Haute G=0.4 M=60% (formule de vote), Très haute G=0.6 M=66% (réglage de l'inertie, « méta-protection ») ; un curseur d'inertie par section.
**Ce qui est faux** : il y a cinq crans ; G n'est pas fixe, il se déduit de la participation habituelle ; M vaut 50, 50, 55, 60, 66 % ; au départ, Unicité, Réciprocité et le titre sont au cran 4, l'ordre de chaque section au cran 2, tout le reste au cran 3, y compris les annexes, la formule et l'inertie ; pas de curseur.
**Titre** gardé : il reste vrai. L'interface dit « Les cinq crans » (`settings.levels`) ; « Les cinq crans d'inertie » serait plus proche d'elle, à ton choix.
### N1.1 · Français (fait foi)
Titre : **Niveaux d'inertie**
```markdown
À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.
`Part exigée = M + (1 − M) × (1 − (T/W)^G)`
**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.
- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.
- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.
- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.
- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.
- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.
**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.
**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.
Exemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.
```
### N1.1 · English
Titre : **Inertia levels**
```markdown
At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.
`Share asked = M + (1 − M) × (1 − (T/W)^G)`
**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.
- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.
- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.
- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.
- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.
- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.
**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.
**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.
Example: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.
```
### N1.1 · Español
Titre : **Niveles de inercia**
```markdown
En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.
`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`
**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.
- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.
- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.
- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.
- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.
- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.
**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.
**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.
Ejemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103.
```
## O1 — Ordonnancement du document
**Aujourd'hui** (`seeds/acte-engagement-certification.json:783`) : « 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. »
**Ce qui est faux** : on vote l'ordre des articles dans chaque section, par un module en fin de section, au cran 2 ; « l'inertie standard » n'existe plus (elle est devenue le cran 3) ; l'ordre des sections entre elles n'est pas soumis au vote.
**Titre** gardé : il reste vrai. Le texte dit aussi le module du titre du document ; si tu préfères un titre qui le nomme : « Ordre des articles et titre du document » (en : “Order of the articles and title of the document”, es : «Orden de los artículos y título del documento»).
### O1 · Français (fait foi)
Titre : **Ordonnancement du document**
```markdown
L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.
Un nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.
Le titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.
```
### O1 · English
Titre : **Ordering of the document**
```markdown
The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.
A new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.
The title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.
```
### O1 · Español
Titre : **Ordenación del documento**
```markdown
El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.
Un artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.
El título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto.
```
## Points à trancher
1. **La voie vers la production** (voir « Avant de coller ») : l'amendement de seed ne couvre que
les textes en vigueur ; ces quatre-là sont des propositions ouvertes.
2. **Crans 1 et 5 :** aucun article du seed n'y est. N1.1 dit « Aucun article au départ » plutôt
que d'inventer un usage.
3. **L'ordre des sections** n'est pas voté (un module par section, rien ne bouge hors de la
section). O1 dit « de chaque section » sans l'écrire en toutes lettres ; si tu veux que le
texte le dise, une phrase suffit : « L'ordre des sections entre elles ne se vote pas. »
4. **« au départ ».** Le cran d'un article ou d'un module se change par un vote (N1) : les textes
disent donc « au départ » pour les crans du seed, et un vote qui en changerait un rendrait la
phrase à reprendre, comme pour tout article qui décrit une règle.
5. **L'exemple de N1.1** (7 224 membres, 120 votants) vient des tests et de l'ADR 0007 (le vote
forgeron de 2024) : c'est une illustration, pas la taille actuelle de la toile. À retirer si tu
ne veux pas de chiffre daté dans un texte voté.
6. **Ce qui deviendra caduc une fois collé :** `docs/adr/0007-inertie-cinq-crans.md:150-151` (« le
texte N1.1 du seed […] n'est pas modifié ») et la question F1.2 de
`docs/chantiers/acte-certification/CHANTIER.md:370-372`.
7. **Hors périmètre, vu en passant :** F1.1 (« Formule du seuil WoT ») appelle B « exposant de
base » (B est la base, W l'exposant), écrit « M = majority_pct / 100 » (un nom de variable) et
garde « Seuil » en français dans ses versions en et es. Dans l'interface anglaise,
`formula.*` écrit *favor* et `settings.*` *favour* (`web/src/lib/i18n/en.ts:364-375`, `:377-399`).
## Les chiffres et leur source
| Chiffre | Où | Source |
|---|---|---|
| `R = C + B^W + (M + (1 − M)·(1 − (T/W)^G)) · max(0, T − C)` | F1.2 (renvoi), N1.1 | `crates/ld-engine/src/threshold.rs:4` ; `docs/adr/0007-inertie-cinq-crans.md:20` |
| Part exigée `M + (1 − M) × (1 − (T/W)^G)` | N1.1 | `crates/ld-engine/src/threshold.rs:45-50` |
| `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)` | N1.1 | `crates/ld-engine/src/inertia.rs:14-15`, `:185-191` ; `docs/adr/0007-inertie-cinq-crans.md:27` |
| C = 50 dans Monnaie libre ; rien sous 50 voix pour | F1.2, N1.1 | `seeds/acte-engagement-certification.json:18` ; `docs/adr/0002-vote-permanent.md:13-14` ; test `crates/ld-engine/src/inertia.rs:349-366` |
| B = 0,1 | F1.2 | `crates/ld-engine/src/threshold.rs:36` ; `crates/ld-engine/src/inertia.rs:210` |
| W relu toutes les 6 heures | F1.2 | `crates/ld-server/src/wot.rs:13` |
| Cinq crans, de 1 à 5 | N1, N1.1 | `crates/ld-engine/src/inertia.rs:27-45` |
| M_k = 50, 50, 55, 60, 66 % | F1.2 (55 %), N1.1 | `crates/ld-engine/src/inertia.rs:32` ; `docs/adr/0007-inertie-cinq-crans.md:30-36` |
| s_k = 60, 67, 75, 83, 90 % | F1.2 (75 %), N1.1 | `crates/ld-engine/src/inertia.rs:34` ; `docs/adr/0007-inertie-cinq-crans.md:30-36` |
| À pleine participation, la part vaut M | N1.1 | `crates/ld-engine/src/threshold.rs:48-49` (participation plafonnée à 1) ; test `crates/ld-engine/src/inertia.rs:343` ; `docs/adr/0007-inertie-cinq-crans.md:64` |
| Noms des crans (très basse … très haute) | F1.2, N1.1 | `web/src/lib/i18n/fr.ts:204-208` ; `en.ts:201-205` ; `es.ts:201-205` |
| Cran 3 par défaut, nouvel article au cran 3 | F1.2, N1.1 | `crates/ld-engine/src/inertia.rs:39-40` ; `crates/ld-server/src/proposals.rs:201` |
| Unicité du compte, Réciprocité au cran 4 | F1.2, N1.1 | `seeds/acte-engagement-certification.json:106`, `:128` |
| Titre du document au cran 4 | F1.2, N1.1, O1 | `seeds/acte-engagement-certification.json:28-29` ; `docs/adr/0009-modules-ordre-et-titre.md:28` |
| Ordre des articles de chaque section au cran 2 | F1.2, N1.1, O1 | `seeds/acte-engagement-certification.json:31-32` ; `docs/adr/0009-modules-ordre-et-titre.md:31` |
| Tous les autres articles au cran 3 | F1.2, N1.1 | `seeds/acte-engagement-certification.json:2` (« Inertie 3 / 5 partout, 4 pour E1 et E2 ») et chaque `"inertia": 3` |
| Un changement d'inertie se vote au cran actuel (l'exemple du cran 4) | N1 | `crates/ld-engine/src/compose.rs:77-85` ; test `:334-340` (E1 au cran 4) ; `docs/adr/0007-inertie-cinq-crans.md:93-95` |
| Participation habituelle : médiane, propositions votées depuis un an (365 jours) | N1.1 | `crates/ld-engine/src/inertia.rs:166-177` ; `crates/ld-server/src/participation.rs:4-13`, `:28` |
| Au moins 5 propositions votées | N1.1 | `crates/ld-engine/src/inertia.rs:139` |
| Avant : deux fois C, 100 votants dans Monnaie libre | N1.1 | `crates/ld-engine/src/inertia.rs:143-147` ; test `:380-381` ; `docs/adr/0007-inertie-cinq-crans.md:53-54` |
| Entre 1 % et 50 % de l'électorat | N1.1 | `crates/ld-engine/src/inertia.rs:140-141` ; `docs/adr/0007-inertie-cinq-crans.md:57` |
| Mesurée une fois par jour (24 h) | N1.1 | `crates/ld-server/src/participation.rs:16`, `:30` |
| Exemple : 7 224 membres, médiane de 120 votants | N1.1 | `crates/ld-engine/src/inertia.rs:230`, `:270-271` ; `docs/adr/0007-inertie-cinq-crans.md:105-109` |
| 92, 97, 103, 109, 113 voix pour à 120 votants | N1.1 | test `crates/ld-engine/src/inertia.rs:272` ; `docs/adr/0007-inertie-cinq-crans.md:109` ; `docs/chantiers/acte-certification/CHANTIER.md:110-111` |
| 70, 102,5, 103 | N1.1 | calcul : 120 − 50 = 70 ; 50 + 0,75 × 70 = 102,5 ; arrondi au-dessus (`crates/ld-engine/src/threshold.rs:65`, `:79-80`) ; même lecture que le commentaire `crates/ld-engine/src/inertia.rs:299-302` (« 90 % of the 70 votes beyond C is exactly 63 ») |
Les règles sans chiffre :
| Règle | Où | Source |
|---|---|---|
| Vote permanent, sans échéance | F1.2 | `docs/adr/0002-vote-permanent.md:7-8` ; `docs/chantiers/acte-certification/CHANTIER.md:120` |
| Changer ou retirer son vote ; le dernier fait foi | F1.2 | `crates/ld-server/src/ballots.rs:133-160` ; `docs/adr/0008-vote-par-transaction.md:155-159` |
| Entrer en vigueur : le seuil, et une marge plus large que le texte en place ; le texte remplacé redevient une proposition | F1.2 | `docs/adr/0002-vote-permanent.md:15-17` ; `crates/ld-engine/src/compose.rs:10-15`, `:137-165` |
| Entre rivales, la marge la plus large | F1.2 | `crates/ld-engine/src/compose.rs:118-125` |
| G se déduit de la participation habituelle | F1.2, N1.1 | `crates/ld-engine/src/inertia.rs:184-191`, `:206-213` |
| Tout membre du groupe peut proposer | N1 | `docs/chantiers/acte-certification/CHANTIER.md:296-297` |
| Le titre et l'ordre ont leur inertie, qui se vote | N1 | `docs/adr/0009-modules-ordre-et-titre.md:20-22` |
| Le module d'ordre : une liste des articles de la section, en fin de section | O1 | `docs/adr/0009-modules-ordre-et-titre.md:31-36` |
| À l'entrée d'un ordre, seuls les articles de la section bougent | O1 | `docs/adr/0009-modules-ordre-et-titre.md:74-77` |
| Nouvel article à la fin de sa section, place votée ensuite | O1 | `docs/adr/0009-modules-ordre-et-titre.md:65-70` |
| Un ordre garde ses votes ; arrivés à la fin, sortis retirés | O1 | `docs/adr/0009-modules-ordre-et-titre.md:49-56` |
| Section d'un seul article : pas d'ordre | O1 | `docs/adr/0009-modules-ordre-et-titre.md:44-45` |
| Le titre d'un article se vote avec son texte | O1 | `docs/adr/0009-modules-ordre-et-titre.md:13-14` |
| Pas d'ordre des sections entre elles | O1 (« ce qui est faux ») | `docs/adr/0009-modules-ordre-et-titre.md:31`, `:74-76` (un module par section, rien ne bouge hors de la section) |
| Pas de curseur d'inertie dans l'interface | F1.2, N1.1 (« ce qui est faux ») | aucun « curseur », « slider », « courbe » dans `web/src/` |
## Annexe : les mêmes textes, prêts à coller dans le JSON
À mettre à la place des objets `title` et `body` de la version `1.0.0` de chaque article.
### F1.2
```json
"title": {
"fr": "Paramètres par défaut",
"en": "Default parameters",
"es": "Parámetros por defecto"
},
"body": {
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.",
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.",
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3."
}
```
### N1
```json
"title": {
"fr": "Réglage de l'inertie",
"en": "Inertia setting",
"es": "Ajuste de la inercia"
},
"body": {
"fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.",
"en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.",
"es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4."
}
```
### N1.1
```json
"title": {
"fr": "Niveaux d'inertie",
"en": "Inertia levels",
"es": "Niveles de inercia"
},
"body": {
"fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.",
"en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.",
"es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103."
}
```
### O1
```json
"title": {
"fr": "Ordonnancement du document",
"en": "Ordering of the document",
"es": "Ordenación del documento"
},
"body": {
"fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.",
"en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.",
"es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto."
}
```
+13 -13
View File
@@ -1,5 +1,5 @@
{ {
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009).", "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02).",
"slug": "acte-engagement-certification", "slug": "acte-engagement-certification",
"title": { "title": {
"fr": "Acte d'engagement Certification", "fr": "Acte d'engagement Certification",
@@ -692,9 +692,9 @@
"es": "Parámetros por defecto" "es": "Parámetros por defecto"
}, },
"body": { "body": {
"fr": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes exprimés (pour + contre)\n- **W** = taille de la toile de confiance\n- **M** = majorité cible (50% par défaut)\n- **G** = gradient d'inertie (0.2 par défaut)\n\n**Valeurs par défaut (inertie standard) :**\n- Durée : 30 jours (vote permanent)\n- Majorité cible M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lecture du curseur d'inertie :**\nLe curseur affiché sur chaque section représente le niveau G. Plus le curseur est à droite, plus le remplacement est difficile. La courbe associée montre le seuil requis en fonction de la participation.\n\nMode compact : **D30M50B.1G.2**", "fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **C = 50** : le nombre minimal de votes « pour » dans Monnaie libre. Sous 50 voix pour, rien ne passe : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, et le groupe demande 75 % de « pour » au-delà de C quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, Unicité du compte, Réciprocité et le titre du document sont au cran 4 (haute), l'ordre des articles de chaque section au cran 2 (basse), tous les autres articles au cran 3.",
"en": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votes cast (for + against)\n- **W** = size of the web of trust\n- **M** = target majority (50% by default)\n- **G** = inertia gradient (0.2 by default)\n\n**Default values (standard inertia):**\n- Duration: 30 days (permanent vote)\n- Target majority M = 50%\n- Gradient G = 0.2\n- Base B = 0.1, Constant C = 0\n\n**Reading the inertia slider:**\nThe slider shown on each section represents the level G. The further to the right the slider, the harder the replacement. The associated curve shows the required threshold as a function of participation.\n\nCompact mode: **D30M50B.1G.2**", "en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **C = 50**: the minimum number of votes in favour in Monnaie libre. Below 50 votes in favour, nothing passes: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, and the group asks for 75% of votes in favour beyond C when it votes as usual. Every new article starts at this level.\n- At the start, Uniqueness of the account, Reciprocity and the title of the document are at level 4 (high), the order of the articles of each section at level 2 (low), all other articles at level 3.",
"es": "**Seuil = M + (1-M) × (1 - (T/W)^G)**\n\n- **T** = votos emitidos (a favor + en contra)\n- **W** = tamaño de la red de confianza\n- **M** = mayoría objetivo (50% por defecto)\n- **G** = gradiente de inercia (0.2 por defecto)\n\n**Valores por defecto (inercia estándar):**\n- Duración: 30 días (votación permanente)\n- Mayoría objetivo M = 50%\n- Gradiente G = 0.2\n- Base B = 0.1, Constante C = 0\n\n**Lectura del cursor de inercia:**\nEl cursor que se muestra en cada sección representa el nivel G. Cuanto más a la derecha está el cursor, más difícil es la sustitución. La curva asociada muestra el umbral requerido en función de la participación.\n\nModo compacto: **D30M50B.1G.2**" "es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **C = 50**: el número mínimo de votos a favor en Monnaie libre. Con menos de 50 votos a favor, nada pasa: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, y el grupo pide un 75 % de votos a favor más allá de C cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, Unicidad de la cuenta, Reciprocidad y el título del documento están en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2 (baja), todos los demás artículos en el nivel 3."
} }
} }
] ]
@@ -736,9 +736,9 @@
"es": "Ajuste de la inercia" "es": "Ajuste de la inercia"
}, },
"body": { "body": {
"fr": "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.", "fr": "Chaque article porte son inertie : un cran de 1 à 5 (voir « Niveaux d'inertie »). Plus le cran est haut, plus il faut de voix pour changer le texte quand peu de gens votent. Le titre du document et l'ordre des articles de chaque section ont aussi la leur, qui se change de la même façon.\n\nTout membre du groupe peut proposer un autre cran. Cette proposition se vote au cran actuel de l'article, comme son texte : l'inertie de l'inertie hérite du cran de l'article. Ainsi, un article au cran 4 ne descend qu'avec les voix que demande le cran 4.",
"en": "The inertia setting defines how difficult it is to replace each section of the document. This setting is itself subject to the highest inertia, to prevent the rules of modification from being modified.", "en": "Every article carries its inertia: a level from 1 to 5 (see “Inertia levels”). The higher the level, the more votes it takes to change the text when few people vote. The title of the document and the order of the articles of each section have theirs too, changed in the same way.\n\nAny member of the group can propose another level. That proposal is voted at the article's current level, like its text: the inertia of the inertia inherits the article's level. So an article at level 4 only comes down with the votes that level 4 asks for.",
"es": "El ajuste de la inercia define la dificultad de sustitución de cada sección del documento. Este ajuste está a su vez sometido a la inercia más elevada, para impedir la modificación de las reglas de modificación." "es": "Cada artículo lleva su inercia: un nivel del 1 al 5 (ver «Niveles de inercia»). Cuanto más alto es el nivel, más votos hacen falta para cambiar el texto cuando vota poca gente. El título del documento y el orden de los artículos de cada sección también tienen la suya, que se cambia del mismo modo.\n\nCualquier miembro del grupo puede proponer otro nivel. Esa propuesta se vota con el nivel actual del artículo, como su texto: la inercia de la inercia hereda el nivel del artículo. Así, un artículo en el nivel 4 solo baja con los votos que pide el nivel 4."
} }
} }
] ]
@@ -758,9 +758,9 @@
"es": "Niveles de inercia" "es": "Niveles de inercia"
}, },
"body": { "body": {
"fr": "**Formule simplifiée :** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nLe **gradient G** contrôle la courbe d'inertie : plus G est élevé, plus le seuil reste haut même avec une participation croissante.\n\n**Basse** — G=0.1, M=50%\n- La courbe descend très vite vers 50%\n- Dès 10% de participation, le seuil est proche de la majorité simple\n- *Usage : annexes, recommandations, conseils*\n\n**Standard** — G=0.2, M=50%\n- Descente progressive, équilibre entre stabilité et réactivité\n- À 25% de participation, le seuil avoisine 65%\n- *Usage : engagements fondamentaux et techniques*\n\n**Haute** — G=0.4, M=60%\n- Courbe résistante, le seuil reste élevé longtemps\n- Super-majorité de 60% même à pleine participation\n- *Usage : formule de vote elle-même*\n\n**Très haute** — G=0.6, M=66%\n- Quasi-unanimité requise à tout niveau de participation\n- Seul un consensus massif (66%+) peut modifier ce réglage\n- *Usage : réglage de l'inertie (méta-protection)*\n\nLe curseur d'inertie associé à chaque section du document reflète visuellement ces niveaux.", "fr": "À chaque cran, le groupe demande d'abord C voix pour, puis une part de « pour » parmi les votes au-delà de C. Cette part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T/W)^G)`\n\n**Les cinq crans** : la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : 67 % comme d'habitude, 50 % quand tout le monde vote. L'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : 83 % comme d'habitude, 60 % quand tout le monde vote. Unicité du compte, Réciprocité, le titre du document.\n- **Cran 5 · très haute** : 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois C : 100 votants dans Monnaie libre. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle p̃, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 92, 97, 103, 109 ou 113 voix pour, du cran 1 au cran 5. Au cran 3 : 50 voix pour, plus 75 % des 70 autres votes, soit 102,5, arrondi à 103.",
"en": "**Simplified formula:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nThe **gradient G** controls the inertia curve: the higher G is, the higher the threshold remains, even as participation increases.\n\n**Low** — G=0.1, M=50%\n- The curve drops very quickly towards 50%\n- From 10% participation, the threshold is close to a simple majority\n- *Use: annexes, recommendations, advice*\n\n**Standard** — G=0.2, M=50%\n- Gradual descent, balance between stability and responsiveness\n- At 25% participation, the threshold is around 65%\n- *Use: fundamental and technical commitments*\n\n**High** — G=0.4, M=60%\n- Resistant curve, the threshold stays high for a long time\n- Supermajority of 60% even at full participation\n- *Use: the voting formula itself*\n\n**Very high** — G=0.6, M=66%\n- Near-unanimity required at every level of participation\n- Only a massive consensus (66%+) can modify this setting\n- *Use: the inertia setting (meta-protection)*\n\nThe inertia slider associated with each section of the document visually reflects these levels.", "en": "At each level, the group first asks for C votes in favour, then for a share of votes in favour among the votes beyond C. That share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T/W)^G)`\n\n**The five levels**: the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: 67% as usual, 50% when everyone votes. The order of the articles of each section.\n- **Level 3 · medium**: 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: 83% as usual, 60% when everyone votes. Uniqueness of the account, Reciprocity, the title of the document.\n- **Level 5 · very high**: 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice C: 100 voters in Monnaie libre. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout p̃, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 92, 97, 103, 109 or 113 votes in favour, from level 1 to level 5. At level 3: 50 votes in favour, plus 75% of the 70 other votes, i.e. 102.5, rounded up to 103.",
"es": "**Fórmula simplificada:** `Seuil = M + (1-M) × (1 - (T/W)^G)`\n\nEl **gradiente G** controla la curva de inercia: cuanto más alto es G, más alto se mantiene el umbral, incluso con una participación creciente.\n\n**Baja** — G=0.1, M=50%\n- La curva desciende muy rápido hacia el 50%\n- A partir del 10% de participación, el umbral se acerca a la mayoría simple\n- *Uso: anexos, recomendaciones, consejos*\n\n**Estándar** — G=0.2, M=50%\n- Descenso progresivo, equilibrio entre estabilidad y reactividad\n- Con un 25% de participación, el umbral ronda el 65%\n- *Uso: compromisos fundamentales y técnicos*\n\n**Alta** — G=0.4, M=60%\n- Curva resistente, el umbral se mantiene elevado durante mucho tiempo\n- Supermayoría del 60% incluso con participación plena\n- *Uso: la propia fórmula de votación*\n\n**Muy alta** — G=0.6, M=66%\n- Se requiere casi unanimidad con cualquier nivel de participación\n- Solo un consenso masivo (66%+) puede modificar este ajuste\n- *Uso: el ajuste de la inercia (metaprotección)*\n\nEl cursor de inercia asociado a cada sección del documento refleja visualmente estos niveles." "es": "En cada nivel, el grupo pide primero C votos a favor, y luego una proporción de votos a favor entre los votos más allá de C. Esa proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T/W)^G)`\n\n**Los cinco niveles**: la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: 67 % como de costumbre, 50 % cuando vota todo el mundo. El orden de los artículos de cada sección.\n- **Nivel 3 · media**: 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: 83 % como de costumbre, 60 % cuando vota todo el mundo. Unicidad de la cuenta, Reciprocidad, el título del documento.\n- **Nivel 5 · muy alta**: 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces C: 100 votantes en Monnaie libre. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual p̃, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 92, 97, 103, 109 o 113 votos a favor, del nivel 1 al nivel 5. En el nivel 3: 50 votos a favor, más el 75 % de los otros 70 votos, es decir 102,5, redondeado a 103."
} }
} }
] ]
@@ -780,9 +780,9 @@
"es": "Ordenación del documento" "es": "Ordenación del documento"
}, },
"body": { "body": {
"fr": "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.", "fr": "L'ordre des articles de chaque section est lui-même soumis au vote. En fin de section, le module « Ordre des articles » se vote comme un article, au cran 2 (basse) au départ : chaque proposition est la liste des articles de la section, dans un autre ordre. Quand un ordre entre en vigueur, seuls les articles de cette section changent de place ; aucun texte ne change.\n\nUn nouvel article se place à la fin de sa section ; sa place se vote ensuite par l'ordre de la section. Un ordre proposé garde ses votes quand la section change : un article arrivé depuis s'y ajoute à la fin, un article sorti en disparaît. Une section d'un seul article n'a pas d'ordre à voter.\n\nLe titre du document est lui aussi un module, voté au cran 4 (haute) au départ. Le titre de chaque article, lui, se vote avec son texte.",
"en": "The order in which items are presented in the document is itself subject to vote. Any reorganisation proposal must reach the adoption threshold with standard inertia.", "en": "The order of the articles of each section is itself put to the vote. At the end of each section, the “Order of the articles” module is voted on like an article, at level 2 (low) at the start: each proposal is the list of the section's articles, in another order. When an order comes into force, only the articles of that section change place; no text changes.\n\nA new article goes at the end of its section; its place is then voted on through the order of the section. A proposed order keeps its votes when the section changes: an article that has arrived since is added at the end, an article that has left drops out of it. A section with a single article has no order to vote on.\n\nThe title of the document is also a module, voted on at level 4 (high) at the start. The title of each article is voted on with its text.",
"es": "El orden de presentación de los elementos en el documento está a su vez sometido a votación. Toda propuesta de reorganización debe alcanzar el umbral de adopción con la inercia estándar." "es": "El orden de los artículos de cada sección está a su vez sometido a votación. Al final de cada sección, el módulo «Orden de los artículos» se vota como un artículo, con el nivel 2 (baja) al inicio: cada propuesta es la lista de los artículos de la sección, en otro orden. Cuando un orden entra en vigor, solo los artículos de esa sección cambian de lugar; ningún texto cambia.\n\nUn artículo nuevo se coloca al final de su sección; su lugar se vota después mediante el orden de la sección. Un orden propuesto conserva sus votos cuando la sección cambia: un artículo llegado después se añade al final, un artículo que ha salido desaparece de él. Una sección de un solo artículo no tiene orden que votar.\n\nEl título del documento es también un módulo, votado con el nivel 4 (alta) al inicio. El título de cada artículo, en cambio, se vota con su texto."
} }
} }
] ]
+46 -8
View File
@@ -37,11 +37,11 @@ async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` }); await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason); await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
await panel.getByRole('button', { name: "Envoyer l'objection" }).click(); await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible(); await expect(page.getByText("Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.")).toBeVisible();
return panel; return panel;
} }
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => { test('C28 — « redit » : plus de « pour » sur la dernière arrivée (contre ou retrait), l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
const aude = await person(browser, 'Aude'); const aude = await person(browser, 'Aude');
const oscar = await person(browser, 'Oscar'); const oscar = await person(browser, 'Oscar');
const vera = await person(browser, 'Vera'); const vera = await person(browser, 'Vera');
@@ -51,19 +51,57 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
// Oscar: it repeats « Unicité du compte ». // Oscar: it repeats « Unicité du compte ».
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.'); const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`); await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible(); await expect(panel.getByText('personne ne peut plus voter pour cette version')).toBeVisible();
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible(); await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
await shots(oscar, 'f1-objection-vue-par-qui-objecte'); await shots(oscar, 'f1-objection-vue-par-qui-objecte');
// Vera, who votes: held back, said so, nothing to press; the list marks it. // Vera, who votes (Yvv, 2026-11-01: « Retirer ou "contre" »): held back, said so;
// « Contre » and the withdrawal, never « Pour »; the list marks it.
await vera.goto(`${TEST_DOC}?vote=${id}`); await vera.goto(`${TEST_DOC}?vote=${id}`);
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` }); const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible(); await expect(
fold.getByText("Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là."),
).toBeVisible();
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible(); await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
const choice = fold.getByRole('group', { name: 'Ton vote', exact: true });
await expect(choice.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection'); await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('retenue par une objection');
await fold.scrollIntoViewIfNeeded(); await fold.scrollIntoViewIfNeeded();
await shots(vera, 'f2-objection-vue-par-qui-vote'); await shots(vera, 'f2-objection-vue-par-qui-vote');
// « Contre », then the withdrawal: the version can only lose votes; « Pour » never comes back.
await choice.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(vera.getByText('Vote enregistré.')).toBeVisible();
await expect(fold.getByText('Ton vote : contre')).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 1 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
await choice.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(vera.getByText('Vote retiré.')).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.locator('.tally')).toHaveText('0 pour · 0 contre');
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the basket: « Contre » or the withdrawal, never « Pour »; « Tout pour » skips it
// and says so, « Tout contre » takes it.
await vera.goto(`${TEST_DOC}?onglet=panier`);
const basket = vera.locator('aside.panel');
await expect(basket.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
const pick = basket.getByRole('group', { name: `Ton choix pour « ${title} »` });
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toBeVisible();
await expect(pick.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
const heldRow = basket.locator('.version.held').filter({ has: vera.getByRole('group', { name: `Ton choix pour « ${title} »` }) });
await expect(heldRow).toContainText('une objection la retient : contre ou retrait, pas de « pour »');
const whole = basket.getByRole('group', { name: 'Tout le document', exact: true });
await whole.getByRole('button', { name: 'Tout pour' }).click();
await expect(basket.getByText(/^Tu as tout mis pour, sauf \d+ versions? qu'une objection retient : sur elles?, tu peux voter contre ou retirer ton vote\.$/)).toBeVisible();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'false');
await shots(vera, 'g1-panier-tout-pour-la-saute');
await whole.getByRole('button', { name: 'Tout contre' }).click();
await expect(pick.getByRole('button', { name: 'Contre', exact: true })).toHaveAttribute('aria-pressed', 'true');
await heldRow.scrollIntoViewIfNeeded();
await shots(vera, 'g2-panier-tout-contre-la-prend');
await whole.getByRole('button', { name: 'Vider' }).click();
await expect(basket.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Aude: a message, and the way to answer — modify the article repeated. // Aude: a message, and the way to answer — modify the article repeated.
await aude.goto(TEST_DOC); await aude.goto(TEST_DOC);
@@ -99,8 +137,8 @@ test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur mo
await expect(answered.locator('.btn')).toHaveCount(0); await expect(answered.locator('.btn')).toHaveCount(0);
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible(); await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
// Vera: it is voted on again. // Vera: it is voted on again, « Pour » too.
await vera.reload(); await vera.goto(`${TEST_DOC}?vote=${id}`);
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0); await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
}); });
+1
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "libredecision-web", "name": "libredecision-web",
"version": "0.3.0", "version": "0.3.0",
"license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"@fontsource-variable/literata": "^5.3.0", "@fontsource-variable/literata": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0", "@fontsource-variable/nunito": "^5.3.0",
+1
View File
@@ -2,6 +2,7 @@
"name": "libredecision-web", "name": "libredecision-web",
"version": "0.3.0", "version": "0.3.0",
"private": true, "private": true,
"license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort", "dev": "vite dev --host 0.0.0.0 --port 3002 --strictPort",
+45 -9
View File
@@ -4,6 +4,10 @@
// whole document — but never an article whose versions compete: there, one // whole document — but never an article whose versions compete: there, one
// chooses one's version. Then one gesture sends it all: one write on the // chooses one's version. Then one gesture sends it all: one write on the
// server, or one transaction on the Ğ1 chain. // server, or one transaction on the Ğ1 chain.
//
// A version held back by an objection (ADR 0010) takes against or a withdrawal,
// never a « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour »
// skips it, « tout contre » takes it.
import type { Choice, Modules, Proposal, Unit } from './types'; import type { Choice, Modules, Proposal, Unit } from './types';
@@ -17,7 +21,10 @@ export interface Slot {
versions: Proposal[]; versions: Proposal[];
/** Open changes of its inertia: another question, never filled by a grouped gesture. */ /** Open changes of its inertia: another question, never filled by a grouped gesture. */
inertia: Proposal[]; inertia: Proposal[];
/** Versions held back by an objection: shown, not votable until it is lifted (ADR 0010). */ /**
* Versions held back by an objection (ADR 0010): apart from those in competition,
* against or a withdrawal only until it is lifted — never a « for ».
*/
held: Proposal[]; held: Proposal[];
} }
@@ -68,24 +75,55 @@ export function sections(units: Unit[], modules: Modules = { title: null, orders
return out; return out;
} }
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */ /**
* « Tout pour » / « tout contre » over some articles: only those with a single version.
* A version held back by an objection: « tout contre » takes it, « tout pour » skips it.
*/
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks { export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
const next = { ...picks }; const next = { ...picks };
for (const s of slots) { for (const s of slots) {
const [only] = s.versions; const [only] = s.versions;
if (only && s.versions.length === 1) next[only.id] = choice; if (only && s.versions.length === 1) next[only.id] = choice;
if (choice === 'against') for (const p of s.held) next[p.id] = choice;
} }
return next; return next;
} }
/** The versions held back by an objection among these articles: those « tout pour » skips. */
export function heldIn(slots: Slot[]): Proposal[] {
return slots.flatMap((s) => s.held);
}
/** Articles of several versions where no version is chosen yet: the interface shows them. */ /** Articles of several versions where no version is chosen yet: the interface shows them. */
export function waiting(slots: Slot[], picks: Picks): Slot[] { export function waiting(slots: Slot[], picks: Picks): Slot[] {
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks)); return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
} }
/**
* The versions one may vote now, and how: `free`, any choice; `held`, held back by an
* objection — against or a withdrawal only.
*/
export type Votable = Map<string, 'free' | 'held'>;
/** The versions one may vote now: those a choice of the basket is sent for. */ /** The versions one may vote now: those a choice of the basket is sent for. */
export function votableIds(all: Section[]): Set<string> { export function votableIds(all: Section[]): Votable {
return new Set(all.flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id)))); const out: Votable = new Map();
for (const x of all.flatMap((s) => s.slots)) {
for (const p of [...x.versions, ...x.inertia]) out.set(p.id, 'free');
for (const p of x.held) out.set(p.id, 'held');
}
return out;
}
/** May this choice be sent for this version now? Never a « for » on a held one. */
export function admits(known: Votable, id: string, choice: Choice): boolean {
const way = known.get(id);
return way === 'free' || (way === 'held' && choice !== 'for');
}
/** The choices of the basket that may be sent now: a version gone, or a « for » on a held one, is left out. */
export function kept(picks: Picks, known: Votable): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id, c]) => admits(known, id, c)));
} }
/** Empties the basket of these articles only. */ /** Empties the basket of these articles only. */
@@ -94,11 +132,9 @@ export function clearScope(slots: Slot[], picks: Picks): Picks {
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id))); return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
} }
/** What is sent: one line per version still in the document; withdrawing is a null choice. */ /** What is sent: one line per choice that may be sent now (`kept`); withdrawing is a null choice. */
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] { export function lines(picks: Picks, known: Votable): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(picks) return Object.entries(kept(picks, known)).map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
.filter(([id]) => known.has(id))
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
} }
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } { export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
+41 -8
View File
@@ -3,9 +3,11 @@
// section entière, ou un article avec son titre, ou séparément »): choices // section entière, ou un article avec son titre, ou séparément »): choices
// article by article, « tout pour » / « tout contre » for a section or the whole // article by article, « tout pour » / « tout contre » for a section or the whole
// document — never for an article whose versions compete —, then one gesture. // document — never for an article whose versions compete —, then one gesture.
// A version held back by an objection is shown, with nothing to choose. // A version held back by an objection takes against or a withdrawal, never a
// « for » (Yvv, 2026-11-01: « Retirer ou "contre" »): « tout pour » skips it
// and says so, « tout contre » takes it.
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket'; import { clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting, type Section, type Slot } from '$lib/basket';
import type { DocState } from '$lib/doc.svelte'; import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels'; import { moduleName, relationOf, sectionLabel, titleOf } from '$lib/labels';
@@ -38,12 +40,14 @@
const slots = $derived(all.flatMap((s) => s.slots)); const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(votableIds(all)); const known = $derived(votableIds(all));
const toSend = $derived(lines(basket.picks, known)); const toSend = $derived(lines(basket.picks, known));
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id))))); const tally = $derived(counts(kept(basket.picks, known)));
const left = $derived(waiting(slots, basket.picks)); const left = $derived(waiting(slots, basket.picks));
const open = $derived(!!access?.open); const open = $derived(!!access?.open);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let list: HTMLElement | undefined = $state(); let list: HTMLElement | undefined = $state();
/** After « tout pour »: where, and how many versions held back by an objection it left aside. */
let skipped = $state<{ key: string | null; n: number } | null>(null);
onMount(() => { onMount(() => {
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' }); if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
@@ -69,6 +73,14 @@
const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? [])); const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? []));
/** « Tout pour » / « tout contre » / « vider » over a scope; « tout pour » says what it skipped. */
function gesture(key: string | null, what: 'for' | 'against' | 'clear') {
const within = scope(key);
basket.set(what === 'clear' ? clearScope(within, basket.picks) : fill(within, what, basket.picks));
const n = what === 'for' ? heldIn(within).length : 0;
skipped = n ? { key, n } : null;
}
async function send() { async function send() {
error = ''; error = '';
if (!toSend.length) return; if (!toSend.length) return;
@@ -91,12 +103,18 @@
{#snippet bulk(key: string | null, label: string)} {#snippet bulk(key: string | null, label: string)}
<div class="bulk" role="group" aria-label={label}> <div class="bulk" role="group" aria-label={label}>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button> <button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'for')}>{t('basket.for')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button> <button class="btn small" type="button" disabled={!open} onclick={() => gesture(key, 'against')}>{t('basket.against')}</button>
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button> <button class="btn quiet small" type="button" onclick={() => gesture(key, 'clear')}>{t('basket.clear')}</button>
</div> </div>
{/snippet} {/snippet}
{#snippet skippedNote(key: string | null)}
{#if skipped && skipped.key === key}
<p class="note" role="status">{i18n.plural('basket.held.skipped', skipped.n)}</p>
{/if}
{/snippet}
<section class="basket" aria-labelledby="basket-title"> <section class="basket" aria-labelledby="basket-title">
<h3 id="basket-title">{t('basket.title')}</h3> <h3 id="basket-title">{t('basket.title')}</h3>
<p class="muted">{t('basket.intro')}</p> <p class="muted">{t('basket.intro')}</p>
@@ -108,6 +126,7 @@
<span class="eyebrow">{t('basket.doc')}</span> <span class="eyebrow">{t('basket.doc')}</span>
{@render bulk('*', t('basket.doc'))} {@render bulk('*', t('basket.doc'))}
</div> </div>
{@render skippedNote('*')}
{#if left.length} {#if left.length}
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p> <p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
{/if} {/if}
@@ -121,6 +140,7 @@
<h4 class="eyebrow">{label}</h4> <h4 class="eyebrow">{label}</h4>
{@render bulk(s.key, label)} {@render bulk(s.key, label)}
</header> </header>
{@render skippedNote(s.key)}
{/if} {/if}
<ul> <ul>
{#each s.slots as slot (slot.unit.id)} {#each s.slots as slot (slot.unit.id)}
@@ -152,9 +172,20 @@
<div class="version held"> <div class="version held">
<span class="about"> <span class="about">
<span class="label">{labelOf(v, slot)}</span> <span class="label">{labelOf(v, slot)}</span>
<span class="faint small">{t('tally.short', { for: v.tally.for, against: v.tally.against })}</span> <span class="faint small">
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}
</span>
<span class="held-note">{t('basket.held')}</span>
</span> </span>
<span class="held-note">{t('basket.held')}</span> <ChoicePicker
held
value={basket.picks[v.id]}
voted={!!v.myVote}
label={t('basket.pick', { title: slot.versions.length || slot.held.length > 1 ? `${title} — ${labelOf(v, slot)}` : title })}
disabled={!open}
onpick={(c) => basket.pick(v.id, c)}
/>
</div> </div>
{/each} {/each}
</li> </li>
@@ -297,6 +328,8 @@
color: var(--ink-soft); color: var(--ink-soft);
} }
.held-note { .held-note {
justify-self: start;
margin-top: 0.2rem;
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
border-radius: var(--r-pill); border-radius: var(--r-pill);
background: var(--ochre-wash); background: var(--ochre-wash);
+15 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
// One choice of the basket: for, against, or withdraw one's vote (when there is // One choice of the basket: for, against, or withdraw one's vote (when there is
// one). Pressing the chosen one again takes the version out of the basket. // one). Pressing the chosen one again takes the version out of the basket. A
// version held back by an objection offers no « for » (Yvv, 2026-11-01).
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import type { Choice } from '$lib/types'; import type { Choice } from '$lib/types';
@@ -8,13 +9,24 @@
value, value,
voted, voted,
label, label,
held = false,
disabled = false, disabled = false,
onpick, onpick,
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props(); }: {
value: Choice | undefined;
voted: boolean;
label: string;
/** Held back by an objection: against or a withdrawal only. */
held?: boolean;
disabled?: boolean;
onpick: (c: Choice) => void;
} = $props();
</script> </script>
<span class="picker" role="group" aria-label={label}> <span class="picker" role="group" aria-label={label}>
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button> {#if !held}
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
{/if}
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button> <button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
{#if voted || value === 'none'} {#if voted || value === 'none'}
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button> <button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
+6 -4
View File
@@ -4,7 +4,8 @@
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals — // toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
// never who voted what —, how far the threshold is, your vote and where it // never who voted what —, how far the threshold is, your vote and where it
// stands, then the inertia and the formula. A version held back by an // stands, then the inertia and the formula. A version held back by an
// objection says so, and tells each one what is theirs to do (ADR 0010). The // objection says so, and tells each one what is theirs to do (ADR 0010); it
// offers « contre » and the withdrawal, never « pour » (Yvv, 2026-11-01). The
// rest of a version (typos, translations, objections, history) lives in the // rest of a version (typos, translations, objections, history) lives in the
// panel, tab « Détails ». // panel, tab « Détails ».
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -200,13 +201,14 @@
{#if !live} {#if !live}
<p class="muted">{t('vote.closed')}</p> <p class="muted">{t('vote.closed')}</p>
{:else if p.blocked}
<!-- Held back: no new vote, nor a vote taken back, until the objection is lifted. -->
{:else if access && !access.open} {:else if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p> <p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{:else if access} {:else if access}
<div class="choice" role="group" aria-label={t('vote.yours')}> <div class="choice" role="group" aria-label={t('vote.yours')}>
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button> <!-- Held back by an objection: against or a withdrawal, never a « for » (Yvv, 2026-11-01). -->
{#if !p.blocked}
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
{/if}
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button> <button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if} {#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
</div> </div>
+12 -10
View File
@@ -142,7 +142,7 @@ export const en: Messages = {
'deep.addition': 'new article', 'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”', 'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia to {level}', 'deep.inertia': 'inertia to {level}',
'deep.held': 'not open to vote: objection', 'deep.held': 'held back by an objection',
'deep.mine': 'your proposal', 'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming', 'deep.stale': 'needs reconfirming',
'deep.wellrated': 'well-rated', 'deep.wellrated': 'well-rated',
@@ -231,7 +231,7 @@ export const en: Messages = {
'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.',
'vote.reconfirm': 'Reconfirm: {choice}', 'vote.reconfirm': 'Reconfirm: {choice}',
'vote.private': 'Nobody sees your vote: only the totals are visible.', 'vote.private': 'Nobody sees your vote: only the totals are visible.',
'vote.held': 'An objection is holding this version back: it cannot be voted on until the objection is lifted. Votes already cast stay.', 'vote.held': 'An objection is holding this version back: until it is lifted, nobody can vote for it any more. You can vote against it or take your vote back; once lifted, it enters into force only with the votes still there.',
'vote.held.redit': 'It repeats “{other}”.', 'vote.held.redit': 'It repeats “{other}”.',
'vote.held.contredit': 'It contradicts “{other}”.', 'vote.held.contredit': 'It contradicts “{other}”.',
'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.', 'vote.held.author.redit': 'For it to be voted on again, change the article it repeats. The person who raised the objection then lifts it or reiterates it; with no answer from them within 3 days, it is lifted.',
@@ -289,7 +289,9 @@ export const en: Messages = {
'basket.sent.other': '{n} votes saved.', 'basket.sent.other': '{n} votes saved.',
'basket.section': 'Vote on this section', 'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}', 'basket.mine': 'your vote: {choice}',
'basket.held': 'not open to vote: an objection holds it back', 'basket.held': 'an objection holds it back: against or withdraw, no “for”',
'basket.held.skipped.one': 'You put everything for, except {n} version an objection holds back: on it, you can vote against or take your vote back.',
'basket.held.skipped.other': 'You put everything for, except {n} versions an objection holds back: on them, you can vote against or take your vote back.',
'sign.title': 'Vote with your Ğ1 wallet', 'sign.title': 'Vote with your Ğ1 wallet',
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.', 'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
@@ -443,12 +445,12 @@ export const en: Messages = {
'objection.reason.redit': 'How these two texts say the same thing', 'objection.reason.redit': 'How these two texts say the same thing',
'objection.reason.contredit': 'How these two texts contradict each other', 'objection.reason.contredit': 'How these two texts contradict each other',
'objection.send': 'Send the objection', 'objection.send': 'Send the objection',
'objection.sent': 'Objection saved: the later of the two versions cannot be voted on until it is lifted.', 'objection.sent': 'Objection saved: until it is lifted, nobody can vote for the later of the two versions any more.',
'objection.note': 'An objection makes the later of the two versions not open to vote until it is lifted — never a text in force. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.', 'objection.note': 'An objection holds back the later of the two versions until it is lifted — never a text in force: nobody can vote for it any more, anyone can vote against it or take their vote back. Its author answers: for a repetition, they change the article it repeats; for a contradiction, they reword. You then lift the objection or reiterate it; with no answer from you within 3 days, it is lifted.',
'objection.says.redit': '“{held}” repeats “{other}”', 'objection.says.redit': '“{held}” repeats “{other}”',
'objection.says.contredit': '“{held}” contradicts “{other}”', 'objection.says.contredit': '“{held}” contradicts “{other}”',
'objection.by': 'objection by {name}', 'objection.by': 'objection by {name}',
'objection.held.here': 'this version cannot be voted on', 'objection.held.here': 'nobody can vote for this version any more',
'objection.phase.author': "waiting for the author's answer", 'objection.phase.author': "waiting for the author's answer",
'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself', 'objection.phase.objector': 'answer received: lifted or reiterated by {date}, otherwise lifted by itself',
'objection.answer.revision': 'answer: the held text was reworded', 'objection.answer.revision': 'answer: the held text was reworded',
@@ -477,9 +479,9 @@ export const en: Messages = {
'messages.open': 'See it in the text', 'messages.open': 'See it in the text',
'messages.topic.objection': 'Objection', 'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Typo', 'messages.topic.typo': 'Typo',
'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. It cannot be voted on until the objection is lifted. To answer, change the article it repeats.', 'messages.objection_raised.redit': '{name} raises an objection: your version “{held}” repeats “{other}”. Until it is lifted, nobody can vote for it any more. To answer, change the article it repeats.',
'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. It cannot be voted on until the objection is lifted. To answer, reword it.', 'messages.objection_raised.contredit': '{name} raises an objection: your version “{held}” contradicts “{other}”. Until it is lifted, nobody can vote for it any more. To answer, reword it.',
'messages.objection_reiterated': '{name} reiterates their objection: “{held}” still cannot be voted on. Answer again.', 'messages.objection_reiterated': '{name} reiterates their objection: nobody can vote for “{held}” yet. Answer again.',
'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.', 'messages.objection_answered': 'Your objection on “{held}” has been answered. Lift it if the answer suits you, or reiterate it. With no answer from you, it is lifted.',
'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.', 'messages.typo_pending.author': '{name} suggests a typo fix in your version “{title}”.',
'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.', 'messages.typo_pending.admin': '{name} suggests a typo fix in “{title}”, a seed text: an administrator validates it.',
@@ -638,7 +640,7 @@ export const en: Messages = {
'error.not_a_title': 'This module is not the title of the document.', 'error.not_a_title': 'This module is not the title of the document.',
'error.same_title': 'This is already the title in force.', 'error.same_title': 'This is already the title in force.',
'error.section': 'This section does not exist in this document.', 'error.section': 'This section does not exist in this document.',
'error.objection': 'An objection is holding this version back: it cannot be voted on until the objection is lifted.', 'error.objection': 'An objection is holding this version back: until it is lifted, you can vote against it or take your vote back, not vote for it.',
'error.kind': 'Say whether this text repeats the other or contradicts it.', 'error.kind': 'Say whether this text repeats the other or contradicts it.',
'error.other': 'Choose another article, in force or proposed.', 'error.other': 'Choose another article, in force or proposed.',
'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.', 'error.both_in_force': 'Both texts are in force: an objection always targets a version that is not in force yet.',
+12 -10
View File
@@ -142,7 +142,7 @@ export const es: Messages = {
'deep.addition': 'artículo nuevo', 'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»', 'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia a {level}', 'deep.inertia': 'inercia a {level}',
'deep.held': 'no votable: objeción', 'deep.held': 'retenida por una objeción',
'deep.mine': 'tu propuesta', 'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar', 'deep.stale': 'por reconfirmar',
'deep.wellrated': 'bien valorada', 'deep.wellrated': 'bien valorada',
@@ -231,7 +231,7 @@ export const es: Messages = {
'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.', 'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.',
'vote.reconfirm': 'Reconfirmar: {choice}', 'vote.reconfirm': 'Reconfirmar: {choice}',
'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.', 'vote.private': 'Tu voto no se muestra a nadie: solo se ven los totales.',
'vote.held': 'Una objeción retiene esta versión: no se vota hasta que se levante. Los votos ya emitidos se mantienen.', 'vote.held': 'Una objeción retiene esta versión: hasta que se levante, nadie puede votar a favor. Puedes votar en contra o retirar tu voto; al levantarse, solo entra en vigor con los votos que sigan ahí.',
'vote.held.redit': 'Repite «{other}».', 'vote.held.redit': 'Repite «{other}».',
'vote.held.contredit': 'Contradice «{other}».', 'vote.held.contredit': 'Contradice «{other}».',
'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.', 'vote.held.author.redit': 'Para que vuelva a votarse, modifica el artículo que repite. La persona que hizo la objeción la levanta entonces o la reitera; sin respuesta suya en 3 días, se levanta.',
@@ -289,7 +289,9 @@ export const es: Messages = {
'basket.sent.other': '{n} votos registrados.', 'basket.sent.other': '{n} votos registrados.',
'basket.section': 'Votar esta sección', 'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}', 'basket.mine': 'tu voto: {choice}',
'basket.held': 'no votable: una objeción la retiene', 'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»',
'basket.held.skipped.one': 'Lo pusiste todo a favor, salvo {n} versión que una objeción retiene: en ella, puedes votar en contra o retirar tu voto.',
'basket.held.skipped.other': 'Lo pusiste todo a favor, salvo {n} versiones que una objeción retiene: en ellas, puedes votar en contra o retirar tu voto.',
'sign.title': 'Votar con tu monedero Ğ1', 'sign.title': 'Votar con tu monedero Ğ1',
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.', 'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
@@ -443,12 +445,12 @@ export const es: Messages = {
'objection.reason.redit': 'En qué dicen lo mismo estos dos textos', 'objection.reason.redit': 'En qué dicen lo mismo estos dos textos',
'objection.reason.contredit': 'En qué se contradicen estos dos textos', 'objection.reason.contredit': 'En qué se contradicen estos dos textos',
'objection.send': 'Enviar la objeción', 'objection.send': 'Enviar la objeción',
'objection.sent': 'Objeción registrada: la más reciente de las dos versiones no se vota hasta que se levante.', 'objection.sent': 'Objeción registrada: hasta que se levante, nadie puede votar a favor de la más reciente de las dos versiones.',
'objection.note': 'Una objeción hace no votable la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.', 'objection.note': 'Una objeción retiene la más reciente de las dos versiones hasta que se levante — nunca un texto en vigor: nadie puede votar a favor, cada cual puede votar en contra o retirar su voto. Su autor responde: para una repetición, modifica el artículo que repite; para una contradicción, reformula. Tú levantas entonces la objeción o la reiteras; sin respuesta tuya en 3 días, se levanta.',
'objection.says.redit': '«{held}» repite «{other}»', 'objection.says.redit': '«{held}» repite «{other}»',
'objection.says.contredit': '«{held}» contradice «{other}»', 'objection.says.contredit': '«{held}» contradice «{other}»',
'objection.by': 'objeción de {name}', 'objection.by': 'objeción de {name}',
'objection.held.here': 'esta versión no se vota', 'objection.held.here': 'nadie puede votar a favor de esta versión',
'objection.phase.author': 'en espera de la respuesta del autor', 'objection.phase.author': 'en espera de la respuesta del autor',
'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola', 'objection.phase.objector': 'respuesta recibida: levantada o reiterada antes del {date}, si no se levanta sola',
'objection.answer.revision': 'respuesta: el texto retenido se reformuló', 'objection.answer.revision': 'respuesta: el texto retenido se reformuló',
@@ -477,9 +479,9 @@ export const es: Messages = {
'messages.open': 'Verlo en el texto', 'messages.open': 'Verlo en el texto',
'messages.topic.objection': 'Objeción', 'messages.topic.objection': 'Objeción',
'messages.topic.typo': 'Errata', 'messages.topic.typo': 'Errata',
'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». No se vota hasta que se levante. Para responder, modifica el artículo que repite.', 'messages.objection_raised.redit': '{name} hace una objeción: tu versión «{held}» repite «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, modifica el artículo que repite.',
'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». No se vota hasta que se levante. Para responder, reformúlala.', 'messages.objection_raised.contredit': '{name} hace una objeción: tu versión «{held}» contradice «{other}». Hasta que se levante, nadie puede votar a favor. Para responder, reformúlala.',
'messages.objection_reiterated': '{name} reitera su objeción: «{held}» sigue sin votarse. Responde de nuevo.', 'messages.objection_reiterated': '{name} reitera su objeción: nadie puede votar aún a favor de «{held}». Responde de nuevo.',
'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.', 'messages.objection_answered': 'Tu objeción sobre «{held}» recibió una respuesta. Levántala si te convence, o reitérala. Sin respuesta tuya, se levanta.',
'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».', 'messages.typo_pending.author': '{name} propone una corrección de errata en tu versión «{title}».',
'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.', 'messages.typo_pending.admin': '{name} propone una corrección de errata en «{title}», un texto del seed: un administrador la valida.',
@@ -638,7 +640,7 @@ export const es: Messages = {
'error.not_a_title': 'Este módulo no es el título del documento.', 'error.not_a_title': 'Este módulo no es el título del documento.',
'error.same_title': 'Ya es el título en vigor.', 'error.same_title': 'Ya es el título en vigor.',
'error.section': 'Esta sección no existe en este documento.', 'error.section': 'Esta sección no existe en este documento.',
'error.objection': 'Una objeción retiene esta versión: no se vota hasta que se levante.', 'error.objection': 'Una objeción retiene esta versión: hasta que se levante, puedes votar en contra o retirar tu voto, no votar a favor.',
'error.kind': 'Di si este texto repite el otro o lo contradice.', 'error.kind': 'Di si este texto repite el otro o lo contradice.',
'error.other': 'Elige otro artículo, en vigor o propuesto.', 'error.other': 'Elige otro artículo, en vigor o propuesto.',
'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.', 'error.both_in_force': 'Los dos textos están en vigor: una objeción siempre apunta a una versión que aún no lo está.',
+12 -10
View File
@@ -145,7 +145,7 @@ export const fr = {
'deep.addition': 'nouvel article', 'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »', 'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie vers {level}', 'deep.inertia': 'inertie vers {level}',
'deep.held': 'non votable : objection', 'deep.held': 'retenue par une objection',
'deep.mine': 'ta proposition', 'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer', 'deep.stale': 'à reconfirmer',
'deep.wellrated': 'bien cotée', 'deep.wellrated': 'bien cotée',
@@ -234,7 +234,7 @@ export const fr = {
'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.", 'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.",
'vote.reconfirm': 'Reconfirmer : {choice}', 'vote.reconfirm': 'Reconfirmer : {choice}',
'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.', 'vote.private': 'Ton vote ne se montre à personne : seuls les totaux sont visibles.',
'vote.held': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.", 'vote.held': "Une objection retient cette version : jusqu'à sa levée, personne ne peut plus voter pour. Tu peux voter contre ou retirer ton vote ; à la levée, elle n'entre en vigueur qu'avec les voix encore là.",
'vote.held.redit': 'Elle redit « {other} ».', 'vote.held.redit': 'Elle redit « {other} ».',
'vote.held.contredit': 'Elle contredit « {other} ».', 'vote.held.contredit': 'Elle contredit « {other} ».',
'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.", 'vote.held.author.redit': "Pour qu'elle se vote à nouveau, modifie l'article visé. La personne qui a fait l'objection la lève alors, ou la réitère ; sans réponse de sa part sous 3 jours, elle est levée.",
@@ -292,7 +292,9 @@ export const fr = {
'basket.sent.other': '{n} votes enregistrés.', 'basket.sent.other': '{n} votes enregistrés.',
'basket.section': 'Voter cette section', 'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}', 'basket.mine': 'ton vote : {choice}',
'basket.held': 'non votable : une objection la retient', 'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »',
'basket.held.skipped.one': "Tu as tout mis pour, sauf {n} version qu'une objection retient : sur elle, tu peux voter contre ou retirer ton vote.",
'basket.held.skipped.other': "Tu as tout mis pour, sauf {n} versions qu'une objection retient : sur elles, tu peux voter contre ou retirer ton vote.",
'sign.title': 'Voter avec ton portefeuille Ğ1', 'sign.title': 'Voter avec ton portefeuille Ğ1',
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.', 'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
@@ -446,12 +448,12 @@ export const fr = {
'objection.reason.redit': 'En quoi ces deux textes disent la même chose', 'objection.reason.redit': 'En quoi ces deux textes disent la même chose',
'objection.reason.contredit': 'En quoi ces deux textes se contredisent', 'objection.reason.contredit': 'En quoi ces deux textes se contredisent',
'objection.send': "Envoyer l'objection", 'objection.send': "Envoyer l'objection",
'objection.sent': "Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.", 'objection.sent': "Objection enregistrée : jusqu'à sa levée, personne ne peut plus voter pour la dernière arrivée des deux versions.",
'objection.note': "Une objection rend la dernière arrivée des deux versions non votable jusqu'à sa levée — jamais un texte en vigueur. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.", 'objection.note': "Une objection retient la dernière arrivée des deux versions jusqu'à sa levée — jamais un texte en vigueur : personne ne peut plus voter pour elle, chacun peut voter contre ou retirer son vote. Son auteur répond : pour une redite, il modifie l'article visé ; pour une contradiction, il reformule. Tu lèves alors l'objection ou tu la réitères ; sans réponse de ta part sous 3 jours, elle est levée.",
'objection.says.redit': '« {held} » redit « {other} »', 'objection.says.redit': '« {held} » redit « {other} »',
'objection.says.contredit': '« {held} » contredit « {other} »', 'objection.says.contredit': '« {held} » contredit « {other} »',
'objection.by': 'objection de {name}', 'objection.by': 'objection de {name}',
'objection.held.here': 'cette version ne se vote plus', 'objection.held.here': 'personne ne peut plus voter pour cette version',
'objection.phase.author': "en attente d'une réponse de l'auteur", 'objection.phase.author': "en attente d'une réponse de l'auteur",
'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même", 'objection.phase.objector': "réponse arrivée : levée ou réitérée d'ici le {date}, sinon levée d'elle-même",
'objection.answer.revision': 'réponse : le texte retenu a été reformulé', 'objection.answer.revision': 'réponse : le texte retenu a été reformulé',
@@ -480,9 +482,9 @@ export const fr = {
'messages.open': 'Voir dans le texte', 'messages.open': 'Voir dans le texte',
'messages.topic.objection': 'Objection', 'messages.topic.objection': 'Objection',
'messages.topic.typo': 'Coquille', 'messages.topic.typo': 'Coquille',
'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, modifie l'article visé.", 'messages.objection_raised.redit': "{name} fait une objection : ta version « {held} » redit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, modifie l'article visé.",
'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Elle ne se vote plus jusqu'à sa levée. Pour répondre, reformule-la.", 'messages.objection_raised.contredit': "{name} fait une objection : ta version « {held} » contredit « {other} ». Jusqu'à sa levée, personne ne peut plus voter pour elle. Pour répondre, reformule-la.",
'messages.objection_reiterated': '{name} réitère son objection : « {held} » ne se vote toujours pas. Réponds à nouveau.', 'messages.objection_reiterated': '{name} réitère son objection : personne ne peut encore voter pour « {held} ». Réponds à nouveau.',
'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.', 'messages.objection_answered': 'Ton objection sur « {held} » a reçu une réponse. Lève-la si elle te convient, ou réitère-la. Sans réponse de ta part, elle est levée.',
'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».', 'messages.typo_pending.author': '{name} propose une correction de coquille dans ta version « {title} ».',
'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.', 'messages.typo_pending.admin': '{name} propose une correction de coquille dans « {title} », un texte du seed : un administrateur la valide.',
@@ -641,7 +643,7 @@ export const fr = {
'error.not_a_title': "Ce module n'est pas le titre du document.", 'error.not_a_title': "Ce module n'est pas le titre du document.",
'error.same_title': "C'est déjà le titre en vigueur.", 'error.same_title': "C'est déjà le titre en vigueur.",
'error.section': "Cette section n'existe pas dans ce document.", 'error.section': "Cette section n'existe pas dans ce document.",
'error.objection': "Une objection retient cette version : elle ne se vote plus jusqu'à sa levée.", 'error.objection': "Une objection retient cette version : jusqu'à sa levée, tu peux voter contre ou retirer ton vote, pas voter pour.",
'error.kind': "Dis si ce texte redit l'autre ou le contredit.", 'error.kind': "Dis si ce texte redit l'autre ou le contredit.",
'error.other': 'Choisis un autre article, en vigueur ou proposé.', 'error.other': 'Choisis un autre article, en vigueur ou proposé.',
'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.', 'error.both_in_force': 'Ces deux textes sont en vigueur : une objection vise toujours une version qui ne l’est pas encore.',
+9 -6
View File
@@ -1,15 +1,18 @@
// The basket of the reader: the choices not sent yet, kept for the tab (a reload // The basket of the reader: the choices not sent yet, kept for the tab (a reload
// or a look at another article does not lose them), one basket per document. // or a look at another article does not lose them), one basket per document.
import type { Picks } from './basket'; import { kept, type Picks, type Votable } from './basket';
import type { Choice } from './types'; import type { Choice } from './types';
export class BasketState { export class BasketState {
picks = $state<Picks>({}); picks = $state<Picks>({});
#key = ''; #key = '';
/** Takes back the basket of this document, keeping only the versions still open to vote. */ /**
load(docId: string, known: Set<string>) { * Takes back the basket of this document, keeping only the choices still open: a
* version gone leaves it, and so does a « for » on a version an objection holds back.
*/
load(docId: string, known: Votable) {
const key = `ld:basket:${docId}`; const key = `ld:basket:${docId}`;
if (key === this.#key) { if (key === this.#key) {
this.#keep(known); this.#keep(known);
@@ -27,9 +30,9 @@ export class BasketState {
this.#keep(known); this.#keep(known);
} }
#keep(known: Set<string>) { #keep(known: Votable) {
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id))); const still = kept(this.picks, known);
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept); if (Object.keys(still).length !== Object.keys(this.picks).length) this.set(still);
} }
set(picks: Picks) { set(picks: Picks) {
@@ -29,7 +29,7 @@
void doc.load(group, slug); void doc.load(group, slug);
}); });
// The basket of this document, kept for the tab; versions no longer open to vote // The basket of this document, kept for the tab; versions no longer open to vote
// (or held back by an objection) leave it. // leave it, and so does a « for » on a version held back by an objection.
$effect(() => { $effect(() => {
const v = doc.view; const v = doc.view;
if (!v) return; if (!v) return;
+24 -10
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, votableIds, waiting } from '../../src/lib/basket'; import { admits, clearScope, counts, fill, heldIn, kept, lines, sections, votableIds, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types'; import type { Choice, Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal { function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return { return {
@@ -84,7 +84,7 @@ describe('the basket of a vote', () => {
}); });
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => { it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']); const known = votableIds(sections([I1, E2, E4]));
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([ expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' }, { proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null }, { proposalId: 'e2', choice: null },
@@ -93,8 +93,9 @@ describe('the basket of a vote', () => {
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 }); expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
}); });
it('a version held back by an objection is shown apart: no grouped gesture fills it, nothing is sent for it', () => { it('a version held back by an objection is shown apart: against or a withdrawal, never for', () => {
// E5 has the text in force and a rival held back; E6 is a new article, held back. // Yvv, 2026-11-01: « Retirer ou "contre" ». E5 has the text in force and a rival
// held back; E6 is a new article, held back.
const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [ const e5 = unit('E5', 'technique', prop('e5', 'E5', { status: 'in_force' }), [
prop('e5r', 'E5', { blocked: true, votable: false }), prop('e5r', 'E5', { blocked: true, votable: false }),
]); ]);
@@ -103,15 +104,28 @@ describe('the basket of a vote', () => {
const [slotE5, slotE6] = s[0]?.slots ?? []; const [slotE5, slotE6] = s[0]?.slots ?? [];
expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']); expect(slotE5?.versions.map((p) => p.id)).toEqual(['e5']);
expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']); expect(slotE5?.held.map((p) => p.id)).toEqual(['e5r']);
// A held article stays in sight, with nothing to choose. // A held article stays in sight, apart from the versions in competition.
expect(slotE6?.versions).toEqual([]); expect(slotE6?.versions).toEqual([]);
expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']); expect(slotE6?.held.map((p) => p.id)).toEqual(['e6']);
const all = s.flatMap((x) => x.slots); const all = s.flatMap((x) => x.slots);
// The held rival does not compete now: the text in force is the only choice. // The held rival does not compete now: the text in force is the only version to choose.
expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(waiting(all, {})).toEqual([]); expect(waiting(all, {})).toEqual([]);
// A choice kept from before the objection is not sent. // « Tout pour » skips the held versions (and says so: `heldIn`); « Tout contre » takes them.
expect(lines({ e5: 'for', e5r: 'for' }, votableIds(s))).toEqual([{ proposalId: 'e5', choice: 'for' }]); expect(fill(all, 'for', {})).toEqual({ e5: 'for' });
expect(heldIn(all).map((p) => p.id)).toEqual(['e5r', 'e6']);
expect(fill(all, 'against', {})).toEqual({ e5: 'against', e5r: 'against', e6: 'against' });
// What may be chosen: anything on a free version; against or withdrawing on a held one.
const known = votableIds(s);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5', c as Choice))).toEqual([true, true, true]);
expect(['for', 'against', 'none'].map((c) => admits(known, 'e5r', c as Choice))).toEqual([false, true, true]);
expect(admits(known, 'gone', 'against')).toBe(false);
// A « for » kept from before the objection is neither kept nor sent; against and withdrawing are.
expect(kept({ e5: 'for', e5r: 'for', e6: 'against', gone: 'none' }, known)).toEqual({ e5: 'for', e6: 'against' });
expect(lines({ e5: 'for', e5r: 'for', e6: 'none' }, known)).toEqual([
{ proposalId: 'e5', choice: 'for' },
{ proposalId: 'e6', choice: null },
]);
expect(lines({ e5r: 'against' }, known)).toEqual([{ proposalId: 'e5r', choice: 'against' }]);
expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' }); expect(clearScope(all, { e5: 'for', e5r: 'against', i1: 'for' })).toEqual({ i1: 'for' });
}); });
}); });