/** * 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 = {} for (const key of Object.keys(value as Record).sort()) { out[key] = canonicalize((value as Record)[key]) } return out } return value } const NOMINATION_BY_METHOD: Partial> = { 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([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 => 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 { 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 }, }, })