Files
decision/frontend/app/stores/decisions.ts
T
YvvandClaude Fable 5 d886302b59 v2 : couche données complète + shell + primitives communes
- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:30:35 +02:00

1143 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Decisions store — the ACTION FAÇADE over collective.current.
*
* No own state: every read goes through useCollectiveStore(), every mutation
* stamps the entity (LWW updatedAt) and persists (debounced). Actions return
* the mutated object, or { ok: false, reason } with ONE French sentence whose
* subject is the collective or the person — never the engine.
*
* EXPLICIT imports only (no Nuxt auto-imports) — testable under plain vitest.
*
* Mode atelier: the optional `asPersonId` of castVote / assentTo / objectTo /
* adviseOn lets the seated person record someone else's gesture in presence;
* the row then carries recordedById (« saisi par X pour Y »).
*
* DOCUMENTED SIMPLE HEURISTICS (runTriage):
* - matchingClauses: a clause matches when its code (≥2 chars) or its title
* (≥4 chars) appears in the typed title (case/accent-insensitive), when a
* typed #tag equals its code, or when it is the explicitly targeted clause
* (amendsClauseId — R3 needs it in the index);
* - similarRecentCount: adopted decisions of the last 90 days sharing ≥2 tags;
* - similarRecordedCount: 'record' entries of the last 90 days sharing ≥2 tags.
*
* Vote corpus (openSession): computed concerns declared-before-snapshot
* concerns THE AUTHOR (the author belongs to the arrested list).
*/
import { defineStore } from 'pinia'
import type {
Advice,
Assent,
ClauseVersion,
Concern,
Decision,
DecisionStatus,
Id,
ISODate,
Mandate,
MandateDraft,
NominationMethod,
Objection,
Protocol,
TriageInput,
TriageContext,
Verdict,
VoteMethod,
VoteSession,
Vote,
} from '../types/domain'
import {
canTransition,
computeConcerned,
crystallize as crystallizeVector,
electionResult,
medianByElement,
nuancedResult,
smithThreshold,
techcommThreshold,
triage,
validateVote,
wotThreshold,
type ElectionOutcome,
type NuancedResult,
type TransitionContext,
type TransitionResult,
} from '../engine'
import { sha256Hex } from '../data/persistence'
import { useCollectiveStore } from './collective'
// ─────────────────────────────────────────────────────────────
// Shared shapes
// ─────────────────────────────────────────────────────────────
export type Refusal = { ok: false; reason: string }
const refuse = (reason: string): Refusal => ({ ok: false, reason })
const NO_COLLECTIVE = 'Aucun collectif actif — ouvre ou crée un collectif d\'abord.'
const NO_DECISION = 'Cette décision est introuvable.'
const NO_SESSION = 'Cette session de vote est introuvable.'
/** User adjustments applied over the verdict when validating the path. */
export type ApplyPathEdits = Partial<
Pick<
Decision,
| 'route' | 'scope' | 'reversibility' | 'weight' | 'urgent' | 'protocolId'
| 'title' | 'body' | 'tags' | 'resources' | 'brief' | 'baselineNote'
| 'amendsClauseId' | 'createsMandate' | 'paramSpec' | 'decidedHow'
| 'stewardIds' | 'measurerIds' | 'visibility' | 'overrideNote' | 'sunsetAt'
>
>
export interface CastVotePayload {
value?: Vote['value']
values?: number[]
choicePersonId?: Id
comment?: string
/** Mode atelier — record the gesture for someone else (recordedById = me). */
asPersonId?: Id
}
export type TallyResult =
| { method: 'consent'; openObjections: number; assentCount: number; adopted: boolean }
| {
method: 'binary'
votesFor: number
votesAgainst: number
total: number
threshold: number
smithMet: boolean
techcommMet: boolean
adopted: boolean
}
| { method: 'nuanced'; result: NuancedResult; adopted: boolean }
| { method: 'parametric'; median: number[] }
| { method: 'election'; result: ElectionOutcome }
/** Prefill returned by reopen() for /decider?parent= — NO mutation. */
export interface ReopenPrefill {
parentDecisionId: Id
chainKind: 'revision'
title: string
tags: string[]
scope: Decision['scope']
reversibility: Decision['reversibility']
weight: Decision['weight']
protocolId?: Id
amendsClauseId?: Id
}
// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────
const HOUR_MS = 3_600_000
const DAY_MS = 86_400_000
function addHours(iso: ISODate, hours: number): ISODate {
return new Date(new Date(iso).getTime() + hours * HOUR_MS).toISOString()
}
function addDays(iso: ISODate, days: number): ISODate {
return new Date(new Date(iso).getTime() + days * DAY_MS).toISOString()
}
/** Case/accent-insensitive normalization (same folding as useSearch). */
function fold(text: string): string {
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
/** #tag extraction — simple: letters, digits, dashes. */
const TAG_RE = /#([\p{L}\p{N}-]+)/gu
function extractTags(title: string): string[] {
return [...title.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase())
}
function sharedTagCount(a: string[], b: string[]): number {
const set = new Set(a.map(fold))
return b.reduce((n, tag) => n + (set.has(fold(tag)) ? 1 : 0), 0)
}
/** A negative position requires a spoken reason (against, 0, 1). */
function isNegative(value: Vote['value']): boolean {
return value === 'against' || value === 0 || value === 1
}
/** Deep sort object keys — canonical JSON for the engraving fingerprint. */
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize)
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = canonicalize((value as Record<string, unknown>)[key])
}
return out
}
return value
}
const NOMINATION_BY_METHOD: Partial<Record<VoteMethod, NominationMethod>> = {
election: 'election-no-candidate',
consent: 'consent',
nuanced: 'nuanced-vote',
}
// ─────────────────────────────────────────────────────────────
// The store
// ─────────────────────────────────────────────────────────────
export const useDecisionsStore = defineStore('decisions', {
actions: {
// ── Internal accessors ─────────────────────────────────────
_col() {
return useCollectiveStore()
},
_decision(decisionId: Id): Decision | undefined {
return this._col().decisions.find(d => d.id === decisionId)
},
_session(sessionId: Id): VoteSession | undefined {
return this._col().sessions.find(s => s.id === sessionId)
},
_protocolOf(session: VoteSession): Protocol | undefined {
return this._col().protocols.find(p => p.id === session.protocolId)
},
/** Latest session of a decision (by opensAt). */
_latestSessionOf(decisionId: Id): VoteSession | undefined {
return this._col()
.sessions.filter(s => s.decisionId === decisionId)
.sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0]
},
/** Whether `personId` may perform the steward gestures on this decision. */
_isSteward(decision: Decision, personId: Id): boolean {
if (decision.stewardIds.length > 0) return decision.stewardIds.includes(personId)
return decision.authorId === personId
},
/** Assemble the TransitionContext consumed by engine/state.ts. */
transitionContext(decisionId: Id): TransitionContext | Refusal {
const col = this._col()
const settings = col.settings
if (!col.current || !settings) return refuse(NO_COLLECTIVE)
const context: TransitionContext = {
concerns: col.concerns,
settings,
children: col.decisions.filter(d => d.parentDecisionId === decisionId),
assents: col.assents.filter(a => a.decisionId === decisionId),
objections: col.objections.filter(o => o.decisionId === decisionId),
now: col.now(),
}
const session = this._latestSessionOf(decisionId)
if (session) context.session = session
return context
},
// ── ÉMERGENCE — the capture ────────────────────────────────
/** One sentence → a draft. selfOnly by default; #tags extracted, simple. */
capture(title: string): Decision | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const now = col.now()
const decision: Decision = {
id: col.newId(),
collectiveId: col.current.collective.id,
createdAt: now,
updatedAt: now,
authorId: me.id,
title: title.trim(),
tags: extractTags(title),
reversibility: 'easy',
weight: 'light',
urgent: false,
scope: { selfOnly: true, circleIds: [], personIds: [] },
route: 'solo',
triageRule: 'R2',
routeOverridden: false,
status: 'draft',
stewardIds: [],
measurerIds: [],
visibility: 'private',
}
col.current.decisions.push(decision)
col.persist()
return decision
},
// ── TRIAGE — the path ──────────────────────────────────────
/** Assemble TriageContext from the state and run the pure engine. */
runTriage(input: TriageInput): Verdict | Refusal {
const col = this._col()
const me = col.me
const settings = col.settings
if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE)
const activeMandates = col.mandates.filter(m => m.status === 'active')
const title = fold(input.title)
const tagSet = new Set(input.tags.map(fold))
const matchingClauses = col.clauses.filter((clause) => {
if (clause.id === input.amendsClauseId) return true // R3 lookup
const code = fold(clause.code)
if (code.length >= 2 && title.includes(code)) return true
const clauseTitle = fold(clause.title)
if (clauseTitle.length >= 4 && title.includes(clauseTitle)) return true
return tagSet.has(code)
})
const now = col.now()
const since = addDays(now, -90)
const recent = col.decisions.filter(d => (d.decidedAt ?? d.createdAt) >= since)
const similarRecentCount = recent.filter(
d => d.status === 'adopted' && d.route !== 'record'
&& sharedTagCount(d.tags, input.tags) >= 2,
).length
const similarRecordedCount = recent.filter(
d => d.route === 'record' && sharedTagCount(d.tags, input.tags) >= 2,
).length
const ctx: TriageContext = {
myActiveMandates: activeMandates.filter(m => m.holderId === me.id),
otherActiveMandates: activeMandates.filter(m => m.holderId !== me.id),
matchingClauses,
similarRecentCount,
similarRecordedCount,
computedConcernedIds: computeConcerned(input.scope, col.circles, activeMandates, me.id)
.map(entry => entry.personId),
}
return triage(input, ctx, settings)
},
/**
* Validate the path: apply the verdict (then the user's edits) onto the
* decision and move it to the starting status of its route.
*/
applyPath(decision: Decision, verdict: Verdict, edits?: ApplyPathEdits): Decision | Refusal {
const col = this._col()
const me = col.me
const settings = col.settings
if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE)
const now = col.now()
// Verdict first, user's edits second (the path recommends, never judges).
decision.route = verdict.route
decision.triageRule = verdict.rule
if (verdict.protocolId !== undefined) decision.protocolId = verdict.protocolId
Object.assign(decision, edits)
decision.routeOverridden = edits?.route !== undefined && edits.route !== verdict.route
if (verdict.windowHours !== undefined) {
decision.windowEndsAt = addHours(now, verdict.windowHours)
}
if (verdict.reviewRequired && !decision.review) {
decision.review = { dueAt: addDays(now, settings.triage.reviewDelayDays) }
}
// My covering mandate → the decision goes under its spotlights.
if (decision.route === 'mandate') {
const covering = col.mandates.find(
m =>
m.status === 'active'
&& m.holderId === me.id
&& decision.scope.circleIds.length > 0
&& decision.scope.circleIds.every(id => m.domain.circleIds.includes(id)),
)
if (covering) decision.underMandateId = covering.id
}
const createConcerns = () => {
const activeMandates = col.mandates.filter(m => m.status === 'active')
const entries = computeConcerned(
decision.scope, col.circles, activeMandates, decision.authorId,
)
for (const entry of entries) {
const concern: Concern = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId: decision.id,
personId: entry.personId,
origin: 'computed',
reason: entry.reason,
beforeSnapshot: true,
}
col.current!.concerns.push(concern)
}
}
// Conservatory urgency: decided NOW, the collective ratifies (chained).
if (verdict.conservatoryChain) {
createConcerns()
decision.status = 'adopted'
decision.decidedAt = now
const child: Decision = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
authorId: decision.authorId,
title: `Ratifier — ${decision.title}`,
tags: [...decision.tags],
reversibility: decision.reversibility,
weight: decision.weight,
urgent: false,
scope: {
selfOnly: decision.scope.selfOnly,
circleIds: [...decision.scope.circleIds],
personIds: [...decision.scope.personIds],
},
route: 'collective',
triageRule: 'R-U',
routeOverridden: false,
...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
parentDecisionId: decision.id,
chainKind: 'ratification',
status: 'draft',
stewardIds: [...decision.stewardIds],
measurerIds: [],
visibility: decision.visibility,
}
col.current.decisions.push(child)
col.stamp(decision)
col.persist()
return decision
}
switch (decision.route) {
case 'solo':
decision.status = 'adopted'
decision.decidedAt = now
break
case 'record':
decision.status = 'adopted'
decision.decidedAt = now
if (edits?.decidedHow !== undefined) decision.decidedHow = edits.decidedHow
break
case 'mandate':
createConcerns()
decision.status = 'objection'
break
case 'advice':
createConcerns()
decision.status = 'advice'
break
case 'collective': {
createConcerns()
if (verdict.framingDays !== undefined) {
decision.status = 'framing'
decision.windowEndsAt = addDays(now, verdict.framingDays)
} else {
const opened = this.openSession(decision)
if ('ok' in opened) return opened
}
break
}
case 'transmit':
decision.status = 'transmitted'
break
}
col.stamp(decision)
col.persist()
return decision
},
// ── The ONE state machine gate ─────────────────────────────
transition(decisionId: Id, to: DecisionStatus): TransitionResult {
const col = this._col()
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
const ctx = this.transitionContext(decisionId)
if ('ok' in ctx) return ctx
const result = canTransition(decision, to, ctx)
if (!result.ok) return result
decision.status = to
if (to === 'adopted') decision.decidedAt = ctx.now
col.stamp(decision)
col.persist()
return result
},
// ── Windows: concerns, objections, assents, advices ────────
/** « Ça me concerne » — a right, never a request. */
declareConcern(decisionId: Id, personId: Id, note?: string): Concern | Refusal {
const col = this._col()
if (!col.current) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
const now = col.now()
const concern: Concern = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId,
personId,
origin: 'declared',
reason: 'se déclare concerné·e',
...(note !== undefined ? { declaredNote: note } : {}),
// Before the arrested list (no session yet) ⇒ vote; after ⇒ consultative.
beforeSnapshot: !col.sessions.some(s => s.decisionId === decisionId),
}
col.current.concerns.push(concern)
col.persist()
return concern
},
/** « J'objecte » — boundary objections SUSPEND the countdown. */
objectTo(
decisionId: Id,
kind: Objection['kind'],
argument: string,
asPersonId?: Id,
): Objection | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
if (argument.trim().length === 0) {
return refuse('Une objection s\'argumente — écris pourquoi.')
}
const now = col.now()
const personId = asPersonId ?? me.id
const objection: Objection = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId,
personId,
kind,
argument: argument.trim(),
status: 'open',
...(personId !== me.id ? { recordedById: me.id } : {}),
}
col.current.objections.push(objection)
if (kind === 'boundary') {
decision.windowSuspendedAt = now
col.stamp(decision)
}
col.persist()
return objection
},
/** « Ça me va » — the stored explicit agreement of objection windows. */
assentTo(decisionId: Id, asPersonId?: Id): Assent | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
const now = col.now()
const personId = asPersonId ?? me.id
const assent: Assent = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId,
personId,
...(personId !== me.id ? { recordedById: me.id } : {}),
}
col.current.assents.push(assent)
col.persist()
return assent
},
/** Deposit an advice on an open advice window. */
adviseOn(
decisionId: Id,
position: Advice['position'],
note?: string,
asPersonId?: Id,
): Advice | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
const now = col.now()
const personId = asPersonId ?? me.id
const advice: Advice = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId,
personId,
position,
...(note !== undefined ? { note } : {}),
...(personId !== me.id ? { recordedById: me.id } : {}),
}
col.current.advices.push(advice)
col.persist()
return advice
},
// ── Sessions & votes ───────────────────────────────────────
/**
* Open the vote session: protocol resolved (decision.protocolId, fallback
* consent), corpus ARRESTED = computed declared-before-snapshot author.
*/
openSession(decision: Decision): VoteSession | Refusal {
const col = this._col()
const settings = col.settings
if (!col.current || !settings) return refuse(NO_COLLECTIVE)
const protocolId = decision.protocolId ?? settings.protocolByRange.consent
const protocol = col.protocols.find(p => p.id === protocolId)
if (!protocol) {
return refuse('Aucun protocole — crée-le ou décide sur avis.')
}
const corpus = new Set<Id>([decision.authorId])
for (const concern of col.concerns) {
if (concern.decisionId !== decision.id) continue
if (concern.origin === 'computed' || concern.beforeSnapshot) {
corpus.add(concern.personId)
}
}
const now = col.now()
const session: VoteSession = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
decisionId: decision.id,
protocolId: protocol.id,
corpusPersonIds: [...corpus],
corpusSize: corpus.size,
opensAt: now,
closesAt: addDays(now, protocol.durationDays),
status: 'open',
}
col.current.sessions.push(session)
decision.protocolId = protocol.id
decision.status = 'voting'
col.stamp(decision)
col.persist()
return session
},
/** Last active votes of a session (supersedes chains resolved). */
activeVotes(sessionId: Id): Vote[] {
const votes = this._col().votes.filter(v => v.sessionId === sessionId)
const superseded = new Set(
votes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined),
)
return votes.filter(v => !superseded.has(v.id))
},
/**
* Cast (or replace) a vote. EXACTLY ONE of value / values / choicePersonId
* per method — except the election blank (none of the three). A negative
* position (against, 0, 1) requires a comment.
*/
castVote(sessionId: Id, payload: CastVotePayload): Vote | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const session = this._session(sessionId)
if (!session) return refuse(NO_SESSION)
if (session.status !== 'open') {
return refuse('Cette session n\'accepte plus de vote — les positions sont figées.')
}
const protocol = this._protocolOf(session)
if (!protocol) return refuse('Le protocole de cette session est introuvable.')
const voterId = payload.asPersonId ?? me.id
if (!session.corpusPersonIds.includes(voterId)) {
return refuse('Tu n\'es pas dans la liste arrêtée — ta voix est consultative.')
}
const decision = this._decision(session.decisionId)
const provided
= Number(payload.value !== undefined)
+ Number(payload.values !== undefined)
+ Number(payload.choicePersonId !== undefined)
switch (protocol.method) {
case 'consent':
return refuse('Le consentement ne se vote pas — dis « Ça me va » ou objecte.')
case 'binary': {
if (provided !== 1 || (payload.value !== 'for' && payload.value !== 'against')) {
return refuse('Ce protocole attend une position pour ou contre — rien d\'autre.')
}
break
}
case 'nuanced': {
const v = payload.value
if (provided !== 1 || typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 5) {
return refuse('Ce protocole attend une nuance de 0 à 5 — rien d\'autre.')
}
break
}
case 'parametric': {
if (provided !== 1 || payload.values === undefined) {
return refuse('Ce protocole attend des positions de curseurs — rien d\'autre.')
}
if (!decision?.paramSpec) {
return refuse('Cette décision n\'a pas de curseurs définis.')
}
try {
validateVote(decision.paramSpec, payload.values)
} catch (error) {
return refuse(error instanceof Error ? error.message : 'Vote invalide.')
}
break
}
case 'election': {
// A deposited vote WITHOUT designation is a BLANK — allowed.
if (provided > 1 || payload.value !== undefined || payload.values !== undefined) {
return refuse('Ce protocole attend une désignation — ou un vote blanc.')
}
if (
payload.choicePersonId !== undefined
&& !session.corpusPersonIds.includes(payload.choicePersonId)
) {
return refuse('La personne désignée ne fait pas partie de la liste arrêtée.')
}
break
}
}
if (isNegative(payload.value) && !payload.comment?.trim()) {
return refuse('Dis pourquoi — un commentaire accompagne toute position négative.')
}
const previous = this.activeVotes(sessionId).find(v => v.voterId === voterId)
const now = col.now()
const vote: Vote = {
id: col.newId(),
collectiveId: session.collectiveId,
createdAt: now,
updatedAt: now,
sessionId,
voterId,
...(payload.value !== undefined ? { value: payload.value } : {}),
...(payload.values !== undefined ? { values: payload.values } : {}),
...(payload.choicePersonId !== undefined
? { choicePersonId: payload.choicePersonId }
: {}),
...(payload.comment !== undefined ? { comment: payload.comment } : {}),
...(previous ? { supersedesVoteId: previous.id } : {}),
...(voterId !== me.id ? { recordedById: me.id } : {}),
}
col.current.votes.push(vote)
col.persist()
return vote
},
/**
* Tally a session from its last active votes — ALWAYS recomputed from
* Vote[], never denormalized. Techcomm heuristic (documented): members of
* a circle whose folded name contains « comite technique ».
*/
tally(session: VoteSession): TallyResult | Refusal {
const col = this._col()
const protocol = this._protocolOf(session)
if (!protocol) return refuse('Le protocole de cette session est introuvable.')
const active = this.activeVotes(session.id)
switch (protocol.method) {
case 'consent': {
const openObjections = col.objections.filter(
o => o.decisionId === session.decisionId && o.status === 'open',
).length
const assentCount = col.assents.filter(
a => a.decisionId === session.decisionId,
).length
return {
method: 'consent', openObjections, assentCount, adopted: openObjections === 0,
}
}
case 'binary': {
const votesFor = active.filter(v => v.value === 'for').length
const votesAgainst = active.filter(v => v.value === 'against').length
const total = votesFor + votesAgainst
const f = protocol.formula
const threshold = wotThreshold(
Math.max(session.corpusSize, 1), total,
f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase,
)
let smithMet = true
if (f.smithExponent !== undefined) {
const smithIds = new Set(
col.people.filter(p => p.wotStatus === 'smith').map(p => p.id),
)
const smithCorpus = session.corpusPersonIds.filter(id => smithIds.has(id))
if (smithCorpus.length > 0) {
const smithFor = active.filter(
v => v.value === 'for' && smithIds.has(v.voterId),
).length
smithMet = smithFor >= smithThreshold(smithCorpus.length, f.smithExponent)
}
}
let techcommMet = true
if (f.techcommExponent !== undefined) {
const techCircle = col.circles.find(c => fold(c.name).includes('comite technique'))
const techIds = new Set(techCircle?.memberIds ?? [])
const techCorpus = session.corpusPersonIds.filter(id => techIds.has(id))
if (techCorpus.length > 0) {
const techFor = active.filter(
v => v.value === 'for' && techIds.has(v.voterId),
).length
techcommMet = techFor >= techcommThreshold(techCorpus.length, f.techcommExponent)
}
}
return {
method: 'binary',
votesFor, votesAgainst, total, threshold, smithMet, techcommMet,
adopted: votesFor >= threshold && smithMet && techcommMet,
}
}
case 'nuanced': {
const values: number[] = active
.map(v => v.value)
.filter((v): v is Exclude<Vote['value'], 'for' | 'against' | undefined> =>
typeof v === 'number')
// A formula without an explicit quorum imposes none (fallback 1) —
// the engine's 59 default is a Ğ1 constant, not a template rule.
const result = nuancedResult(
values,
protocol.formula.nuancedThresholdPct ?? 80,
protocol.formula.nuancedMinParticipants ?? 1,
)
return { method: 'nuanced', result, adopted: result.adopted }
}
case 'parametric': {
const vectors = active
.map(v => v.values)
.filter((v): v is number[] => v !== undefined)
return { method: 'parametric', median: medianByElement(vectors) }
}
case 'election':
return { method: 'election', result: electionResult(active, protocol.formula) }
}
},
/**
* Close a session at its deadline.
* Parametric ⇒ 'frozen' (the steward crystallizes — a HUMAN gesture).
* Election ⇒ automatic, except a tie stays 'tie' (humans break ties).
* Others ⇒ outcome + decision transition + AUTOMATIC application
* (clause version, mandate creation, parent revocation).
*/
closeSession(session: VoteSession): VoteSession | Refusal {
const col = this._col()
if (!col.current) return refuse(NO_COLLECTIVE)
if (session.status !== 'open') {
return refuse('Cette session est déjà close ou figée.')
}
const protocol = this._protocolOf(session)
if (!protocol) return refuse('Le protocole de cette session est introuvable.')
const decision = this._decision(session.decisionId)
if (!decision) return refuse(NO_DECISION)
if (protocol.method === 'parametric') {
session.status = 'frozen'
col.stamp(session)
col.persist()
return session
}
const tallied = this.tally(session)
if ('ok' in tallied) return tallied
if (tallied.method === 'election') {
session.status = 'closed'
const res = tallied.result
if (res.outcome === 'tie') {
session.outcome = 'tie' // the tool NEVER breaks a tie
} else if (res.outcome === 'elected') {
session.outcome = 'adopted'
const moved = this.transition(decision.id, 'adopted')
if (moved.ok) this._applyAdoption(decision, res.winnerId)
} else {
session.outcome = 'rejected'
this.transition(decision.id, 'rejected')
}
col.stamp(session)
col.persist()
return session
}
const adopted = tallied.method === 'parametric' ? false : tallied.adopted
session.status = 'closed'
session.outcome = adopted ? 'adopted' : 'rejected'
const moved = this.transition(decision.id, adopted ? 'adopted' : 'rejected')
if (adopted && moved.ok) this._applyAdoption(decision)
col.stamp(session)
col.persist()
return session
},
/**
* Automatic application at adoption — zero manual acceptance button:
* - amendsClauseId ⇒ the linked ClauseVersion becomes 'current', the old
* one 'superseded' (the proposed version of this decision if it exists,
* otherwise a version created from the decision body);
* - createsMandate ⇒ an active Mandate born of this decision;
* - chainKind 'revocation' ⇒ the parent decision moves to 'revoked'.
*/
_applyAdoption(decision: Decision, holderId?: Id): void {
const col = this._col()
if (!col.current) return
const now = col.now()
if (decision.amendsClauseId) {
const clause = col.clauses.find(c => c.id === decision.amendsClauseId)
if (clause) {
const clauseVersions = col.versions.filter(v => v.clauseId === clause.id)
let adoptedVersion = clauseVersions.find(
v => v.decisionId === decision.id && v.status === 'proposed',
)
if (adoptedVersion) {
adoptedVersion.status = 'current'
adoptedVersion.adoptedAt = now
col.stamp(adoptedVersion)
} else {
adoptedVersion = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
clauseId: clause.id,
decisionId: decision.id,
versionLabel: `v${clauseVersions.length + 1}`,
content: decision.body ?? decision.title,
status: 'current',
adoptedAt: now,
} satisfies ClauseVersion
// Cast: pushing into the reactive array re-triggers the
// recursive-Json UnwrapRef explosion (settingValue) — same shape.
;(col.current.versions as unknown as ClauseVersion[]).push(adoptedVersion)
}
for (const version of clauseVersions) {
if (version.id !== adoptedVersion.id && version.status === 'current') {
version.status = 'superseded'
col.stamp(version)
}
}
clause.currentVersionId = adoptedVersion.id
col.stamp(clause)
}
}
if (decision.createsMandate) {
const draft: MandateDraft = decision.createsMandate
const protocol = col.protocols.find(p => p.id === decision.protocolId)
const mandate: Mandate = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
title: draft.title,
holderId: holderId ?? decision.authorId,
originDecisionId: decision.id,
domain: { circleIds: [...draft.domainCircleIds], tags: [...draft.domainTags] },
startsAt: now,
endsAt: addDays(now, draft.durationDays),
electorCircleId:
decision.scope.circleIds[0] ?? col.current.collective.rootCircleId,
nominationMethod:
(protocol && NOMINATION_BY_METHOD[protocol.method]) ?? 'ratified-self',
reports: draft.reportEveryDays !== undefined
? [{ dueAt: addDays(now, draft.reportEveryDays) }]
: [],
status: 'active',
}
col.current.mandates.push(mandate)
}
if (decision.chainKind === 'revocation' && decision.parentDecisionId) {
this.transition(decision.parentDecisionId, 'revoked')
}
col.persist()
},
/**
* THE GESTURE — the steward crystallizes a frozen parametric session:
* dated, signed, never automatic. Quorum below parametricMinParticipants
* ⇒ rejected, observed at the same gesture.
*/
crystallize(sessionId: Id): VoteSession | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const session = this._session(sessionId)
if (!session) return refuse(NO_SESSION)
if (session.status !== 'frozen') {
return refuse('Les votes ne sont pas figés — la cristallisation attend la clôture.')
}
const decision = this._decision(session.decisionId)
if (!decision?.paramSpec) return refuse(NO_DECISION)
if (!this._isSteward(decision, me.id)) {
return refuse('Seul·e un·e garant·e de cette décision peut cristalliser.')
}
const protocol = this._protocolOf(session)
if (!protocol) return refuse('Le protocole de cette session est introuvable.')
const now = col.now()
const vectors = this.activeVotes(sessionId)
.map(v => v.values)
.filter((v): v is number[] => v !== undefined)
session.crystallizedById = me.id
session.crystallizedAt = now
session.status = 'closed'
const quorum = protocol.formula.parametricMinParticipants
if (quorum !== undefined && vectors.length < quorum) {
session.outcome = 'rejected'
col.stamp(session)
this.transition(decision.id, 'rejected')
col.persist()
return session
}
const position = crystallizeVector(decision.paramSpec, vectors)
const readable = decision.paramSpec.params
.map((p, i) => `${p.label} : ${position[i] ?? '—'}${p.unit ? ` ${p.unit}` : ''}`)
.join(' · ')
decision.body
= `${decision.body ? `${decision.body}\n\n` : ''}Position cristallisée : ${readable}`
session.outcome = 'adopted'
col.stamp(session)
col.stamp(decision)
const moved = this.transition(decision.id, 'adopted')
if (moved.ok) this._applyAdoption(decision)
col.persist()
return session
},
// ── L'épreuve du réel ──────────────────────────────────────
/**
* « Ça tient / À revoir / À révoquer ». 'confirmed' closes the review
* (a one-shot decision — sunset already past — moves to 'closed');
* 'revise'/'revoke' pre-fills a chained child under the ORIGINAL protocol.
*/
reviewVerdict(
decisionId: Id,
verdict: 'confirmed' | 'revise' | 'revoke',
note?: string,
): Decision | Refusal {
const col = this._col()
const me = col.me
if (!col.current || !me) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
if (!decision.review) {
return refuse('Cette décision n\'a pas d\'épreuve du réel posée.')
}
const now = col.now()
decision.review.verdict = verdict
if (note !== undefined) decision.review.note = note
decision.review.decidedAt = now
col.stamp(decision)
if (verdict === 'confirmed') {
if (decision.sunsetAt !== undefined && decision.sunsetAt <= now) {
this.transition(decision.id, 'closed')
}
col.persist()
return decision
}
const chainKind = verdict === 'revise' ? 'revision' : 'revocation'
const child: Decision = {
id: col.newId(),
collectiveId: decision.collectiveId,
createdAt: now,
updatedAt: now,
authorId: me.id,
title: `${verdict === 'revise' ? 'Réviser' : 'Révoquer'}${decision.title}`,
tags: [...decision.tags],
reversibility: decision.reversibility,
weight: decision.weight,
urgent: false,
scope: {
selfOnly: decision.scope.selfOnly,
circleIds: [...decision.scope.circleIds],
personIds: [...decision.scope.personIds],
},
route: 'collective',
triageRule: decision.triageRule,
routeOverridden: false,
...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
...(chainKind === 'revision' && decision.amendsClauseId !== undefined
? { amendsClauseId: decision.amendsClauseId }
: {}),
parentDecisionId: decision.id,
chainKind,
status: 'draft',
stewardIds: [...decision.stewardIds],
measurerIds: [...decision.measurerIds],
visibility: decision.visibility,
}
col.current.decisions.push(child)
col.persist()
return child
},
/** « Remettre en question » — prefill for /decider?parent=, NO mutation. */
reopen(decisionId: Id): ReopenPrefill | Refusal {
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
return {
parentDecisionId: decision.id,
chainKind: 'revision',
title: decision.title,
tags: [...decision.tags],
scope: {
selfOnly: decision.scope.selfOnly,
circleIds: [...decision.scope.circleIds],
personIds: [...decision.scope.personIds],
},
reversibility: decision.reversibility,
weight: decision.weight,
...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}),
...(decision.amendsClauseId !== undefined
? { amendsClauseId: decision.amendsClauseId }
: {}),
}
},
// ── Preuve ─────────────────────────────────────────────────
/** « empreinte locale — démo » : sha256 of the canonical decision JSON. */
async engrave(decisionId: Id): Promise<Decision | Refusal> {
const col = this._col()
if (!col.current) return refuse(NO_COLLECTIVE)
const decision = this._decision(decisionId)
if (!decision) return refuse(NO_DECISION)
const { engraving: _previous, ...fingerprintable } = decision
const canonical = JSON.stringify(canonicalize(fingerprintable))
decision.engraving = {
sha256: await sha256Hex(canonical),
engravedAt: col.now(),
proofLevel: 'local',
}
col.stamp(decision)
col.persist()
return decision
},
},
})