v2 : conception ultracode + moteur de formules porté en TS

- Blueprint définitif (docs/dev/BLUEPRINT-V2.md + JSON) : panel 4 visions
  + 3 juges, corpus doctrinal (Mon nom est personne, Une économie du don,
  SejeteralO), 4 critiques d'intégration, 3 vérificateurs, réparations
- Corpus d'intention + lectures brutes archivés
- Moteur porté Python→TS pur : threshold (inertie WoT, Smith, ComTech),
  nuancé 6 niveaux, DSL modeParams — 45 tests vitest, parité croisée
  vérifiée (Forgeron W=7224 T=120 ⇒ 94)
- Contrat de domaine v2 (types/domain.ts) + lexique UI (lexicon.ts)
- vitest + idb-keyval en dépendances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 05:59:33 +02:00
co-authored by Claude Fable 5
parent 172eab4c7c
commit 53d8752e40
17 changed files with 5641 additions and 3 deletions
+521
View File
@@ -0,0 +1,521 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — domain core (BLUEPRINT-V2.md, docs/dev/)
// Local-first, sync-ready. English identifiers, French UI via app/lexicon.ts.
// INVARIANT 1: THE DECISION IS THE PIVOT — mandates, clauses, revocations,
// ratifications and dossier elements are chained specializations of it.
// INVARIANT 2: that unification is an architectural fact, NEVER UI vocabulary.
// INVARIANT 3: no aggregate/score on a person, anywhere — we evaluate things.
// INVARIANT 4: no computation ever produces adopted content without a dated
// human gesture (parametric crystallization, election tie-break, dossier close).
// ─────────────────────────────────────────────────────────────
export type Id = string // UUIDv7 — time-sortable, sync-ready
export type ISODate = string // ISO 8601 UTC
export type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
// Every entity: collective scoping + sync clock + soft delete.
// Sync-ready rules: never a physical delete (archivedAt);
// Vote/Advice/Objection/Assent append-only (trivial merge); the rest LWW by updatedAt.
export interface Entity {
id: Id
collectiveId: Id
createdAt: ISODate
updatedAt: ISODate
archivedAt?: ISODate
}
// ── The tenant ───────────────────────────────────────────────
export type CollectiveTemplate =
| 'blank' // « Page blanche — observatoire d'abord » : one-clause Pact + A1, Consent protocol ONLY
| 'informal'
| 'association'
| 'cooperative'
| 'commune'
| 'free-currency' // Ğ1 heritage — ONLY template where protocolByRange.large = inertial binary
| 'symmetric' // « Institution symétrique » — the founding militant gesture (MNP)
export interface Collective {
id: Id
slug: string
name: string
color: string // hex accent
icon: string // i-lucide-*
template: CollectiveTemplate
isTransparent: boolean
pactDocId: Id // TextDoc role='pact' — settings AS a voted document
rootCircleId: Id
lineage?: {
// seeding trail: set automatically when importing a bundle from another collective
sourceSlug: string
exportedAt: ISODate
sha256: string
} // NO parentCollectiveId — federation is never a table
createdAt: ISODate
updatedAt: ISODate
}
// ── The people ───────────────────────────────────────────────
export interface Person extends Entity {
displayName: string
isMe: boolean // local profile (v2 single-seat; « mode atelier » for in-person capture)
attributes?: Record<string, number> // SELF-declared attributes (free keys: 'heures/mois'…),
// edited in /donnees — feed ONLY the « Pour moi » card;
// never shown to others, never aggregated (invariant 3)
duniterAddress?: string // SS58 — reserved for Duniter auth (out of v2 scope)
wotStatus?: 'member' | 'smith'
}
export type CircleKind = 'place' | 'theme' | 'team'
export interface Circle extends Entity {
name: string
purpose: string
kind?: CircleKind // lieu / thème / équipe — iconography, grouping, suggestion order
// ONLY; never a right; non-blocking hint when a 'team' exceeds 12
memberIds: Id[] // v2: nominative list = the ONLY membership rule (auditable)
parentCircleId?: Id // nesting (couches d'oignon) — root = rootCircle
domains: string[] // tags — SUGGESTION/discovery only, never a voting right
}
// Corpus of a decision = COMPUTABLE union: members of scoped circles
// named persons holders of mandates whose domain intersects.
// Every inclusion keeps its reason (Concern.reason) — auditable in one tap.
// ── THE DECISION (pivot) ─────────────────────────────────────
export type Reversibility = 'easy' | 'costly' | 'irreversible'
export type Weight = 'light' | 'binding' | 'structural'
export type DecisionRoute =
| 'solo' // « Je décide » (optional trace outside mandate)
| 'mandate' // « Je décide, sous mandat » (mandatory trace + objection window)
| 'transmit' // « Je transmets » — someone else's mandate covers
| 'advice' // « J'écoute, puis je décide »
| 'collective' // « Nous décidons » (protocol modality)
| 'record' // « Déjà tranché — je le consigne » : the observatory in one gesture, adopted immediately
export type DecisionStatus =
| 'draft'
| 'advice'
| 'objection'
| 'framing'
| 'voting'
| 'adopted'
| 'rejected'
| 'revoked'
| 'transmitted'
| 'closed'
export type TriageRule = 'R-U' | 'R0a' | 'R0b' | 'R0c' | 'R2' | 'R3' | 'R4' | 'R5' | 'R6'
// The matter — « il n'est pas consulté, il est instruit »
export interface Effect {
label: string // sought effect, one line
target?: string // optional measurable target (« ≤ 400 € », « +5 membres »)
measured?: { note: string; at: ISODate; byId: Id } // observed — filled by measurerIds at review time
}
export interface Brief {
context?: string
sources?: { label: string; url: string }[]
symptomsVsCauses?: string // doctrinal qualification of the subject
effects: Effect[] // GUARD (state.ts, opening of a COLLECTIVE session only):
// required when weight ∈ Pact 'triage.requireEffects' ('binding' covers binding+structural);
// structural under guard ⇒ ≥1 effect with target. NEVER for weight 'light',
// NEVER in the tunnel, NEVER for solo/record.
}
// The linked resource decision — « chaque décision implique une allocation »
export interface Resources {
note: string // « Ce que ça engage » in one sentence
amount?: number
unit?: 'heures' | '€' | 'DU' | string // oil/water: units NEVER converted nor summed together
}
// The collective tuning — « raisonner curseur / table de mixage »
export type ParamKind = 'slider' | 'share' // 'curve' reserved for v3 (full SejeteralO)
export interface ParamDef {
key: string
label: string // MANDATORY business label — never raw a, b, c
kind: ParamKind
min: number
max: number
step: number
unit?: string
baseline?: number // status quo — hollow marker always displayed
derived?: boolean // resolved by the constraint, never voted — labelled « calculé »
}
export interface ParamSpec {
params: ParamDef[] // ≤ 7 (SejeteralO lesson: small manipulable space)
constraint: 'none' | 'sum100' // sum100: 'share' params total 100; EXACTLY ONE 'derived'
// share required (validated at spec creation) — the absorption variable, resolved
// linearly (100 Σ others)
impactAttrKey?: string // key of Person.attributes → « Pour moi » card ('linear-share')
}
export type ChainKind = 'ratification' | 'revision' | 'revocation' | 'element'
export interface Decision extends Entity {
authorId: Id
title: string // THE capture sentence — only mandatory field
body?: string // progressive disclosure
brief?: Brief // matter — see opening guard above
baselineNote?: string // « Aujourd'hui : … » — suggested on advice/collective routes, never solo
resources?: Resources // SUGGESTED-unfolded in the tunnel (never blocking); REQUIRED by the
// window/session opening guard (state.ts) when weight != 'light' outside solo
tags: string[] // discovery + recurrence — NEVER a corpus
// — triage verdict (pre-filled by the engine, adjustable) —
reversibility: Reversibility
weight: Weight
urgent: boolean
scope: {
selfOnly: boolean
circleIds: Id[] // floor: widening free, NEVER narrowing below the computation
personIds: Id[]
}
route: DecisionRoute
triageRule: TriageRule // relegated to the « pourquoi ? » disclosure, PV, journal — never on the card
routeOverridden: boolean
overrideNote?: string // MANDATORY when lightening (derogation asymmetry)
scopeKeptNote?: string // influx reached + perimeter kept ⇒ mandatory PUBLIC motivated note
// — chained specializations (the generative folding) —
underMandateId?: Id // decided UNDER this mandate → the mandate's « feux de la rampe »
createsMandate?: MandateDraft
amendsClauseId?: Id
protocolId?: Id // resolved by the Pact, adjustable by weighting up
paramSpec?: ParamSpec // when parametric modality (« Réglage collectif »)
parentDecisionId?: Id
chainKind?: ChainKind
// 'element': micro-decision of a split dossier (« décision incrémentielle »).
// Parent of elements: framing→closed transition guarded by
// « all element children terminal » — steward GESTURE (Fil card), never automatic;
// the closing cartography becomes the dossier's PV.
decidedHow?: string // route 'record': « comment ça s'est décidé », one sentence
// — time & windows —
status: DecisionStatus
windowEndsAt?: ISODate
windowSuspendedAt?: ISODate // boundary objection → countdown SUSPENDED (visible)
decidedAt?: ISODate
review?: Review // mandatory when irreversible || structural
sunsetAt?: ISODate
stewardIds: Id[] // stewards — REALIZE (and crystallize parametric, close dossiers)
measurerIds: Id[] // measurers — OBSERVE (fill Effect.measured): the doctrinal pair
// — proof & visibility —
visibility: 'private' | 'scope' | 'collective' // anti-surveillance: trace owned outside mandate
engraving?: Engraving
}
export interface Review {
dueAt: ISODate
verdict?: 'confirmed' | 'revise' | 'revoke' // UI: « Ça tient / À revoir / À révoquer »
note?: string
decidedAt?: ISODate
}
export interface Engraving {
sha256: string
engravedAt: ISODate
proofLevel: 'local' // v2: ONLY 'local' — « empreinte locale — démo »
ipfsCid?: string // reserved v3 — never displayed until real
chainRef?: string
}
// ── Inclusion & windows ──────────────────────────────────────
export interface Concern extends Entity {
decisionId: Id
personId: Id
origin: 'computed' | 'declared' // UI: « concerné·e en premier lieu » / « en second lieu »
reason: string // « membre du cercle Forgerons » — shown on tap
declaredNote?: string
beforeSnapshot: boolean // before the list is arrested ⇒ vote; after ⇒ consultative voice
priority?: 0 | 1 | 2 | 3 // « pondère tes enjeux » — per element of a dossier;
// feeds the closing cartography, never the voting right
}
export interface Assent extends Entity {
// « Ça me va » STORED — the explicit agreement of objection windows
decisionId: Id
personId: Id
recordedById?: Id // mode atelier
}
// OBJECTION WINDOW RULE (state.ts): at deadline with no open objection —
// easy ⇒ adopted (silence counts as agreement); costly/irreversible ⇒ adopted ONLY if
// ≥1 Assent from a concerned person ≠ author, otherwise EXTENSION by one notch
// (+objectionWindowHours, Fil reminder) — never adoption by pure silence outside easy.
export interface Objection extends Entity {
decisionId: Id
personId: Id
kind: 'content' | 'boundary' // boundary SUSPENDS the window — the frontier precedes substance
argument: string
status: 'open' | 'withdrawn' | 'integrated' | 'escalated'
resolutionNote?: string
recordedById?: Id // mode atelier: « saisi par X pour Y — atelier du <date> »
}
export interface Advice extends Entity {
decisionId: Id
personId: Id
position: 'favorable' | 'reserved' | 'unfavorable'
note?: string
recordedById?: Id // mode atelier
}
// ── The power ────────────────────────────────────────────────
export type NominationMethod =
| 'ratified-self'
| 'election-no-candidate'
| 'nuanced-vote'
| 'consent'
| 'draw'
| 'rotation'
export interface Mandate extends Entity {
title: string
holderId: Id
originDecisionId: Id // the mandate IS born of a decision — clickable
domain: { circleIds: Id[]; tags: string[] }
startsAt: ISODate
endsAt: ISODate // always bounded
electorCircleId: Id // election corpus = revocation corpus
nominationMethod: NominationMethod
reports: MandateReport[]
status: 'proposed' | 'active' | 'expired' | 'revoked'
// NO aggregate/gauge: the sheet shows counted facts (« Exercice du mandat »),
// never synthesized; the public stream = « Feux de la rampe »
}
export interface MandateReport {
dueAt: ISODate
deliveredAt?: ISODate
content?: string
}
export interface MandateDraft {
title: string
domainCircleIds: Id[]
domainTags: string[]
durationDays: number
reportEveryDays?: number
}
// ── The texts ────────────────────────────────────────────────
export interface TextDoc extends Entity {
slug: string
title: string
role: 'pact' | 'reference'
description: string // seeded Pact: « Notre contrat social — sacralisé, jamais immuable »
provenance?: Provenance
}
export interface Provenance {
sources: { title: string; url: string; date?: string; version?: string }[]
voteRecord?: {
url: string
modeParams: string
period: string
result: {
for: number
against: number
invalid?: number
wotSize: number
thresholdRequired: number
status: string
}
}
contributors?: string[]
notes?: string // typos of voted texts: FLAGGED, never fixed without a vote
}
export type InertiaPreset = 'low' | 'standard' | 'high' | 'max'
// REAL wiring: low={M:50,G:0.1} standard={M:50,G:0.2} high={M:60,G:0.4} max={M:66,G:0.6} (B=0.1, C=0)
export interface Clause extends Entity {
docId: Id
section: string
position: number
code: string
title: string
inertia: InertiaPreset
currentVersionId?: Id
settingKey?: string // Pact clauses: e.g. 'triage.smallGroupMax'
}
export interface ClauseVersion extends Entity {
clauseId: Id
decisionId: Id // EVERY version is born of a decision (founding seeded ones included)
versionLabel: string
content: string
settingValue?: Json
status: 'current' | 'proposed' | 'superseded' | 'rejected'
adoptedAt?: ISODate
}
// ── Modalities & vote ────────────────────────────────────────
export type VoteMethod = 'consent' | 'binary' | 'nuanced' | 'parametric' | 'election'
// UI: consentement · pour/contre (last-resort tool) · nuancé ·
// « Réglage collectif » (au curseur) · élection
export interface Protocol extends Entity {
name: string
method: VoteMethod
description: string
durationDays: number
ballot: 'open' | 'secret' // decided by the Pact — default 'open' (feux de la rampe).
// v2 single-seat: « secret d'affichage — démo » (aggregate tallies and anonymized
// comments only); real crypto arrives with sync (v3)
formula: FormulaParams
modeParams: string // DERIVED display DSL: 'D30M50B.1G.2S.1' — never hand-edited
pactClauseId?: Id // referenced by the Pact ⇒ changing it = amending the Pact
}
export interface FormulaParams {
majorityPct: number // M
baseExponent: number // B (default 0.1)
gradientExponent: number // G — the inertia
constantBase: number // C (default 0)
smithExponent?: number
techcommExponent?: number
nuancedMinParticipants?: number
nuancedThresholdPct?: number
parametricMinParticipants?: number // collective-tuning quorum — not reached ⇒ rejected at the gesture
electionMinParticipants?: number // election quorum — not reached ⇒ rejected
tieBreak?: 'runoff' | 'draw' // election tie-break DECIDED IN ADVANCE by the Pact
// (default 'runoff': chained runoff session among tied; 'draw': draw among tied —
// never chosen by the engine)
}
export interface VoteSession extends Entity {
decisionId: Id
protocolId: Id
corpusPersonIds: Id[] // SNAPSHOT at opening — UI: « liste arrêtée le … »
corpusSize: number // W — never 0 again (letter reserved to the Atelier)
opensAt: ISODate
closesAt: ISODate
status: 'open' | 'frozen' | 'closed'
// 'frozen': PARAMETRIC only — at closesAt votes are frozen, the frozen median is shown,
// and the session WAITS for the steward's human gesture (Fil reminder if late).
crystallizedById?: Id // the « Cristalliser » gesture: who, when — dated and signed
crystallizedAt?: ISODate
outcome?: 'adopted' | 'rejected' | 'tie'
// 'tie': tied election — the tool NEVER breaks a tie: chained runoff session
// (restricted to tied) or draw IF the protocol planned it (tieBreak).
// Consent/nuanced/binary: open→closed automatic at closesAt (the engine only observes a
// threshold over human-written content). Parametric: open→frozen→closed (gesture).
// INVARIANT: tallies, histograms AND medians ALWAYS recomputed from Vote[] — never denormalized.
}
export type NuancedValue = 0 | 1 | 2 | 3 | 4 | 5
export interface Vote extends Entity {
sessionId: Id
voterId: Id
value?: 'for' | 'against' | NuancedValue
// nuancé UI: 0 Pas du tout · 1 Plutôt non · 2 Réservé · 3 Plutôt oui · 4 Oui · 5 Tout à fait
values?: number[] // parametric — order of paramSpec.params EXCLUDING derived
choicePersonId?: Id // election — designation; a vote without choicePersonId = BLANK
// (counts for participation, not designation)
// INVARIANT: EXACTLY ONE of value / values / choicePersonId per method (validated by the
// store) — except an election blank (none of the three, method 'election' only)
comment?: string // MANDATORY when negative (against, 0, 1)
supersedesVoteId?: Id // audited re-vote — PRIVACY: the chain is NEVER displayed publicly
// under a voter's name; visible to its AUTHOR only; even with ballot 'open', only the
// LAST active vote appears under a name, aggregate tallies recomputed
// (« pardonner les positions »)
recordedById?: Id // mode atelier
}
// ── Resolved Pact settings (pure function — NOT a settings table) ──
export interface CollectiveSettings {
// resolveSettings(pactClauses, versions)
triage: {
smallGroupMax: number // default 5
collectiveMin: number // default 50
consentMax: number // default 7
objectionWindowHours: number // default 48
adviceWindowHours: number // default 72
framingDays: number // default 14 — « s'instruire et formuler des contre-propositions »
concernEscalateRatio: number // default 0.5 — reached ⇒ MANDATORY handling (state.ts guard)
recurrenceThreshold: number // default 3
reviewDelayDays: number // default 90
requireEffects: 'none' | 'structural' | 'binding'
// key 'triage.requireEffects' — governs the matter guard of COLLECTIVE sessions:
// 'binding' = binding AND structural (default of structured templates);
// 'structural' = structural only; 'none' seeded in blank and informal.
// Under guard, structural ⇒ ≥1 effect with measurable target.
}
protocolByRange: {
consent: Id // MANDATORY — invariant: every collective has a Consent protocol
// (every template seeds it, import validates it)
nuanced?: Id
large?: Id // ex-inertialBinary: the name no longer presumes the method.
// Standard templates → nuanced; free-currency → inertial binary
parametric?: Id // « Réglage collectif »
election?: Id
clauseByInertia?: Record<InertiaPreset, Id> // the Ğ1 heritage lives here, intact
}
// SPECIFIED FALLBACK (resolveSettings): any unresolved optional key falls back to consent;
// consent unfindable (corrupted bundle) ⇒ triage routes 'advice' with the banner
// « Aucun protocole — crée-le ou décide sur avis » — NEVER a crash.
}
// ── Triage engine contracts ──────────────────────────────────
export interface TriageInput {
title: string
tags: string[]
scope: Decision['scope']
reversibility: Reversibility
weight: Weight
urgent: boolean
amendsClauseId?: Id
}
export interface TriageContext {
// assembled by the store, consumed pure — testable without I/O
myActiveMandates: Mandate[]
otherActiveMandates: Mandate[]
matchingClauses: Clause[] // Q0 index « déjà décidé ? » — the SAME index as Cmd+K
similarRecentCount: number // adopted decisions sharing ≥2 tags over 90 d
similarRecordedCount: number // 'record' entries sharing ≥2 tags — maturation
computedConcernedIds: Id[]
}
export interface Verdict {
route: DecisionRoute
rule: TriageRule
explanation: string // ONE French sentence — displayed ALONE, without rule code
protocolId?: Id
windowHours?: number
framingDays?: number
reviewRequired: boolean
engravingSuggested: boolean
conservatoryChain?: boolean
parametricHint?: boolean // number/%/amount detected ⇒ « C'est un réglage » put forward
alternatives: { route: DecisionRoute; label: string; cost: string }[]
// always present: « C'est un réglage — décidez au curseur » (parametric);
// « Déjà tranché — je le consigne » (record); binary with its cost (« deux camps, un perdant »)
suggestion?: { kind: 'claim-mandate' | 'create-rule' | 'protocolize'; prefill: Json }
}
// ── Local persistence & future sync ──────────────────────────
export interface Bundle {
// export/import AND seeds — the SAME code path
schemaVersion: 2
exportedAt: ISODate
collective: Collective
people: Person[]
circles: Circle[]
decisions: Decision[]
concerns: Concern[]
objections: Objection[]
advices: Advice[]
assents: Assent[]
mandates: Mandate[]
docs: TextDoc[]
clauses: Clause[]
versions: ClauseVersion[]
protocols: Protocol[]
sessions: VoteSession[]
votes: Vote[]
}
// data/persistence.ts: IndexedDB (idb-keyval), key `ld2:<collectiveId>`, 500 ms debounce.
// importBundle() validates protocolByRange.consent + sets Collective.lineage for foreign bundles.
// Sync-ready: UUIDv7, updatedAt (LWW), archivedAt, Vote/Advice/Objection/Assent append-only.
// Pinia store contract unchanged — the future FastAPI adapter changes NO screen.
// Contract notes: ballot 'secret' must become cryptographic server-side (v3);
// the privacy of supersedesVoteId chains (author-visible only) is a contract invariant.