- 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>
1143 lines
42 KiB
TypeScript
1143 lines
42 KiB
TypeScript
/**
|
||
* 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
|
||
},
|
||
},
|
||
})
|