v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Pact settings resolution — the Pact IS the settings store (Δ10).
|
||||
*
|
||||
* resolveSettings() reads the Pact clauses that carry a settingKey, finds the
|
||||
* 'current' ClauseVersion bearing a settingValue for each, and produces the
|
||||
* resolved CollectiveSettings consumed by triage.ts and state.ts. Pure
|
||||
* function — NOT a settings table: every threshold is a voted clause.
|
||||
*
|
||||
* Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10):
|
||||
* - any absent key falls back to SETTINGS_DEFAULTS;
|
||||
* - invalid values (wrong type, negative number, unknown enum member) fall
|
||||
* back to the default SILENTLY — a corrupted Pact never crashes the engine;
|
||||
* - any unresolved OPTIONAL protocolByRange key stays undefined: the caller
|
||||
* falls back to the consent protocol;
|
||||
* - a missing 'protocols.consent' (corrupted bundle) still returns a full
|
||||
* settings object with protocolByRange.consent === '' — hasConsentProtocol()
|
||||
* lets the triage route 'advice' with the banner « Aucun protocole — crée-le
|
||||
* ou décide sur avis », NEVER a crash.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Clause,
|
||||
ClauseVersion,
|
||||
CollectiveSettings,
|
||||
Id,
|
||||
InertiaPreset,
|
||||
Json,
|
||||
} from '../types/domain'
|
||||
|
||||
/** Default triage settings — used for every key the Pact does not resolve. */
|
||||
export const SETTINGS_DEFAULTS = {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
} as const satisfies CollectiveSettings['triage']
|
||||
|
||||
const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
|
||||
|
||||
/** Most recent wins when a clause carries several 'current' versions (data anomaly). */
|
||||
function stampOf(version: ClauseVersion): string {
|
||||
return version.adoptedAt ?? version.updatedAt
|
||||
}
|
||||
|
||||
/** The settingValue of the clause's 'current' version, if any. */
|
||||
function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined {
|
||||
let best: ClauseVersion | undefined
|
||||
for (const version of versions) {
|
||||
if (version.clauseId !== clause.id || version.archivedAt) continue
|
||||
if (version.status !== 'current' || version.settingValue === undefined) continue
|
||||
if (!best || stampOf(version) > stampOf(best)) best = version
|
||||
}
|
||||
return best?.settingValue
|
||||
}
|
||||
|
||||
/** First non-archived clause holding this settingKey with a resolvable current value. */
|
||||
function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined {
|
||||
for (const clause of clauses) {
|
||||
if (clause.settingKey !== key || clause.archivedAt) continue
|
||||
const value = currentValueOf(clause, versions)
|
||||
if (value !== undefined) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Wrong type or negative number ⇒ default, silently. */
|
||||
function numberOrDefault(value: Json | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
/** Unknown enum member ⇒ default, silently. */
|
||||
function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' {
|
||||
return value === 'none' || value === 'structural' || value === 'binding'
|
||||
? value
|
||||
: SETTINGS_DEFAULTS.requireEffects
|
||||
}
|
||||
|
||||
/** Protocol ids must be non-empty strings — anything else counts as unresolved. */
|
||||
function idOrUndefined(value: Json | undefined): Id | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the collective's settings from its Pact clauses.
|
||||
*
|
||||
* Recognized setting keys:
|
||||
* triage.smallGroupMax · triage.collectiveMin · triage.consentMax ·
|
||||
* triage.objectionWindowHours · triage.adviceWindowHours ·
|
||||
* triage.framingDays · triage.concernEscalateRatio ·
|
||||
* triage.recurrenceThreshold · triage.reviewDelayDays ·
|
||||
* triage.requireEffects · protocols.consent · protocols.nuanced ·
|
||||
* protocols.large · protocols.parametric · protocols.election ·
|
||||
* protocols.clauseByInertia.{low,standard,high,max}
|
||||
*/
|
||||
export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings {
|
||||
const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions)
|
||||
|
||||
const triage: CollectiveSettings['triage'] = {
|
||||
smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax),
|
||||
collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin),
|
||||
consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax),
|
||||
objectionWindowHours: numberOrDefault(
|
||||
raw('triage.objectionWindowHours'),
|
||||
SETTINGS_DEFAULTS.objectionWindowHours,
|
||||
),
|
||||
adviceWindowHours: numberOrDefault(
|
||||
raw('triage.adviceWindowHours'),
|
||||
SETTINGS_DEFAULTS.adviceWindowHours,
|
||||
),
|
||||
framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays),
|
||||
concernEscalateRatio: numberOrDefault(
|
||||
raw('triage.concernEscalateRatio'),
|
||||
SETTINGS_DEFAULTS.concernEscalateRatio,
|
||||
),
|
||||
recurrenceThreshold: numberOrDefault(
|
||||
raw('triage.recurrenceThreshold'),
|
||||
SETTINGS_DEFAULTS.recurrenceThreshold,
|
||||
),
|
||||
reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays),
|
||||
requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')),
|
||||
}
|
||||
|
||||
// Consent is the MANDATORY invariant key. When unfindable (corrupted
|
||||
// bundle), we still return a complete object with consent === '' so the
|
||||
// caller can degrade gracefully (hasConsentProtocol) — never a crash.
|
||||
const protocolByRange: CollectiveSettings['protocolByRange'] = {
|
||||
consent: idOrUndefined(raw('protocols.consent')) ?? '',
|
||||
}
|
||||
|
||||
const nuanced = idOrUndefined(raw('protocols.nuanced'))
|
||||
if (nuanced) protocolByRange.nuanced = nuanced
|
||||
const large = idOrUndefined(raw('protocols.large'))
|
||||
if (large) protocolByRange.large = large
|
||||
const parametric = idOrUndefined(raw('protocols.parametric'))
|
||||
if (parametric) protocolByRange.parametric = parametric
|
||||
const election = idOrUndefined(raw('protocols.election'))
|
||||
if (election) protocolByRange.election = election
|
||||
|
||||
// The Ğ1 heritage lives here, intact: the map is exposed only when ALL four
|
||||
// presets resolve (the type is a complete Record — a partial map would lie).
|
||||
// Otherwise the caller falls back to consent, like any optional key.
|
||||
const byInertia: Partial<Record<InertiaPreset, Id>> = {}
|
||||
for (const preset of INERTIA_PRESETS) {
|
||||
const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`))
|
||||
if (id) byInertia[preset] = id
|
||||
}
|
||||
if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) {
|
||||
protocolByRange.clauseByInertia = byInertia as Record<InertiaPreset, Id>
|
||||
}
|
||||
|
||||
return { triage, protocolByRange }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the collective has a resolvable consent protocol.
|
||||
* False ⇒ the triage routes 'advice' with the banner
|
||||
* « Aucun protocole — crée-le ou décide sur avis » — never a crash.
|
||||
*/
|
||||
export function hasConsentProtocol(settings: CollectiveSettings): boolean {
|
||||
return settings.protocolByRange.consent.trim().length > 0
|
||||
}
|
||||
Reference in New Issue
Block a user