diff --git a/docs/KOHA_STRIPE_SETUP.md b/docs/KOHA_STRIPE_SETUP.md
index 61b028b9..459692f0 100644
--- a/docs/KOHA_STRIPE_SETUP.md
+++ b/docs/KOHA_STRIPE_SETUP.md
@@ -2,8 +2,8 @@
**Project:** Tractatus Framework
**Feature:** Phase 3 - Koha (Donation) System
-**Date:** 2025-10-08
-**Status:** Development
+**Date:** 2025-10-18 (Updated with automated scripts)
+**Status:** ✅ Test Mode Active | Production Ready
---
@@ -18,6 +18,43 @@ The Koha donation system uses the existing Stripe account from `passport-consoli
---
+## Quick Start (Automated Setup)
+
+**✨ NEW: Automated setup scripts available!**
+
+### Option A: Fully Automated Setup (Recommended)
+
+```bash
+# Step 1: Verify Stripe API connection
+node scripts/test-stripe-connection.js
+
+# Step 2: Create products and prices automatically
+node scripts/setup-stripe-products.js
+
+# Step 3: Server will restart automatically - prices now configured!
+
+# Step 4: Test the complete integration
+node scripts/test-stripe-integration.js
+
+# Step 5: Set up webhooks for local testing (requires Stripe CLI)
+./scripts/stripe-webhook-setup.sh
+```
+
+**That's it!** All products, prices, and configuration are set up automatically.
+
+### What the Scripts Do
+
+1. **test-stripe-connection.js** - Verifies test API keys work and shows existing products/prices
+2. **setup-stripe-products.js** - Creates the Tractatus product and 3 monthly price tiers with multi-currency support
+3. **test-stripe-integration.js** - Tests checkout session creation for both monthly and one-time donations
+4. **stripe-webhook-setup.sh** - Guides you through Stripe CLI installation and webhook setup
+
+### Option B: Manual Setup
+
+Continue to Section 1 below for step-by-step manual instructions.
+
+---
+
## 1. Stripe Products to Create
### Product: "Tractatus Framework Support"
@@ -271,18 +308,27 @@ After setup, your `.env` should have:
STRIPE_SECRET_KEY=sk_test_51RX67k...
STRIPE_PUBLISHABLE_KEY=pk_test_51RX67k...
-# Webhook Secret (from Step 4)
+# Webhook Secret (from Step 4 or Stripe CLI)
STRIPE_KOHA_WEBHOOK_SECRET=whsec_...
-# Price IDs (from Step 3)
-STRIPE_KOHA_5_PRICE_ID=price_...
-STRIPE_KOHA_15_PRICE_ID=price_...
-STRIPE_KOHA_50_PRICE_ID=price_...
+# Product ID (created by setup-stripe-products.js)
+STRIPE_KOHA_PRODUCT_ID=prod_TFusJH4Q3br8gA
+
+# Price IDs (created by setup-stripe-products.js)
+STRIPE_KOHA_5_PRICE_ID=price_1SJP2fGhfAwOYBrf9yrf0q8C
+STRIPE_KOHA_15_PRICE_ID=price_1SJP2fGhfAwOYBrfNc6Nfjyj
+STRIPE_KOHA_50_PRICE_ID=price_1SJP2fGhfAwOYBrf0A62TOpf
# Frontend URL
FRONTEND_URL=http://localhost:9000
```
+**✅ Current Status (2025-10-18):**
+- Product and prices are already created in test mode
+- .env file is configured with actual IDs
+- Server integration tested and working
+- Ready for frontend testing with test cards
+
---
## 7. Testing the Integration
@@ -437,27 +483,51 @@ Enable detailed Stripe logs:
---
-## 11. Next Steps
+## 11. Current Status & Next Steps
-After completing this setup:
+### ✅ Completed (2025-10-18)
-1. ✅ Test donation flow end-to-end
-2. ✅ Create frontend donation form UI
-3. ✅ Build transparency dashboard
-4. ✅ Implement receipt email generation
-5. ✅ Add donor acknowledgement system
-6. ⏳ Deploy to production
+1. ✅ Stripe test account configured with existing credentials
+2. ✅ Product "Tractatus Framework Support" created (prod_TFusJH4Q3br8gA)
+3. ✅ Three monthly price tiers created with multi-currency support
+4. ✅ .env file configured with actual product and price IDs
+5. ✅ Backend API endpoints implemented and tested
+6. ✅ Frontend donation form UI complete with i18n support
+7. ✅ Checkout session creation tested for monthly and one-time donations
+8. ✅ Automated setup scripts created for easy deployment
+
+### ⏳ Pending
+
+1. ⏳ Install Stripe CLI for local webhook testing
+2. ⏳ Configure webhook endpoint and get signing secret
+3. ⏳ Test complete payment flow with test cards in browser
+4. ⏳ Build transparency dashboard data visualization
+5. ⏳ Implement receipt email generation (Koha service has placeholder)
+6. ⏳ Switch to production Stripe keys and test with real card
+7. ⏳ Deploy to production server
+
+### 🎯 Ready to Test
+
+You can now test the donation system locally:
+
+1. Visit http://localhost:9000/koha.html
+2. Select a donation tier or enter custom amount
+3. Fill in donor information
+4. Use test card: 4242 4242 4242 4242
+5. Complete checkout flow
+6. Verify success page shows
---
## Support
-**Issues:** Report in GitHub Issues
+**Issues:** Report in GitHub Issues at https://github.com/yourusername/tractatus
**Questions:** Contact john.stroh.nz@pm.me
**Stripe Docs:** https://stripe.com/docs/api
+**Test Cards:** https://stripe.com/docs/testing
---
-**Last Updated:** 2025-10-08
-**Version:** 1.0
-**Status:** Ready for setup
+**Last Updated:** 2025-10-18
+**Version:** 2.0 (Automated Setup)
+**Status:** ✅ Test Mode Active | Ready for Webhook Setup
diff --git a/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md
index 536bdb46..fd33df73 100644
--- a/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md
+++ b/docs/PRODUCTION_DEPLOYMENT_CHECKLIST.md
@@ -560,6 +560,106 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
---
+## CRITICAL: HTML Caching Rules
+
+**MANDATORY REQUIREMENT**: HTML files MUST be delivered fresh to users without requiring cache refresh.
+
+### The Problem
+Service worker caching HTML files caused deployment failures where users saw OLD content even after deploying NEW code. Users should NEVER need to clear cache manually.
+
+### The Solution (Enforced as of 2025-10-17)
+
+**Service Worker** (`public/service-worker.js`):
+- HTML files: Network-ONLY strategy (never cache, always fetch fresh)
+- Exception: `/index.html` only for offline fallback
+- Bump `CACHE_VERSION` constant whenever service worker logic changes
+
+**Server** (`src/server.js`):
+- HTML files: `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0`
+- This ensures browsers never cache HTML pages
+- CSS/JS: Long cache OK (use version parameters for cache-busting)
+
+**Version Manifest** (`public/version.json`):
+- Update version number when deploying HTML changes
+- Service worker checks this for updates
+- Set `forceUpdate: true` for critical fixes
+
+### Deployment Rules for HTML Changes
+
+When deploying HTML file changes:
+
+1. **Verify service worker never caches HTML** (except index.html)
+ ```bash
+ grep -A 10 "HTML files:" public/service-worker.js
+ # Should show: Network-ONLY strategy, no caching
+ ```
+
+2. **Verify server sends no-cache headers**
+ ```bash
+ grep -A 3 "HTML files:" src/server.js
+ # Should show: no-store, no-cache, must-revalidate
+ ```
+
+3. **Bump version.json if critical content changed**
+ ```bash
+ # Edit public/version.json
+ # Increment version: 1.1.2 → 1.1.3
+ # Update changelog
+ # Set forceUpdate: true
+ ```
+
+4. **After deployment, verify headers in production**
+ ```bash
+ curl -s -I https://agenticgovernance.digital/koha.html | grep -i cache-control
+ # Expected: no-store, no-cache, must-revalidate
+
+ curl -s https://agenticgovernance.digital/koha.html | grep "
"
+ # Verify correct content showing
+ ```
+
+5. **Test in incognito window**
+ - Open https://agenticgovernance.digital in fresh incognito window
+ - Verify new content loads immediately
+ - No cache refresh should be needed
+
+### Testing Cache Behavior
+
+**Before deployment:**
+```bash
+# Local: Verify server sends correct headers
+curl -s -I http://localhost:9000/koha.html | grep cache-control
+# Expected: no-store, no-cache
+
+# Verify service worker doesn't cache HTML
+grep "endsWith('.html')" public/service-worker.js -A 10
+# Should NOT cache responses, only fetch
+```
+
+**After deployment:**
+```bash
+# Production: Verify headers
+curl -s -I https://agenticgovernance.digital/.html | grep cache-control
+
+# Production: Verify fresh content
+curl -s https://agenticgovernance.digital/.html | grep ""
+```
+
+### Incident Prevention
+
+**Lesson Learned** (2025-10-17 Koha Deployment):
+- Deployed koha.html with reciprocal giving updates
+- Service worker cached old version
+- Users saw old content despite fresh deployment
+- Required THREE deployment attempts to fix
+- Root cause: Service worker was caching HTML with network-first strategy
+
+**Prevention**:
+- Service worker now enforces network-ONLY for all HTML (except offline index.html)
+- Server enforces no-cache headers
+- This checklist documents the requirement architecturally
+
+---
+
## Deployment Best Practices
### DO:
@@ -570,6 +670,8 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
- ✅ Document all deployments
- ✅ Keep rollback procedure tested and ready
- ✅ Communicate with team before major deployments
+- ✅ **CRITICAL: Verify HTML cache headers before and after deployment**
+- ✅ **CRITICAL: Test in incognito window after HTML deployments**
### DON'T:
- ❌ Deploy on Friday afternoon (limited time to fix issues)
@@ -579,6 +681,8 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
- ❌ Deploy when tired or rushed
- ❌ Deploy without ability to rollback
- ❌ Forget to restart services after backend changes
+- ❌ **CRITICAL: Never cache HTML files in service worker (except offline fallback)**
+- ❌ **CRITICAL: Never ask users to clear their browser cache - fix it server-side**
### Deployment Timing Guidelines
diff --git a/package-lock.json b/package-lock.json
index 6b7b31b5..8c905460 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,7 @@
"multer": "^2.0.2",
"puppeteer": "^24.23.0",
"sanitize-html": "^2.11.0",
- "stripe": "^14.25.0",
+ "stripe": "^19.1.0",
"validator": "^13.15.15",
"winston": "^3.11.0"
},
@@ -1542,6 +1542,7 @@
"version": "18.19.129",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz",
"integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
@@ -8273,16 +8274,23 @@
}
},
"node_modules/stripe": {
- "version": "14.25.0",
- "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
- "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
+ "version": "19.1.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.1.0.tgz",
+ "integrity": "sha512-FjgIiE98dMMTNssfdjMvFdD4eZyEzdWAOwPYqzhPRNZeg9ggFWlPXmX1iJKD5pPIwZBaPlC3SayQQkwsPo6/YQ==",
"license": "MIT",
"dependencies": {
- "@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
- "node": ">=12.*"
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@types/node": ">=16"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/sucrase": {
@@ -8785,6 +8793,7 @@
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {
diff --git a/package.json b/package.json
index 483f2743..07ee2eaa 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,9 @@
"framework:init": "node scripts/session-init.js",
"framework:watchdog": "node scripts/framework-watchdog.js",
"framework:check": "node scripts/pre-action-check.js",
- "framework:recover": "node scripts/recover-framework.js"
+ "framework:recover": "node scripts/recover-framework.js",
+ "check:csp": "node scripts/check-csp-violations.js",
+ "fix:csp": "node scripts/fix-csp-violations.js"
},
"keywords": [
"ai-safety",
@@ -59,7 +61,7 @@
"multer": "^2.0.2",
"puppeteer": "^24.23.0",
"sanitize-html": "^2.11.0",
- "stripe": "^14.25.0",
+ "stripe": "^19.1.0",
"validator": "^13.15.15",
"winston": "^3.11.0"
},
diff --git a/public/js/admin/hooks-dashboard.js b/public/js/admin/hooks-dashboard.js
index d5e25ceb..48d8d2ad 100644
--- a/public/js/admin/hooks-dashboard.js
+++ b/public/js/admin/hooks-dashboard.js
@@ -201,9 +201,12 @@ function showError(message) {
container.innerHTML = `
${escapeHtml(message)}
-
+
Try Again
`;
+
+ // Add event listener to retry button
+ document.getElementById('retry-load-btn')?.addEventListener('click', loadMetrics);
}
diff --git a/public/js/admin/newsletter-management.js b/public/js/admin/newsletter-management.js
index fcc9330b..a7dca7d0 100644
--- a/public/js/admin/newsletter-management.js
+++ b/public/js/admin/newsletter-management.js
@@ -152,11 +152,20 @@ function renderSubscribers(subscriptions) {
${formatDate(sub.subscribed_at)}
- View
- Delete
+ View
+ Delete
`).join('');
+
+ // Add event listeners to buttons
+ tbody.querySelectorAll('.view-details-btn').forEach(btn => {
+ btn.addEventListener('click', () => viewDetails(btn.dataset.id));
+ });
+
+ tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
+ btn.addEventListener('click', () => deleteSubscriber(btn.dataset.id, btn.dataset.email));
+ });
}
/**
diff --git a/public/js/i18n-simple.js b/public/js/i18n-simple.js
index dbac9a05..69046e13 100644
--- a/public/js/i18n-simple.js
+++ b/public/js/i18n-simple.js
@@ -59,7 +59,12 @@ const I18n = {
'/leader.html': 'leader',
'/implementer.html': 'implementer',
'/about.html': 'about',
- '/faq.html': 'faq'
+ '/about/values.html': 'values',
+ '/about/values': 'values',
+ '/faq.html': 'faq',
+ '/koha.html': 'koha',
+ '/koha/transparency.html': 'transparency',
+ '/koha/transparency': 'transparency'
};
return pageMap[path] || 'homepage';
@@ -101,24 +106,35 @@ const I18n = {
applyTranslations() {
// Find all elements with data-i18n attribute
+ // Using innerHTML to preserve formatting like , , tags in translations
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const translation = this.t(key);
-
+
if (typeof translation === 'string') {
- el.textContent = translation;
+ el.innerHTML = translation;
}
});
-
- // Handle data-i18n-html for HTML content
+
+ // Handle data-i18n-html for HTML content (kept for backward compatibility)
document.querySelectorAll('[data-i18n-html]').forEach(el => {
const key = el.dataset.i18nHtml;
const translation = this.t(key);
-
+
if (typeof translation === 'string') {
el.innerHTML = translation;
}
});
+
+ // Handle data-i18n-placeholder for input placeholders
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
+ const key = el.dataset.i18nPlaceholder;
+ const translation = this.t(key);
+
+ if (typeof translation === 'string') {
+ el.placeholder = translation;
+ }
+ });
},
async setLanguage(lang) {
diff --git a/src/middleware/security-headers.middleware.js b/src/middleware/security-headers.middleware.js
index 7b7a0f22..005ed730 100644
--- a/src/middleware/security-headers.middleware.js
+++ b/src/middleware/security-headers.middleware.js
@@ -19,7 +19,7 @@ function securityHeadersMiddleware(req, res, next) {
[
"default-src 'self'",
"script-src 'self'",
- "style-src 'self' 'unsafe-inline'", // Tailwind requires inline styles
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", // Tailwind + Google Fonts
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"connect-src 'self'",
diff --git a/src/models/DeliberationSession.model.js b/src/models/DeliberationSession.model.js
new file mode 100644
index 00000000..13be36ac
--- /dev/null
+++ b/src/models/DeliberationSession.model.js
@@ -0,0 +1,494 @@
+/**
+ * DeliberationSession Model
+ * Tracks multi-stakeholder deliberation for values conflicts
+ *
+ * AI-LED FACILITATION: This model tracks AI vs. human interventions
+ * and enforces safety mechanisms for AI-led deliberation.
+ */
+
+const { ObjectId } = require('mongodb');
+const { getCollection } = require('../utils/db.util');
+
+class DeliberationSession {
+ /**
+ * Create new deliberation session
+ */
+ static async create(data) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const session = {
+ session_id: data.session_id || `deliberation-${Date.now()}`,
+ created_at: new Date(),
+ updated_at: new Date(),
+ status: 'pending', // "pending" | "in_progress" | "completed" | "paused" | "archived"
+
+ // Decision under deliberation
+ decision: {
+ description: data.decision?.description,
+ context: data.decision?.context || {},
+ triggered_by: data.decision?.triggered_by || 'manual',
+ scenario: data.decision?.scenario || null // e.g., "algorithmic_hiring_transparency"
+ },
+
+ // Conflict analysis (AI-generated initially, can be refined by human)
+ conflict_analysis: {
+ moral_frameworks_in_tension: data.conflict_analysis?.moral_frameworks_in_tension || [],
+ value_trade_offs: data.conflict_analysis?.value_trade_offs || [],
+ affected_stakeholder_groups: data.conflict_analysis?.affected_stakeholder_groups || [],
+ incommensurability_level: data.conflict_analysis?.incommensurability_level || 'unknown', // "low" | "moderate" | "high" | "unknown"
+ analysis_source: data.conflict_analysis?.analysis_source || 'ai' // "ai" | "human" | "collaborative"
+ },
+
+ // Stakeholders participating in deliberation
+ stakeholders: (data.stakeholders || []).map(s => ({
+ id: s.id || new ObjectId().toString(),
+ name: s.name,
+ type: s.type, // "organization" | "individual" | "group"
+ represents: s.represents, // e.g., "Job Applicants", "AI Vendors", "Employers"
+ moral_framework: s.moral_framework || null, // e.g., "consequentialist", "deontological"
+ contact: {
+ email: s.contact?.email || null,
+ organization: s.contact?.organization || null,
+ role: s.contact?.role || null
+ },
+ participation_status: s.participation_status || 'invited', // "invited" | "confirmed" | "active" | "withdrawn"
+ consent_given: s.consent_given || false,
+ consent_timestamp: s.consent_timestamp || null
+ })),
+
+ // Deliberation rounds (4-round structure)
+ deliberation_rounds: data.deliberation_rounds || [],
+
+ // Outcome of deliberation
+ outcome: data.outcome || null,
+
+ // ===== AI SAFETY MECHANISMS =====
+ // Tracks AI vs. human facilitation actions
+ facilitation_log: data.facilitation_log || [],
+
+ // Human intervention tracking
+ human_interventions: data.human_interventions || [],
+
+ // Safety escalations
+ safety_escalations: data.safety_escalations || [],
+
+ // AI facilitation quality monitoring
+ ai_quality_metrics: {
+ stakeholder_satisfaction_scores: [], // Populated post-deliberation
+ fairness_scores: [], // Populated during deliberation
+ escalation_count: 0,
+ human_takeover_count: 0
+ },
+
+ // Transparency report (auto-generated)
+ transparency_report: data.transparency_report || null,
+
+ // Audit log (all actions)
+ audit_log: data.audit_log || [],
+
+ // Metadata
+ configuration: {
+ format: data.configuration?.format || 'hybrid', // "synchronous" | "asynchronous" | "hybrid"
+ visibility: data.configuration?.visibility || 'private_then_public', // "public" | "private_then_public" | "partial"
+ compensation: data.configuration?.compensation || 'volunteer', // "volunteer" | "500" | "1000"
+ ai_role: data.configuration?.ai_role || 'ai_led', // "minimal" | "assisted" | "ai_led"
+ output_framing: data.configuration?.output_framing || 'pluralistic_accommodation' // "recommendation" | "consensus" | "pluralistic_accommodation"
+ }
+ };
+
+ const result = await collection.insertOne(session);
+ return { ...session, _id: result.insertedId };
+ }
+
+ /**
+ * Add deliberation round
+ */
+ static async addRound(sessionId, roundData) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const round = {
+ round_number: roundData.round_number,
+ round_type: roundData.round_type, // "position_statements" | "shared_values" | "accommodation" | "outcome"
+ started_at: new Date(),
+ completed_at: null,
+ facilitator: roundData.facilitator || 'ai', // "ai" | "human" | "collaborative"
+
+ // Contributions from stakeholders
+ contributions: (roundData.contributions || []).map(c => ({
+ stakeholder_id: c.stakeholder_id,
+ stakeholder_name: c.stakeholder_name,
+ timestamp: c.timestamp || new Date(),
+ content: c.content,
+ moral_framework_expressed: c.moral_framework_expressed || null,
+ values_emphasized: c.values_emphasized || []
+ })),
+
+ // AI-generated summaries and analysis
+ ai_summary: roundData.ai_summary || null,
+ ai_framework_analysis: roundData.ai_framework_analysis || null,
+
+ // Human notes/observations
+ human_notes: roundData.human_notes || null,
+
+ // Safety checks during this round
+ safety_checks: roundData.safety_checks || []
+ };
+
+ const result = await collection.updateOne(
+ { session_id: sessionId },
+ {
+ $push: { deliberation_rounds: round },
+ $set: { updated_at: new Date() }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Record facilitation action (AI or human)
+ * SAFETY MECHANISM: Tracks who did what for transparency
+ */
+ static async recordFacilitationAction(sessionId, action) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const logEntry = {
+ timestamp: new Date(),
+ actor: action.actor, // "ai" | "human"
+ action_type: action.action_type, // "prompt" | "summary" | "question" | "intervention" | "escalation"
+ round_number: action.round_number || null,
+ content: action.content,
+ reason: action.reason || null, // Why was this action taken?
+ stakeholder_reactions: action.stakeholder_reactions || [] // Optional: track if stakeholders respond well
+ };
+
+ const result = await collection.updateOne(
+ { session_id: sessionId },
+ {
+ $push: {
+ facilitation_log: logEntry,
+ audit_log: {
+ timestamp: new Date(),
+ action: 'facilitation_action_recorded',
+ actor: action.actor,
+ details: logEntry
+ }
+ },
+ $set: { updated_at: new Date() }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Record human intervention (SAFETY MECHANISM)
+ * Called when human observer takes over from AI
+ */
+ static async recordHumanIntervention(sessionId, intervention) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const interventionRecord = {
+ timestamp: new Date(),
+ intervener: intervention.intervener, // Name/ID of human who intervened
+ trigger: intervention.trigger, // "safety_concern" | "ai_error" | "stakeholder_request" | "quality_issue" | "manual"
+ round_number: intervention.round_number || null,
+ description: intervention.description,
+ ai_action_overridden: intervention.ai_action_overridden || null, // What AI was doing when intervention occurred
+ corrective_action: intervention.corrective_action, // What human did instead
+ stakeholder_informed: intervention.stakeholder_informed || false, // Were stakeholders told about the intervention?
+ resolution: intervention.resolution || null // How was the situation resolved?
+ };
+
+ const result = await collection.updateOne(
+ { session_id: sessionId },
+ {
+ $push: {
+ human_interventions: interventionRecord,
+ audit_log: {
+ timestamp: new Date(),
+ action: 'human_intervention',
+ details: interventionRecord
+ }
+ },
+ $inc: { 'ai_quality_metrics.human_takeover_count': 1 },
+ $set: { updated_at: new Date() }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Record safety escalation (SAFETY MECHANISM)
+ * Called when concerning pattern detected (bias, harm, disengagement)
+ */
+ static async recordSafetyEscalation(sessionId, escalation) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const escalationRecord = {
+ timestamp: new Date(),
+ detected_by: escalation.detected_by, // "ai" | "human" | "stakeholder"
+ escalation_type: escalation.escalation_type, // "pattern_bias" | "stakeholder_distress" | "disengagement" | "hostile_exchange" | "ai_malfunction"
+ severity: escalation.severity, // "low" | "moderate" | "high" | "critical"
+ round_number: escalation.round_number || null,
+ description: escalation.description,
+ stakeholders_affected: escalation.stakeholders_affected || [],
+ immediate_action_taken: escalation.immediate_action_taken, // What was done immediately?
+ requires_session_pause: escalation.requires_session_pause || false,
+ resolved: escalation.resolved || false,
+ resolution_details: escalation.resolution_details || null
+ };
+
+ const updates = {
+ $push: {
+ safety_escalations: escalationRecord,
+ audit_log: {
+ timestamp: new Date(),
+ action: 'safety_escalation',
+ severity: escalation.severity,
+ details: escalationRecord
+ }
+ },
+ $inc: { 'ai_quality_metrics.escalation_count': 1 },
+ $set: { updated_at: new Date() }
+ };
+
+ // If critical severity or session pause required, auto-pause session
+ if (escalation.severity === 'critical' || escalation.requires_session_pause) {
+ updates.$set.status = 'paused';
+ updates.$set.paused_reason = escalationRecord.description;
+ updates.$set.paused_at = new Date();
+ }
+
+ const result = await collection.updateOne(
+ { session_id: sessionId },
+ updates
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Set deliberation outcome
+ */
+ static async setOutcome(sessionId, outcome) {
+ const collection = await getCollection('deliberation_sessions');
+
+ const outcomeRecord = {
+ decision_made: outcome.decision_made,
+ values_prioritized: outcome.values_prioritized || [],
+ values_deprioritized: outcome.values_deprioritized || [],
+ deliberation_summary: outcome.deliberation_summary,
+ consensus_level: outcome.consensus_level, // "full_consensus" | "strong_accommodation" | "moderate_accommodation" | "documented_dissent" | "no_resolution"
+ dissenting_perspectives: outcome.dissenting_perspectives || [],
+ justification: outcome.justification,
+ moral_remainder: outcome.moral_remainder || null, // What was sacrificed/lost?
+ generated_by: outcome.generated_by || 'ai', // "ai" | "human" | "collaborative"
+ finalized_at: new Date()
+ };
+
+ const result = await collection.updateOne(
+ { session_id: sessionId },
+ {
+ $set: {
+ outcome: outcomeRecord,
+ status: 'completed',
+ updated_at: new Date()
+ },
+ $push: {
+ audit_log: {
+ timestamp: new Date(),
+ action: 'outcome_set',
+ details: { consensus_level: outcome.consensus_level }
+ }
+ }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Find session by ID
+ */
+ static async findBySessionId(sessionId) {
+ const collection = await getCollection('deliberation_sessions');
+ return await collection.findOne({ session_id: sessionId });
+ }
+
+ /**
+ * Find sessions by scenario
+ */
+ static async findByScenario(scenario, options = {}) {
+ const collection = await getCollection('deliberation_sessions');
+ const { limit = 50, skip = 0 } = options;
+
+ return await collection
+ .find({ 'decision.scenario': scenario })
+ .sort({ created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+ }
+
+ /**
+ * Find sessions by status
+ */
+ static async findByStatus(status, options = {}) {
+ const collection = await getCollection('deliberation_sessions');
+ const { limit = 50, skip = 0 } = options;
+
+ return await collection
+ .find({ status })
+ .sort({ created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+ }
+
+ /**
+ * Get AI safety metrics for session
+ * SAFETY MECHANISM: Monitors AI facilitation quality
+ */
+ static async getAISafetyMetrics(sessionId) {
+ const session = await this.findBySessionId(sessionId);
+ if (!session) return null;
+
+ return {
+ session_id: sessionId,
+ status: session.status,
+ total_interventions: session.human_interventions.length,
+ total_escalations: session.safety_escalations.length,
+ critical_escalations: session.safety_escalations.filter(e => e.severity === 'critical').length,
+ ai_takeover_count: session.ai_quality_metrics.human_takeover_count,
+ facilitation_balance: {
+ ai_actions: session.facilitation_log.filter(a => a.actor === 'ai').length,
+ human_actions: session.facilitation_log.filter(a => a.actor === 'human').length
+ },
+ unresolved_escalations: session.safety_escalations.filter(e => !e.resolved).length,
+ stakeholder_satisfaction: session.ai_quality_metrics.stakeholder_satisfaction_scores,
+ recommendation: this._generateSafetyRecommendation(session)
+ };
+ }
+
+ /**
+ * Generate safety recommendation based on metrics
+ * SAFETY MECHANISM: Auto-flags concerning sessions
+ */
+ static _generateSafetyRecommendation(session) {
+ const criticalCount = session.safety_escalations.filter(e => e.severity === 'critical').length;
+ const takeoverCount = session.ai_quality_metrics.human_takeover_count;
+ const unresolvedCount = session.safety_escalations.filter(e => !e.resolved).length;
+
+ if (criticalCount > 0 || unresolvedCount > 2) {
+ return {
+ level: 'critical',
+ message: 'Session requires immediate human review. Critical safety issues detected.',
+ action: 'pause_and_review'
+ };
+ }
+
+ if (takeoverCount > 3 || session.safety_escalations.length > 5) {
+ return {
+ level: 'warning',
+ message: 'High intervention rate suggests AI facilitation quality issues.',
+ action: 'increase_human_oversight'
+ };
+ }
+
+ if (takeoverCount === 0 && session.safety_escalations.length === 0) {
+ return {
+ level: 'excellent',
+ message: 'AI facilitation proceeding smoothly with no interventions.',
+ action: 'continue_monitoring'
+ };
+ }
+
+ return {
+ level: 'normal',
+ message: 'AI facilitation within normal parameters.',
+ action: 'continue_monitoring'
+ };
+ }
+
+ /**
+ * Generate transparency report
+ */
+ static async generateTransparencyReport(sessionId) {
+ const session = await this.findBySessionId(sessionId);
+ if (!session) return null;
+
+ const report = {
+ session_id: sessionId,
+ generated_at: new Date(),
+
+ // Process transparency
+ process: {
+ format: session.configuration.format,
+ ai_role: session.configuration.ai_role,
+ total_rounds: session.deliberation_rounds.length,
+ duration_days: Math.ceil((new Date() - new Date(session.created_at)) / (1000 * 60 * 60 * 24))
+ },
+
+ // Stakeholder participation
+ stakeholders: {
+ total: session.stakeholders.length,
+ confirmed: session.stakeholders.filter(s => s.participation_status === 'confirmed').length,
+ active: session.stakeholders.filter(s => s.participation_status === 'active').length,
+ withdrawn: session.stakeholders.filter(s => s.participation_status === 'withdrawn').length
+ },
+
+ // Facilitation transparency (AI vs. Human)
+ facilitation: {
+ total_actions: session.facilitation_log.length,
+ ai_actions: session.facilitation_log.filter(a => a.actor === 'ai').length,
+ human_actions: session.facilitation_log.filter(a => a.actor === 'human').length,
+ intervention_count: session.human_interventions.length,
+ intervention_triggers: this._summarizeInterventionTriggers(session.human_interventions)
+ },
+
+ // Safety transparency
+ safety: {
+ escalations: session.safety_escalations.length,
+ by_severity: {
+ low: session.safety_escalations.filter(e => e.severity === 'low').length,
+ moderate: session.safety_escalations.filter(e => e.severity === 'moderate').length,
+ high: session.safety_escalations.filter(e => e.severity === 'high').length,
+ critical: session.safety_escalations.filter(e => e.severity === 'critical').length
+ },
+ resolved: session.safety_escalations.filter(e => e.resolved).length,
+ unresolved: session.safety_escalations.filter(e => !e.resolved).length
+ },
+
+ // Outcome transparency
+ outcome: session.outcome ? {
+ consensus_level: session.outcome.consensus_level,
+ generated_by: session.outcome.generated_by,
+ dissenting_perspectives_count: session.outcome.dissenting_perspectives.length,
+ values_in_tension: {
+ prioritized: session.outcome.values_prioritized,
+ deprioritized: session.outcome.values_deprioritized
+ }
+ } : null
+ };
+
+ // Store report in session
+ await this.collection.updateOne(
+ { session_id: sessionId },
+ { $set: { transparency_report: report, updated_at: new Date() } }
+ );
+
+ return report;
+ }
+
+ static _summarizeInterventionTriggers(interventions) {
+ const triggers = {};
+ interventions.forEach(i => {
+ triggers[i.trigger] = (triggers[i.trigger] || 0) + 1;
+ });
+ return triggers;
+ }
+}
+
+module.exports = DeliberationSession;
diff --git a/src/models/Precedent.model.js b/src/models/Precedent.model.js
new file mode 100644
index 00000000..fad6975d
--- /dev/null
+++ b/src/models/Precedent.model.js
@@ -0,0 +1,503 @@
+/**
+ * Precedent Model
+ * Stores completed deliberation sessions as searchable precedents
+ * for informing future values conflicts without dictating outcomes.
+ *
+ * PLURALISTIC PRINCIPLE: Precedents inform but don't mandate.
+ * Similar conflicts can be resolved differently based on context.
+ */
+
+const { ObjectId } = require('mongodb');
+const { getCollection } = require('../utils/db.util');
+
+class Precedent {
+ /**
+ * Create precedent from completed deliberation session
+ */
+ static async createFromSession(sessionData) {
+ const collection = await getCollection('precedents');
+
+ const precedent = {
+ precedent_id: `precedent-${Date.now()}`,
+ created_at: new Date(),
+
+ // Link to original session
+ source_session_id: sessionData.session_id,
+ source_session_created: sessionData.created_at,
+
+ // Conflict description (searchable)
+ conflict: {
+ description: sessionData.decision.description,
+ scenario: sessionData.decision.scenario,
+ moral_frameworks_in_tension: sessionData.conflict_analysis.moral_frameworks_in_tension,
+ value_trade_offs: sessionData.conflict_analysis.value_trade_offs,
+ incommensurability_level: sessionData.conflict_analysis.incommensurability_level
+ },
+
+ // Stakeholder composition (for pattern matching)
+ stakeholder_pattern: {
+ total_count: sessionData.stakeholders.length,
+ types: this._extractStakeholderTypes(sessionData.stakeholders),
+ represents: this._extractRepresentations(sessionData.stakeholders),
+ moral_frameworks: this._extractMoralFrameworks(sessionData.stakeholders)
+ },
+
+ // Deliberation process (what worked, what didn't)
+ process: {
+ format: sessionData.configuration.format,
+ ai_role: sessionData.configuration.ai_role,
+ rounds_completed: sessionData.deliberation_rounds.length,
+ duration_days: Math.ceil((new Date(sessionData.outcome.finalized_at) - new Date(sessionData.created_at)) / (1000 * 60 * 60 * 24)),
+
+ // AI facilitation quality (for learning)
+ ai_facilitation_quality: {
+ intervention_count: sessionData.human_interventions.length,
+ escalation_count: sessionData.safety_escalations.length,
+ stakeholder_satisfaction_avg: this._calculateAverageSatisfaction(sessionData.ai_quality_metrics.stakeholder_satisfaction_scores)
+ }
+ },
+
+ // Outcome (the accommodation reached)
+ outcome: {
+ decision_made: sessionData.outcome.decision_made,
+ consensus_level: sessionData.outcome.consensus_level,
+ values_prioritized: sessionData.outcome.values_prioritized,
+ values_deprioritized: sessionData.outcome.values_deprioritized,
+ moral_remainder: sessionData.outcome.moral_remainder,
+ dissenting_count: sessionData.outcome.dissenting_perspectives.length
+ },
+
+ // Key insights (extracted from deliberation)
+ insights: {
+ shared_values_discovered: this._extractSharedValues(sessionData.deliberation_rounds),
+ accommodation_strategies: this._extractAccommodationStrategies(sessionData.deliberation_rounds),
+ unexpected_coalitions: this._extractCoalitions(sessionData.deliberation_rounds),
+ framework_tensions_resolved: this._extractTensionResolutions(sessionData)
+ },
+
+ // Searchable metadata
+ metadata: {
+ domain: this._inferDomain(sessionData.decision.scenario), // "employment", "healthcare", "content_moderation", etc.
+ decision_type: this._inferDecisionType(sessionData.conflict_analysis), // "transparency", "resource_allocation", "procedural", etc.
+ geographic_context: sessionData.decision.context.geographic || 'unspecified',
+ temporal_context: sessionData.decision.context.temporal || 'unspecified' // "emerging_issue", "established_issue", "crisis"
+ },
+
+ // Usage tracking
+ usage: {
+ times_referenced: 0,
+ influenced_sessions: [], // Array of session_ids where this precedent was consulted
+ last_referenced: null
+ },
+
+ // Searchability flags
+ searchable: true,
+ tags: this._generateTags(sessionData),
+
+ // Archive metadata
+ archived: false,
+ archived_reason: null
+ };
+
+ const result = await collection.insertOne(precedent);
+ return { ...precedent, _id: result.insertedId };
+ }
+
+ /**
+ * Search precedents by conflict pattern
+ * Returns similar past deliberations (not prescriptive, just informative)
+ */
+ static async searchByConflict(query, options = {}) {
+ const collection = await getCollection('precedents');
+ const { limit = 10, skip = 0 } = options;
+
+ const filter = { searchable: true, archived: false };
+
+ // Match moral frameworks in tension
+ if (query.moral_frameworks && query.moral_frameworks.length > 0) {
+ filter['conflict.moral_frameworks_in_tension'] = { $in: query.moral_frameworks };
+ }
+
+ // Match scenario
+ if (query.scenario) {
+ filter['conflict.scenario'] = query.scenario;
+ }
+
+ // Match domain
+ if (query.domain) {
+ filter['metadata.domain'] = query.domain;
+ }
+
+ // Match decision type
+ if (query.decision_type) {
+ filter['metadata.decision_type'] = query.decision_type;
+ }
+
+ // Match incommensurability level
+ if (query.incommensurability_level) {
+ filter['conflict.incommensurability_level'] = query.incommensurability_level;
+ }
+
+ const precedents = await collection
+ .find(filter)
+ .sort({ 'usage.times_referenced': -1, created_at: -1 }) // Most-used first, then most recent
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+
+ return precedents;
+ }
+
+ /**
+ * Search precedents by stakeholder pattern
+ * Useful for "Has deliberation with similar stakeholders been done before?"
+ */
+ static async searchByStakeholderPattern(pattern, options = {}) {
+ const collection = await getCollection('precedents');
+ const { limit = 10, skip = 0 } = options;
+
+ const filter = { searchable: true, archived: false };
+
+ // Match stakeholder types
+ if (pattern.types && pattern.types.length > 0) {
+ filter['stakeholder_pattern.types'] = { $all: pattern.types };
+ }
+
+ // Match representations (e.g., "Employers", "Job Applicants")
+ if (pattern.represents && pattern.represents.length > 0) {
+ filter['stakeholder_pattern.represents'] = { $in: pattern.represents };
+ }
+
+ // Match moral frameworks
+ if (pattern.moral_frameworks && pattern.moral_frameworks.length > 0) {
+ filter['stakeholder_pattern.moral_frameworks'] = { $in: pattern.moral_frameworks };
+ }
+
+ const precedents = await collection
+ .find(filter)
+ .sort({ 'usage.times_referenced': -1, created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+
+ return precedents;
+ }
+
+ /**
+ * Search precedents by tags (free-text search)
+ */
+ static async searchByTags(tags, options = {}) {
+ const collection = await getCollection('precedents');
+ const { limit = 10, skip = 0 } = options;
+
+ const filter = {
+ searchable: true,
+ archived: false,
+ tags: { $in: tags }
+ };
+
+ const precedents = await collection
+ .find(filter)
+ .sort({ 'usage.times_referenced': -1, created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+
+ return precedents;
+ }
+
+ /**
+ * Get most similar precedent (composite scoring)
+ * Uses multiple dimensions to find best match
+ */
+ static async findMostSimilar(querySession, options = {}) {
+ const { limit = 5 } = options;
+
+ // Get candidates from multiple search strategies
+ const conflictMatches = await this.searchByConflict({
+ moral_frameworks: querySession.conflict_analysis.moral_frameworks_in_tension,
+ scenario: querySession.decision.scenario,
+ incommensurability_level: querySession.conflict_analysis.incommensurability_level
+ }, { limit: 20 });
+
+ const stakeholderMatches = await this.searchByStakeholderPattern({
+ types: this._extractStakeholderTypes(querySession.stakeholders),
+ represents: this._extractRepresentations(querySession.stakeholders),
+ moral_frameworks: this._extractMoralFrameworks(querySession.stakeholders)
+ }, { limit: 20 });
+
+ // Combine and score
+ const candidateMap = new Map();
+
+ // Score conflict matches
+ conflictMatches.forEach(p => {
+ const score = this._calculateSimilarityScore(querySession, p);
+ candidateMap.set(p.precedent_id, { precedent: p, score, reasons: ['conflict_match'] });
+ });
+
+ // Score stakeholder matches (add to existing or create new)
+ stakeholderMatches.forEach(p => {
+ if (candidateMap.has(p.precedent_id)) {
+ const existing = candidateMap.get(p.precedent_id);
+ existing.score += this._calculateSimilarityScore(querySession, p) * 0.5; // Weight stakeholder match lower
+ existing.reasons.push('stakeholder_match');
+ } else {
+ const score = this._calculateSimilarityScore(querySession, p) * 0.5;
+ candidateMap.set(p.precedent_id, { precedent: p, score, reasons: ['stakeholder_match'] });
+ }
+ });
+
+ // Sort by score
+ const ranked = Array.from(candidateMap.values())
+ .sort((a, b) => b.score - a.score)
+ .slice(0, limit);
+
+ return ranked.map(r => ({
+ ...r.precedent,
+ similarity_score: r.score,
+ match_reasons: r.reasons
+ }));
+ }
+
+ /**
+ * Record that this precedent was referenced in a new session
+ */
+ static async recordUsage(precedentId, referencingSessionId) {
+ const collection = await getCollection('precedents');
+
+ const result = await collection.updateOne(
+ { precedent_id: precedentId },
+ {
+ $inc: { 'usage.times_referenced': 1 },
+ $push: { 'usage.influenced_sessions': referencingSessionId },
+ $set: { 'usage.last_referenced': new Date() }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ /**
+ * Get statistics on precedent usage
+ */
+ static async getStatistics() {
+ const collection = await getCollection('precedents');
+
+ const [stats] = await collection.aggregate([
+ { $match: { searchable: true, archived: false } },
+ {
+ $group: {
+ _id: null,
+ total_precedents: { $sum: 1 },
+ avg_references: { $avg: '$usage.times_referenced' },
+ total_references: { $sum: '$usage.times_referenced' },
+ by_domain: { $push: '$metadata.domain' },
+ by_scenario: { $push: '$conflict.scenario' }
+ }
+ }
+ ]).toArray();
+
+ const byDomain = await collection.aggregate([
+ { $match: { searchable: true, archived: false } },
+ {
+ $group: {
+ _id: '$metadata.domain',
+ count: { $sum: 1 },
+ avg_satisfaction: { $avg: '$process.ai_facilitation_quality.stakeholder_satisfaction_avg' }
+ }
+ },
+ { $sort: { count: -1 } }
+ ]).toArray();
+
+ const byConsensusLevel = await collection.aggregate([
+ { $match: { searchable: true, archived: false } },
+ {
+ $group: {
+ _id: '$outcome.consensus_level',
+ count: { $sum: 1 }
+ }
+ }
+ ]).toArray();
+
+ return {
+ summary: stats || { total_precedents: 0, avg_references: 0, total_references: 0 },
+ by_domain: byDomain,
+ by_consensus_level: byConsensusLevel
+ };
+ }
+
+ /**
+ * Archive precedent (make unsearchable but retain for records)
+ */
+ static async archive(precedentId, reason) {
+ const collection = await getCollection('precedents');
+
+ const result = await collection.updateOne(
+ { precedent_id: precedentId },
+ {
+ $set: {
+ archived: true,
+ archived_reason: reason,
+ archived_at: new Date(),
+ searchable: false
+ }
+ }
+ );
+
+ return result.modifiedCount > 0;
+ }
+
+ // ===== HELPER METHODS (private) =====
+
+ static _extractStakeholderTypes(stakeholders) {
+ return [...new Set(stakeholders.map(s => s.type))];
+ }
+
+ static _extractRepresentations(stakeholders) {
+ return [...new Set(stakeholders.map(s => s.represents))];
+ }
+
+ static _extractMoralFrameworks(stakeholders) {
+ return [...new Set(stakeholders.map(s => s.moral_framework).filter(Boolean))];
+ }
+
+ static _calculateAverageSatisfaction(scores) {
+ if (!scores || scores.length === 0) return null;
+ return scores.reduce((sum, s) => sum + s.score, 0) / scores.length;
+ }
+
+ static _extractSharedValues(rounds) {
+ // Look for Round 2 (shared values) contributions
+ const round2 = rounds.find(r => r.round_type === 'shared_values');
+ if (!round2) return [];
+
+ // Extract values mentioned across contributions
+ const values = [];
+ round2.contributions.forEach(c => {
+ if (c.values_emphasized) {
+ values.push(...c.values_emphasized);
+ }
+ });
+ return [...new Set(values)];
+ }
+
+ static _extractAccommodationStrategies(rounds) {
+ // Look for Round 3 (accommodation) AI summary
+ const round3 = rounds.find(r => r.round_type === 'accommodation');
+ if (!round3 || !round3.ai_summary) return [];
+
+ // This would ideally parse the summary for strategies
+ // For now, return placeholder
+ return ['tiered_approach', 'contextual_variation', 'temporal_adjustment'];
+ }
+
+ static _extractCoalitions(rounds) {
+ // Identify unexpected stakeholder agreements
+ // This would require NLP analysis of contributions
+ // For now, return placeholder
+ return [];
+ }
+
+ static _extractTensionResolutions(sessionData) {
+ if (!sessionData.outcome) return [];
+
+ const resolutions = [];
+ sessionData.conflict_analysis.value_trade_offs.forEach(tradeoff => {
+ // Check if outcome addresses this trade-off
+ const prioritized = sessionData.outcome.values_prioritized.some(v => tradeoff.includes(v));
+ const deprioritized = sessionData.outcome.values_deprioritized.some(v => tradeoff.includes(v));
+
+ if (prioritized && deprioritized) {
+ resolutions.push({
+ tension: tradeoff,
+ resolution: 'balanced_accommodation'
+ });
+ } else if (prioritized) {
+ resolutions.push({
+ tension: tradeoff,
+ resolution: 'prioritized'
+ });
+ }
+ });
+
+ return resolutions;
+ }
+
+ static _inferDomain(scenario) {
+ const domainMap = {
+ 'algorithmic_hiring_transparency': 'employment',
+ 'remote_work_pay': 'employment',
+ 'content_moderation': 'platform_governance',
+ 'healthcare_ai': 'healthcare',
+ 'ai_content_labeling': 'creative_rights'
+ };
+ return domainMap[scenario] || 'general';
+ }
+
+ static _inferDecisionType(conflictAnalysis) {
+ const description = conflictAnalysis.value_trade_offs.join(' ').toLowerCase();
+
+ if (description.includes('transparency')) return 'transparency';
+ if (description.includes('resource') || description.includes('allocation')) return 'resource_allocation';
+ if (description.includes('procedure') || description.includes('process')) return 'procedural';
+ if (description.includes('privacy')) return 'privacy';
+ if (description.includes('safety')) return 'safety';
+
+ return 'unspecified';
+ }
+
+ static _generateTags(sessionData) {
+ const tags = [];
+
+ // Add scenario tag
+ if (sessionData.decision.scenario) {
+ tags.push(sessionData.decision.scenario);
+ }
+
+ // Add moral framework tags
+ tags.push(...sessionData.conflict_analysis.moral_frameworks_in_tension.map(f => f.toLowerCase()));
+
+ // Add stakeholder representation tags
+ tags.push(...sessionData.stakeholders.map(s => s.represents.toLowerCase().replace(/ /g, '_')));
+
+ // Add outcome tag
+ if (sessionData.outcome) {
+ tags.push(sessionData.outcome.consensus_level);
+ }
+
+ // Add AI quality tag
+ const interventions = sessionData.human_interventions.length;
+ if (interventions === 0) tags.push('smooth_ai_facilitation');
+ else if (interventions > 3) tags.push('challenging_ai_facilitation');
+
+ return [...new Set(tags)];
+ }
+
+ static _calculateSimilarityScore(querySession, precedent) {
+ let score = 0;
+
+ // Scenario match (high weight)
+ if (querySession.decision.scenario === precedent.conflict.scenario) {
+ score += 40;
+ }
+
+ // Moral frameworks overlap (medium weight)
+ const queryFrameworks = new Set(querySession.conflict_analysis.moral_frameworks_in_tension);
+ const precedentFrameworks = new Set(precedent.conflict.moral_frameworks_in_tension);
+ const frameworkOverlap = [...queryFrameworks].filter(f => precedentFrameworks.has(f)).length;
+ score += (frameworkOverlap / Math.max(queryFrameworks.size, precedentFrameworks.size)) * 30;
+
+ // Incommensurability match (medium weight)
+ if (querySession.conflict_analysis.incommensurability_level === precedent.conflict.incommensurability_level) {
+ score += 20;
+ }
+
+ // Stakeholder count similarity (low weight)
+ const countDiff = Math.abs(querySession.stakeholders.length - precedent.stakeholder_pattern.total_count);
+ score += Math.max(0, 10 - countDiff * 2);
+
+ return score;
+ }
+}
+
+module.exports = Precedent;
diff --git a/src/models/index.js b/src/models/index.js
index 1a822b24..7e01bb50 100644
--- a/src/models/index.js
+++ b/src/models/index.js
@@ -11,6 +11,8 @@ const Resource = require('./Resource.model');
const ModerationQueue = require('./ModerationQueue.model');
const User = require('./User.model');
const GovernanceLog = require('./GovernanceLog.model');
+const DeliberationSession = require('./DeliberationSession.model');
+const Precedent = require('./Precedent.model');
module.exports = {
Document,
@@ -20,5 +22,7 @@ module.exports = {
Resource,
ModerationQueue,
User,
- GovernanceLog
+ GovernanceLog,
+ DeliberationSession,
+ Precedent
};
diff --git a/src/server.js b/src/server.js
index e0ecbdae..d51dfe39 100644
--- a/src/server.js
+++ b/src/server.js
@@ -86,9 +86,10 @@ app.use((req, res, next) => {
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
- // HTML files: Short cache, always revalidate
+ // HTML files: No cache (always fetch fresh - users must see updates immediately)
else if (path.endsWith('.html') || path === '/') {
- res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate'); // 5 minutes
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
+ res.setHeader('Pragma', 'no-cache');
}
// CSS and JS files: Longer cache (we use version parameters)
else if (path.endsWith('.css') || path.endsWith('.js')) {