From ec9beee59e1efc5e054b60375776f30347221bcf Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 00:15:40 -0300
Subject: [PATCH 01/25] feat:translation on some enums to facilitate frontend
use
---
backend/app/session/enums.py | 26 +++++++++++++-------------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index aaa0116..59c0a4c 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -61,33 +61,33 @@ class ChairEvents(StrEnum):
# --- Additional Info ---
class DebateTypes(StrEnum):
- SPEAKERS_LIST = "Speakers List"
- MODERATED_DEBATE = "Moderated Debate" # During this type, the queue to speak should not be automatic
- UNMODERATED_DEBATE = "Unmoderated Debate"
+ SPEAKERS_LIST = "Lista de Discursos"
+ MODERATED_DEBATE = "Debate Moderado" # During this type, the queue to speak should not be automatic
+ UNMODERATED_DEBATE = "Debate não Moderado"
class Motions(StrEnum):
CHANGE_DEBATE_TYPE = "Mudar Tipo de Debate"
- POSTPONE_SESSION = "Adiaamento de Sessão"
- REOPEN_SESSION = "Reabrir Sessão"
+ POSTPONE_SESSION = "Adiamento de Sessão"
+ REOPEN_SESSION = "Reabertura de Sessão"
TOUR_DE_TABLE = "Tour de Table"
END_DEBATE = "Encerramento de Debate" # TODO: map this out since "motion to close debate" means clear GSL and go to voting procedures in modeldiplomat and can also mean the same as "motion to move into voting procedures"
VOTE_AMENDMENT = "Votação de Emenda" # TODO: check the way this is used, since amendments MUST be voted if they're present during VOTING_PROCEDURES
VOTE_BY_ROLL_CALL = "Votação por Chamada" # TODO: check the way this is used
CLOSE_SPEAKERS_LIST = "Fechamento da Lista de Discursos"
- REOPEN_SPEAKERS_LIST = "Reabrir a Lista de Discursos"
- SPLIT_PROPOSAL = "Divisão de Proposta"
- INTRODUCE_RESOLUTION_PROPOSAL = "Introdução de Proposta de Resolução"
- INTRODUCE_AMENDMENT_PROPOSAL = "Introdução de Proposta de Emenda"
+ REOPEN_SPEAKERS_LIST = "Reabertura de Lista de Discursos"
+ SPLIT_PROPOSAL = "Divisão da Proposta"
+ INTRODUCE_RESOLUTION_PROPOSAL = "Introdução da Proposta de Resolução"
+ INTRODUCE_AMENDMENT_PROPOSAL = "Introdução da Proposta de Emenda"
CHANGE_TOPIC = "Mudança de Tópico"
- QUORUM = "Quórum"
+ QUORUM = "Contagem de Quórum"
CUSTOM_MOTION = "" # not implemented
class Questions(StrEnum):
- ORDER = "Order"
- QUESTION = "Question"
- PERSONAL_PRIVILEGE = "Personal Privilege"
+ ORDER = "Ordem"
+ QUESTION = "Questão"
+ PERSONAL_PRIVILEGE = "Privilégio Pessoal"
class RollCallChoice(StrEnum):
From 23012cfc4de004018a1360a2d59d5069c51edf0c Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 00:17:15 -0300
Subject: [PATCH 02/25] fix: removed redundant delegate from the delegates
payload
---
backend/app/session/schemas.py | 2 --
frontend/src/schemas/types.gen.ts | 39 +++++++++++--------------------
2 files changed, 13 insertions(+), 28 deletions(-)
diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py
index 239a93c..7c1ef50 100644
--- a/backend/app/session/schemas.py
+++ b/backend/app/session/schemas.py
@@ -18,7 +18,6 @@ class SessionCreationSchema(BaseModel):
# TODO: refactor this to only reflect the payload received by delegates, with MotionModel being a separated entity
class DelegateMotionPayload(BaseModel):
type: enums.Motions
- delegate: int
debate_type: enums.DebateTypes | None = None
total_duration_minutes: int | None = None
@@ -30,7 +29,6 @@ class DelegateMotionPayload(BaseModel):
class DelegateQuestionPayload(BaseModel):
type: enums.Questions
- delegate: int
details: str | None = None
diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts
index 6723ddf..9631fb1 100644
--- a/frontend/src/schemas/types.gen.ts
+++ b/frontend/src/schemas/types.gen.ts
@@ -271,9 +271,9 @@ export type DebateContext = {
* DebateTypes
*/
export const DebateTypes = {
- SPEAKERS_LIST: 'Speakers List',
- MODERATED_DEBATE: 'Moderated Debate',
- UNMODERATED_DEBATE: 'Unmoderated Debate'
+ LISTA_DE_DISCURSOS: 'Lista de Discursos',
+ DEBATE_MODERADO: 'Debate Moderado',
+ DEBATE_NÃO_MODERADO: 'Debate não Moderado'
} as const;
/**
@@ -304,10 +304,6 @@ export type DelegateEvents = typeof DelegateEvents[keyof typeof DelegateEvents];
*/
export type DelegateMotionPayload = {
type: Motions;
- /**
- * Delegate
- */
- delegate: number;
debate_type?: DebateTypes | null;
/**
* Total Duration Minutes
@@ -332,10 +328,6 @@ export type DelegateMotionPayload = {
*/
export type DelegateQuestionPayload = {
type: Questions;
- /**
- * Delegate
- */
- delegate: number;
/**
* Details
*/
@@ -581,19 +573,19 @@ export type MotionContext = {
*/
export const Motions = {
MUDAR_TIPO_DE_DEBATE: 'Mudar Tipo de Debate',
- ADIAAMENTO_DE_SESSÃO: 'Adiaamento de Sessão',
- REABRIR_SESSÃO: 'Reabrir Sessão',
+ ADIAMENTO_DE_SESSÃO: 'Adiamento de Sessão',
+ REABERTURA_DE_SESSÃO: 'Reabertura de Sessão',
TOUR_DE_TABLE: 'Tour de Table',
ENCERRAMENTO_DE_DEBATE: 'Encerramento de Debate',
VOTAÇÃO_DE_EMENDA: 'Votação de Emenda',
VOTAÇÃO_POR_CHAMADA: 'Votação por Chamada',
FECHAMENTO_DA_LISTA_DE_DISCURSOS: 'Fechamento da Lista de Discursos',
- REABRIR_A_LISTA_DE_DISCURSOS: 'Reabrir a Lista de Discursos',
- DIVISÃO_DE_PROPOSTA: 'Divisão de Proposta',
- INTRODUÇÃO_DE_PROPOSTA_DE_RESOLUÇÃO: 'Introdução de Proposta de Resolução',
- INTRODUÇÃO_DE_PROPOSTA_DE_EMENDA: 'Introdução de Proposta de Emenda',
+ REABERTURA_DE_LISTA_DE_DISCURSOS: 'Reabertura de Lista de Discursos',
+ DIVISÃO_DA_PROPOSTA: 'Divisão da Proposta',
+ INTRODUÇÃO_DA_PROPOSTA_DE_RESOLUÇÃO: 'Introdução da Proposta de Resolução',
+ INTRODUÇÃO_DA_PROPOSTA_DE_EMENDA: 'Introdução da Proposta de Emenda',
MUDANÇA_DE_TÓPICO: 'Mudança de Tópico',
- QUÓRUM: 'Quórum',
+ CONTAGEM_DE_QUÓRUM: 'Contagem de Quórum',
'': ''
} as const;
@@ -651,9 +643,9 @@ export type QuestionContext = {
* Questions
*/
export const Questions = {
- ORDER: 'Order',
- QUESTION: 'Question',
- PERSONAL_PRIVILEGE: 'Personal Privilege'
+ ORDEM: 'Ordem',
+ QUESTÃO: 'Questão',
+ PRIVILÉGIO_PESSOAL: 'Privilégio Pessoal'
} as const;
/**
@@ -996,11 +988,6 @@ export type VotingContext = {
voting_registry?: {
[key: string]: VotingChoice;
};
- majority: MajorityTypes;
- /**
- * Veto Power
- */
- veto_power: boolean;
};
/**
From 1ed354fc2f746ec8a92bdf9b236b3d4831f7e758 Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 00:17:44 -0300
Subject: [PATCH 03/25] fix: as operator ignores type safety, replaced by
satisfies
---
frontend/src/components/session/Agenda.tsx | 8 ++++----
.../session/bottom-bar-buttons/SessionButton.tsx | 6 +++---
.../session/bottom-bar-buttons/VoteButton.tsx | 4 ++--
frontend/src/components/session/delegation-map.tsx | 10 +++++-----
frontend/src/components/session/manual-quorum.tsx | 2 +-
frontend/src/components/session/speaker-list.tsx | 2 +-
frontend/src/components/session/timer.tsx | 4 ++--
frontend/src/components/session/voting-popup.tsx | 8 ++++----
8 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/frontend/src/components/session/Agenda.tsx b/frontend/src/components/session/Agenda.tsx
index a5ca987..a33278d 100644
--- a/frontend/src/components/session/Agenda.tsx
+++ b/frontend/src/components/session/Agenda.tsx
@@ -84,7 +84,7 @@ export default function Agenda() {
sendMessage(
- {type:ChairEvents.MARK_AGENDA_ITEM_EVENT, payload:{index: index,indiscussion:true}} as MarkAgendaItemEvent
+ {type:ChairEvents.MARK_AGENDA_ITEM_EVENT, payload:{index: index,indiscussion:true}} satisfies MarkAgendaItemEvent
)}>
@@ -96,7 +96,7 @@ export default function Agenda() {
sendMessage(
- {type:ChairEvents.MARK_AGENDA_ITEM_EVENT, payload:{index:index,discussed:true}} as MarkAgendaItemEvent
+ {type:ChairEvents.MARK_AGENDA_ITEM_EVENT, payload:{index:index,discussed:true}} satisfies MarkAgendaItemEvent
)}>
@@ -108,7 +108,7 @@ export default function Agenda() {
sendMessage(
- {type:ChairEvents.DELETE_AGENDA_ITEM_EVENT, payload:{index: index}} as DeleteAgendaItemEvent
+ {type:ChairEvents.DELETE_AGENDA_ITEM_EVENT, payload:{index: index}} satisfies DeleteAgendaItemEvent
)}>
@@ -145,7 +145,7 @@ export default function Agenda() {
() => {
if(numinput.current && topicinput.current) {
sendMessage({type:ChairEvents.SET_AGENDA_ITEM_EVENT,
- payload:{index:numinput.current.value, topic: topicinput.current.value}} as SetAgendaItemEvent)
+ payload:{index:numinput.current.value, topic: topicinput.current.value}} satisfies SetAgendaItemEvent)
numinput.current.value = ""
topicinput.current.value = ""
}
diff --git a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx
index 448661c..8db02ea 100644
--- a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx
+++ b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx
@@ -51,13 +51,13 @@ export default function TestButton() {
{currentState === States.SETUP_ROOM &&
- sendMessage({ type: ChairEvents.OPEN_SESSION_EVENT, payload: {} } as OpenSessionEvent)}>Abrir Sessao e iniciar Quórum }
+ sendMessage({ type: ChairEvents.OPEN_SESSION_EVENT, payload: {} } satisfies OpenSessionEvent)}>Abrir Sessao e iniciar Quórum }
{currentState === States.ROLL_CALL &&
- sendMessage({ type: ChairEvents.CLOSE_ROLL_CALL_EVENT, payload: {} } as CloseRollCallEvent)}>
+ sendMessage({ type: ChairEvents.CLOSE_ROLL_CALL_EVENT, payload: {} } satisfies CloseRollCallEvent)}>
Fechar Quórum
}
{currentState !== States.SETUP_ROOM && currentState !== States.ROLL_CALL &&
- sendMessage({ type: ChairEvents.CLOSE_SESSION_EVENT, payload: {} } as CloseSessionEvent)}>
+ sendMessage({ type: ChairEvents.CLOSE_SESSION_EVENT, payload: {} } satisfies CloseSessionEvent)}>
Fechar Sessão
}
diff --git a/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx b/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx
index 2721088..49ca724 100644
--- a/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx
+++ b/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx
@@ -60,8 +60,8 @@ export default function VoteButton() {
- {sendMessage({type: ChairEvents.OPEN_INFORMAL_VOTING_EVENT, payload:{title: title.current?.value, veto_power: veto.current?.checked, majority: majority.current?.value}} as OpenInformalVotingEvent)}} className="w-full bg-green-800 text-white hover:bg-green-700">Iniciar Votação
- {sendMessage({type: ChairEvents.CLOSE_INFORMAL_VOTING_EVENT, payload:{} } as CloseInformalVotingEvent)}} className="w-full bg-red-800 text-white hover:bg-red-700">Fechar Votação
+ {sendMessage({type: ChairEvents.OPEN_INFORMAL_VOTING_EVENT, payload:{title: title.current?.value, veto_power: veto.current?.checked as boolean, majority: (majority.current?.value) as MajorityTypes}} satisfies OpenInformalVotingEvent)}} className="w-full bg-green-800 text-white hover:bg-green-700">Iniciar Votação
+ {sendMessage({type: ChairEvents.CLOSE_INFORMAL_VOTING_EVENT, payload:{} } satisfies CloseInformalVotingEvent)}} className="w-full bg-red-800 text-white hover:bg-red-700">Fechar Votação
diff --git a/frontend/src/components/session/delegation-map.tsx b/frontend/src/components/session/delegation-map.tsx
index 7d1e901..c4bfbc6 100644
--- a/frontend/src/components/session/delegation-map.tsx
+++ b/frontend/src/components/session/delegation-map.tsx
@@ -160,10 +160,10 @@ export default function DelegationMap({
Ações sobre a Delegação
- sendMessage({ type: ChairEvents.INSERT_QUEUE_EVENT, payload: { target: delegation.id } } as ChairInsertQueueEvent)}>
+ sendMessage({ type: ChairEvents.INSERT_QUEUE_EVENT, payload: { target: delegation.id } } satisfies ChairInsertQueueEvent)}>
Colocar na Lista de Discursos
- sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: { speaker_id: delegation.id } } as SpeakerEvent)}>
+ sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: { speaker_id: delegation.id } } satisfies SpeakerEvent)}>
Dar a palavra
@@ -173,13 +173,13 @@ export default function DelegationMap({
Mudar Presença
- sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT_AND_VOTING } } as MarkRollCallEvent)}>
+ sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT_AND_VOTING } } satisfies MarkRollCallEvent)}>
Presente Votante
- sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT } } as MarkRollCallEvent)}>
+ sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT } } satisfies MarkRollCallEvent)}>
Presente
- sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.ABSENT } } as MarkRollCallEvent)}>
+ sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.ABSENT } } satisfies MarkRollCallEvent)}>
Ausente
diff --git a/frontend/src/components/session/manual-quorum.tsx b/frontend/src/components/session/manual-quorum.tsx
index 0fa3589..5c15ff4 100644
--- a/frontend/src/components/session/manual-quorum.tsx
+++ b/frontend/src/components/session/manual-quorum.tsx
@@ -95,7 +95,7 @@ export default function ManualQuorum() {
{
- sendMessage({ type: ChairEvents.MARK_ROLL_CALL_BULK_EVENT, payload: { Rollcalls: tempQuorum } } as MarkRollCallBulkEvent)
+ sendMessage({ type: ChairEvents.MARK_ROLL_CALL_BULK_EVENT, payload: { Rollcalls: tempQuorum } } satisfies MarkRollCallBulkEvent)
}}>
Aceitar e Salvar Quorum
diff --git a/frontend/src/components/session/speaker-list.tsx b/frontend/src/components/session/speaker-list.tsx
index 33f4eac..6be1327 100644
--- a/frontend/src/components/session/speaker-list.tsx
+++ b/frontend/src/components/session/speaker-list.tsx
@@ -92,7 +92,7 @@ export default function SpeakerList() {
variant="outline"
className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
disabled={waitingCount === 0}
- onClick={() => sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: {} } as SpeakerEvent)}
+ onClick={() => sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: {} } satisfies SpeakerEvent)}
>
Proximo
Proximo Orador
diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx
index a09c441..6693a67 100644
--- a/frontend/src/components/session/timer.tsx
+++ b/frontend/src/components/session/timer.tsx
@@ -73,10 +73,10 @@ const [Seconds, setRemainingSeconds] = useState(0);
{isChair && (
-
{sendMessage({type: ChairEvents.TOGGLE_TIMER_EVENT, payload: {}} as ToggleTimerEvent);}}>
+ {sendMessage({type: ChairEvents.TOGGLE_TIMER_EVENT, payload: {}} satisfies ToggleTimerEvent);}}>
{timerIsRunning ? : }
- sendMessage({type: ChairEvents.INCREASE_TIMER_EVENT, payload: { seconds: 5 }} as IncreaseTimerEvent)}> 5s
+ sendMessage({type: ChairEvents.INCREASE_TIMER_EVENT, payload: { seconds: 5 }} satisfies IncreaseTimerEvent)}> 5s
)}
diff --git a/frontend/src/components/session/voting-popup.tsx b/frontend/src/components/session/voting-popup.tsx
index a8038b2..b1fd9ad 100644
--- a/frontend/src/components/session/voting-popup.tsx
+++ b/frontend/src/components/session/voting-popup.tsx
@@ -49,7 +49,7 @@ export default function VotingPopup() {
{isRollCall1
? "Primeira rodada: Sua delegação pode votar com ou sem direitos. Caso deseje pular, podera votar apos todos, porem sem a possibilidade de pedir direitos"
- : "Segunda rodada: apenas as delegações que pularam na primeira rodada podem votar, sem direitos."}
+ : "Segunda rodada: apenas satisfies delegações que pularam na primeira rodada podem votar, sem direitos."}
)}
@@ -77,11 +77,11 @@ export default function VotingPopup() {
{setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} as CastVoteEvent)}}
+ () => {setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
className="flex-1 bg-green-800 text-white hover:bg-green-700">A Favor
{setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} as CastVoteEvent)}}
+ () => {setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
disabled={isRollCall && !canAbstain}
className="flex-1 bg-gray-700 text-white hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -92,7 +92,7 @@ export default function VotingPopup() {
Pular
)}
- {setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} as CastVoteEvent)}}
+ {setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
className="flex-1 bg-red-800 text-white hover:bg-red-700">Contra
From 5b5bdb488f8f4c3d500be97ff5a05786e36cd4bd Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Fri, 7 Aug 2026 00:18:02 -0300
Subject: [PATCH 04/25] feat:motions button now (mostly) works
---
.../bottom-bar-buttons/MotionsButton.tsx | 126 ++++++++++--------
1 file changed, 70 insertions(+), 56 deletions(-)
diff --git a/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx b/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx
index b1661ce..5de6b28 100644
--- a/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx
+++ b/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx
@@ -39,53 +39,42 @@ import {
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useCommitteeStore } from "@/store/useCommitteeStore"
-import { States } from "@/schemas/types.gen"
+import { MajorityTypes, States } from "@/schemas/types.gen"
import { useSession } from "@/context/SessionContext"
import { SessionRoles } from "@/schemas/types.gen"
+import {
+ Motions,
+ Questions,
+ DebateTypes,
+ DelegateEvents,
+ type SubmitMotionEvent,
+ type SubmitQuestionEvent,
+ type DelegateQuestionPayload,
+ type DelegateMotionPayload } from "@/schemas/types.gen"
+import { sendMessage } from "@/context/SessionContext"
-const motions = [
- "Moção para Adiamento de Sessão",
- "Moção para Reabertura de Sessão",
- "Moção para Mudar Tipo de Debate",
- "Moção para Tour de Table",
- "Moção para Encerramento de Debate",
- "Moção para Votação de Emenda",
- "Moção para Fechamento de Lista de Discursos",
- "Moção para Reabertura de Lista de Discursos",
- "Moção para Divisão da Proposta",
- "Moção para Introdução da Proposta de Resolução",
- "Moção para Introdução de Proposta de Emenda",
- "Moção para Votação por Chamada",
- "Moção para contagem de Quórum",
-] as const
-
-const points = [
- "Questão de Privilégio Pessoal",
- "Questão de Ordem",
- "Questão de Dúvida",
-] as const
-
-const motionRequiredMajority: Record = {
- "Moção para Adiamento de Sessão": "Maioria simples",
- "Moção para Reabertura de Sessão": "Maioria simples",
- "Moção para Mudar Tipo de Debate": "Maioria simples",
- "Moção para Tour de Table": "Maioria simples",
- "Moção para Encerramento de Debate": "Maioria qualificada",
- "Moção para Votação de Emenda": "Maioria qualificada",
- "Moção para Fechamento de Lista de Discursos": "Maioria simples",
- "Moção para Reabertura de Lista de Discursos": "Maioria simples",
- "Moção para Divisão da Proposta": "Maioria simples",
- "Moção para Introdução da Proposta de Resolução": "Maioria simples",
- "Moção para Introdução de Proposta de Emenda": "Maioria simples",
- "Moção para Votação por Chamada": "Maioria simples",
- "Moção para contagem de Quórum": "Maioria simples",
+const motionRequiredMajority: Record = {
+ [Motions.ADIAMENTO_DE_SESSÃO]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.REABERTURA_DE_SESSÃO]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.MUDAR_TIPO_DE_DEBATE]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.TOUR_DE_TABLE]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.ENCERRAMENTO_DE_DEBATE]: MajorityTypes.MAIORIA_QUALIFICADA,
+ [Motions.VOTAÇÃO_DE_EMENDA]: MajorityTypes.MAIORIA_QUALIFICADA,
+ [Motions.FECHAMENTO_DA_LISTA_DE_DISCURSOS]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.REABERTURA_DE_LISTA_DE_DISCURSOS]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.DIVISÃO_DA_PROPOSTA]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.INTRODUÇÃO_DA_PROPOSTA_DE_RESOLUÇÃO]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.INTRODUÇÃO_DA_PROPOSTA_DE_EMENDA]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.VOTAÇÃO_POR_CHAMADA]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.MUDANÇA_DE_TÓPICO]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions.CONTAGEM_DE_QUÓRUM]: MajorityTypes.MAIORIA_SIMPLES,
+ [Motions[""]]: "",
}
type MotionKind = "moção" | "questão"
-type DebateKind = "moderado" | "não moderado" | "lista de discursos" | ""
function QuestionsMotionsList(type: MotionKind) {
- return type === "moção" ? motions : points
+ return Object.values(type === "moção" ? Motions : Questions)
}
export default function TestButton() {
@@ -95,8 +84,9 @@ export default function TestButton() {
const currentState = useCommitteeStore((state) => state.current_state)
const [motionKind, setMotionKind] = useState("moção")
- const [selectedMotion, setSelectedMotion] = useState("")
- const [debateKindChange, setDebateKind] = useState("")
+ const [selectedMotion, setSelectedMotion] = useState("")
+ const [selectedQuestion, setSelectedQuestion] = useState("")
+ const [debateKindChange, setDebateKind] = useState("")
const [unmoderatedMinutes, setUnmoderatedMinutes] = useState("")
const [speechCount, setSpeechCount] = useState("")
const [minutesPerSpeech, setMinutesPerSpeech] = useState("")
@@ -105,14 +95,33 @@ export default function TestButton() {
const [answerText, setAnswerText] = useState("")
const motionOptions = QuestionsMotionsList(motionKind)
- const showDebateKindField = selectedMotion === "Moção para Mudar Tipo de Debate"
- const showUnmoderatedField = showDebateKindField && debateKindChange === "não moderado"
- const showModeratedFields = showDebateKindField && debateKindChange === "moderado"
+ const showDebateKindField = selectedMotion === Motions.MUDAR_TIPO_DE_DEBATE
+ const showUnmoderatedField = showDebateKindField && debateKindChange === DebateTypes.DEBATE_NÃO_MODERADO
+ const showModeratedFields = showDebateKindField && debateKindChange === DebateTypes.DEBATE_MODERADO
const showMotionDecision = motionKind === "moção" && selectedMotion.length > 0
const selectedMotionMajority = motionRequiredMajority[selectedMotion] ?? "Maioria não definida"
+ const motionBody : DelegateMotionPayload = {
+ type: selectedMotion,
+ ...(unmoderatedMinutes !== "" && {total_duration_minutes: Number(unmoderatedMinutes)}),
+ ...(minutesPerSpeech !== "" && {per_speaker_seconds: Number(minutesPerSpeech)}), //TODO: Fix inconsitency in minutes / seconds
+ ...(debateKindChange !== "" && {debate_type: debateKindChange}),
+ ...(minutesPerSpeech !== "" && {per_speaker_seconds: Number(minutesPerSpeech)}),
+ //TODO: add change topic
+ }
+
+ const questionBody : DelegateQuestionPayload | null =
+ selectedQuestion === "" ? null :
+ {
+ type: selectedQuestion,
+ details: questionText
+ }
+
+
+
const resetMotionFields = () => {
setSelectedMotion("")
+ setSelectedQuestion("")
setDebateKind("")
setUnmoderatedMinutes("")
setSpeechCount("")
@@ -199,8 +208,9 @@ export default function TestButton() {
{
- setSelectedMotion(value)
+ onValueChange={(value : any) => {
+ if(motionKind === "moção") setSelectedMotion(value)
+ else if(motionKind === "questão") setSelectedQuestion(value)
setDebateKind("")
setUnmoderatedMinutes("")
setSpeechCount("")
@@ -215,11 +225,13 @@ export default function TestButton() {
- {motionOptions.map((item) => (
+ {motionOptions.map((item) =>
+ {
+ if(item !== "") return (
{item}
- ))}
+ )})}
@@ -228,15 +240,15 @@ export default function TestButton() {
{showDebateKindField && (
Para qual tipo de debate?
- setDebateKind(value as DebateKind)}>
+ setDebateKind(value as DebateTypes)}>
- Lista de Discursos
- Debate moderado
- Debate não moderado
+ Lista de Discursos
+ Debate moderado
+ Debate não moderado
@@ -305,7 +317,7 @@ export default function TestButton() {
)}
{showMotionDecision && !isChair && (
-
+ {sendMessage({type:DelegateEvents.SUBMIT_MOTION_EVENT, payload: motionBody} satisfies SubmitMotionEvent)}} className="flex-1 bg-green-800 text-white hover:bg-green-700" type="button">
Enviar moção
@@ -321,6 +333,7 @@ export default function TestButton() {
onChange={(event) => setQuestionText(event.target.value)}
/>
+ { isChair && (
Digite a resposta
setAnswerText(event.target.value)}
/>
-
-
- Registrar questão e resposta
+ )
+ }
+ {if(questionBody)sendMessage({type:DelegateEvents.SUBMIT_QUESTION_EVENT, payload: questionBody} satisfies SubmitQuestionEvent)}} className="w-full bg-green-800 text-white hover:bg-green-700" type="button">
+ Registrar questão{isChair && " e resposta"}
>
)}
From 494968e913a1256ea3fc67d5ea65f450fed3afde Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Fri, 7 Aug 2026 12:59:37 -0300
Subject: [PATCH 05/25] revert(backend): revert VotingContext removal of
Majority Type
---
backend/app/session/enums.py | 3 +++
backend/app/session/models.py | 18 +++++++++++++-----
backend/app/tests/session/test_engine.py | 16 ++++++++--------
3 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index 59c0a4c..c4d6c24 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -112,6 +112,9 @@ class VotingChoice(StrEnum):
FAVOUR = "Favour"
AGAINST = "Against"
ABSTAIN = "Abstain"
+ YES_WITH_RIGHTS = "Yes With Rights"
+ NO_WITH_RIGHTS = "No With Rights"
+ PASS = "Pass"
class VotingType(StrEnum):
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index e539e16..29e747c 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -59,19 +59,27 @@ class QuestionContext(BaseModel):
class VotingContext(BaseModel):
target_type: enums.VotingType
- motion_in_vote: MotionContext | None = None
title: str | None = None
return_state: enums.States
voting_registry: dict[int, enums.VotingChoice] = {}
+ majority: enums.MajorityTypes | None = None
+
+ motion_in_vote: MotionContext | None = None
+ # resolution_in_vote: ResolutionContext
+ allow_veto_power: bool = False
+
+ def is_choice_allowed(self, choice: enums.VotingChoice) -> bool:
+ if self.target_type == enums.VotingType.PROCEDURAL:
+ return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST)
+
+ return True
class DebateContext(BaseModel):
debate_type: enums.DebateTypes
return_state: enums.States
- total_duration_seconds: int | None = None # TODO: check if this is needed
- total_speeches: int | None = (
- None # Check if we use total duration or this for calculating overall time, it can also go overtime
- )
+ total_duration_seconds: int | None = None # check if its needed
+ total_speeches: int | None = None
per_speaker_seconds: int | None = None
expires_at: datetime | None = None
topic: str | None = None
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index 295b7e6..21f4b70 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -13,7 +13,7 @@ def open_gsl_state(session_state: md.SessionLiveState) -> md.SessionLiveState:
@pytest.fixture
-def voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState:
+def informal_voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState:
session_state.current_state = enums.States.VOTING_EXECUTION
session_state.voting = md.VotingContext(
target_type=enums.VotingType.INFORMAL,
@@ -368,11 +368,11 @@ def test_delegate_cannot_leave_queue_when_not_queued(
def test_delegate_can_cast_vote(
engine: eng.SessionEngine,
- voting_state: md.SessionLiveState,
+ informal_voting_state: md.SessionLiveState,
cast_vote_event: sch.CastVoteEvent,
delegate_actor: md.SessionActor,
) -> None:
- state = engine.dispatch(voting_state, cast_vote_event, delegate_actor)
+ state = engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor)
assert state.voting is not None
assert state.voting.voting_registry == {0: enums.VotingChoice.FAVOUR}
@@ -380,14 +380,14 @@ def test_delegate_can_cast_vote(
def test_delegate_cannot_cast_vote_twice(
engine: eng.SessionEngine,
- voting_state: md.SessionLiveState,
+ informal_voting_state: md.SessionLiveState,
cast_vote_event: sch.CastVoteEvent,
delegate_actor: md.SessionActor,
) -> None:
- engine.dispatch(voting_state, cast_vote_event, delegate_actor)
+ engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor)
with pytest.raises(eng.InvalidProceduralMove, match="Already cast vote"):
- engine.dispatch(voting_state, cast_vote_event, delegate_actor)
+ engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor)
def test_delegate_cannot_cast_vote_without_voting_context(
@@ -522,11 +522,11 @@ def test_delegate_cannot_open_informal_voting(
def test_chair_can_close_informal_voting(
engine: eng.SessionEngine,
- voting_state: md.SessionLiveState,
+ informal_voting_state: md.SessionLiveState,
close_informal_voting_event: sch.CloseInformalVotingEvent,
chair_actor: md.SessionActor,
) -> None:
- state = engine.dispatch(voting_state, close_informal_voting_event, chair_actor)
+ state = engine.dispatch(informal_voting_state, close_informal_voting_event, chair_actor)
assert state.current_state == enums.States.OPEN_GSL
assert state.voting is None
From 3a6cd810a634e5e78584c7de91d6c8caca64d215 Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Sun, 9 Aug 2026 01:31:28 -0300
Subject: [PATCH 06/25] feat:motions list now functional
---
.../src/components/session/motions-list.tsx | 34 ++++++-------------
frontend/src/schemas/types.gen.ts | 12 +++++--
2 files changed, 21 insertions(+), 25 deletions(-)
diff --git a/frontend/src/components/session/motions-list.tsx b/frontend/src/components/session/motions-list.tsx
index b81608c..d217a36 100644
--- a/frontend/src/components/session/motions-list.tsx
+++ b/frontend/src/components/session/motions-list.tsx
@@ -14,25 +14,15 @@ import { Badge } from "@/components/ui/badge"
import Flags from "@/components/ui/flags"
import { useSession } from "@/context/SessionContext"
import { SessionRoles } from "@/schemas/types.gen"
+import { useCommitteeStore } from "@/store/useCommitteeStore"
-export type Motion = {
- id: string
- timestamp: string
- title: string
- proposer: string
- proposerCode: string
- priority: number
-}
-
-type MotionsListProps = {
- motions: Motion[]
-}
-
-
-export default function MotionsList({ motions }: MotionsListProps) {
+export default function MotionsList() {
const {role} = useSession()
const isChair = role===SessionRoles.CHAIR
+ const motions = useCommitteeStore((state)=>state.submitted_motions)!
+ const delegations = useCommitteeStore((state)=>state.delegations)
+
const toMinutes = (time: string): number => {
const [hours, minutes] = time.split(":").map(Number)
if (Number.isNaN(hours) || Number.isNaN(minutes)) {
@@ -42,11 +32,9 @@ export default function MotionsList({ motions }: MotionsListProps) {
}
const sortedMotions = [...motions].sort((a, b) => {
- if (b.priority !== a.priority) {
- return b.priority - a.priority
- }
+ return b.priority! - a.priority!
- return toMinutes(a.timestamp) - toMinutes(b.timestamp)
+ //return toMinutes(a.timestamp) - toMinutes(b.timestamp)
})
const queueCount = sortedMotions.length
@@ -60,14 +48,14 @@ export default function MotionsList({ motions }: MotionsListProps) {
-
-
{motion.timestamp}
+ {"00:00"}
- {motion.title}
+ {motion.type}
-
- {motion.proposer}
+
+ {delegations[motion.delegate_id!].name}
diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts
index 9631fb1..855745c 100644
--- a/frontend/src/schemas/types.gen.ts
+++ b/frontend/src/schemas/types.gen.ts
@@ -963,7 +963,10 @@ export type ValidationError = {
export const VotingChoice = {
FAVOUR: 'Favour',
AGAINST: 'Against',
- ABSTAIN: 'Abstain'
+ ABSTAIN: 'Abstain',
+ YES_WITH_RIGHTS: 'Yes With Rights',
+ NO_WITH_RIGHTS: 'No With Rights',
+ PASS: 'Pass'
} as const;
/**
@@ -976,7 +979,6 @@ export type VotingChoice = typeof VotingChoice[keyof typeof VotingChoice];
*/
export type VotingContext = {
target_type: VotingType;
- motion_in_vote?: MotionContext | null;
/**
* Title
*/
@@ -988,6 +990,12 @@ export type VotingContext = {
voting_registry?: {
[key: string]: VotingChoice;
};
+ majority?: MajorityTypes | null;
+ motion_in_vote?: MotionContext | null;
+ /**
+ * Allow Veto Power
+ */
+ allow_veto_power?: boolean;
};
/**
From 86de2c7563e1b0112de892cb810feb09237f4fab Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Sun, 9 Aug 2026 16:38:00 -0300
Subject: [PATCH 07/25] fix/feat: minor fixes and integration on speaker-list
and voting popup
---
.../src/components/session/speaker-list.tsx | 10 ++++++----
.../src/components/session/voting-popup.tsx | 18 +++++++++++-------
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/frontend/src/components/session/speaker-list.tsx b/frontend/src/components/session/speaker-list.tsx
index 6be1327..b07c3b5 100644
--- a/frontend/src/components/session/speaker-list.tsx
+++ b/frontend/src/components/session/speaker-list.tsx
@@ -16,20 +16,21 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { sendMessage } from "@/context/SessionContext"
-import { type SpeakerEvent, ChairEvents } from "@/schemas/types.gen"
+import { type JoinQueueEvent, type SpeakerEvent, ChairEvents, DelegateEvents } from "@/schemas/types.gen"
import { useSession } from "@/context/SessionContext"
import { SessionRoles } from "@/schemas/types.gen"
-const isAlredyInQueue = true // Replace with actual logic to determine if the user is already in the queue
//TODO determine if queue is open, if not obscure the button and show a message that the queue is closed
export default function SpeakerList() {
- const {role} = useSession()
+ const {role, representation_id} = useSession()
const isChair = role===SessionRoles.CHAIR
const gslQueue = useCommitteeStore((state) => state.gsl_queue ?? [])
const currentSpeaker = useCommitteeStore((state) => state.current_speaker)
const delegationsById = useCommitteeStore((state) => state.delegations)
+
+ const alreadyInQueue = representation_id ? gslQueue.includes(representation_id) : false
const queuedDelegations = gslQueue.flatMap((delegationId) => {
const delegation = delegationsById[String(delegationId)]
return delegation ? [delegation] : []
@@ -79,7 +80,8 @@ export default function SpeakerList() {
sendMessage({type:DelegateEvents.JOIN_QUEUE_EVENT, payload:{}} satisfies JoinQueueEvent)}
>
Se colocar na lista de oradores
diff --git a/frontend/src/components/session/voting-popup.tsx b/frontend/src/components/session/voting-popup.tsx
index b1fd9ad..814b633 100644
--- a/frontend/src/components/session/voting-popup.tsx
+++ b/frontend/src/components/session/voting-popup.tsx
@@ -16,8 +16,8 @@ import {
} from "@/components/ui/field"
import { useState } from "react"
import { useCommitteeStore } from "@/store/useCommitteeStore"
-import { sendMessage } from "@/context/SessionContext"
-import { DelegateEvents, VotingChoice, type CastVoteEvent } from "@/schemas/types.gen"
+import { sendMessage, useSession } from "@/context/SessionContext"
+import { DelegateEvents, SessionRoles, VotingChoice, type CastVoteEvent } from "@/schemas/types.gen"
type VoteType = "rollCall1" | "rollCall2" | "procedural" | "informal"
@@ -27,6 +27,9 @@ const voteType : VoteType = "procedural"
export default function VotingPopup() {
+ const {role, representation_id} = useSession()
+ const isChair = role == SessionRoles.CHAIR
+
//TODO: Implement rollcall voting
const isRollCall1 = voteType === "rollCall1"
const isRollCall2 = voteType === "rollCall2"
@@ -35,11 +38,12 @@ export default function VotingPopup() {
//TEMPORARY^^^^
const voting = useCommitteeStore((state) => state.voting ?? null)
+ const voted = voting && representation_id ? representation_id in voting.voting_registry! : true
const voteTitle = voting?.title
- const [voted, setVoted] = useState(false)
+ const [clicked, setClicked] = useState(false)
return (
-
+
Votação
@@ -77,11 +81,11 @@ export default function VotingPopup() {
{setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
+ () => {setClicked(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
className="flex-1 bg-green-800 text-white hover:bg-green-700">A Favor
{setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
+ () => {setClicked(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
disabled={isRollCall && !canAbstain}
className="flex-1 bg-gray-700 text-white hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -92,7 +96,7 @@ export default function VotingPopup() {
Pular
)}
- {setVoted(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
+ {setClicked(true); sendMessage({type:DelegateEvents.CAST_VOTE_EVENT, payload: {vote:VotingChoice.FAVOUR}} satisfies CastVoteEvent)}}
className="flex-1 bg-red-800 text-white hover:bg-red-700">Contra
From 78c2a9bbd099977f6655491c9afb780ed560aa3b Mon Sep 17 00:00:00 2001
From: Extroias <91105220+Extroias@users.noreply.github.com>
Date: Sun, 9 Aug 2026 16:38:27 -0300
Subject: [PATCH 08/25] feat:time-stamp on motions list, and now you can
deny/accept them
---
backend/app/session/engine.py | 1 +
backend/app/session/models.py | 1 +
.../src/components/session/motions-list.tsx | 28 +++++++++----------
frontend/src/schemas/types.gen.ts | 4 +++
4 files changed, 19 insertions(+), 15 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index e1705cc..f0b1207 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -278,6 +278,7 @@ def handle_submit_motion(
id=generate_next_motion_id(state),
priority=get_motion_priority(payload.type),
type=payload.type,
+ timestamp=datetime.now(UTC),
delegate_id=actor.delegation.id if actor.delegation is not None else None,
total_duration_minutes=payload.total_duration_minutes,
per_speaker_seconds=payload.per_speaker_seconds,
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 29e747c..d92c888 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -40,6 +40,7 @@ class MotionContext(BaseModel):
priority: int = 0
type: enums.Motions
delegate_id: int | None = None
+ timestamp: datetime
debate_type: enums.DebateTypes | None = None
total_duration_minutes: int | None = None
diff --git a/frontend/src/components/session/motions-list.tsx b/frontend/src/components/session/motions-list.tsx
index d217a36..4b6f7b9 100644
--- a/frontend/src/components/session/motions-list.tsx
+++ b/frontend/src/components/session/motions-list.tsx
@@ -12,10 +12,11 @@ import {
} from "@/components/ui/item"
import { Badge } from "@/components/ui/badge"
import Flags from "@/components/ui/flags"
-import { useSession } from "@/context/SessionContext"
-import { SessionRoles } from "@/schemas/types.gen"
+import { sendMessage,useSession } from "@/context/SessionContext"
+import { SessionRoles, ChairEvents, type ResolveMotionEvent} from "@/schemas/types.gen"
import { useCommitteeStore } from "@/store/useCommitteeStore"
+
export default function MotionsList() {
const {role} = useSession()
const isChair = role===SessionRoles.CHAIR
@@ -23,18 +24,10 @@ export default function MotionsList() {
const motions = useCommitteeStore((state)=>state.submitted_motions)!
const delegations = useCommitteeStore((state)=>state.delegations)
- const toMinutes = (time: string): number => {
- const [hours, minutes] = time.split(":").map(Number)
- if (Number.isNaN(hours) || Number.isNaN(minutes)) {
- return Number.MAX_SAFE_INTEGER
- }
- return hours * 60 + minutes
- }
-
const sortedMotions = [...motions].sort((a, b) => {
- return b.priority! - a.priority!
+ if(b.priority !== a.priority) return b.priority! - a.priority!
- //return toMinutes(a.timestamp) - toMinutes(b.timestamp)
+ return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
})
const queueCount = sortedMotions.length
@@ -48,7 +41,10 @@ export default function MotionsList() {
-
-
{"00:00"}
+ {new Date(motion.timestamp).toLocaleTimeString('pt-BR', {
+ hour: '2-digit',
+ minute: '2-digit'
+})}
@@ -61,8 +57,10 @@ export default function MotionsList() {
{isChair && (
- Acatar
- Rejeitar
+ sendMessage({type:ChairEvents.RESOLVE_MOTION_EVENT, payload:{motion_id:motion.id!, action:true }} satisfies ResolveMotionEvent)}>Acatar
+ sendMessage({type:ChairEvents.RESOLVE_MOTION_EVENT, payload:{motion_id:motion.id!, action:false }} satisfies ResolveMotionEvent)}>Rejeitar
)}
diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts
index 855745c..3ea3f68 100644
--- a/frontend/src/schemas/types.gen.ts
+++ b/frontend/src/schemas/types.gen.ts
@@ -549,6 +549,10 @@ export type MotionContext = {
* Delegate Id
*/
delegate_id?: number | null;
+ /**
+ * Timestamp
+ */
+ timestamp: string;
debate_type?: DebateTypes | null;
/**
* Total Duration Minutes
From ba18c8c7ddfb105dae7ed6bac9fa614b8b587be1 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 20:36:20 -0300
Subject: [PATCH 09/25] refactor(backend): split MotionEvent into Delegate and
Chair events
---
backend/app/session/engine.py | 101 ++++++++++++++++-------
backend/app/session/enums.py | 8 ++
backend/app/session/models.py | 11 +--
backend/app/session/schemas.py | 26 ++++--
backend/app/tests/session/test_engine.py | 46 +++++++++--
5 files changed, 144 insertions(+), 48 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index f0b1207..0d502c0 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -98,19 +98,10 @@ def generate_next_question_id(state: SessionLiveState) -> int:
return state._question_id_counter
-# TODO: map more things to be needed here
def validate_motion_payload(
- payload: schemas.DelegateMotionPayload, state: SessionLiveState
+ payload: schemas.MotionPayload, state: SessionLiveState
) -> None:
- """Should validate motion payload and correct it before submitting"""
- # can correct things
- if payload.target_topic is None:
- payload.target_topic = (
- state.agenda_topics[state.active_topic_index][0]
- if state.active_topic_index is not None
- and 0 <= state.active_topic_index < len(state.agenda_topics)
- else None
- )
+ """Should validate motion payload before submitting"""
# can also raise error if there are missing fields
if (
@@ -120,11 +111,6 @@ def validate_motion_payload(
raise InvalidProceduralMove("Cannot submit motion without speaking time")
-def validate_question_payload(
- payload: schemas.DelegateQuestionPayload, state: SessionLiveState
-) -> None: ...
-
-
def count_present_delegations(state: SessionLiveState) -> int:
"""Count total present delegations.
A delegation is considered present (even if AFK)
@@ -142,7 +128,8 @@ def count_present_delegations(state: SessionLiveState) -> int:
)
-def tally_votes(voting: VotingContext, total_presents: int) -> bool:
+def needs_simple_majority_type(motion: enums.Motions) -> bool:
+ """Simple map for majority type. Used when creating VotingContext and tallying votes"""
qualified_motions = (
Motions.POSTPONE_SESSION,
Motions.CHANGE_DEBATE_TYPE,
@@ -150,7 +137,14 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool:
Motions.CLOSE_SPEAKERS_LIST,
Motions.SPLIT_PROPOSAL,
)
- """Helper for computing votes.
+
+ if motion not in qualified_motions:
+ return True
+ return False
+
+
+def tally_votes(voting: VotingContext, total_presents: int) -> bool:
+ """Helper for computing votes.
Unless motion is explicitly requiring qualified majority,
will use simple majority (also counts for informal votes)"""
if total_presents == 0:
@@ -170,12 +164,10 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool:
if motion is None:
return in_favor_count >= simple
- # Use qualified majority for "important" motions
if (
- motion.type in qualified_motions
- and in_favor_count >= qualified
- or motion.type not in qualified_motions
+ needs_simple_majority_type(motion.type)
and in_favor_count >= simple
+ or in_favor_count >= qualified
):
return True
@@ -251,10 +243,12 @@ def require_chair(actor: SessionActor) -> None:
# -------------- HANDLERS --------------
-def handle_submit_motion(
+def handle_delegate_submit_motion(
state: SessionLiveState, event: schemas.SubmitMotionEvent, actor: SessionActor
) -> SessionLiveState:
"""Handles/Maps all possible states through a motion"""
+ delegate = require_delegate(actor)
+
# Extract payload (as DelegateMotionSchema)
payload = event.payload
current_state = state.current_state
@@ -269,17 +263,15 @@ def handle_submit_motion(
):
raise InvalidProceduralMove("Submitting motions during caucuses is disabled")
- # if actor.delegation is None and actor.role != SessionRole.CHAIR:
- # raise InvalidProceduralMove("Delegation context is missing")
-
validate_motion_payload(payload, state)
context = MotionContext(
id=generate_next_motion_id(state),
- priority=get_motion_priority(payload.type),
+ priority=get_motion_priority(payload.type) or 1,
type=payload.type,
timestamp=datetime.now(UTC),
delegate_id=actor.delegation.id if actor.delegation is not None else None,
+ debate_type=payload.debate_type,
total_duration_minutes=payload.total_duration_minutes,
per_speaker_seconds=payload.per_speaker_seconds,
target_topic=payload.target_topic,
@@ -293,15 +285,13 @@ def handle_submit_motion(
def handle_submit_question(
state: SessionLiveState, event: schemas.SubmitQuestionEvent, actor: SessionActor
) -> SessionLiveState:
- # TODO: change this so Chair can also catalog motions for delegations
require_delegate(actor)
payload = event.payload
- validate_question_payload(payload, state)
context = QuestionContext(
id=generate_next_question_id(state),
- priority=get_question_priority(payload.type),
+ priority=get_question_priority(payload.type) or 1,
type=payload.type,
delegate_id=actor.delegation.id, # type:ignore since require_delegate assumes actor delegate is not none
details=payload.details,
@@ -658,12 +648,19 @@ def handle_resolve_motion(
if motion is None:
raise InvalidProceduralMove("Motion not found")
+ majority_type = (
+ enums.MajorityTypes.SIMPLE
+ if needs_simple_majority_type(motion.type)
+ else enums.MajorityTypes.QUALIFIED
+ )
+
if payload.action:
state.voting = VotingContext(
target_type=enums.VotingType.PROCEDURAL,
motion_in_vote=motion,
return_state=state.current_state,
voting_registry={},
+ majority=majority_type,
)
state.current_state = States.VOTING_EXECUTION
@@ -673,6 +670,46 @@ def handle_resolve_motion(
return state
+def handle_chair_submit_motion(
+ state: SessionLiveState, event: schemas.LogMotionEvent, actor: SessionActor
+) -> SessionLiveState:
+ require_chair(actor)
+
+ payload = event.payload
+ validate_motion_payload(payload=payload, state=state)
+
+ # create motion context
+ context = MotionContext(
+ id=generate_next_motion_id(state),
+ priority=get_motion_priority(payload.type) or 1,
+ type=payload.type,
+ delegate_id=payload.representation_id,
+ debate_type=payload.debate_type,
+ total_duration_minutes=payload.total_duration_minutes,
+ per_speaker_seconds=payload.per_speaker_seconds,
+ target_topic=payload.target_topic,
+ details=payload.details,
+ )
+
+ majority_type = (
+ enums.MajorityTypes.SIMPLE
+ if needs_simple_majority_type(payload.type)
+ else enums.MajorityTypes.QUALIFIED
+ )
+
+ # set state to be in voting execution
+ state.voting = VotingContext(
+ target_type=enums.VotingType.PROCEDURAL,
+ motion_in_vote=context,
+ return_state=state.current_state,
+ voting_registry={},
+ majority=majority_type,
+ )
+
+ state.current_state = States.VOTING_EXECUTION
+ return state
+
+
def handle_set_agenda(
state: SessionLiveState, event: schemas.SetAgendaEvent, actor: SessionActor
) -> SessionLiveState: ...
@@ -814,9 +851,8 @@ def handle_insert_queue(
SessionLiveState, # Return type
]
-# TODO: check if list has all events, since it's generated by codex
EVENT_HANDLERS: dict[DelegateEvents | ChairEvents, EventHandler] = {
- DelegateEvents.SUBMIT_MOTION: handle_submit_motion,
+ DelegateEvents.SUBMIT_MOTION: handle_delegate_submit_motion,
DelegateEvents.SUBMIT_QUESTION: handle_submit_question,
DelegateEvents.JOIN_QUEUE: handle_join_queue,
DelegateEvents.LEAVE_QUEUE: handle_leave_queue,
@@ -829,6 +865,7 @@ def handle_insert_queue(
ChairEvents.CLOSE_INFORMAL_VOTING: handle_close_informal_voting,
ChairEvents.CLOSE_PROCEDURAL_VOTING: handle_close_procedural_voting,
ChairEvents.RESOLVE_MOTION: handle_resolve_motion,
+ ChairEvents.LOG_MOTION: handle_chair_submit_motion,
ChairEvents.SET_AGENDA: handle_set_agenda,
ChairEvents.SET_AGENDA_ITEM: handle_set_agenda_item,
ChairEvents.MARK_AGENDA_ITEM: handle_mark_agenda_item,
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index c4d6c24..baf1321 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -40,6 +40,7 @@ class ChairEvents(StrEnum):
INCREASE_TIMER = "IncreaseTimerEvent"
OPEN_INFORMAL_VOTING = "OpenInformalVotingEvent"
RESOLVE_MOTION = "ResolveMotionEvent"
+ LOG_MOTION = "LogMotionEvent"
CLOSE_PROCEDURAL_VOTING = "CloseProceduralVotingEvent"
CLOSE_INFORMAL_VOTING = "CloseInformalVotingEvent"
@@ -121,3 +122,10 @@ class VotingType(StrEnum):
INFORMAL = "Informal"
PROCEDURAL = "Procedural"
SUBSTANTIVE = "Substantive"
+
+
+class MotionDecision(StrEnum):
+ """Tracks decision for LogMotionEvent for chair"""
+
+ ACCEPT = "Accept"
+ DENY = "Deny"
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index d92c888..4939518 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -64,22 +64,23 @@ class VotingContext(BaseModel):
return_state: enums.States
voting_registry: dict[int, enums.VotingChoice] = {}
majority: enums.MajorityTypes | None = None
-
+
motion_in_vote: MotionContext | None = None
# resolution_in_vote: ResolutionContext
allow_veto_power: bool = False
-
+
def is_choice_allowed(self, choice: enums.VotingChoice) -> bool:
- if self.target_type == enums.VotingType.PROCEDURAL:
+ if self.target_type == enums.VotingType.PROCEDURAL:
return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST)
-
+
return True
+
class DebateContext(BaseModel):
debate_type: enums.DebateTypes
return_state: enums.States
- total_duration_seconds: int | None = None # check if its needed
+ total_duration_seconds: int | None = None # check if its needed
total_speeches: int | None = None
per_speaker_seconds: int | None = None
expires_at: datetime | None = None
diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py
index 7c1ef50..8b0b5a8 100644
--- a/backend/app/session/schemas.py
+++ b/backend/app/session/schemas.py
@@ -14,19 +14,22 @@ class SessionCreationSchema(BaseModel):
name: str | None = None
-# --- Delegate Payloads ---
-# TODO: refactor this to only reflect the payload received by delegates, with MotionModel being a separated entity
-class DelegateMotionPayload(BaseModel):
+class MotionPayload(BaseModel):
+ """General motion payload. Used on Delegate and Chair payloads"""
+
type: enums.Motions
debate_type: enums.DebateTypes | None = None
-
total_duration_minutes: int | None = None
per_speaker_seconds: int | None = None
target_topic: str | None = None
-
details: str | None = None
+# --- Delegate Payloads ---
+class DelegateMotionPayload(MotionPayload):
+ pass
+
+
class DelegateQuestionPayload(BaseModel):
type: enums.Questions
details: str | None = None
@@ -75,6 +78,13 @@ class AnswerRollCallEvent(BaseModel):
# --- Chair Payloads ---
+class ChairMotionPayload(MotionPayload):
+ """Extended payload for motions. Used to log motions"""
+
+ representation_id: int
+ decision: enums.MotionDecision
+
+
class ChairIncreaseTimerPayload(BaseModel):
seconds: int = 5
@@ -140,6 +150,11 @@ class MarkRollCallBulkPayload(BaseModel):
# --- Chair Events ---
+class LogMotionEvent(BaseModel):
+ type: Literal[enums.ChairEvents.LOG_MOTION]
+ payload: ChairMotionPayload
+
+
class OpenSessionEvent(BaseModel):
type: Literal[enums.ChairEvents.OPEN_SESSION]
payload: EmptyPayload
@@ -238,6 +253,7 @@ class DeleteAgendaItemEvent(BaseModel):
| AnswerRollCallEvent
| JoinQueueEvent
| LeaveQueueEvent
+ | LogMotionEvent
| OpenSessionEvent
| CloseSessionEvent
| IncreaseTimerEvent
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index 21f4b70..ab058bf 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -31,7 +31,6 @@ def submit_debate_motion_event(
type=enums.DelegateEvents.SUBMIT_MOTION,
payload=sch.DelegateMotionPayload(
type=enums.Motions.CHANGE_DEBATE_TYPE,
- delegate=delegate_actor.delegation.id, # type: ignore[union-attr]
debate_type=enums.DebateTypes.MODERATED_DEBATE,
total_duration_minutes=10,
per_speaker_seconds=60,
@@ -39,6 +38,21 @@ def submit_debate_motion_event(
)
+@pytest.fixture
+def log_motion_event(chair_actor: md.SessionActor) -> sch.LogMotionEvent:
+ return sch.LogMotionEvent(
+ type=enums.ChairEvents.LOG_MOTION,
+ payload=sch.ChairMotionPayload(
+ type=enums.Motions.CHANGE_DEBATE_TYPE,
+ debate_type=enums.DebateTypes.MODERATED_DEBATE,
+ total_duration_minutes=10,
+ per_speaker_seconds=60,
+ representation_id=1,
+ decision=enums.MotionDecision.ACCEPT,
+ ),
+ )
+
+
@pytest.fixture
def submit_question_event(delegate_actor: md.SessionActor) -> sch.SubmitQuestionEvent:
return sch.SubmitQuestionEvent(
@@ -272,10 +286,28 @@ def test_delegate_cannot_submit_motion_outside_allowed_phase(
engine.dispatch(session_state, submit_debate_motion_event, delegate_actor)
-@pytest.mark.xfail(
- strict=True,
- reason="handle_submit_motion does not currently reject chair actors.",
-)
+def test_delegate_cannot_submit_chair_motion(
+ engine: eng.SessionEngine,
+ session_state: md.SessionLiveState,
+ log_motion_event: sch.LogMotionEvent,
+ delegate_actor: md.SessionActor,
+) -> None:
+ with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"):
+ engine.dispatch(session_state, log_motion_event, delegate_actor)
+
+
+def test_chair_can_log_motion(
+ engine: eng.SessionEngine,
+ session_state: md.SessionLiveState,
+ log_motion_event: sch.LogMotionEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ state = engine.dispatch(session_state, log_motion_event, chair_actor)
+ assert state.current_state == enums.States.VOTING_EXECUTION
+ assert state.voting is not None and state.voting.motion_in_vote is not None
+ assert state.voting.motion_in_vote.type == log_motion_event.payload.type
+
+
def test_chair_cannot_submit_delegate_motion(
engine: eng.SessionEngine,
open_gsl_state: md.SessionLiveState,
@@ -526,7 +558,9 @@ def test_chair_can_close_informal_voting(
close_informal_voting_event: sch.CloseInformalVotingEvent,
chair_actor: md.SessionActor,
) -> None:
- state = engine.dispatch(informal_voting_state, close_informal_voting_event, chair_actor)
+ state = engine.dispatch(
+ informal_voting_state, close_informal_voting_event, chair_actor
+ )
assert state.current_state == enums.States.OPEN_GSL
assert state.voting is None
From 3209848a489045f78b48eacfaef0a42711402c2f Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 20:39:38 -0300
Subject: [PATCH 10/25] docs: update events docs
---
docs/realtime/events-and-payloads.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/realtime/events-and-payloads.md b/docs/realtime/events-and-payloads.md
index b339380..63d4ca7 100644
--- a/docs/realtime/events-and-payloads.md
+++ b/docs/realtime/events-and-payloads.md
@@ -69,6 +69,10 @@ Privilege`.
- Moves an allowed session to `Finished` and clears active debate and timer
data.
+- `LogMotionEvent`
+ - Payload: same variables from `SubmitMotionEvent` plus `representation_id` and `decision` (`Accept` or `DENY`)
+ - Records the decision from chair for a specific motion, not sent in the system by any delegation
+
- `ToggleTimerEvent`
- Payload: optional `toggle` (defaults to `true`).
- Starts or pauses the timer. The current implementation toggles based on
From 1b7ca3b1e9c316ddae08f784f4fc210471b42fc5 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 20:55:39 -0300
Subject: [PATCH 11/25] refactor(backend): extract passed motion application
---
backend/app/session/engine.py | 196 +++++++++++++++++-----------------
1 file changed, 99 insertions(+), 97 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 0d502c0..4b7d5c2 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -501,6 +501,104 @@ def handle_close_informal_voting(
return state
+def apply_passed_motion(state: SessionLiveState, motion: MotionContext) -> None:
+ """Apply a passed procedural motion to the live session state in place."""
+ next_state = state.current_state # as fallback
+ state.current_speaker = None
+ state.timer_is_running = False
+ state.timer_expiration = None
+
+ # 1st block: change of debate motions
+ if motion.type == Motions.CHANGE_DEBATE_TYPE and motion.debate_type is not None:
+ state.caucus_list = []
+ state.current_speaker = None
+ duration_seconds = (
+ (motion.total_duration_minutes * 60)
+ if motion.total_duration_minutes is not None
+ else 600
+ ) # defaults to 10 minutes as fallback
+
+ match motion.debate_type:
+ case DebateTypes.MODERATED_DEBATE:
+ next_state = States.MODERATED_CAUCUS
+ state.debate = DebateContext(
+ debate_type=DebateTypes.MODERATED_DEBATE,
+ return_state=state.current_state,
+ total_duration_seconds=duration_seconds,
+ per_speaker_seconds=motion.per_speaker_seconds,
+ expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds),
+ )
+ reset_timer(
+ state,
+ motion.per_speaker_seconds
+ if motion.per_speaker_seconds is not None
+ else 60,
+ )
+
+ case DebateTypes.UNMODERATED_DEBATE:
+ next_state = States.UNMODERATED_CAUCUS
+ state.debate = DebateContext(
+ debate_type=DebateTypes.UNMODERATED_DEBATE,
+ return_state=state.current_state,
+ total_duration_seconds=duration_seconds,
+ per_speaker_seconds=None,
+ expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds),
+ )
+ reset_timer(state) # should not display per_speaker timer
+
+ case DebateTypes.SPEAKERS_LIST:
+ next_state = States.OPEN_GSL
+ state.debate = None
+ reset_timer(state, state.gsl_default_time_seconds)
+
+ case _:
+ raise InvalidProceduralMove("Undefined debate type")
+
+ match motion.type:
+ case Motions.POSTPONE_SESSION:
+ # TODO: create a type of force_to_database function here? or query if it's a postpone session on service.py
+ pass
+ case Motions.REOPEN_SESSION:
+ # TODO: same as above
+ pass
+ case Motions.TOUR_DE_TABLE:
+ # note: seems like belongs to debate type
+ pass
+ case Motions.END_DEBATE:
+ # clean gsl list
+ state.gsl_queue = []
+ state.debate = None
+ reset_timer(state)
+ next_state = States.VOTING_PROCEDURES # or VOTING_PREPARATION
+
+ case Motions.VOTE_AMENDMENT:
+ # note: seems more like an informal consultation
+ pass
+ case Motions.VOTE_BY_ROLL_CALL:
+ # will define the VotingContext for resolutions
+ pass
+ case Motions.CLOSE_SPEAKERS_LIST:
+ next_state = States.CLOSED_GSL
+
+ case Motions.REOPEN_SPEAKERS_LIST:
+ next_state = States.OPEN_GSL
+
+ case Motions.SPLIT_PROPOSAL:
+ # note: seems more like an informal consultation
+ pass
+ case Motions.CHANGE_TOPIC:
+ # note: seems more like an informal consultation
+ pass
+ case Motions.QUORUM:
+ state.roll_call = RollCallContext(registry={})
+ next_state = States.ROLL_CALL
+ case _:
+ raise InvalidProceduralMove("Undefined motion type")
+
+ # additional case: if we went from GSL to something, save gsl structures
+ state.current_state = next_state
+
+
def handle_close_procedural_voting(
state: SessionLiveState,
event: schemas.CloseProceduralVotingEvent,
@@ -525,104 +623,8 @@ def handle_close_procedural_voting(
present = count_present_delegations(state)
passed = tally_votes(state.voting, present)
- # TODO: pass everything here into a helper "apply_passed_motion" and "apply_change_debate"
if passed:
- next_state = state.current_state # as fallback
- state.current_speaker = None
- state.timer_is_running = False
- state.timer_expiration = None
-
- # 1st block: change of debate motions
- if motion.type == Motions.CHANGE_DEBATE_TYPE and motion.debate_type is not None:
- state.caucus_list = []
- state.current_speaker = None
- duration_seconds = (
- (motion.total_duration_minutes * 60)
- if motion.total_duration_minutes is not None
- else 600
- ) # defaults to 10 minutes as fallback
-
- match motion.debate_type:
- case DebateTypes.MODERATED_DEBATE:
- next_state = States.MODERATED_CAUCUS
- state.debate = DebateContext(
- debate_type=DebateTypes.MODERATED_DEBATE,
- return_state=state.current_state,
- total_duration_seconds=duration_seconds,
- per_speaker_seconds=motion.per_speaker_seconds,
- expires_at=datetime.now(UTC)
- + timedelta(seconds=duration_seconds),
- )
- reset_timer(
- state,
- motion.per_speaker_seconds
- if motion.per_speaker_seconds is not None
- else 60,
- )
-
- case DebateTypes.UNMODERATED_DEBATE:
- next_state = States.UNMODERATED_CAUCUS
- state.debate = DebateContext(
- debate_type=DebateTypes.UNMODERATED_DEBATE,
- return_state=state.current_state,
- total_duration_seconds=duration_seconds,
- per_speaker_seconds=None,
- expires_at=datetime.now(UTC)
- + timedelta(seconds=duration_seconds),
- )
- reset_timer(state) # should not display per_speaker timer
-
- case DebateTypes.SPEAKERS_LIST:
- next_state = States.OPEN_GSL
- state.debate = None
- reset_timer(state, state.gsl_default_time_seconds)
-
- case _:
- raise InvalidProceduralMove("Undefined debate type")
-
- match motion.type:
- case Motions.POSTPONE_SESSION:
- # TODO: create a type of force_to_database function here? or query if it's a postpone session on service.py
- pass
- case Motions.REOPEN_SESSION:
- # TODO: same as above
- pass
- case Motions.TOUR_DE_TABLE:
- # note: seems like belongs to debate type
- pass
- case Motions.END_DEBATE:
- # clean gsl list
- state.gsl_queue = []
- state.debate = None
- reset_timer(state)
- next_state = States.VOTING_PROCEDURES # or VOTING_PREPARATION
-
- case Motions.VOTE_AMENDMENT:
- # note: seems more like an informal consultation
- pass
- case Motions.VOTE_BY_ROLL_CALL:
- # will define the VotingContext for resolutions
- pass
- case Motions.CLOSE_SPEAKERS_LIST:
- next_state = States.CLOSED_GSL
-
- case Motions.REOPEN_SPEAKERS_LIST:
- next_state = States.OPEN_GSL
-
- case Motions.SPLIT_PROPOSAL:
- # note: seems more like an informal consultation
- pass
- case Motions.CHANGE_TOPIC:
- # note: seems more like an informal consultation
- pass
- case Motions.QUORUM:
- state.roll_call = RollCallContext(registry={})
- next_state = States.ROLL_CALL
- case _:
- raise InvalidProceduralMove("Undefined motion type")
-
- # additional case: if we went from GSL to something, save gsl structures
- state.current_state = next_state
+ apply_passed_motion(state, motion)
else:
# motion failed, so return to last state
state.current_state = state.voting.return_state
From 490f3bb6936345a0dbf69e0ccc50bc6f5dfca5d7 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 20:56:41 -0300
Subject: [PATCH 12/25] fix(backend): timestamp chair logged motions
---
backend/app/session/engine.py | 1 +
backend/app/tests/session/test_engine.py | 4 ++++
2 files changed, 5 insertions(+)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 4b7d5c2..727e5a3 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -685,6 +685,7 @@ def handle_chair_submit_motion(
id=generate_next_motion_id(state),
priority=get_motion_priority(payload.type) or 1,
type=payload.type,
+ timestamp=datetime.now(UTC),
delegate_id=payload.representation_id,
debate_type=payload.debate_type,
total_duration_minutes=payload.total_duration_minutes,
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index ab058bf..6762ee1 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -1,3 +1,5 @@
+from datetime import UTC, datetime
+
import pytest
import app.session.engine as eng
@@ -151,6 +153,7 @@ def close_speakers_list_motion(
id=1,
priority=1,
type=enums.Motions.CLOSE_SPEAKERS_LIST,
+ timestamp=datetime.now(UTC),
delegate_id=delegate_actor.delegation.id, # type: ignore[union-attr]
)
@@ -161,6 +164,7 @@ def reopen_speakers_list_motion(delegate_actor: md.SessionActor) -> md.MotionCon
id=1,
priority=1,
type=enums.Motions.REOPEN_SPEAKERS_LIST,
+ timestamp=datetime.now(UTC),
delegate_id=delegate_actor.delegation.id, # type: ignore[union-attr]
)
From c6de05164fa3e93bb6647865830818684d73db9f Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 20:57:30 -0300
Subject: [PATCH 13/25] refactor(backend): remove unused variable
---
backend/app/session/engine.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 727e5a3..f8c8f9f 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -247,7 +247,7 @@ def handle_delegate_submit_motion(
state: SessionLiveState, event: schemas.SubmitMotionEvent, actor: SessionActor
) -> SessionLiveState:
"""Handles/Maps all possible states through a motion"""
- delegate = require_delegate(actor)
+ require_delegate(actor)
# Extract payload (as DelegateMotionSchema)
payload = event.payload
From 38707bd5b0081aab3596e4fdff1efe415c8ebd62 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:59:07 -0300
Subject: [PATCH 14/25] feat(backend): add finish caucus event
Co-authored-by: Codex
---
backend/app/session/engine.py | 21 +++++++++
backend/app/session/enums.py | 1 +
backend/app/session/schemas.py | 6 +++
backend/app/tests/session/test_engine.py | 58 ++++++++++++++++++++++++
4 files changed, 86 insertions(+)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index f8c8f9f..eb3a095 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -634,6 +634,26 @@ def handle_close_procedural_voting(
return state
+def handle_finish_caucus(
+ state: SessionLiveState, event: schemas.FinishCaucusEvent, actor: SessionActor
+) -> SessionLiveState:
+ require_chair(actor)
+
+ if state.debate is None or state.current_state not in {
+ States.MODERATED_CAUCUS,
+ States.UNMODERATED_CAUCUS,
+ }:
+ raise InvalidProceduralMove("No active caucus")
+
+ return_state = state.debate.return_state
+ state.current_speaker = None
+ state.caucus_list = []
+ state.debate = None
+ reset_timer(state)
+ state.current_state = return_state
+ return state
+
+
# handles setting state into VOTING_EXECUTION or rejecting the motion
def handle_resolve_motion(
state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor
@@ -867,6 +887,7 @@ def handle_insert_queue(
ChairEvents.OPEN_INFORMAL_VOTING: handle_open_informal_voting,
ChairEvents.CLOSE_INFORMAL_VOTING: handle_close_informal_voting,
ChairEvents.CLOSE_PROCEDURAL_VOTING: handle_close_procedural_voting,
+ ChairEvents.FINISH_CAUCUS: handle_finish_caucus,
ChairEvents.RESOLVE_MOTION: handle_resolve_motion,
ChairEvents.LOG_MOTION: handle_chair_submit_motion,
ChairEvents.SET_AGENDA: handle_set_agenda,
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index baf1321..7afe84c 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -43,6 +43,7 @@ class ChairEvents(StrEnum):
LOG_MOTION = "LogMotionEvent"
CLOSE_PROCEDURAL_VOTING = "CloseProceduralVotingEvent"
CLOSE_INFORMAL_VOTING = "CloseInformalVotingEvent"
+ FINISH_CAUCUS = "FinishCaucusEvent"
# Disruptive events (i.e manual override events)
MANUAL_PHASE_SET = "SetPhaseEvent"
diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py
index 8b0b5a8..bfc02c2 100644
--- a/backend/app/session/schemas.py
+++ b/backend/app/session/schemas.py
@@ -210,6 +210,11 @@ class CloseProceduralVotingEvent(BaseModel):
payload: EmptyPayload
+class FinishCaucusEvent(BaseModel):
+ type: Literal[enums.ChairEvents.FINISH_CAUCUS]
+ payload: EmptyPayload
+
+
class MarkRollCallEvent(BaseModel):
type: Literal[enums.ChairEvents.MARK_ROLLCALL]
payload: MarkRollCallPayload
@@ -261,6 +266,7 @@ class DeleteAgendaItemEvent(BaseModel):
| OpenInformalVotingEvent
| CloseProceduralVotingEvent
| CloseInformalVotingEvent
+ | FinishCaucusEvent
| ResolveMotionEvent
| SpeakerEvent
| SetAgendaEvent
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index 6762ee1..349d779 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -145,6 +145,14 @@ def close_procedural_voting_event() -> sch.CloseProceduralVotingEvent:
)
+@pytest.fixture
+def finish_caucus_event() -> sch.FinishCaucusEvent:
+ return sch.FinishCaucusEvent(
+ type=enums.ChairEvents.FINISH_CAUCUS,
+ payload=sch.EmptyPayload(),
+ )
+
+
@pytest.fixture
def close_speakers_list_motion(
delegate_actor: md.SessionActor,
@@ -700,6 +708,56 @@ def test_delegate_cannot_close_procedural_vote(
)
+def test_chair_can_finish_caucus_and_restore_original_gsl_state(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ finish_caucus_event: sch.FinishCaucusEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.MODERATED_CAUCUS
+ open_gsl_state.current_speaker = 0
+ open_gsl_state.caucus_list = [0, 1]
+ open_gsl_state.timer_is_running = True
+ open_gsl_state.timer_remaining_seconds = 30
+ open_gsl_state.debate = md.DebateContext(
+ debate_type=enums.DebateTypes.MODERATED_DEBATE,
+ return_state=enums.States.OPEN_GSL,
+ total_duration_seconds=600,
+ per_speaker_seconds=60,
+ expires_at=datetime.now(UTC),
+ )
+
+ state = engine.dispatch(open_gsl_state, finish_caucus_event, chair_actor)
+
+ assert state.current_state == enums.States.OPEN_GSL
+ assert state.debate is None
+ assert state.current_speaker is None
+ assert state.caucus_list == []
+ assert state.timer_is_running is False
+ assert state.timer_expiration is None
+ assert state.timer_remaining_seconds == 0
+
+
+def test_delegate_cannot_finish_caucus(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ finish_caucus_event: sch.FinishCaucusEvent,
+ delegate_actor: md.SessionActor,
+) -> None:
+ with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"):
+ engine.dispatch(open_gsl_state, finish_caucus_event, delegate_actor)
+
+
+def test_chair_cannot_finish_without_active_caucus(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ finish_caucus_event: sch.FinishCaucusEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ with pytest.raises(eng.InvalidProceduralMove, match="No active caucus"):
+ engine.dispatch(open_gsl_state, finish_caucus_event, chair_actor)
+
+
def test_tally_votes_correctly_marks_success_simple(
reopen_speakers_list_motion: md.MotionContext,
) -> None:
From 3bb396145d119a98f7c277183b23a27c6e29fd58 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:39:40 -0300
Subject: [PATCH 15/25] feat(backend): add debate-aware speaker controls
---
backend/app/session/engine.py | 121 +++++++++-----
backend/app/session/enums.py | 6 +-
backend/app/session/models.py | 2 -
backend/app/session/schemas.py | 36 +++--
backend/app/tests/session/test_engine.py | 191 +++++++++++++++++++----
5 files changed, 271 insertions(+), 85 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index eb3a095..51240d6 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -373,7 +373,7 @@ def handle_open_session(
raise InvalidProceduralMove("Session can only be opened from setup")
state.current_state = States.ROLL_CALL
- state.roll_call = RollCallContext(registry={}, current_delegation=None)
+ state.roll_call = RollCallContext(registry={})
state.voting_choice = {}
state.gsl_queue = []
state.current_speaker = None
@@ -501,9 +501,13 @@ def handle_close_informal_voting(
return state
-def apply_passed_motion(state: SessionLiveState, motion: MotionContext) -> None:
+def apply_passed_motion(
+ state: SessionLiveState,
+ motion: MotionContext,
+ return_state: States
+) -> None:
"""Apply a passed procedural motion to the live session state in place."""
- next_state = state.current_state # as fallback
+ next_state = return_state # as fallback
state.current_speaker = None
state.timer_is_running = False
state.timer_expiration = None
@@ -523,7 +527,7 @@ def apply_passed_motion(state: SessionLiveState, motion: MotionContext) -> None:
next_state = States.MODERATED_CAUCUS
state.debate = DebateContext(
debate_type=DebateTypes.MODERATED_DEBATE,
- return_state=state.current_state,
+ return_state=return_state,
total_duration_seconds=duration_seconds,
per_speaker_seconds=motion.per_speaker_seconds,
expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds),
@@ -539,7 +543,7 @@ def apply_passed_motion(state: SessionLiveState, motion: MotionContext) -> None:
next_state = States.UNMODERATED_CAUCUS
state.debate = DebateContext(
debate_type=DebateTypes.UNMODERATED_DEBATE,
- return_state=state.current_state,
+ return_state=return_state,
total_duration_seconds=duration_seconds,
per_speaker_seconds=None,
expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds),
@@ -556,14 +560,13 @@ def apply_passed_motion(state: SessionLiveState, motion: MotionContext) -> None:
match motion.type:
case Motions.POSTPONE_SESSION:
- # TODO: create a type of force_to_database function here? or query if it's a postpone session on service.py
pass
case Motions.REOPEN_SESSION:
- # TODO: same as above
pass
case Motions.TOUR_DE_TABLE:
- # note: seems like belongs to debate type
- pass
+ next_state = States.TOUR_DE_TABLE
+ state.caucus_list = [del_id for del_id, choice in state.roll_call.registry.items() if choice in (enums.RollCallChoice.PRESENT, enums.RollCallChoice.PRESENT_AND_VOTING)]
+
case Motions.END_DEBATE:
# clean gsl list
state.gsl_queue = []
@@ -624,7 +627,7 @@ def handle_close_procedural_voting(
passed = tally_votes(state.voting, present)
if passed:
- apply_passed_motion(state, motion)
+ apply_passed_motion(state, motion, return_state=state.voting.return_state)
else:
# motion failed, so return to last state
state.current_state = state.voting.return_state
@@ -779,24 +782,81 @@ def handle_manual_phase_set(
) -> SessionLiveState: ...
-def handle_choose_speaker(
- state: SessionLiveState, event: schemas.SpeakerEvent, actor: SessionActor
+def handle_next_speaker(
+ state: SessionLiveState, event: schemas.NextSpeakerEvent, actor: SessionActor
) -> SessionLiveState:
require_chair(actor)
- seconds = event.payload.seconds or get_default_speaker_seconds(state)
- if (
- event.payload.speaker_id is None
- and state.current_state in (States.OPEN_GSL, States.CLOSED_GSL)
- and state.gsl_queue
- ):
+ if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
+ if not state.gsl_queue:
+ state.current_speaker = None
+ reset_timer(state)
+ return state
state.current_speaker = state.gsl_queue.pop(0)
+ reset_timer(state, state.gsl_default_time_seconds)
+ return state
+
+ if state.current_state == States.TOUR_DE_TABLE:
+ if not state.caucus_list:
+ state.current_speaker = None
+ reset_timer(state)
+ return state
+ state.current_speaker = state.caucus_list.pop(0)
+ reset_timer(state, state.gsl_default_time_seconds)
+ return state
+
+ if state.current_state == States.MODERATED_CAUCUS:
+ raise InvalidProceduralMove("Chair must grant floor during moderated caucus")
+
+ raise InvalidProceduralMove("Cannot advance speaker right now")
+
+
+def handle_add_gsl_speaker(
+ state: SessionLiveState, event: schemas.AddGslSpeakerEvent, actor: SessionActor
+) -> SessionLiveState:
+ require_chair(actor)
+
+ if state.current_state not in {States.OPEN_GSL, States.CLOSED_GSL}:
+ raise InvalidProceduralMove("Can only add speakers to the GSL")
+
+ representation_id = event.payload.representation_id
+ if representation_id not in state.delegations:
+ raise InvalidProceduralMove("Representation not found")
+ if representation_id in state.gsl_queue:
+ raise InvalidProceduralMove("Representation already in GSL queue")
+
+ state.gsl_queue.append(representation_id)
+ return state
+
+
+def handle_grant_floor(
+ state: SessionLiveState, event: schemas.GrantFloorEvent, actor: SessionActor
+) -> SessionLiveState:
+ require_chair(actor)
+
+ representation_id = event.payload.representation_id
+ if representation_id not in state.delegations:
+ raise InvalidProceduralMove("Representation not found")
+
+ if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
+ if representation_id in state.gsl_queue:
+ state.gsl_queue.remove(representation_id)
+ seconds = event.payload.seconds or state.gsl_default_time_seconds
+ elif state.current_state == States.MODERATED_CAUCUS:
+ if state.debate is None:
+ raise InvalidProceduralMove("No active moderated caucus")
+ seconds = event.payload.seconds or state.debate.per_speaker_seconds or 60
+ elif state.current_state == States.TOUR_DE_TABLE:
+ if representation_id in state.caucus_list:
+ state.caucus_list.remove(representation_id)
+ seconds = event.payload.seconds or state.gsl_default_time_seconds
+ elif state.current_state == States.UNMODERATED_CAUCUS:
+ raise InvalidProceduralMove("Cannot grant floor during unmoderated caucus")
else:
- state.current_speaker = event.payload.speaker_id
+ raise InvalidProceduralMove("Cannot grant floor right now")
- state.timer_is_running = False
- state.timer_expiration = None # will be calculated when timer is toggled
- state.timer_remaining_seconds = seconds or 60 # default to something
+ state.current_speaker = representation_id
+ reset_timer(state, seconds)
return state
@@ -856,18 +916,6 @@ def handle_close_roll_call(
return state
-def handle_insert_queue(
- state: SessionLiveState, event: schemas.ChairInsertQueueEvent, actor: SessionActor
-) -> SessionLiveState:
- require_chair(actor)
- del_id: int = event.payload.target
- delegate = state.delegations.get(del_id)
- if delegate is None:
- raise InvalidProceduralMove("Delegate not found")
- state.gsl_queue.append(delegate.id)
- return state
-
-
# Signature for events/handlers, uses legacy(ish) 3.11 TypeAlias
EventHandler: TypeAlias = Callable[
[SessionLiveState, Any, SessionActor], # overall signature
@@ -896,11 +944,12 @@ def handle_insert_queue(
ChairEvents.DELETE_AGENDA_ITEM: handle_delete_agenda_item,
ChairEvents.MANUAL_PHASE_SET: handle_manual_phase_set,
ChairEvents.CLOSE_SESSION: handle_close_session,
- ChairEvents.CHOOSE_SPEAKER: handle_choose_speaker,
+ ChairEvents.NEXT_SPEAKER: handle_next_speaker,
+ ChairEvents.ADD_GSL_SPEAKER: handle_add_gsl_speaker,
+ ChairEvents.GRANT_FLOOR: handle_grant_floor,
ChairEvents.MARK_ROLLCALL: handle_mark_roll_call,
ChairEvents.MARK_ROLLCALL_BULK: handle_mark_roll_call_bulk,
ChairEvents.CLOSE_ROLLCALL: handle_close_roll_call,
- ChairEvents.INSERT_QUEUE: handle_insert_queue,
}
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index 7afe84c..fd51869 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -19,6 +19,7 @@ class States(StrEnum):
# States based on motions, resolutions, etc
MODERATED_CAUCUS = "Moderated Caucus"
UNMODERATED_CAUCUS = "Unmoderated Caucus"
+ TOUR_DE_TABLE = "Tour de Table"
VOTING_EXECUTION = "Voting Execution" # this handles either "motion to moderated caucus" or "motion to voting procedures", for example
BETWEEN_DEBATES = "Between Debates"
@@ -54,11 +55,12 @@ class ChairEvents(StrEnum):
MARK_AGENDA_ITEM = "MarkAgendaItemEvent"
DELETE_AGENDA_ITEM = "DeleteAgendaItemEvent"
SET_AGENDA = "SetAgenda"
- CHOOSE_SPEAKER = "SpeakerEvent"
+ NEXT_SPEAKER = "NextSpeakerEvent"
+ ADD_GSL_SPEAKER = "AddGslSpeakerEvent"
+ GRANT_FLOOR = "GrantFloorEvent"
MARK_ROLLCALL = "MarkRollCallEvent"
MARK_ROLLCALL_BULK = "MarkRollCallBulkEvent"
CLOSE_ROLLCALL = "CloseRollCallEvent"
- INSERT_QUEUE = "InsertQueueEvent"
# --- Additional Info ---
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 4939518..83cac5b 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -89,7 +89,6 @@ class DebateContext(BaseModel):
class RollCallContext(BaseModel):
registry: dict[int, enums.RollCallChoice] = {} # Delegation Id as key
- current_delegation: int | None = None # perhaps not needed
class AgendaItem(BaseModel):
@@ -121,7 +120,6 @@ class SessionLiveState(BaseModel):
gsl_default_time_seconds: int = 60
# Caucus variables
- # TODO: how to add a popup placard that fades away after some moment in frontend? related to CHOOSE_SPEAKER
caucus_list: list[
int
] = [] # special list that is only used during moderated caucus, has different semantic functionality than gsl queue
diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py
index bfc02c2..79bc205 100644
--- a/backend/app/session/schemas.py
+++ b/backend/app/session/schemas.py
@@ -105,9 +105,9 @@ class ChairResolveMotionPayload(BaseModel):
action: bool
-class ChairForceSpeakerPayload(BaseModel):
- speaker_id: int | None = None # if none, will pass onto next speaker
- seconds: int | None = None # if none, will be based on the current seconds
+class GrantFloorPayload(BaseModel):
+ representation_id: int
+ seconds: int | None = Field(default=None, ge=1)
class ChairSetAgendaPayload(BaseModel):
@@ -118,8 +118,8 @@ class ChairSetPhasePayload(BaseModel):
target_phase: enums.States
-class ChairInsertQueuePayload(BaseModel):
- target: int # Delegate Id
+class AddGslSpeakerPayload(BaseModel):
+ representation_id: int
class SetAgendaItemPayload(BaseModel):
@@ -185,9 +185,19 @@ class ResolveMotionEvent(BaseModel):
payload: ChairResolveMotionPayload
-class SpeakerEvent(BaseModel):
- type: Literal[enums.ChairEvents.CHOOSE_SPEAKER]
- payload: ChairForceSpeakerPayload
+class NextSpeakerEvent(BaseModel):
+ type: Literal[enums.ChairEvents.NEXT_SPEAKER]
+ payload: EmptyPayload
+
+
+class AddGslSpeakerEvent(BaseModel):
+ type: Literal[enums.ChairEvents.ADD_GSL_SPEAKER]
+ payload: AddGslSpeakerPayload
+
+
+class GrantFloorEvent(BaseModel):
+ type: Literal[enums.ChairEvents.GRANT_FLOOR]
+ payload: GrantFloorPayload
class SetAgendaEvent(BaseModel):
@@ -230,11 +240,6 @@ class CloseRollCallEvent(BaseModel):
payload: EmptyPayload
-class ChairInsertQueueEvent(BaseModel):
- type: Literal[enums.ChairEvents.INSERT_QUEUE]
- payload: ChairInsertQueuePayload
-
-
class MarkAgendaItemEvent(BaseModel):
type: Literal[enums.ChairEvents.MARK_AGENDA_ITEM]
payload: MarkAgendaItemPayload
@@ -268,7 +273,9 @@ class DeleteAgendaItemEvent(BaseModel):
| CloseInformalVotingEvent
| FinishCaucusEvent
| ResolveMotionEvent
- | SpeakerEvent
+ | NextSpeakerEvent
+ | AddGslSpeakerEvent
+ | GrantFloorEvent
| SetAgendaEvent
| SetAgendaItemEvent
| MarkAgendaItemEvent
@@ -276,7 +283,6 @@ class DeleteAgendaItemEvent(BaseModel):
| SetPhaseEvent
| MarkRollCallEvent
| CloseRollCallEvent
- | ChairInsertQueueEvent
| MarkRollCallBulkEvent,
Field(discriminator="type"),
]
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index 349d779..b392460 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -201,10 +201,26 @@ def resolve_motion_event() -> sch.ResolveMotionEvent:
@pytest.fixture
-def choose_speaker_event() -> sch.SpeakerEvent:
- return sch.SpeakerEvent(
- type=enums.ChairEvents.CHOOSE_SPEAKER,
- payload=sch.ChairForceSpeakerPayload(speaker_id=1, seconds=45),
+def next_speaker_event() -> sch.NextSpeakerEvent:
+ return sch.NextSpeakerEvent(
+ type=enums.ChairEvents.NEXT_SPEAKER,
+ payload=sch.EmptyPayload(),
+ )
+
+
+@pytest.fixture
+def add_gsl_speaker_event() -> sch.AddGslSpeakerEvent:
+ return sch.AddGslSpeakerEvent(
+ type=enums.ChairEvents.ADD_GSL_SPEAKER,
+ payload=sch.AddGslSpeakerPayload(representation_id=1),
+ )
+
+
+@pytest.fixture
+def grant_floor_event() -> sch.GrantFloorEvent:
+ return sch.GrantFloorEvent(
+ type=enums.ChairEvents.GRANT_FLOOR,
+ payload=sch.GrantFloorPayload(representation_id=1, seconds=45),
)
@@ -232,14 +248,6 @@ def mark_roll_call_bulk_event() -> sch.MarkRollCallBulkEvent:
)
-@pytest.fixture
-def insert_queue_event() -> sch.ChairInsertQueueEvent:
- return sch.ChairInsertQueueEvent(
- type=enums.ChairEvents.INSERT_QUEUE,
- payload=sch.ChairInsertQueuePayload(target=0),
- )
-
-
@pytest.fixture
def open_session_event() -> sch.OpenSessionEvent:
return sch.OpenSessionEvent(
@@ -797,28 +805,162 @@ def test_tally_votes_correctly_marks_fail_majority(
assert not res
-def test_chair_can_choose_speaker(
+def test_chair_can_advance_gsl_speaker(
engine: eng.SessionEngine,
open_gsl_state: md.SessionLiveState,
- choose_speaker_event: sch.SpeakerEvent,
+ next_speaker_event: sch.NextSpeakerEvent,
chair_actor: md.SessionActor,
) -> None:
- state = engine.dispatch(open_gsl_state, choose_speaker_event, chair_actor)
+ open_gsl_state.gsl_queue = [1, 2]
+
+ state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor)
assert state.current_speaker == 1
+ assert state.gsl_queue == [2]
assert state.timer_is_running is False
assert state.timer_expiration is None
- assert state.timer_remaining_seconds == 45
+ assert state.timer_remaining_seconds == state.gsl_default_time_seconds
+
+
+def test_chair_can_advance_tour_de_table_speaker(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ next_speaker_event: sch.NextSpeakerEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.TOUR_DE_TABLE
+ open_gsl_state.caucus_list = [1, 2]
+ state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor)
-def test_delegate_cannot_choose_speaker(
+ assert state.current_speaker == 1
+ assert state.caucus_list == [2]
+ assert state.timer_remaining_seconds == state.gsl_default_time_seconds
+
+
+def test_next_speaker_clears_current_speaker_when_gsl_empty(
engine: eng.SessionEngine,
open_gsl_state: md.SessionLiveState,
- choose_speaker_event: sch.SpeakerEvent,
+ next_speaker_event: sch.NextSpeakerEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_speaker = 1
+ open_gsl_state.timer_is_running = True
+
+ state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor)
+
+ assert state.current_speaker is None
+ assert state.timer_remaining_seconds == 0
+ assert state.timer_is_running is False
+
+
+def test_next_speaker_rejects_moderated_caucus(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ next_speaker_event: sch.NextSpeakerEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.MODERATED_CAUCUS
+
+ with pytest.raises(eng.InvalidProceduralMove, match="must grant floor"):
+ engine.dispatch(open_gsl_state, next_speaker_event, chair_actor)
+
+
+def test_delegate_cannot_advance_speaker(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ next_speaker_event: sch.NextSpeakerEvent,
delegate_actor: md.SessionActor,
) -> None:
with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"):
- engine.dispatch(open_gsl_state, choose_speaker_event, delegate_actor)
+ engine.dispatch(open_gsl_state, next_speaker_event, delegate_actor)
+
+
+def test_chair_can_add_gsl_speaker(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ add_gsl_speaker_event: sch.AddGslSpeakerEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ state = engine.dispatch(open_gsl_state, add_gsl_speaker_event, chair_actor)
+
+ assert state.gsl_queue == [1]
+
+
+def test_add_gsl_speaker_rejects_duplicates(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ add_gsl_speaker_event: sch.AddGslSpeakerEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.gsl_queue = [1]
+
+ with pytest.raises(eng.InvalidProceduralMove, match="already in GSL queue"):
+ engine.dispatch(open_gsl_state, add_gsl_speaker_event, chair_actor)
+
+
+def test_chair_can_grant_floor_and_remove_gsl_queue_entry(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ grant_floor_event: sch.GrantFloorEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.gsl_queue = [0, 1, 2]
+
+ state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor)
+
+ assert state.current_speaker == 1
+ assert state.gsl_queue == [0, 2]
+ assert state.timer_remaining_seconds == 45
+
+
+def test_chair_can_grant_floor_and_remove_tour_de_table_entry(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ grant_floor_event: sch.GrantFloorEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.TOUR_DE_TABLE
+ open_gsl_state.caucus_list = [0, 1, 2]
+
+ state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor)
+
+ assert state.current_speaker == 1
+ assert state.caucus_list == [0, 2]
+ assert state.timer_remaining_seconds == 45
+
+
+def test_chair_can_grant_floor_in_moderated_caucus(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ grant_floor_event: sch.GrantFloorEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.MODERATED_CAUCUS
+ open_gsl_state.debate = md.DebateContext(
+ debate_type=enums.DebateTypes.MODERATED_DEBATE,
+ return_state=enums.States.OPEN_GSL,
+ per_speaker_seconds=60,
+ )
+ open_gsl_state.caucus_list = [0, 1]
+
+ state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor)
+
+ assert state.current_speaker == 1
+ assert state.caucus_list == [0, 1]
+ assert state.timer_remaining_seconds == 45
+
+
+def test_grant_floor_rejects_unmoderated_caucus(
+ engine: eng.SessionEngine,
+ open_gsl_state: md.SessionLiveState,
+ grant_floor_event: sch.GrantFloorEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ open_gsl_state.current_state = enums.States.UNMODERATED_CAUCUS
+
+ with pytest.raises(eng.InvalidProceduralMove, match="unmoderated caucus"):
+ engine.dispatch(open_gsl_state, grant_floor_event, chair_actor)
def test_chair_can_mark_roll_call(
@@ -890,17 +1032,6 @@ def test_chair_cannot_mark_roll_call_bulk_nonexistent_delegations(
engine.dispatch(session_state, event, chair_actor)
-def test_chair_insert_queue_uses_delegation_id(
- engine: eng.SessionEngine,
- open_gsl_state: md.SessionLiveState,
- insert_queue_event: sch.ChairInsertQueueEvent,
- chair_actor: md.SessionActor,
-) -> None:
- state = engine.dispatch(open_gsl_state, insert_queue_event, chair_actor)
-
- assert state.gsl_queue == [0]
-
-
def test_chair_open_session_starts_roll_call(
engine: eng.SessionEngine,
session_state: md.SessionLiveState,
From 7431db97ad47003d663500861ce19919c9479b4e Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:41:02 -0300
Subject: [PATCH 16/25] docs: document debate speaker events
---
docs/realtime/events-and-payloads.md | 33 ++++++++++++++++++++--------
1 file changed, 24 insertions(+), 9 deletions(-)
diff --git a/docs/realtime/events-and-payloads.md b/docs/realtime/events-and-payloads.md
index 63d4ca7..69d9924 100644
--- a/docs/realtime/events-and-payloads.md
+++ b/docs/realtime/events-and-payloads.md
@@ -101,11 +101,30 @@ Privilege`.
- Tallies the current procedural vote and applies its result or returns to
the earlier phase.
-- `SpeakerEvent`
- - Payload: optional `speaker_id` and `seconds`.
- - Sets a current speaker and the timer duration. Although the schema allows
- an omitted `speaker_id`, the current handler requires an existing
- delegation ID.
+- `FinishCaucusEvent`
+ - Payload: `{}`.
+ - Ends an active moderated or unmoderated caucus. Clears its speaker, timer,
+ and caucus list, then returns to the GSL state stored when the caucus was
+ opened.
+
+- `NextSpeakerEvent`
+ - Payload: `{}`.
+ - In Open or Closed GSL, pops the next `representation_id` from `gsl_queue`
+ into `current_speaker`. In Tour de Table, does the same with `caucus_list`.
+ An empty list clears the current speaker and timer. It is not valid for a
+ moderated or unmoderated caucus.
+
+- `AddGslSpeakerEvent`
+ - Payload: `representation_id`.
+ - Adds a valid representation to the GSL queue during Open or Closed GSL.
+ Duplicate queue entries are rejected.
+
+- `GrantFloorEvent`
+ - Payload: `representation_id` and optional positive `seconds`.
+ - Makes a valid representation the current speaker in GSL, moderated
+ caucus, or Tour de Table. In GSL and Tour de Table, removes that
+ representation from its pending queue/list first. In moderated caucus, it
+ leaves `caucus_list` unchanged. It is not valid during unmoderated caucus.
- `MarkRollCallEvent`
- Payload: `delegation_id` and `choice` (`Present`, `Present and Voting`, or
@@ -122,10 +141,6 @@ Privilege`.
- Marks unrecorded delegations absent, creates voting eligibility, and moves
to Open GSL.
-- `InsertQueueEvent`
- - Payload: `target`.
- - Adds the target delegation to the GSL queue.
-
- `SetAgendaEvent`
- Payload: `agenda`, a list of strings.
- Declared in the schema, but its handler is not implemented. Do not use it
From c0100e865a501bb2317cd9f5a5e63c9d39ac75e9 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:44:37 -0300
Subject: [PATCH 17/25] style: format files
---
backend/app/session/engine.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 51240d6..ad8336b 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -502,9 +502,7 @@ def handle_close_informal_voting(
def apply_passed_motion(
- state: SessionLiveState,
- motion: MotionContext,
- return_state: States
+ state: SessionLiveState, motion: MotionContext, return_state: States
) -> None:
"""Apply a passed procedural motion to the live session state in place."""
next_state = return_state # as fallback
@@ -565,7 +563,15 @@ def apply_passed_motion(
pass
case Motions.TOUR_DE_TABLE:
next_state = States.TOUR_DE_TABLE
- state.caucus_list = [del_id for del_id, choice in state.roll_call.registry.items() if choice in (enums.RollCallChoice.PRESENT, enums.RollCallChoice.PRESENT_AND_VOTING)]
+ state.caucus_list = [
+ del_id
+ for del_id, choice in state.roll_call.registry.items()
+ if choice
+ in (
+ enums.RollCallChoice.PRESENT,
+ enums.RollCallChoice.PRESENT_AND_VOTING,
+ )
+ ]
case Motions.END_DEBATE:
# clean gsl list
From 3d843801521e063b5d4fffb1dfcb092e2accd626 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:58:42 -0300
Subject: [PATCH 18/25] fix(backend): restore state after quorum roll call
Co-authored-by: Codex
---
backend/app/session/engine.py | 6 +-
backend/app/session/models.py | 1 +
backend/app/tests/session/test_engine.py | 77 ++++++++++++++++++++++++
3 files changed, 81 insertions(+), 3 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index ad8336b..db58185 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -599,7 +599,7 @@ def apply_passed_motion(
# note: seems more like an informal consultation
pass
case Motions.QUORUM:
- state.roll_call = RollCallContext(registry={})
+ state.roll_call = RollCallContext(registry={}, return_state=return_state)
next_state = States.ROLL_CALL
case _:
raise InvalidProceduralMove("Undefined motion type")
@@ -910,8 +910,8 @@ def handle_close_roll_call(
for delegation_id in state.delegations:
state.roll_call.registry.setdefault(delegation_id, RollCallChoice.ABSENT)
- # may also empty roll call if needed, to avoid loading stale values
- state.current_state = States.OPEN_GSL
+ # Initial roll call enters Open GSL; quorum roll calls restore their source state.
+ state.current_state = state.roll_call.return_state or States.OPEN_GSL
state.voting_choice = {
delegation_id: RollCallChoice.PRESENT_AND_VOTING
if choice == RollCallChoice.PRESENT_AND_VOTING
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 83cac5b..340d49c 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -89,6 +89,7 @@ class DebateContext(BaseModel):
class RollCallContext(BaseModel):
registry: dict[int, enums.RollCallChoice] = {} # Delegation Id as key
+ return_state: enums.States | None = None
class AgendaItem(BaseModel):
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index b392460..a0d1933 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -487,6 +487,83 @@ def test_chair_can_close_roll_call(
}
+def test_quorum_roll_call_restores_closed_gsl(
+ engine: eng.SessionEngine,
+ session_state: md.SessionLiveState,
+ close_procedural_voting_event: sch.CloseProceduralVotingEvent,
+ close_roll_call_event: sch.CloseRollCallEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ session_state.current_state = enums.States.VOTING_EXECUTION
+ session_state.voting_choice = {
+ 0: enums.RollCallChoice.PRESENT,
+ 1: enums.RollCallChoice.PRESENT,
+ 2: enums.RollCallChoice.PRESENT,
+ }
+ session_state.voting = md.VotingContext(
+ target_type=enums.VotingType.PROCEDURAL,
+ return_state=enums.States.CLOSED_GSL,
+ motion_in_vote=md.MotionContext(
+ id=1,
+ type=enums.Motions.QUORUM,
+ timestamp=datetime.now(UTC),
+ ),
+ voting_registry={
+ 0: enums.VotingChoice.FAVOUR,
+ 1: enums.VotingChoice.FAVOUR,
+ },
+ )
+
+ state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor)
+
+ assert state.current_state == enums.States.ROLL_CALL
+ assert state.roll_call.return_state == enums.States.CLOSED_GSL
+
+ state = engine.dispatch(state, close_roll_call_event, chair_actor)
+
+ assert state.current_state == enums.States.CLOSED_GSL
+
+
+def test_quorum_roll_call_restores_moderated_caucus(
+ engine: eng.SessionEngine,
+ session_state: md.SessionLiveState,
+ close_procedural_voting_event: sch.CloseProceduralVotingEvent,
+ close_roll_call_event: sch.CloseRollCallEvent,
+ chair_actor: md.SessionActor,
+) -> None:
+ debate = md.DebateContext(
+ debate_type=enums.DebateTypes.MODERATED_DEBATE,
+ return_state=enums.States.OPEN_GSL,
+ per_speaker_seconds=60,
+ )
+ session_state.current_state = enums.States.VOTING_EXECUTION
+ session_state.debate = debate
+ session_state.voting_choice = {
+ 0: enums.RollCallChoice.PRESENT,
+ 1: enums.RollCallChoice.PRESENT,
+ 2: enums.RollCallChoice.PRESENT,
+ }
+ session_state.voting = md.VotingContext(
+ target_type=enums.VotingType.PROCEDURAL,
+ return_state=enums.States.MODERATED_CAUCUS,
+ motion_in_vote=md.MotionContext(
+ id=1,
+ type=enums.Motions.QUORUM,
+ timestamp=datetime.now(UTC),
+ ),
+ voting_registry={
+ 0: enums.VotingChoice.FAVOUR,
+ 1: enums.VotingChoice.FAVOUR,
+ },
+ )
+
+ state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor)
+ state = engine.dispatch(state, close_roll_call_event, chair_actor)
+
+ assert state.current_state == enums.States.MODERATED_CAUCUS
+ assert state.debate == debate
+
+
def test_delegate_cannot_close_roll_call(
engine: eng.SessionEngine,
session_state: md.SessionLiveState,
From c3ac3220785bb78e7aef01d3b5b383d8fef2cbc1 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 05:45:02 -0300
Subject: [PATCH 19/25] Implement resolution voting lifecycle
Co-authored-by: Codex
---
backend/README.md | 16 +
backend/app/session/engine.py | 446 ++++++++++++++++--
backend/app/session/enums.py | 22 +
backend/app/session/models.py | 35 +-
backend/app/session/schemas.py | 36 ++
.../session/test_resolution_lifecycle.py | 147 ++++++
6 files changed, 674 insertions(+), 28 deletions(-)
create mode 100644 backend/app/tests/session/test_resolution_lifecycle.py
diff --git a/backend/README.md b/backend/README.md
index b43e307..35483dc 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -47,3 +47,19 @@ uv run pytest
We deeply recommend contributers to first lint and test their code before sending the commit. This way we make our codebase better.
+## Realtime resolution voting events
+
+Resolution-related motions use delegation-supplied `resolution_id`,
+`amendment_id`, and `split_resolution_id`, along with `resolution_title`,
+`target_resolution_id`, `is_friendly`, and `split_title` as applicable. The server
+derives the submitting representation and accepts a draft before it enters state.
+Once debate ends, the chair starts the first `DRAFT` item in `draft_resolutions`
+with `StartResolutionVoteEvent {}`. Pending unfriendly amendments are voted
+procedurally before substantive voting begins.
+
+For substantive votes, delegates use `CastVoteEvent { vote }`; chairs can record
+a vote with `RecordSubstantiveVoteEvent { representation_id, vote }`. Chairs close
+ordinary substantive votes with `CloseSubstantiveVotingEvent`. Roll-call votes use
+`AdvanceSubstantiveVoteRoundEvent` for the initial, yes-with-rights, and
+no-with-rights rounds; right-of-reply speakers are served through the existing
+`NextSpeakerEvent` or `GrantFloorEvent` controls for 30 seconds each.
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index db58185..e18582f 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -18,10 +18,12 @@
)
from .models import (
AgendaItem,
+ AmendmentContext,
DebateContext,
DelegationContext,
MotionContext,
QuestionContext,
+ ResolutionContext,
RollCallContext,
SessionActor,
SessionLiveState,
@@ -44,7 +46,6 @@ class InvalidProceduralMove(Exception):
Motions.POSTPONE_SESSION,
Motions.TOUR_DE_TABLE,
Motions.END_DEBATE,
- Motions.VOTE_AMENDMENT,
Motions.VOTE_BY_ROLL_CALL,
Motions.CLOSE_SPEAKERS_LIST,
Motions.SPLIT_PROPOSAL,
@@ -57,7 +58,6 @@ class InvalidProceduralMove(Exception):
States.CLOSED_GSL: {
Motions.REOPEN_SPEAKERS_LIST,
Motions.END_DEBATE,
- Motions.VOTE_AMENDMENT,
Motions.VOTE_BY_ROLL_CALL,
Motions.INTRODUCE_RESOLUTION_PROPOSAL,
Motions.INTRODUCE_AMENDMENT_PROPOSAL,
@@ -105,11 +105,33 @@ def validate_motion_payload(
# can also raise error if there are missing fields
if (
- payload.type in {States.MODERATED_CAUCUS}
+ payload.type == Motions.CHANGE_DEBATE_TYPE
+ and payload.debate_type == DebateTypes.MODERATED_DEBATE
and payload.per_speaker_seconds is None
):
raise InvalidProceduralMove("Cannot submit motion without speaking time")
+ required_fields: dict[Motions, tuple[str, ...]] = {
+ Motions.INTRODUCE_RESOLUTION_PROPOSAL: ("resolution_title", "resolution_id"),
+ Motions.INTRODUCE_AMENDMENT_PROPOSAL: (
+ "target_resolution_id",
+ "amendment_id",
+ "is_friendly",
+ ),
+ Motions.SPLIT_PROPOSAL: (
+ "target_resolution_id",
+ "split_title",
+ "split_resolution_id",
+ ),
+ Motions.VOTE_BY_ROLL_CALL: ("target_resolution_id",),
+ }
+ for field in required_fields.get(payload.type, ()):
+ if getattr(payload, field) is None:
+ raise InvalidProceduralMove(f"{field} is required for this motion")
+
+ if payload.type == Motions.VOTE_AMENDMENT:
+ raise InvalidProceduralMove("Vote amendment is handled automatically")
+
def count_present_delegations(state: SessionLiveState) -> int:
"""Count total present delegations.
@@ -156,7 +178,7 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool:
[
True
for _, vote in voting.voting_registry.items()
- if vote == enums.VotingChoice.FAVOUR
+ if vote in {enums.VotingChoice.FAVOUR, enums.VotingChoice.YES_WITH_RIGHTS}
]
)
motion = voting.motion_in_vote
@@ -183,7 +205,6 @@ def get_motion_priority(motion: Motions) -> int | None:
Motions.CHANGE_DEBATE_TYPE: 3,
Motions.TOUR_DE_TABLE: 3,
Motions.END_DEBATE: 4,
- Motions.VOTE_AMENDMENT: 4,
Motions.CLOSE_SPEAKERS_LIST: 5,
Motions.REOPEN_SPEAKERS_LIST: 5,
Motions.SPLIT_PROPOSAL: 6,
@@ -264,6 +285,12 @@ def handle_delegate_submit_motion(
raise InvalidProceduralMove("Submitting motions during caucuses is disabled")
validate_motion_payload(payload, state)
+ if payload.type in {
+ Motions.INTRODUCE_AMENDMENT_PROPOSAL,
+ Motions.SPLIT_PROPOSAL,
+ Motions.VOTE_BY_ROLL_CALL,
+ }:
+ _get_draft_resolution(state, payload.target_resolution_id)
context = MotionContext(
id=generate_next_motion_id(state),
@@ -275,6 +302,13 @@ def handle_delegate_submit_motion(
total_duration_minutes=payload.total_duration_minutes,
per_speaker_seconds=payload.per_speaker_seconds,
target_topic=payload.target_topic,
+ resolution_title=payload.resolution_title,
+ resolution_id=payload.resolution_id,
+ target_resolution_id=payload.target_resolution_id,
+ amendment_id=payload.amendment_id,
+ is_friendly=payload.is_friendly,
+ split_title=payload.split_title,
+ split_resolution_id=payload.split_resolution_id,
details=payload.details,
)
@@ -340,17 +374,51 @@ def handle_cast_vote(
if voting_context is None:
raise InvalidProceduralMove("Cannot vote during this stage")
- # initial voting workflow, may be reviewed later
- # TODO: perhaps allow casting another vote if first one fails
- if delegate.id in voting_context.voting_registry:
- raise InvalidProceduralMove("Already cast vote")
-
- # register vote on voting context
- voting_context.voting_registry[delegate.id] = event.payload.vote
+ _record_vote(state, voting_context, delegate.id, event.payload.vote)
return state
+def _record_vote(
+ state: SessionLiveState,
+ voting: VotingContext,
+ representation_id: int,
+ vote: enums.VotingChoice,
+) -> None:
+ """Validate and record one vote. Substantive eligibility is frozen on start."""
+ if representation_id in voting.voting_registry:
+ raise InvalidProceduralMove("Already cast vote")
+ if voting.target_type == enums.VotingType.PROCEDURAL:
+ if not voting.is_choice_allowed(vote):
+ raise InvalidProceduralMove("Vote choice is not allowed")
+ elif voting.target_type == enums.VotingType.SUBSTANTIVE:
+ if representation_id not in _eligible_roll_call_representation_ids(state):
+ raise InvalidProceduralMove("Representation is not eligible to vote")
+ if voting.substantive_round != enums.SubstantiveVoteRound.INITIAL:
+ raise InvalidProceduralMove("Votes are closed during rights of reply")
+ allowed = {
+ enums.VotingChoice.FAVOUR,
+ enums.VotingChoice.AGAINST,
+ enums.VotingChoice.ABSTAIN,
+ }
+ if voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote:
+ allowed |= {
+ enums.VotingChoice.YES_WITH_RIGHTS,
+ enums.VotingChoice.NO_WITH_RIGHTS,
+ }
+ if vote not in allowed:
+ raise InvalidProceduralMove("Vote choice is not allowed")
+ if (
+ vote == enums.VotingChoice.ABSTAIN
+ and state.roll_call.registry.get(representation_id)
+ == enums.RollCallChoice.PRESENT_AND_VOTING
+ ):
+ raise InvalidProceduralMove(
+ "Present and voting representations cannot abstain"
+ )
+ voting.voting_registry[representation_id] = vote
+
+
def handle_answer_roll_call(
state: SessionLiveState, event: schemas.AnswerRollCallEvent, actor: SessionActor
) -> SessionLiveState:
@@ -578,14 +646,13 @@ def apply_passed_motion(
state.gsl_queue = []
state.debate = None
reset_timer(state)
- next_state = States.VOTING_PROCEDURES # or VOTING_PREPARATION
+ next_state = States.VOTING_PREPARATION # or VOTING_PREPARATION
case Motions.VOTE_AMENDMENT:
- # note: seems more like an informal consultation
- pass
+ raise InvalidProceduralMove("Vote amendment is handled automatically")
case Motions.VOTE_BY_ROLL_CALL:
- # will define the VotingContext for resolutions
- pass
+ resolution = _get_draft_resolution(state, motion.target_resolution_id)
+ resolution.roll_call_vote = True
case Motions.CLOSE_SPEAKERS_LIST:
next_state = States.CLOSED_GSL
@@ -593,8 +660,19 @@ def apply_passed_motion(
next_state = States.OPEN_GSL
case Motions.SPLIT_PROPOSAL:
- # note: seems more like an informal consultation
- pass
+ parent = _get_draft_resolution(state, motion.target_resolution_id)
+ if motion.split_title is None or motion.split_resolution_id is None:
+ raise InvalidProceduralMove("Invalid split proposal")
+ _ensure_resolution_id_available(state, motion.split_resolution_id)
+ state.draft_resolutions.append(
+ ResolutionContext(
+ id=motion.split_resolution_id,
+ title=motion.split_title,
+ delegate_id=parent.delegate_id,
+ parent_resolution_id=parent.id,
+ roll_call_vote=parent.roll_call_vote,
+ )
+ )
case Motions.CHANGE_TOPIC:
# note: seems more like an informal consultation
pass
@@ -608,6 +686,72 @@ def apply_passed_motion(
state.current_state = next_state
+def _ensure_resolution_id_available(
+ state: SessionLiveState, resolution_id: str
+) -> None:
+ if any(resolution.id == resolution_id for resolution in state.draft_resolutions):
+ raise InvalidProceduralMove("Resolution ID already exists")
+
+
+def _ensure_amendment_id_available(state: SessionLiveState, amendment_id: str) -> None:
+ if any(
+ amendment.id == amendment_id
+ for resolution in state.draft_resolutions
+ for amendment in resolution.amendments
+ ):
+ raise InvalidProceduralMove("Amendment ID already exists")
+
+
+def _get_draft_resolution(
+ state: SessionLiveState, resolution_id: str | None
+) -> ResolutionContext:
+ resolution = next(
+ (r for r in state.draft_resolutions if r.id == resolution_id), None
+ )
+ if resolution is None or resolution.status != enums.ResolutionStatus.DRAFT:
+ raise InvalidProceduralMove("Draft resolution not found")
+ return resolution
+
+
+def _eligible_roll_call_representation_ids(state: SessionLiveState) -> list[int]:
+ return [
+ representation_id
+ for representation_id, choice in state.roll_call.registry.items()
+ if choice in {RollCallChoice.PRESENT, RollCallChoice.PRESENT_AND_VOTING}
+ ]
+
+
+def _open_next_amendment_or_substantive(state: SessionLiveState) -> None:
+ """Open pending amendment votes in submission order, then the resolution vote."""
+ if state.voting is None or state.voting.resolution_in_vote is None:
+ raise InvalidProceduralMove("No resolution vote in progress")
+ resolution = state.voting.resolution_in_vote
+ pending = next(
+ (a for a in resolution.amendments if a.status == enums.AmendmentStatus.PENDING),
+ None,
+ )
+ if pending is not None:
+ state.voting = VotingContext(
+ target_type=enums.VotingType.PROCEDURAL,
+ return_state=States.VOTING_PROCEDURES,
+ voting_registry={},
+ majority=enums.MajorityTypes.SIMPLE,
+ resolution_in_vote=resolution,
+ amendment_in_vote=pending,
+ )
+ return
+
+ state.voting = VotingContext(
+ target_type=enums.VotingType.SUBSTANTIVE,
+ return_state=States.VOTING_PREPARATION,
+ voting_registry={},
+ majority=enums.MajorityTypes.SIMPLE,
+ resolution_in_vote=resolution,
+ substantive_round=enums.SubstantiveVoteRound.INITIAL,
+ )
+ state.current_state = States.VOTING_PROCEDURES
+
+
def handle_close_procedural_voting(
state: SessionLiveState,
event: schemas.CloseProceduralVotingEvent,
@@ -619,19 +763,40 @@ def handle_close_procedural_voting(
raise InvalidProceduralMove("No voting present")
if (
- state.current_state != States.VOTING_EXECUTION
- or state.voting.target_type != enums.VotingType.PROCEDURAL
+ state.voting.target_type != enums.VotingType.PROCEDURAL
+ or state.current_state
+ not in {
+ States.VOTING_EXECUTION,
+ States.VOTING_PROCEDURES,
+ }
):
raise InvalidProceduralMove("Can't close voting")
+ amendment = state.voting.amendment_in_vote
+ resolution = state.voting.resolution_in_vote
motion = state.voting.motion_in_vote
+ present = (
+ len(_eligible_roll_call_representation_ids(state))
+ if amendment is not None
+ else count_present_delegations(state)
+ )
+ passed = tally_votes(state.voting, present)
+
+ if amendment is not None and resolution is not None:
+ amendment.status = (
+ enums.AmendmentStatus.ADOPTED if passed else enums.AmendmentStatus.REJECTED
+ )
+ state.voting = VotingContext(
+ target_type=enums.VotingType.SUBSTANTIVE,
+ return_state=States.VOTING_PREPARATION,
+ resolution_in_vote=resolution,
+ )
+ _open_next_amendment_or_substantive(state)
+ return state
if motion is None:
raise InvalidProceduralMove("Can't close voting if motion is None")
- present = count_present_delegations(state)
- passed = tally_votes(state.voting, present)
-
if passed:
apply_passed_motion(state, motion, return_state=state.voting.return_state)
else:
@@ -663,7 +828,6 @@ def handle_finish_caucus(
return state
-# handles setting state into VOTING_EXECUTION or rejecting the motion
def handle_resolve_motion(
state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor
) -> SessionLiveState:
@@ -671,7 +835,6 @@ def handle_resolve_motion(
require_chair(actor)
payload = event.payload
- # next() function with generator expression
motion = next(
(m for m in state.submitted_motions if m.id == payload.motion_id), None
)
@@ -679,6 +842,58 @@ def handle_resolve_motion(
if motion is None:
raise InvalidProceduralMove("Motion not found")
+ if motion.type == Motions.INTRODUCE_RESOLUTION_PROPOSAL:
+ if payload.action:
+ if (
+ motion.resolution_title is None
+ or motion.resolution_id is None
+ or motion.delegate_id is None
+ ):
+ raise InvalidProceduralMove("Invalid resolution introduction")
+ _ensure_resolution_id_available(state, motion.resolution_id)
+ state.draft_resolutions.append(
+ ResolutionContext(
+ id=motion.resolution_id,
+ title=motion.resolution_title,
+ delegate_id=motion.delegate_id,
+ )
+ )
+ state.submitted_motions.remove(motion)
+ return state
+
+ if motion.type == Motions.INTRODUCE_AMENDMENT_PROPOSAL:
+ if payload.action:
+ target = _get_draft_resolution(state, motion.target_resolution_id)
+ if (
+ motion.is_friendly is None
+ or motion.amendment_id is None
+ or motion.delegate_id is None
+ ):
+ raise InvalidProceduralMove("Invalid amendment introduction")
+ _ensure_amendment_id_available(state, motion.amendment_id)
+ target.amendments.append(
+ AmendmentContext(
+ id=motion.amendment_id,
+ target_resolution_id=target.id,
+ is_friendly=motion.is_friendly,
+ representation_id=motion.delegate_id,
+ status=(
+ enums.AmendmentStatus.ADOPTED
+ if motion.is_friendly
+ else enums.AmendmentStatus.PENDING
+ ),
+ )
+ )
+ state.submitted_motions.remove(motion)
+ return state
+
+ if motion.type in {Motions.SPLIT_PROPOSAL, Motions.VOTE_BY_ROLL_CALL}:
+ if state.current_state != States.VOTING_PREPARATION:
+ raise InvalidProceduralMove(
+ "This motion is only available in voting preparation"
+ )
+ _get_draft_resolution(state, motion.target_resolution_id)
+
majority_type = (
enums.MajorityTypes.SIMPLE
if needs_simple_majority_type(motion.type)
@@ -720,6 +935,13 @@ def handle_chair_submit_motion(
total_duration_minutes=payload.total_duration_minutes,
per_speaker_seconds=payload.per_speaker_seconds,
target_topic=payload.target_topic,
+ resolution_title=payload.resolution_title,
+ resolution_id=payload.resolution_id,
+ target_resolution_id=payload.target_resolution_id,
+ amendment_id=payload.amendment_id,
+ is_friendly=payload.is_friendly,
+ split_title=payload.split_title,
+ split_resolution_id=payload.split_resolution_id,
details=payload.details,
)
@@ -793,6 +1015,24 @@ def handle_next_speaker(
) -> SessionLiveState:
require_chair(actor)
+ if (
+ state.current_state == States.VOTING_PROCEDURES
+ and state.voting is not None
+ and state.voting.target_type == enums.VotingType.SUBSTANTIVE
+ and state.voting.substantive_round
+ in {
+ enums.SubstantiveVoteRound.YES_WITH_RIGHTS,
+ enums.SubstantiveVoteRound.NO_WITH_RIGHTS,
+ }
+ ):
+ if not state.voting.rights_queue:
+ state.current_speaker = None
+ reset_timer(state)
+ return state
+ state.current_speaker = state.voting.rights_queue.pop(0)
+ reset_timer(state, 30)
+ return state
+
if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
if not state.gsl_queue:
state.current_speaker = None
@@ -844,7 +1084,21 @@ def handle_grant_floor(
if representation_id not in state.delegations:
raise InvalidProceduralMove("Representation not found")
- if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
+ if (
+ state.current_state == States.VOTING_PROCEDURES
+ and state.voting is not None
+ and state.voting.target_type == enums.VotingType.SUBSTANTIVE
+ and state.voting.substantive_round
+ in {
+ enums.SubstantiveVoteRound.YES_WITH_RIGHTS,
+ enums.SubstantiveVoteRound.NO_WITH_RIGHTS,
+ }
+ ):
+ if representation_id not in state.voting.rights_queue:
+ raise InvalidProceduralMove("Representation has no right to speak")
+ state.voting.rights_queue.remove(representation_id)
+ seconds = 30
+ elif state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
if representation_id in state.gsl_queue:
state.gsl_queue.remove(representation_id)
seconds = event.payload.seconds or state.gsl_default_time_seconds
@@ -867,6 +1121,140 @@ def handle_grant_floor(
return state
+def handle_start_resolution_vote(
+ state: SessionLiveState,
+ event: schemas.StartResolutionVoteEvent,
+ actor: SessionActor,
+) -> SessionLiveState:
+ require_chair(actor)
+ if state.current_state != States.VOTING_PREPARATION or state.voting is not None:
+ raise InvalidProceduralMove(
+ "Resolution voting can only start in voting preparation"
+ )
+ resolution = next(
+ (
+ draft
+ for draft in state.draft_resolutions
+ if draft.status == enums.ResolutionStatus.DRAFT
+ ),
+ None,
+ )
+ if resolution is None:
+ raise InvalidProceduralMove("No draft resolution is available to vote")
+ state.current_state = States.VOTING_PROCEDURES
+ state.voting = VotingContext(
+ target_type=enums.VotingType.SUBSTANTIVE,
+ return_state=States.VOTING_PREPARATION,
+ resolution_in_vote=resolution,
+ )
+ _open_next_amendment_or_substantive(state)
+ return state
+
+
+def _all_eligible_voted(state: SessionLiveState, voting: VotingContext) -> bool:
+ return set(_eligible_roll_call_representation_ids(state)) <= set(
+ voting.voting_registry
+ )
+
+
+def _finish_substantive_vote(state: SessionLiveState) -> None:
+ if state.voting is None or state.voting.resolution_in_vote is None:
+ raise InvalidProceduralMove("No substantive vote in progress")
+ resolution = state.voting.resolution_in_vote
+ passed = tally_votes(
+ state.voting, len(_eligible_roll_call_representation_ids(state))
+ )
+ resolution.status = (
+ enums.ResolutionStatus.ADOPTED if passed else enums.ResolutionStatus.REJECTED
+ )
+ state.voting = None
+ state.current_speaker = None
+ reset_timer(state)
+ state.current_state = States.VOTING_PREPARATION
+
+
+def handle_record_substantive_vote(
+ state: SessionLiveState,
+ event: schemas.RecordSubstantiveVoteEvent,
+ actor: SessionActor,
+) -> SessionLiveState:
+ require_chair(actor)
+ voting = state.voting
+ if state.current_state != States.VOTING_PROCEDURES or voting is None:
+ raise InvalidProceduralMove("No substantive vote in progress")
+ if voting.target_type != enums.VotingType.SUBSTANTIVE:
+ raise InvalidProceduralMove("No substantive vote in progress")
+ if event.payload.representation_id not in state.delegations:
+ raise InvalidProceduralMove("Representation not found")
+ _record_vote(state, voting, event.payload.representation_id, event.payload.vote)
+ return state
+
+
+def handle_close_substantive_voting(
+ state: SessionLiveState,
+ event: schemas.CloseSubstantiveVotingEvent,
+ actor: SessionActor,
+) -> SessionLiveState:
+ require_chair(actor)
+ voting = state.voting
+ if (
+ state.current_state != States.VOTING_PROCEDURES
+ or voting is None
+ or voting.target_type != enums.VotingType.SUBSTANTIVE
+ or voting.resolution_in_vote is None
+ ):
+ raise InvalidProceduralMove("No substantive vote in progress")
+ if voting.resolution_in_vote.roll_call_vote:
+ raise InvalidProceduralMove(
+ "Use advance substantive vote round for roll call votes"
+ )
+ _finish_substantive_vote(state)
+ return state
+
+
+def handle_advance_substantive_vote_round(
+ state: SessionLiveState,
+ event: schemas.AdvanceSubstantiveVoteRoundEvent,
+ actor: SessionActor,
+) -> SessionLiveState:
+ require_chair(actor)
+ voting = state.voting
+ if (
+ state.current_state != States.VOTING_PROCEDURES
+ or voting is None
+ or voting.target_type != enums.VotingType.SUBSTANTIVE
+ or voting.resolution_in_vote is None
+ or not voting.resolution_in_vote.roll_call_vote
+ ):
+ raise InvalidProceduralMove("No roll-call substantive vote in progress")
+ if voting.substantive_round == enums.SubstantiveVoteRound.INITIAL:
+ if not _all_eligible_voted(state, voting):
+ raise InvalidProceduralMove("Every eligible representation must vote")
+ voting.substantive_round = enums.SubstantiveVoteRound.YES_WITH_RIGHTS
+ voting.rights_queue = [
+ representation_id
+ for representation_id, choice in voting.voting_registry.items()
+ if choice == enums.VotingChoice.YES_WITH_RIGHTS
+ ]
+ return state
+ if voting.substantive_round == enums.SubstantiveVoteRound.YES_WITH_RIGHTS:
+ if voting.rights_queue:
+ raise InvalidProceduralMove("Finish the rights queue before advancing")
+ voting.substantive_round = enums.SubstantiveVoteRound.NO_WITH_RIGHTS
+ voting.rights_queue = [
+ representation_id
+ for representation_id, choice in voting.voting_registry.items()
+ if choice == enums.VotingChoice.NO_WITH_RIGHTS
+ ]
+ return state
+ if voting.substantive_round == enums.SubstantiveVoteRound.NO_WITH_RIGHTS:
+ if voting.rights_queue:
+ raise InvalidProceduralMove("Finish the rights queue before advancing")
+ _finish_substantive_vote(state)
+ return state
+ raise InvalidProceduralMove("Unknown substantive voting round")
+
+
def handle_mark_roll_call(
state: SessionLiveState, event: schemas.MarkRollCallEvent, actor: SessionActor
) -> SessionLiveState:
@@ -956,6 +1344,10 @@ def handle_close_roll_call(
ChairEvents.MARK_ROLLCALL: handle_mark_roll_call,
ChairEvents.MARK_ROLLCALL_BULK: handle_mark_roll_call_bulk,
ChairEvents.CLOSE_ROLLCALL: handle_close_roll_call,
+ ChairEvents.START_RESOLUTION_VOTE: handle_start_resolution_vote,
+ ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND: handle_advance_substantive_vote_round,
+ ChairEvents.RECORD_SUBSTANTIVE_VOTE: handle_record_substantive_vote,
+ ChairEvents.CLOSE_SUBSTANTIVE_VOTING: handle_close_substantive_voting,
}
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index fd51869..785cadb 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -61,6 +61,10 @@ class ChairEvents(StrEnum):
MARK_ROLLCALL = "MarkRollCallEvent"
MARK_ROLLCALL_BULK = "MarkRollCallBulkEvent"
CLOSE_ROLLCALL = "CloseRollCallEvent"
+ START_RESOLUTION_VOTE = "StartResolutionVoteEvent"
+ ADVANCE_SUBSTANTIVE_VOTE_ROUND = "AdvanceSubstantiveVoteRoundEvent"
+ RECORD_SUBSTANTIVE_VOTE = "RecordSubstantiveVoteEvent"
+ CLOSE_SUBSTANTIVE_VOTING = "CloseSubstantiveVotingEvent"
# --- Additional Info ---
@@ -132,3 +136,21 @@ class MotionDecision(StrEnum):
ACCEPT = "Accept"
DENY = "Deny"
+
+
+class ResolutionStatus(StrEnum):
+ DRAFT = "DRAFT"
+ ADOPTED = "ADOPTED"
+ REJECTED = "REJECTED"
+
+
+class AmendmentStatus(StrEnum):
+ PENDING = "PENDING"
+ ADOPTED = "ADOPTED"
+ REJECTED = "REJECTED"
+
+
+class SubstantiveVoteRound(StrEnum):
+ INITIAL = "INITIAL"
+ YES_WITH_RIGHTS = "YES_WITH_RIGHTS"
+ NO_WITH_RIGHTS = "NO_WITH_RIGHTS"
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 340d49c..467db57 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -47,9 +47,37 @@ class MotionContext(BaseModel):
per_speaker_seconds: int | None = None
target_topic: str | None = None
+ # Substantive-related things
+ resolution_title: str | None = None
+ resolution_id: str | None = None
+ target_resolution_id: str | None = None
+ amendment_id: str | None = None
+ is_friendly: bool | None = None
+ split_title: str | None = None
+ split_resolution_id: str | None = None
+
details: str | None = None
+class AmendmentContext(BaseModel):
+ id: str
+ target_resolution_id: str
+ is_friendly: bool
+ representation_id: int
+ status: enums.AmendmentStatus = enums.AmendmentStatus.PENDING
+
+
+class ResolutionContext(BaseModel):
+ id: str
+ title: str
+ delegate_id: int
+ amendments: list[AmendmentContext] = []
+
+ roll_call_vote: bool = False
+ parent_resolution_id: str | None = None
+ status: enums.ResolutionStatus = enums.ResolutionStatus.DRAFT
+
+
class QuestionContext(BaseModel):
id: int | None = None
priority: int = 0
@@ -66,7 +94,10 @@ class VotingContext(BaseModel):
majority: enums.MajorityTypes | None = None
motion_in_vote: MotionContext | None = None
- # resolution_in_vote: ResolutionContext
+ resolution_in_vote: ResolutionContext | None = None
+ amendment_in_vote: AmendmentContext | None = None
+ substantive_round: enums.SubstantiveVoteRound | None = None
+ rights_queue: list[int] = []
allow_veto_power: bool = False
@@ -147,5 +178,7 @@ class SessionLiveState(BaseModel):
roll_call: RollCallContext # Not None, even if registry is empty
+ draft_resolutions: list[ResolutionContext] = []
+
# Additional config
has_veto_power: bool = False
diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py
index 79bc205..c875a86 100644
--- a/backend/app/session/schemas.py
+++ b/backend/app/session/schemas.py
@@ -22,6 +22,13 @@ class MotionPayload(BaseModel):
total_duration_minutes: int | None = None
per_speaker_seconds: int | None = None
target_topic: str | None = None
+ resolution_title: str | None = None
+ resolution_id: str | None = None
+ target_resolution_id: str | None = None
+ amendment_id: str | None = None
+ is_friendly: bool | None = None
+ split_title: str | None = None
+ split_resolution_id: str | None = None
details: str | None = None
@@ -105,6 +112,11 @@ class ChairResolveMotionPayload(BaseModel):
action: bool
+class RecordSubstantiveVotePayload(BaseModel):
+ representation_id: int
+ vote: enums.VotingChoice
+
+
class GrantFloorPayload(BaseModel):
representation_id: int
seconds: int | None = Field(default=None, ge=1)
@@ -220,6 +232,26 @@ class CloseProceduralVotingEvent(BaseModel):
payload: EmptyPayload
+class StartResolutionVoteEvent(BaseModel):
+ type: Literal[enums.ChairEvents.START_RESOLUTION_VOTE]
+ payload: EmptyPayload
+
+
+class AdvanceSubstantiveVoteRoundEvent(BaseModel):
+ type: Literal[enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND]
+ payload: EmptyPayload
+
+
+class RecordSubstantiveVoteEvent(BaseModel):
+ type: Literal[enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE]
+ payload: RecordSubstantiveVotePayload
+
+
+class CloseSubstantiveVotingEvent(BaseModel):
+ type: Literal[enums.ChairEvents.CLOSE_SUBSTANTIVE_VOTING]
+ payload: EmptyPayload
+
+
class FinishCaucusEvent(BaseModel):
type: Literal[enums.ChairEvents.FINISH_CAUCUS]
payload: EmptyPayload
@@ -270,6 +302,10 @@ class DeleteAgendaItemEvent(BaseModel):
| ToggleTimerEvent
| OpenInformalVotingEvent
| CloseProceduralVotingEvent
+ | StartResolutionVoteEvent
+ | AdvanceSubstantiveVoteRoundEvent
+ | RecordSubstantiveVoteEvent
+ | CloseSubstantiveVotingEvent
| CloseInformalVotingEvent
| FinishCaucusEvent
| ResolveMotionEvent
diff --git a/backend/app/tests/session/test_resolution_lifecycle.py b/backend/app/tests/session/test_resolution_lifecycle.py
new file mode 100644
index 0000000..9a77791
--- /dev/null
+++ b/backend/app/tests/session/test_resolution_lifecycle.py
@@ -0,0 +1,147 @@
+import app.session.enums as enums
+import app.session.models as models
+import app.session.schemas as schemas
+
+
+def _submit_motion(engine, state, actor, **payload):
+ return engine.dispatch(
+ state,
+ schemas.SubmitMotionEvent(
+ type=enums.DelegateEvents.SUBMIT_MOTION,
+ payload=schemas.DelegateMotionPayload(**payload),
+ ),
+ actor,
+ )
+
+
+def _resolve_last(engine, state, chair):
+ return engine.dispatch(
+ state,
+ schemas.ResolveMotionEvent(
+ type=enums.ChairEvents.RESOLVE_MOTION,
+ payload=schemas.ChairResolveMotionPayload(
+ motion_id=state.submitted_motions[-1].id, action=True
+ ),
+ ),
+ chair,
+ )
+
+
+def test_resolution_and_pending_amendment_are_voted_in_order(
+ engine, session_state, delegate_actor, chair_actor
+):
+ session_state.current_state = enums.States.OPEN_GSL
+ _resolve_last(
+ engine,
+ _submit_motion(
+ engine,
+ session_state,
+ delegate_actor,
+ type=enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
+ resolution_title="Clean Water",
+ resolution_id="resolution-clean-water",
+ ),
+ chair_actor,
+ )
+ resolution = session_state.draft_resolutions[0]
+ _resolve_last(
+ engine,
+ _submit_motion(
+ engine,
+ session_state,
+ delegate_actor,
+ type=enums.Motions.INTRODUCE_AMENDMENT_PROPOSAL,
+ target_resolution_id=resolution.id,
+ amendment_id="amendment-clean-water-1",
+ is_friendly=False,
+ ),
+ chair_actor,
+ )
+
+ session_state.current_state = enums.States.VOTING_PREPARATION
+ engine.dispatch(
+ session_state,
+ schemas.StartResolutionVoteEvent(
+ type=enums.ChairEvents.START_RESOLUTION_VOTE,
+ payload=schemas.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ assert session_state.voting is not None
+ assert session_state.voting.amendment_in_vote is not None
+
+ session_state.roll_call.registry = {0: enums.RollCallChoice.PRESENT}
+ engine.dispatch(
+ session_state,
+ schemas.CastVoteEvent(
+ type=enums.DelegateEvents.CAST_VOTE,
+ payload=schemas.DelegateVotingPayload(vote=enums.VotingChoice.FAVOUR),
+ ),
+ delegate_actor,
+ )
+ engine.dispatch(
+ session_state,
+ schemas.CloseProceduralVotingEvent(
+ type=enums.ChairEvents.CLOSE_PROCEDURAL_VOTING,
+ payload=schemas.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ assert resolution.amendments[0].status == enums.AmendmentStatus.ADOPTED
+ assert session_state.voting is not None
+ assert session_state.voting.target_type == enums.VotingType.SUBSTANTIVE
+
+
+def test_roll_call_requires_all_votes_then_processes_rights_queue(
+ engine, session_state, chair_actor
+):
+ resolution = models.ResolutionContext(
+ id="resolution-1", title="Test", delegate_id=0, roll_call_vote=True
+ )
+ session_state.draft_resolutions.append(resolution)
+ session_state.current_state = enums.States.VOTING_PREPARATION
+ session_state.roll_call.registry = {
+ 0: enums.RollCallChoice.PRESENT,
+ 1: enums.RollCallChoice.PRESENT_AND_VOTING,
+ }
+ engine.dispatch(
+ session_state,
+ schemas.StartResolutionVoteEvent(
+ type=enums.ChairEvents.START_RESOLUTION_VOTE,
+ payload=schemas.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ for representation_id, vote in (
+ (0, enums.VotingChoice.YES_WITH_RIGHTS),
+ (1, enums.VotingChoice.AGAINST),
+ ):
+ engine.dispatch(
+ session_state,
+ schemas.RecordSubstantiveVoteEvent(
+ type=enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE,
+ payload=schemas.RecordSubstantiveVotePayload(
+ representation_id=representation_id, vote=vote
+ ),
+ ),
+ chair_actor,
+ )
+ advance = schemas.AdvanceSubstantiveVoteRoundEvent(
+ type=enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND,
+ payload=schemas.EmptyPayload(),
+ )
+ engine.dispatch(session_state, advance, chair_actor)
+ assert session_state.voting is not None
+ assert session_state.voting.rights_queue == [0]
+ engine.dispatch(
+ session_state,
+ schemas.NextSpeakerEvent(
+ type=enums.ChairEvents.NEXT_SPEAKER, payload=schemas.EmptyPayload()
+ ),
+ chair_actor,
+ )
+ assert session_state.timer_remaining_seconds == 30
+ engine.dispatch(session_state, advance, chair_actor)
+ engine.dispatch(session_state, advance, chair_actor)
+ assert resolution.status == enums.ResolutionStatus.ADOPTED
+ assert session_state.current_state == enums.States.VOTING_PREPARATION
From f35cd1fe8285d1850aa999cde5df59a4447b294e Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 06:13:46 -0300
Subject: [PATCH 20/25] Document resolution voting flow
Co-authored-by: Codex
---
backend/app/session/engine.py | 45 ++++++++++++-------
docs/realtime/events-and-payloads.md | 53 +++++++++++++++++++---
docs/realtime/mun-flow.md | 67 ++++++++++++++++++++++++----
docs/realtime/session-state.md | 15 ++++++-
4 files changed, 148 insertions(+), 32 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index e18582f..6be1f99 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -648,17 +648,34 @@ def apply_passed_motion(
reset_timer(state)
next_state = States.VOTING_PREPARATION # or VOTING_PREPARATION
- case Motions.VOTE_AMENDMENT:
- raise InvalidProceduralMove("Vote amendment is handled automatically")
- case Motions.VOTE_BY_ROLL_CALL:
- resolution = _get_draft_resolution(state, motion.target_resolution_id)
- resolution.roll_call_vote = True
case Motions.CLOSE_SPEAKERS_LIST:
next_state = States.CLOSED_GSL
case Motions.REOPEN_SPEAKERS_LIST:
next_state = States.OPEN_GSL
+ case Motions.CHANGE_TOPIC:
+ # note: seems more like an informal consultation
+ pass
+ case Motions.QUORUM:
+ state.roll_call = RollCallContext(registry={}, return_state=return_state)
+ next_state = States.ROLL_CALL
+ case _:
+ raise InvalidProceduralMove("Undefined motion type")
+
+ # additional case: if we went from GSL to something, save gsl structures
+ state.current_state = next_state
+
+
+def apply_passed_voting_preparation_motion(
+ state: SessionLiveState, motion: MotionContext
+) -> None:
+ """Apply a passed motion that changes a draft resolution, not debate state."""
+ match motion.type:
+ case Motions.VOTE_BY_ROLL_CALL:
+ _get_draft_resolution(
+ state, motion.target_resolution_id
+ ).roll_call_vote = True
case Motions.SPLIT_PROPOSAL:
parent = _get_draft_resolution(state, motion.target_resolution_id)
if motion.split_title is None or motion.split_resolution_id is None:
@@ -673,17 +690,8 @@ def apply_passed_motion(
roll_call_vote=parent.roll_call_vote,
)
)
- case Motions.CHANGE_TOPIC:
- # note: seems more like an informal consultation
- pass
- case Motions.QUORUM:
- state.roll_call = RollCallContext(registry={}, return_state=return_state)
- next_state = States.ROLL_CALL
case _:
- raise InvalidProceduralMove("Undefined motion type")
-
- # additional case: if we went from GSL to something, save gsl structures
- state.current_state = next_state
+ raise InvalidProceduralMove("Not a voting preparation motion")
def _ensure_resolution_id_available(
@@ -798,7 +806,11 @@ def handle_close_procedural_voting(
raise InvalidProceduralMove("Can't close voting if motion is None")
if passed:
- apply_passed_motion(state, motion, return_state=state.voting.return_state)
+ if motion.type in {Motions.SPLIT_PROPOSAL, Motions.VOTE_BY_ROLL_CALL}:
+ apply_passed_voting_preparation_motion(state, motion)
+ state.current_state = state.voting.return_state
+ else:
+ apply_passed_motion(state, motion, return_state=state.voting.return_state)
else:
# motion failed, so return to last state
state.current_state = state.voting.return_state
@@ -831,7 +843,6 @@ def handle_finish_caucus(
def handle_resolve_motion(
state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor
) -> SessionLiveState:
- # TODO: check how to resolve INTRODUCE_RESOLUTION_PROPOSAL and INTRODUCE_AMENDMENT_PROPOSAL motions separately from procedural motions
require_chair(actor)
payload = event.payload
diff --git a/docs/realtime/events-and-payloads.md b/docs/realtime/events-and-payloads.md
index 69d9924..7553eed 100644
--- a/docs/realtime/events-and-payloads.md
+++ b/docs/realtime/events-and-payloads.md
@@ -16,7 +16,7 @@ An event is an action a client requests. Its payload is the event-specific input
## Delegate events
- `SubmitMotionEvent`
- - Payload: `type`, `delegate`, and optional `debate_type`,
+ - Payload: `type` and optional `debate_type`,
`total_duration_minutes`, `per_speaker_seconds`, `target_topic`, and
`details`.
- Submits a motion allowed in the current phase. The backend records the
@@ -35,9 +35,10 @@ An event is an action a client requests. Its payload is the event-specific input
- Removes the authenticated delegate from the open GSL queue.
- `CastVoteEvent`
- - Payload: `type` (`FORMAL` or `INFORMAL`), `vote` (`FAVOUR`, `AGAINST`, or
- `ABSTAIN`), and optional `motion_id` and `title`.
+ - Payload: `vote`.
- Records one vote from the authenticated delegate while voting is active.
+ Substantive votes permit `Favour`, `Against`, and `Abstain`; a roll-call
+ initial round also permits `Yes With Rights` and `No With Rights`.
- `AnswerRollCallEvent`
- Payload: `choice` (`Present` or `Present and Voting`).
@@ -53,6 +54,19 @@ An event is an action a client requests. Its payload is the event-specific input
`SubmitMotionEvent.payload.type` is one of the backend `Motions` values, such
as `Mudar Tipo de Debate`, `Encerramento de Debate`, or `Quórum`. Which motion types are allowed depends on `current_state`. The optional fields are validated when the selected motion needs them; for example, a debate-type motion can need `debate_type` and duration information.
+Resolution-related motions require the following additional fields:
+
+- `Introdução da Proposta de Resolução`: `resolution_id`, `resolution_title`.
+- `Introdução da Proposta de Emenda`: `target_resolution_id`, `amendment_id`,
+ `is_friendly`.
+- `Divisão da Proposta`: `target_resolution_id`, `split_resolution_id`,
+ `split_title`.
+- `Votação por Chamada`: `target_resolution_id`.
+
+These IDs are supplied by the delegate and are accepted into live state only
+after chair acceptance. The authenticated actor supplies the submitting
+representation; clients do not send it.
+
### Question payload values
`SubmitQuestionEvent.payload.type` is `Order`, `Question`, or `Personal
@@ -101,6 +115,30 @@ Privilege`.
- Tallies the current procedural vote and applies its result or returns to
the earlier phase.
+- `StartResolutionVoteEvent`
+ - Payload: `{}`.
+ - Valid only in Voting Preparation. Starts the first `DRAFT` resolution in
+ `draft_resolutions` list order and enters Voting Procedures. Pending
+ unfriendly amendments open procedural votes before the substantive vote.
+
+- `RecordSubstantiveVoteEvent`
+ - Payload: `representation_id`, `vote`.
+ - Lets the chair record one allowed substantive vote for an eligible
+ representation. The representation must exist in the session and cannot
+ be recorded twice.
+
+- `CloseSubstantiveVotingEvent`
+ - Payload: `{}`.
+ - Closes and tallies a standard substantive resolution vote. It is not used
+ for a roll-call substantive vote.
+
+- `AdvanceSubstantiveVoteRoundEvent`
+ - Payload: `{}`.
+ - Advances a roll-call substantive vote from its initial vote to the
+ yes-with-rights queue, then the no-with-rights queue, then the final tally.
+ Every eligible representation must vote before the first advance, and the
+ active rights queue must be empty before later advances.
+
- `FinishCaucusEvent`
- Payload: `{}`.
- Ends an active moderated or unmoderated caucus. Clears its speaker, timer,
@@ -111,8 +149,9 @@ Privilege`.
- Payload: `{}`.
- In Open or Closed GSL, pops the next `representation_id` from `gsl_queue`
into `current_speaker`. In Tour de Table, does the same with `caucus_list`.
- An empty list clears the current speaker and timer. It is not valid for a
- moderated or unmoderated caucus.
+ An empty list clears the current speaker and timer. During a roll-call
+ rights round, it serves the next representation in that rights queue for
+ 30 seconds. It is not valid for a moderated or unmoderated caucus.
- `AddGslSpeakerEvent`
- Payload: `representation_id`.
@@ -124,7 +163,9 @@ Privilege`.
- Makes a valid representation the current speaker in GSL, moderated
caucus, or Tour de Table. In GSL and Tour de Table, removes that
representation from its pending queue/list first. In moderated caucus, it
- leaves `caucus_list` unchanged. It is not valid during unmoderated caucus.
+ leaves `caucus_list` unchanged. During a roll-call rights round, only a
+ queued representation may receive the floor and the duration is forced to
+ 30 seconds. It is not valid during unmoderated caucus.
- `MarkRollCallEvent`
- Payload: `delegation_id` and `choice` (`Present`, `Present and Voting`, or
diff --git a/docs/realtime/mun-flow.md b/docs/realtime/mun-flow.md
index d894067..5907e38 100644
--- a/docs/realtime/mun-flow.md
+++ b/docs/realtime/mun-flow.md
@@ -41,12 +41,19 @@ Open GSL <-------------------------------+
v
procedural motion / chair action
-Open or Closed GSL -- accepted end-debate motion --> Voting Procedures
+Open or Closed GSL -- accepted end-debate motion --> Voting Preparation
|
- | substantive-resolution flow
- | is not implemented yet
+ | chair starts next draft
v
- Finished
+ Voting Procedures
+ |
+ pending amendments -> procedural amendment votes
+ |
+ v
+ substantive resolution vote
+ |
+ v
+ Voting Preparation
```
`Finished` can also be reached when the chair closes the session from an
@@ -123,10 +130,48 @@ WebMUN currently supports two distinct voting mechanisms:
uses `Voting Execution` temporarily and returns to its origin phase when
closed; it does not itself apply a procedural transition.
-`Voting Procedures` is the intended destination after an accepted
-end-debate motion. Substantive voting on resolutions, amendments, and related
-final outcomes is not implemented yet. This is a known boundary, rather than
-a promise that the state name alone provides a complete resolution workflow.
+An accepted end-debate motion enters `Voting Preparation`. The chair then uses
+`StartResolutionVoteEvent` to start the first `DRAFT` item in
+`draft_resolutions`, in list order. The session enters `Voting Procedures`.
+
+Before a resolution's substantive vote, each pending unfriendly amendment on
+that resolution is voted procedurally in submission order. A passed amendment
+becomes adopted; a failed amendment becomes rejected. Friendly amendments are
+accepted directly when the chair accepts their introduction.
+
+The substantive vote has two forms:
+
+- **Standard vote:** each eligible delegate may submit `Favour`, `Against`, or
+ `Abstain`; the chair may record a vote on a delegation's behalf and closes
+ the vote with `CloseSubstantiveVotingEvent`.
+- **Roll-call vote:** a passed `Vote By Roll Call` motion marks one draft for
+ roll call. Every representation marked `Present` or `Present and Voting` in
+ the completed roll call must vote. The initial round also accepts
+ `Yes With Rights` and `No With Rights`. The chair advances through the yes
+ and no rights queues using `AdvanceSubstantiveVoteRoundEvent`; the existing
+ floor controls serve each queued representation for 30 seconds.
+
+`Present and Voting` representations cannot abstain. A terminal substantive
+vote returns to `Voting Preparation`, where the chair may start the next draft
+when one is available.
+
+### Resolution preparation motions
+
+Delegates submit resolution-related motion fields as strings. The chair must
+accept the introduction before the draft enters live state; acceptance is the
+formatting and procedural gate.
+
+- Resolution introduction requires `resolution_id` and `resolution_title`.
+- Amendment introduction requires `target_resolution_id`, `amendment_id`, and
+ `is_friendly`.
+- Split Proposal requires `target_resolution_id`, `split_resolution_id`, and
+ `split_title`. When passed in Voting Preparation, it retains the parent and
+ appends a child draft with the parent's submitter and roll-call setting.
+- Vote By Roll Call requires `target_resolution_id` and, when passed in Voting
+ Preparation, marks only that draft for a roll-call substantive vote.
+
+`VOTE_AMENDMENT` is not an available motion. Pending unfriendly amendments are
+handled automatically as part of their target resolution's vote.
### 6. Close the session
@@ -151,6 +196,12 @@ The state includes both the phase and the context required to continue it:
- submitted motions and questions; and
- the current voting context and the phase to return to.
+Draft resolution and amendment terminal statuses are currently kept in the
+live snapshot so connected clients can render the result. There is not yet a
+separate durable audit/event stream. A future audit implementation should move
+terminal outcomes and tallies out of the live draft list and notify clients
+with explicit result events.
+
### Commands in, snapshots out
Clients connect to the session WebSocket, authenticate with a Supabase JWT,
diff --git a/docs/realtime/session-state.md b/docs/realtime/session-state.md
index 405bfc8..f15f113 100644
--- a/docs/realtime/session-state.md
+++ b/docs/realtime/session-state.md
@@ -25,7 +25,20 @@ event. It is also persisted so an active session can be restored.
`agenda_topics` hold pending procedural information.
- **Voting and attendance:** `voting`, `voting_choice`, and `roll_call` hold
- voting context and roll-call records.
+ voting context and roll-call records. New substantive eligibility is derived
+ from `roll_call.registry`; `voting_choice` is legacy state planned for
+ removal.
+
+- **Drafts:** `draft_resolutions` holds accepted active resolution drafts. A
+ draft has its delegate-supplied `id`, title, submitter, optional parent ID,
+ roll-call flag, amendments, and current status. Amendments record their
+ delegate-supplied ID, target, submitter, friendliness, and status.
+
+- **Active vote:** for a resolution vote, `voting.resolution_in_vote` identifies
+ the draft. Pending amendment procedural votes also set
+ `voting.amendment_in_vote`. A roll-call substantive vote uses
+ `voting.substantive_round` and `voting.rights_queue` to represent its active
+ stage and speaker order.
## Using a snapshot on the frontend
From fc40d7ebf5444d7cae71b9ace3d55c3d45eb5cec Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 06:32:29 -0300
Subject: [PATCH 21/25] refactor(backend): remove deprecated voting_choice
---
AGENTS.md | 1 +
backend/app/session/engine.py | 7 +++----
backend/app/session/models.py | 2 --
backend/app/tests/session/test_engine.py | 10 +++++-----
sonar-project.properties | 1 +
5 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 8aee52b..4a4e81d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,3 +15,4 @@
- Treat `representation_id` as the delegation identity everywhere.
- For cross-boundary changes, verify the backend tests and the frontend build/typecheck. Report pre-existing failures separately.
- Inspect security advisories before changing dependencies. Do not run `npm audit fix --force` without approval and a review of the resulting version changes.
+- For any commit done by an agent, include the agent as coauthor (for example, Codex should append `-m "Co-authored-by: Codex "`)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 6be1f99..74281d7 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -137,13 +137,13 @@ def count_present_delegations(state: SessionLiveState) -> int:
"""Count total present delegations.
A delegation is considered present (even if AFK)
if it's Roll Call Choice is Present / Present and Voting"""
- if state.voting_choice is None:
+ if not state.roll_call.registry:
return 0
return len(
[
True
- for _, vote in state.voting_choice.items()
+ for _, vote in state.roll_call.registry.items()
if vote == enums.RollCallChoice.PRESENT
or vote == enums.RollCallChoice.PRESENT_AND_VOTING
]
@@ -442,7 +442,6 @@ def handle_open_session(
state.current_state = States.ROLL_CALL
state.roll_call = RollCallContext(registry={})
- state.voting_choice = {}
state.gsl_queue = []
state.current_speaker = None
state.debate = None
@@ -1311,7 +1310,7 @@ def handle_close_roll_call(
# Initial roll call enters Open GSL; quorum roll calls restore their source state.
state.current_state = state.roll_call.return_state or States.OPEN_GSL
- state.voting_choice = {
+ state.roll_call.registry = {
delegation_id: RollCallChoice.PRESENT_AND_VOTING
if choice == RollCallChoice.PRESENT_AND_VOTING
else RollCallChoice.PRESENT
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 467db57..0de6dca 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -174,8 +174,6 @@ class SessionLiveState(BaseModel):
voting: VotingContext | None = None
# present delegations with voting choice
- voting_choice: dict[int, enums.RollCallChoice] | None = None # DelegationId as key
-
roll_call: RollCallContext # Not None, even if registry is empty
draft_resolutions: list[ResolutionContext] = []
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index a0d1933..18afbfd 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -481,7 +481,7 @@ def test_chair_can_close_roll_call(
state = engine.dispatch(session_state, close_roll_call_event, chair_actor)
assert state.current_state == enums.States.OPEN_GSL
- assert state.voting_choice == {
+ assert state.roll_call.registry == {
1: enums.RollCallChoice.PRESENT,
2: enums.RollCallChoice.PRESENT_AND_VOTING,
}
@@ -495,7 +495,7 @@ def test_quorum_roll_call_restores_closed_gsl(
chair_actor: md.SessionActor,
) -> None:
session_state.current_state = enums.States.VOTING_EXECUTION
- session_state.voting_choice = {
+ session_state.roll_call.registry = {
0: enums.RollCallChoice.PRESENT,
1: enums.RollCallChoice.PRESENT,
2: enums.RollCallChoice.PRESENT,
@@ -538,7 +538,7 @@ def test_quorum_roll_call_restores_moderated_caucus(
)
session_state.current_state = enums.States.VOTING_EXECUTION
session_state.debate = debate
- session_state.voting_choice = {
+ session_state.roll_call.registry = {
0: enums.RollCallChoice.PRESENT,
1: enums.RollCallChoice.PRESENT,
2: enums.RollCallChoice.PRESENT,
@@ -729,7 +729,7 @@ def test_chair_can_close_passed_procedural_vote(
close_procedural_voting_event: sch.CloseProceduralVotingEvent,
chair_actor: md.SessionActor,
) -> None:
- procedural_voting_state.voting_choice = {
+ procedural_voting_state.roll_call.registry = {
0: enums.RollCallChoice.PRESENT,
1: enums.RollCallChoice.PRESENT,
2: enums.RollCallChoice.PRESENT,
@@ -757,7 +757,7 @@ def test_chair_can_close_failed_procedural_vote(
close_procedural_voting_event: sch.CloseProceduralVotingEvent,
chair_actor: md.SessionActor,
) -> None:
- procedural_voting_state.voting_choice = {
+ procedural_voting_state.roll_call.registry = {
0: enums.RollCallChoice.PRESENT,
1: enums.RollCallChoice.PRESENT,
2: enums.RollCallChoice.PRESENT,
diff --git a/sonar-project.properties b/sonar-project.properties
index 02e03c5..08cc809 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -13,5 +13,6 @@ sonar.python.version=3.11
sonar.sources=backend/app,frontend/src
sonar.tests=backend/app/tests
sonar.exclusions=backend/app/tests/**
+sonar.exclusions=frontend/src/components/ui/**/*
sonar.coverage.exclusions=frontend/src/**
From 1b9fc07e6417cb9df1e221781800466a1f484519 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 06:38:05 -0300
Subject: [PATCH 22/25] ci: refactor sonar exclusions
---
sonar-project.properties | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/sonar-project.properties b/sonar-project.properties
index 08cc809..d633df7 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,7 +12,6 @@ sonar.python.version=3.11
sonar.sources=backend/app,frontend/src
sonar.tests=backend/app/tests
-sonar.exclusions=backend/app/tests/**
-sonar.exclusions=frontend/src/components/ui/**/*
+sonar.exclusions=backend/app/tests/**, frontend/src/components/ui/**
sonar.coverage.exclusions=frontend/src/**
From ad057250cc6eeef578d7cefcdd04e4be2a48816b Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:38:29 -0300
Subject: [PATCH 23/25] refactor(backend): refactor _record_vote
---
backend/app/session/engine.py | 44 ++++++++++++-----------------------
backend/app/session/models.py | 17 +++++++++++++-
2 files changed, 31 insertions(+), 30 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index 74281d7..cab9fe2 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -99,7 +99,7 @@ def generate_next_question_id(state: SessionLiveState) -> int:
def validate_motion_payload(
- payload: schemas.MotionPayload, state: SessionLiveState
+ payload: schemas.MotionPayload
) -> None:
"""Should validate motion payload before submitting"""
@@ -284,7 +284,7 @@ def handle_delegate_submit_motion(
):
raise InvalidProceduralMove("Submitting motions during caucuses is disabled")
- validate_motion_payload(payload, state)
+ validate_motion_payload(payload)
if payload.type in {
Motions.INTRODUCE_AMENDMENT_PROPOSAL,
Motions.SPLIT_PROPOSAL,
@@ -388,34 +388,20 @@ def _record_vote(
"""Validate and record one vote. Substantive eligibility is frozen on start."""
if representation_id in voting.voting_registry:
raise InvalidProceduralMove("Already cast vote")
- if voting.target_type == enums.VotingType.PROCEDURAL:
- if not voting.is_choice_allowed(vote):
- raise InvalidProceduralMove("Vote choice is not allowed")
- elif voting.target_type == enums.VotingType.SUBSTANTIVE:
- if representation_id not in _eligible_roll_call_representation_ids(state):
+
+ attendance = state.roll_call.registry.get(representation_id)
+ if voting.target_type == enums.VotingType.SUBSTANTIVE and attendance not in {
+ enums.RollCallChoice.PRESENT, enums.RollCallChoice.PRESENT_AND_VOTING
+ }:
raise InvalidProceduralMove("Representation is not eligible to vote")
- if voting.substantive_round != enums.SubstantiveVoteRound.INITIAL:
- raise InvalidProceduralMove("Votes are closed during rights of reply")
- allowed = {
- enums.VotingChoice.FAVOUR,
- enums.VotingChoice.AGAINST,
- enums.VotingChoice.ABSTAIN,
- }
- if voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote:
- allowed |= {
- enums.VotingChoice.YES_WITH_RIGHTS,
- enums.VotingChoice.NO_WITH_RIGHTS,
- }
- if vote not in allowed:
+
+ if not voting.is_choice_allowed(
+ choice=vote,
+ is_roll_call= bool(voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote),
+ is_present_and_voting= attendance == enums.RollCallChoice.PRESENT_AND_VOTING
+ ):
raise InvalidProceduralMove("Vote choice is not allowed")
- if (
- vote == enums.VotingChoice.ABSTAIN
- and state.roll_call.registry.get(representation_id)
- == enums.RollCallChoice.PRESENT_AND_VOTING
- ):
- raise InvalidProceduralMove(
- "Present and voting representations cannot abstain"
- )
+
voting.voting_registry[representation_id] = vote
@@ -932,7 +918,7 @@ def handle_chair_submit_motion(
require_chair(actor)
payload = event.payload
- validate_motion_payload(payload=payload, state=state)
+ validate_motion_payload(payload=payload)
# create motion context
context = MotionContext(
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 0de6dca..16311a0 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -101,10 +101,25 @@ class VotingContext(BaseModel):
allow_veto_power: bool = False
- def is_choice_allowed(self, choice: enums.VotingChoice) -> bool:
+ def is_choice_allowed(
+ self,
+ choice: enums.VotingChoice,
+ is_roll_call: bool,
+ is_present_and_voting: bool
+ ) -> bool:
if self.target_type == enums.VotingType.PROCEDURAL:
return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST)
+ if self.target_type == enums.VotingType.SUBSTANTIVE:
+ if is_present_and_voting and choice == enums.VotingChoice.ABSTAIN:
+ return False
+ if not is_roll_call and choice in {
+ enums.VotingChoice.YES_WITH_RIGHTS,
+ enums.VotingChoice.NO_WITH_RIGHTS,
+ enums.VotingChoice.PASS
+ }:
+ return False
+
return True
From ba14ad3c84863c60b6244d85bb12fde5fd86f4ee Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 13:34:00 -0300
Subject: [PATCH 24/25] Simplify session voting state
Co-authored-by: Codex
---
backend/app/session/engine.py | 253 ++++++++----------
backend/app/session/enums.py | 12 -
backend/app/session/models.py | 12 +-
backend/app/tests/session/test_engine.py | 7 +-
.../session/test_resolution_lifecycle.py | 21 +-
docs/realtime/mun-flow.md | 10 +-
docs/realtime/session-state.md | 5 +-
7 files changed, 143 insertions(+), 177 deletions(-)
diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py
index cab9fe2..984a569 100644
--- a/backend/app/session/engine.py
+++ b/backend/app/session/engine.py
@@ -2,7 +2,7 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from math import ceil
-from typing import Any, TypeAlias
+from typing import Any, TypeAlias, cast
import app.session.enums as enums
import app.session.schemas as schemas
@@ -99,11 +99,19 @@ def generate_next_question_id(state: SessionLiveState) -> int:
def validate_motion_payload(
- payload: schemas.MotionPayload
+ payload: schemas.MotionPayload,
+ state: SessionLiveState,
+ representation_id: int,
) -> None:
- """Should validate motion payload before submitting"""
+ """Validate a motion before reserving it in the submitted-motion queue."""
+ if payload.type not in MOTIONS_ALLOWED.get(state.current_state, set()):
+ raise InvalidProceduralMove("Cannot submit this motion at this phase")
+ if (
+ state.current_state in {States.MODERATED_CAUCUS, States.UNMODERATED_CAUCUS}
+ and not state.can_set_motion
+ ):
+ raise InvalidProceduralMove("Submitting motions during caucuses is disabled")
- # can also raise error if there are missing fields
if (
payload.type == Motions.CHANGE_DEBATE_TYPE
and payload.debate_type == DebateTypes.MODERATED_DEBATE
@@ -132,6 +140,23 @@ def validate_motion_payload(
if payload.type == Motions.VOTE_AMENDMENT:
raise InvalidProceduralMove("Vote amendment is handled automatically")
+ if representation_id not in state.delegations:
+ raise InvalidProceduralMove("Representation not found")
+
+ match payload.type:
+ case Motions.INTRODUCE_RESOLUTION_PROPOSAL:
+ _ensure_resolution_id_available(state, cast(str, payload.resolution_id))
+ case Motions.INTRODUCE_AMENDMENT_PROPOSAL:
+ _get_draft_resolution(state, payload.target_resolution_id)
+ _ensure_amendment_id_available(state, cast(str, payload.amendment_id))
+ case Motions.SPLIT_PROPOSAL:
+ _get_draft_resolution(state, payload.target_resolution_id)
+ _ensure_resolution_id_available(
+ state, cast(str, payload.split_resolution_id)
+ )
+ case Motions.VOTE_BY_ROLL_CALL:
+ _get_draft_resolution(state, payload.target_resolution_id)
+
def count_present_delegations(state: SessionLiveState) -> int:
"""Count total present delegations.
@@ -270,27 +295,8 @@ def handle_delegate_submit_motion(
"""Handles/Maps all possible states through a motion"""
require_delegate(actor)
- # Extract payload (as DelegateMotionSchema)
payload = event.payload
- current_state = state.current_state
-
- # check if motion can be made for this state
- if payload.type not in MOTIONS_ALLOWED.get(current_state, set()):
- raise InvalidProceduralMove("Cannot submit this motion at this phase")
-
- if (
- current_state in {States.MODERATED_CAUCUS, States.UNMODERATED_CAUCUS}
- and not state.can_set_motion
- ):
- raise InvalidProceduralMove("Submitting motions during caucuses is disabled")
-
- validate_motion_payload(payload)
- if payload.type in {
- Motions.INTRODUCE_AMENDMENT_PROPOSAL,
- Motions.SPLIT_PROPOSAL,
- Motions.VOTE_BY_ROLL_CALL,
- }:
- _get_draft_resolution(state, payload.target_resolution_id)
+ validate_motion_payload(payload, state, actor.delegation.id) # type: ignore
context = MotionContext(
id=generate_next_motion_id(state),
@@ -391,16 +397,19 @@ def _record_vote(
attendance = state.roll_call.registry.get(representation_id)
if voting.target_type == enums.VotingType.SUBSTANTIVE and attendance not in {
- enums.RollCallChoice.PRESENT, enums.RollCallChoice.PRESENT_AND_VOTING
+ enums.RollCallChoice.PRESENT,
+ enums.RollCallChoice.PRESENT_AND_VOTING,
}:
- raise InvalidProceduralMove("Representation is not eligible to vote")
+ raise InvalidProceduralMove("Representation is not eligible to vote")
if not voting.is_choice_allowed(
- choice=vote,
- is_roll_call= bool(voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote),
- is_present_and_voting= attendance == enums.RollCallChoice.PRESENT_AND_VOTING
+ choice=vote,
+ is_roll_call=bool(
+ voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote
+ ),
+ is_present_and_voting=attendance == enums.RollCallChoice.PRESENT_AND_VOTING,
):
- raise InvalidProceduralMove("Vote choice is not allowed")
+ raise InvalidProceduralMove("Vote choice is not allowed")
voting.voting_registry[representation_id] = vote
@@ -663,13 +672,10 @@ def apply_passed_voting_preparation_motion(
).roll_call_vote = True
case Motions.SPLIT_PROPOSAL:
parent = _get_draft_resolution(state, motion.target_resolution_id)
- if motion.split_title is None or motion.split_resolution_id is None:
- raise InvalidProceduralMove("Invalid split proposal")
- _ensure_resolution_id_available(state, motion.split_resolution_id)
state.draft_resolutions.append(
ResolutionContext(
- id=motion.split_resolution_id,
- title=motion.split_title,
+ id=cast(str, motion.split_resolution_id),
+ title=cast(str, motion.split_title),
delegate_id=parent.delegate_id,
parent_resolution_id=parent.id,
roll_call_vote=parent.roll_call_vote,
@@ -684,6 +690,11 @@ def _ensure_resolution_id_available(
) -> None:
if any(resolution.id == resolution_id for resolution in state.draft_resolutions):
raise InvalidProceduralMove("Resolution ID already exists")
+ if any(
+ resolution_id in {motion.resolution_id, motion.split_resolution_id}
+ for motion in state.submitted_motions
+ ):
+ raise InvalidProceduralMove("Resolution ID already reserved")
def _ensure_amendment_id_available(state: SessionLiveState, amendment_id: str) -> None:
@@ -693,6 +704,8 @@ def _ensure_amendment_id_available(state: SessionLiveState, amendment_id: str) -
for amendment in resolution.amendments
):
raise InvalidProceduralMove("Amendment ID already exists")
+ if any(amendment_id == motion.amendment_id for motion in state.submitted_motions):
+ raise InvalidProceduralMove("Amendment ID already reserved")
def _get_draft_resolution(
@@ -701,26 +714,18 @@ def _get_draft_resolution(
resolution = next(
(r for r in state.draft_resolutions if r.id == resolution_id), None
)
- if resolution is None or resolution.status != enums.ResolutionStatus.DRAFT:
+ if resolution is None:
raise InvalidProceduralMove("Draft resolution not found")
return resolution
-def _eligible_roll_call_representation_ids(state: SessionLiveState) -> list[int]:
- return [
- representation_id
- for representation_id, choice in state.roll_call.registry.items()
- if choice in {RollCallChoice.PRESENT, RollCallChoice.PRESENT_AND_VOTING}
- ]
-
-
def _open_next_amendment_or_substantive(state: SessionLiveState) -> None:
"""Open pending amendment votes in submission order, then the resolution vote."""
if state.voting is None or state.voting.resolution_in_vote is None:
raise InvalidProceduralMove("No resolution vote in progress")
resolution = state.voting.resolution_in_vote
pending = next(
- (a for a in resolution.amendments if a.status == enums.AmendmentStatus.PENDING),
+ (amendment for amendment in resolution.amendments if not amendment.is_friendly),
None,
)
if pending is not None:
@@ -768,17 +773,11 @@ def handle_close_procedural_voting(
amendment = state.voting.amendment_in_vote
resolution = state.voting.resolution_in_vote
motion = state.voting.motion_in_vote
- present = (
- len(_eligible_roll_call_representation_ids(state))
- if amendment is not None
- else count_present_delegations(state)
- )
+ present = count_present_delegations(state)
passed = tally_votes(state.voting, present)
if amendment is not None and resolution is not None:
- amendment.status = (
- enums.AmendmentStatus.ADOPTED if passed else enums.AmendmentStatus.REJECTED
- )
+ resolution.amendments.remove(amendment)
state.voting = VotingContext(
target_type=enums.VotingType.SUBSTANTIVE,
return_state=States.VOTING_PREPARATION,
@@ -838,76 +837,47 @@ def handle_resolve_motion(
if motion is None:
raise InvalidProceduralMove("Motion not found")
+ state.submitted_motions.remove(motion)
+ if not payload.action:
+ return state
+
if motion.type == Motions.INTRODUCE_RESOLUTION_PROPOSAL:
- if payload.action:
- if (
- motion.resolution_title is None
- or motion.resolution_id is None
- or motion.delegate_id is None
- ):
- raise InvalidProceduralMove("Invalid resolution introduction")
- _ensure_resolution_id_available(state, motion.resolution_id)
- state.draft_resolutions.append(
- ResolutionContext(
- id=motion.resolution_id,
- title=motion.resolution_title,
- delegate_id=motion.delegate_id,
- )
+ state.draft_resolutions.append(
+ ResolutionContext(
+ id=cast(str, motion.resolution_id),
+ title=cast(str, motion.resolution_title),
+ delegate_id=cast(int, motion.delegate_id),
)
- state.submitted_motions.remove(motion)
+ )
return state
if motion.type == Motions.INTRODUCE_AMENDMENT_PROPOSAL:
- if payload.action:
- target = _get_draft_resolution(state, motion.target_resolution_id)
- if (
- motion.is_friendly is None
- or motion.amendment_id is None
- or motion.delegate_id is None
- ):
- raise InvalidProceduralMove("Invalid amendment introduction")
- _ensure_amendment_id_available(state, motion.amendment_id)
- target.amendments.append(
- AmendmentContext(
- id=motion.amendment_id,
- target_resolution_id=target.id,
- is_friendly=motion.is_friendly,
- representation_id=motion.delegate_id,
- status=(
- enums.AmendmentStatus.ADOPTED
- if motion.is_friendly
- else enums.AmendmentStatus.PENDING
- ),
- )
+ target = _get_draft_resolution(state, motion.target_resolution_id)
+ is_friendly = cast(bool, motion.is_friendly)
+ target.amendments.append(
+ AmendmentContext(
+ id=cast(str, motion.amendment_id),
+ target_resolution_id=target.id,
+ is_friendly=is_friendly,
+ representation_id=cast(int, motion.delegate_id),
)
- state.submitted_motions.remove(motion)
+ )
return state
- if motion.type in {Motions.SPLIT_PROPOSAL, Motions.VOTE_BY_ROLL_CALL}:
- if state.current_state != States.VOTING_PREPARATION:
- raise InvalidProceduralMove(
- "This motion is only available in voting preparation"
- )
- _get_draft_resolution(state, motion.target_resolution_id)
-
majority_type = (
enums.MajorityTypes.SIMPLE
if needs_simple_majority_type(motion.type)
else enums.MajorityTypes.QUALIFIED
)
- if payload.action:
- state.voting = VotingContext(
- target_type=enums.VotingType.PROCEDURAL,
- motion_in_vote=motion,
- return_state=state.current_state,
- voting_registry={},
- majority=majority_type,
- )
-
- state.current_state = States.VOTING_EXECUTION
-
- state.submitted_motions.remove(motion)
+ state.voting = VotingContext(
+ target_type=enums.VotingType.PROCEDURAL,
+ motion_in_vote=motion,
+ return_state=state.current_state,
+ voting_registry={},
+ majority=majority_type,
+ )
+ state.current_state = States.VOTING_EXECUTION
return state
@@ -918,7 +888,7 @@ def handle_chair_submit_motion(
require_chair(actor)
payload = event.payload
- validate_motion_payload(payload=payload)
+ validate_motion_payload(payload, state, payload.representation_id)
# create motion context
context = MotionContext(
@@ -1080,6 +1050,17 @@ def handle_grant_floor(
if representation_id not in state.delegations:
raise InvalidProceduralMove("Representation not found")
+ seconds = _grant_floor_seconds(state, representation_id, event.payload.seconds)
+ state.current_speaker = representation_id
+ reset_timer(state, seconds)
+
+ return state
+
+
+def _grant_floor_seconds(
+ state: SessionLiveState, representation_id: int, requested_seconds: int | None
+) -> int:
+ """Apply phase-specific floor rules and return the resulting speaker time."""
if (
state.current_state == States.VOTING_PROCEDURES
and state.voting is not None
@@ -1093,28 +1074,26 @@ def handle_grant_floor(
if representation_id not in state.voting.rights_queue:
raise InvalidProceduralMove("Representation has no right to speak")
state.voting.rights_queue.remove(representation_id)
- seconds = 30
- elif state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
+ return 30
+
+ if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}:
if representation_id in state.gsl_queue:
state.gsl_queue.remove(representation_id)
- seconds = event.payload.seconds or state.gsl_default_time_seconds
- elif state.current_state == States.MODERATED_CAUCUS:
+ return requested_seconds or state.gsl_default_time_seconds
+
+ if state.current_state == States.MODERATED_CAUCUS:
if state.debate is None:
raise InvalidProceduralMove("No active moderated caucus")
- seconds = event.payload.seconds or state.debate.per_speaker_seconds or 60
- elif state.current_state == States.TOUR_DE_TABLE:
+ return requested_seconds or state.debate.per_speaker_seconds or 60
+
+ if state.current_state == States.TOUR_DE_TABLE:
if representation_id in state.caucus_list:
state.caucus_list.remove(representation_id)
- seconds = event.payload.seconds or state.gsl_default_time_seconds
- elif state.current_state == States.UNMODERATED_CAUCUS:
- raise InvalidProceduralMove("Cannot grant floor during unmoderated caucus")
- else:
- raise InvalidProceduralMove("Cannot grant floor right now")
-
- state.current_speaker = representation_id
- reset_timer(state, seconds)
+ return requested_seconds or state.gsl_default_time_seconds
- return state
+ if state.current_state == States.UNMODERATED_CAUCUS:
+ raise InvalidProceduralMove("Cannot grant floor during unmoderated caucus")
+ raise InvalidProceduralMove("Cannot grant floor right now")
def handle_start_resolution_vote(
@@ -1127,14 +1106,7 @@ def handle_start_resolution_vote(
raise InvalidProceduralMove(
"Resolution voting can only start in voting preparation"
)
- resolution = next(
- (
- draft
- for draft in state.draft_resolutions
- if draft.status == enums.ResolutionStatus.DRAFT
- ),
- None,
- )
+ resolution = next(iter(state.draft_resolutions), None)
if resolution is None:
raise InvalidProceduralMove("No draft resolution is available to vote")
state.current_state = States.VOTING_PROCEDURES
@@ -1147,22 +1119,11 @@ def handle_start_resolution_vote(
return state
-def _all_eligible_voted(state: SessionLiveState, voting: VotingContext) -> bool:
- return set(_eligible_roll_call_representation_ids(state)) <= set(
- voting.voting_registry
- )
-
-
def _finish_substantive_vote(state: SessionLiveState) -> None:
if state.voting is None or state.voting.resolution_in_vote is None:
raise InvalidProceduralMove("No substantive vote in progress")
resolution = state.voting.resolution_in_vote
- passed = tally_votes(
- state.voting, len(_eligible_roll_call_representation_ids(state))
- )
- resolution.status = (
- enums.ResolutionStatus.ADOPTED if passed else enums.ResolutionStatus.REJECTED
- )
+ state.draft_resolutions.remove(resolution)
state.voting = None
state.current_speaker = None
reset_timer(state)
@@ -1224,7 +1185,7 @@ def handle_advance_substantive_vote_round(
):
raise InvalidProceduralMove("No roll-call substantive vote in progress")
if voting.substantive_round == enums.SubstantiveVoteRound.INITIAL:
- if not _all_eligible_voted(state, voting):
+ if len(voting.voting_registry) != count_present_delegations(state):
raise InvalidProceduralMove("Every eligible representation must vote")
voting.substantive_round = enums.SubstantiveVoteRound.YES_WITH_RIGHTS
voting.rights_queue = [
diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py
index 785cadb..a576450 100644
--- a/backend/app/session/enums.py
+++ b/backend/app/session/enums.py
@@ -138,18 +138,6 @@ class MotionDecision(StrEnum):
DENY = "Deny"
-class ResolutionStatus(StrEnum):
- DRAFT = "DRAFT"
- ADOPTED = "ADOPTED"
- REJECTED = "REJECTED"
-
-
-class AmendmentStatus(StrEnum):
- PENDING = "PENDING"
- ADOPTED = "ADOPTED"
- REJECTED = "REJECTED"
-
-
class SubstantiveVoteRound(StrEnum):
INITIAL = "INITIAL"
YES_WITH_RIGHTS = "YES_WITH_RIGHTS"
diff --git a/backend/app/session/models.py b/backend/app/session/models.py
index 16311a0..8fe9c6a 100644
--- a/backend/app/session/models.py
+++ b/backend/app/session/models.py
@@ -64,7 +64,6 @@ class AmendmentContext(BaseModel):
target_resolution_id: str
is_friendly: bool
representation_id: int
- status: enums.AmendmentStatus = enums.AmendmentStatus.PENDING
class ResolutionContext(BaseModel):
@@ -75,7 +74,6 @@ class ResolutionContext(BaseModel):
roll_call_vote: bool = False
parent_resolution_id: str | None = None
- status: enums.ResolutionStatus = enums.ResolutionStatus.DRAFT
class QuestionContext(BaseModel):
@@ -102,10 +100,10 @@ class VotingContext(BaseModel):
allow_veto_power: bool = False
def is_choice_allowed(
- self,
+ self,
choice: enums.VotingChoice,
is_roll_call: bool,
- is_present_and_voting: bool
+ is_present_and_voting: bool,
) -> bool:
if self.target_type == enums.VotingType.PROCEDURAL:
return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST)
@@ -115,10 +113,10 @@ def is_choice_allowed(
return False
if not is_roll_call and choice in {
enums.VotingChoice.YES_WITH_RIGHTS,
- enums.VotingChoice.NO_WITH_RIGHTS,
- enums.VotingChoice.PASS
+ enums.VotingChoice.NO_WITH_RIGHTS,
+ enums.VotingChoice.PASS,
}:
- return False
+ return False
return True
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index 18afbfd..e11115c 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -318,13 +318,14 @@ def test_delegate_cannot_submit_chair_motion(
def test_chair_can_log_motion(
engine: eng.SessionEngine,
- session_state: md.SessionLiveState,
+ open_gsl_state: md.SessionLiveState,
log_motion_event: sch.LogMotionEvent,
chair_actor: md.SessionActor,
) -> None:
- state = engine.dispatch(session_state, log_motion_event, chair_actor)
+ state = engine.dispatch(open_gsl_state, log_motion_event, chair_actor)
assert state.current_state == enums.States.VOTING_EXECUTION
- assert state.voting is not None and state.voting.motion_in_vote is not None
+ assert state.voting is not None
+ assert state.voting.motion_in_vote is not None
assert state.voting.motion_in_vote.type == log_motion_event.payload.type
diff --git a/backend/app/tests/session/test_resolution_lifecycle.py b/backend/app/tests/session/test_resolution_lifecycle.py
index 9a77791..286d294 100644
--- a/backend/app/tests/session/test_resolution_lifecycle.py
+++ b/backend/app/tests/session/test_resolution_lifecycle.py
@@ -1,6 +1,8 @@
import app.session.enums as enums
+import app.session.engine as engine_module
import app.session.models as models
import app.session.schemas as schemas
+import pytest
def _submit_motion(engine, state, actor, **payload):
@@ -87,7 +89,7 @@ def test_resolution_and_pending_amendment_are_voted_in_order(
),
chair_actor,
)
- assert resolution.amendments[0].status == enums.AmendmentStatus.ADOPTED
+ assert resolution.amendments == []
assert session_state.voting is not None
assert session_state.voting.target_type == enums.VotingType.SUBSTANTIVE
@@ -143,5 +145,20 @@ def test_roll_call_requires_all_votes_then_processes_rights_queue(
assert session_state.timer_remaining_seconds == 30
engine.dispatch(session_state, advance, chair_actor)
engine.dispatch(session_state, advance, chair_actor)
- assert resolution.status == enums.ResolutionStatus.ADOPTED
+ assert resolution not in session_state.draft_resolutions
assert session_state.current_state == enums.States.VOTING_PREPARATION
+
+
+def test_duplicate_pending_resolution_id_is_rejected(
+ engine, session_state, delegate_actor
+):
+ session_state.current_state = enums.States.OPEN_GSL
+ payload = {
+ "type": enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
+ "resolution_title": "Clean Water",
+ "resolution_id": "resolution-clean-water",
+ }
+ _submit_motion(engine, session_state, delegate_actor, **payload)
+
+ with pytest.raises(engine_module.InvalidProceduralMove, match="already reserved"):
+ _submit_motion(engine, session_state, delegate_actor, **payload)
diff --git a/docs/realtime/mun-flow.md b/docs/realtime/mun-flow.md
index 5907e38..b38f731 100644
--- a/docs/realtime/mun-flow.md
+++ b/docs/realtime/mun-flow.md
@@ -196,11 +196,11 @@ The state includes both the phase and the context required to continue it:
- submitted motions and questions; and
- the current voting context and the phase to return to.
-Draft resolution and amendment terminal statuses are currently kept in the
-live snapshot so connected clients can render the result. There is not yet a
-separate durable audit/event stream. A future audit implementation should move
-terminal outcomes and tallies out of the live draft list and notify clients
-with explicit result events.
+`draft_resolutions` contains only active drafts. A terminal substantive vote
+removes its resolution from the live list, and a completed unfriendly amendment
+is removed from its pending list. The planned audit/event stream will retain
+the outcome and tally, then notify connected clients with explicit result
+events.
### Commands in, snapshots out
diff --git a/docs/realtime/session-state.md b/docs/realtime/session-state.md
index f15f113..13061f5 100644
--- a/docs/realtime/session-state.md
+++ b/docs/realtime/session-state.md
@@ -31,8 +31,9 @@ event. It is also persisted so an active session can be restored.
- **Drafts:** `draft_resolutions` holds accepted active resolution drafts. A
draft has its delegate-supplied `id`, title, submitter, optional parent ID,
- roll-call flag, amendments, and current status. Amendments record their
- delegate-supplied ID, target, submitter, friendliness, and status.
+ roll-call flag, and amendments. Amendments record their delegate-supplied ID,
+ target, submitter, and friendliness. Terminal resolutions and completed
+ unfriendly amendments are removed from live state.
- **Active vote:** for a resolution vote, `voting.resolution_in_vote` identifies
the draft. Pending amendment procedural votes also set
From 05092e5c6d8ef88f9b40ded88316276625e88113 Mon Sep 17 00:00:00 2001
From: wate <110754234+r0liveir@users.noreply.github.com>
Date: Mon, 10 Aug 2026 13:37:43 -0300
Subject: [PATCH 25/25] test(backend): consolidate resolution lifecycle
coverage
Co-authored-by: Codex
---
backend/app/tests/session/test_engine.py | 159 +++++++++++++++++
.../session/test_resolution_lifecycle.py | 164 ------------------
2 files changed, 159 insertions(+), 164 deletions(-)
delete mode 100644 backend/app/tests/session/test_resolution_lifecycle.py
diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py
index e11115c..7867fcb 100644
--- a/backend/app/tests/session/test_engine.py
+++ b/backend/app/tests/session/test_engine.py
@@ -1148,3 +1148,162 @@ def test_chair_can_manually_set_phase(
state = engine.dispatch(session_state, manual_phase_set_event, chair_actor)
assert state.current_state == enums.States.OPEN_GSL
+
+
+def _submit_resolution_motion(engine, state, actor, **payload):
+ return engine.dispatch(
+ state,
+ sch.SubmitMotionEvent(
+ type=enums.DelegateEvents.SUBMIT_MOTION,
+ payload=sch.DelegateMotionPayload(**payload),
+ ),
+ actor,
+ )
+
+
+def _resolve_last_motion(engine, state, chair):
+ return engine.dispatch(
+ state,
+ sch.ResolveMotionEvent(
+ type=enums.ChairEvents.RESOLVE_MOTION,
+ payload=sch.ChairResolveMotionPayload(
+ motion_id=state.submitted_motions[-1].id, action=True
+ ),
+ ),
+ chair,
+ )
+
+
+def test_resolution_and_pending_amendment_are_voted_in_order(
+ engine, session_state, delegate_actor, chair_actor
+):
+ session_state.current_state = enums.States.OPEN_GSL
+ _resolve_last_motion(
+ engine,
+ _submit_resolution_motion(
+ engine,
+ session_state,
+ delegate_actor,
+ type=enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
+ resolution_title="Clean Water",
+ resolution_id="resolution-clean-water",
+ ),
+ chair_actor,
+ )
+ resolution = session_state.draft_resolutions[0]
+ _resolve_last_motion(
+ engine,
+ _submit_resolution_motion(
+ engine,
+ session_state,
+ delegate_actor,
+ type=enums.Motions.INTRODUCE_AMENDMENT_PROPOSAL,
+ target_resolution_id=resolution.id,
+ amendment_id="amendment-clean-water-1",
+ is_friendly=False,
+ ),
+ chair_actor,
+ )
+
+ session_state.current_state = enums.States.VOTING_PREPARATION
+ engine.dispatch(
+ session_state,
+ sch.StartResolutionVoteEvent(
+ type=enums.ChairEvents.START_RESOLUTION_VOTE,
+ payload=sch.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ assert session_state.voting is not None
+ assert session_state.voting.amendment_in_vote is not None
+
+ session_state.roll_call.registry = {0: enums.RollCallChoice.PRESENT}
+ engine.dispatch(
+ session_state,
+ sch.CastVoteEvent(
+ type=enums.DelegateEvents.CAST_VOTE,
+ payload=sch.DelegateVotingPayload(vote=enums.VotingChoice.FAVOUR),
+ ),
+ delegate_actor,
+ )
+ engine.dispatch(
+ session_state,
+ sch.CloseProceduralVotingEvent(
+ type=enums.ChairEvents.CLOSE_PROCEDURAL_VOTING,
+ payload=sch.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ assert resolution.amendments == []
+ assert session_state.voting is not None
+ assert session_state.voting.target_type == enums.VotingType.SUBSTANTIVE
+
+
+def test_roll_call_requires_all_votes_then_processes_rights_queue(
+ engine, session_state, chair_actor
+):
+ resolution = md.ResolutionContext(
+ id="resolution-1", title="Test", delegate_id=0, roll_call_vote=True
+ )
+ session_state.draft_resolutions.append(resolution)
+ session_state.current_state = enums.States.VOTING_PREPARATION
+ session_state.roll_call.registry = {
+ 0: enums.RollCallChoice.PRESENT,
+ 1: enums.RollCallChoice.PRESENT_AND_VOTING,
+ }
+ engine.dispatch(
+ session_state,
+ sch.StartResolutionVoteEvent(
+ type=enums.ChairEvents.START_RESOLUTION_VOTE,
+ payload=sch.EmptyPayload(),
+ ),
+ chair_actor,
+ )
+ for representation_id, vote in (
+ (0, enums.VotingChoice.YES_WITH_RIGHTS),
+ (1, enums.VotingChoice.AGAINST),
+ ):
+ engine.dispatch(
+ session_state,
+ sch.RecordSubstantiveVoteEvent(
+ type=enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE,
+ payload=sch.RecordSubstantiveVotePayload(
+ representation_id=representation_id, vote=vote
+ ),
+ ),
+ chair_actor,
+ )
+ advance = sch.AdvanceSubstantiveVoteRoundEvent(
+ type=enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND,
+ payload=sch.EmptyPayload(),
+ )
+ engine.dispatch(session_state, advance, chair_actor)
+ assert session_state.voting is not None
+ assert session_state.voting.rights_queue == [0]
+ engine.dispatch(
+ session_state,
+ sch.NextSpeakerEvent(
+ type=enums.ChairEvents.NEXT_SPEAKER, payload=sch.EmptyPayload()
+ ),
+ chair_actor,
+ )
+ assert session_state.timer_remaining_seconds == 30
+ engine.dispatch(session_state, advance, chair_actor)
+ engine.dispatch(session_state, advance, chair_actor)
+ assert resolution not in session_state.draft_resolutions
+ assert session_state.current_state == enums.States.VOTING_PREPARATION
+
+
+def test_duplicate_pending_resolution_id_is_rejected(
+ engine, session_state, delegate_actor
+):
+ session_state.current_state = enums.States.OPEN_GSL
+ payload = {
+ "type": enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
+ "resolution_title": "Clean Water",
+ "resolution_id": "resolution-clean-water",
+ }
+ _submit_resolution_motion(engine, session_state, delegate_actor, **payload)
+
+ with pytest.raises(eng.InvalidProceduralMove, match="already reserved"):
+ _submit_resolution_motion(engine, session_state, delegate_actor, **payload)
diff --git a/backend/app/tests/session/test_resolution_lifecycle.py b/backend/app/tests/session/test_resolution_lifecycle.py
deleted file mode 100644
index 286d294..0000000
--- a/backend/app/tests/session/test_resolution_lifecycle.py
+++ /dev/null
@@ -1,164 +0,0 @@
-import app.session.enums as enums
-import app.session.engine as engine_module
-import app.session.models as models
-import app.session.schemas as schemas
-import pytest
-
-
-def _submit_motion(engine, state, actor, **payload):
- return engine.dispatch(
- state,
- schemas.SubmitMotionEvent(
- type=enums.DelegateEvents.SUBMIT_MOTION,
- payload=schemas.DelegateMotionPayload(**payload),
- ),
- actor,
- )
-
-
-def _resolve_last(engine, state, chair):
- return engine.dispatch(
- state,
- schemas.ResolveMotionEvent(
- type=enums.ChairEvents.RESOLVE_MOTION,
- payload=schemas.ChairResolveMotionPayload(
- motion_id=state.submitted_motions[-1].id, action=True
- ),
- ),
- chair,
- )
-
-
-def test_resolution_and_pending_amendment_are_voted_in_order(
- engine, session_state, delegate_actor, chair_actor
-):
- session_state.current_state = enums.States.OPEN_GSL
- _resolve_last(
- engine,
- _submit_motion(
- engine,
- session_state,
- delegate_actor,
- type=enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
- resolution_title="Clean Water",
- resolution_id="resolution-clean-water",
- ),
- chair_actor,
- )
- resolution = session_state.draft_resolutions[0]
- _resolve_last(
- engine,
- _submit_motion(
- engine,
- session_state,
- delegate_actor,
- type=enums.Motions.INTRODUCE_AMENDMENT_PROPOSAL,
- target_resolution_id=resolution.id,
- amendment_id="amendment-clean-water-1",
- is_friendly=False,
- ),
- chair_actor,
- )
-
- session_state.current_state = enums.States.VOTING_PREPARATION
- engine.dispatch(
- session_state,
- schemas.StartResolutionVoteEvent(
- type=enums.ChairEvents.START_RESOLUTION_VOTE,
- payload=schemas.EmptyPayload(),
- ),
- chair_actor,
- )
- assert session_state.voting is not None
- assert session_state.voting.amendment_in_vote is not None
-
- session_state.roll_call.registry = {0: enums.RollCallChoice.PRESENT}
- engine.dispatch(
- session_state,
- schemas.CastVoteEvent(
- type=enums.DelegateEvents.CAST_VOTE,
- payload=schemas.DelegateVotingPayload(vote=enums.VotingChoice.FAVOUR),
- ),
- delegate_actor,
- )
- engine.dispatch(
- session_state,
- schemas.CloseProceduralVotingEvent(
- type=enums.ChairEvents.CLOSE_PROCEDURAL_VOTING,
- payload=schemas.EmptyPayload(),
- ),
- chair_actor,
- )
- assert resolution.amendments == []
- assert session_state.voting is not None
- assert session_state.voting.target_type == enums.VotingType.SUBSTANTIVE
-
-
-def test_roll_call_requires_all_votes_then_processes_rights_queue(
- engine, session_state, chair_actor
-):
- resolution = models.ResolutionContext(
- id="resolution-1", title="Test", delegate_id=0, roll_call_vote=True
- )
- session_state.draft_resolutions.append(resolution)
- session_state.current_state = enums.States.VOTING_PREPARATION
- session_state.roll_call.registry = {
- 0: enums.RollCallChoice.PRESENT,
- 1: enums.RollCallChoice.PRESENT_AND_VOTING,
- }
- engine.dispatch(
- session_state,
- schemas.StartResolutionVoteEvent(
- type=enums.ChairEvents.START_RESOLUTION_VOTE,
- payload=schemas.EmptyPayload(),
- ),
- chair_actor,
- )
- for representation_id, vote in (
- (0, enums.VotingChoice.YES_WITH_RIGHTS),
- (1, enums.VotingChoice.AGAINST),
- ):
- engine.dispatch(
- session_state,
- schemas.RecordSubstantiveVoteEvent(
- type=enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE,
- payload=schemas.RecordSubstantiveVotePayload(
- representation_id=representation_id, vote=vote
- ),
- ),
- chair_actor,
- )
- advance = schemas.AdvanceSubstantiveVoteRoundEvent(
- type=enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND,
- payload=schemas.EmptyPayload(),
- )
- engine.dispatch(session_state, advance, chair_actor)
- assert session_state.voting is not None
- assert session_state.voting.rights_queue == [0]
- engine.dispatch(
- session_state,
- schemas.NextSpeakerEvent(
- type=enums.ChairEvents.NEXT_SPEAKER, payload=schemas.EmptyPayload()
- ),
- chair_actor,
- )
- assert session_state.timer_remaining_seconds == 30
- engine.dispatch(session_state, advance, chair_actor)
- engine.dispatch(session_state, advance, chair_actor)
- assert resolution not in session_state.draft_resolutions
- assert session_state.current_state == enums.States.VOTING_PREPARATION
-
-
-def test_duplicate_pending_resolution_id_is_rejected(
- engine, session_state, delegate_actor
-):
- session_state.current_state = enums.States.OPEN_GSL
- payload = {
- "type": enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL,
- "resolution_title": "Clean Water",
- "resolution_id": "resolution-clean-water",
- }
- _submit_motion(engine, session_state, delegate_actor, **payload)
-
- with pytest.raises(engine_module.InvalidProceduralMove, match="already reserved"):
- _submit_motion(engine, session_state, delegate_actor, **payload)