← Back to index | ← 04 Config schema | 06 Scoring + calibration →
How a config change in the dashboard reaches the running quiz, and how long it takes.
Sequence diagram
sequenceDiagram
autonumber
participant Admin
participant Dashboard
participant Gateway
participant Postgres
participant Snapshots
participant Audit
participant QuizApp as Public quiz app
participant Visitor
Admin->>Dashboard: Edit field (debounced 300 ms)
Dashboard->>Gateway: PATCH /framework/.../config?client=tsg<br/>{ partial, reason?, expected_version? }
Gateway->>Gateway: Validate JWT + tier + tenant
Gateway->>Postgres: SELECT current row<br/>(check expected_version)
Gateway->>Postgres: UPSERT merged row<br/>(bump version)
Gateway->>Snapshots: INSERT new full state
Gateway->>Audit: INSERT audit row<br/>(autocommit conn)
Gateway-->>Dashboard: 200 + new full config
Dashboard-->>Admin: Toast "Saved"
Note over QuizApp,Visitor: ~30 s of cache TTL
Visitor->>QuizApp: Lands on quiz page
QuizApp->>Gateway: GET /framework/.../resolved?client=tsg<br/>(Next.js fetch cache: revalidate 30 s)
Gateway->>Postgres: SELECT row + framework JSON
Gateway->>Gateway: Merge defaults + overrides<br/>+ _canonicalize_mode_keys()<br/>+ inject _tenant_urls
Gateway-->>QuizApp: 200 resolved view
QuizApp-->>Visitor: Render with new config
End-to-end sequence
Admin edits a field in the dashboard
│
│ debounced auto-save (300 ms)
▼
PATCH /api/v1/assess/framework/money_archetypes/config?client=<tenant>
Body: { partial: {...}, reason?: "...", expected_version?: N }
│
▼
Gateway router (assess_framework.py)
├─ Validate JWT / Clerk session
├─ Authorise tenant access
├─ Load current row + verify expected_version (optimistic lock)
├─ Apply per-column merge (shallow for archetypes; wholesale otherwise)
├─ UPSERT framework_configs
├─ Bump version (atomic)
├─ Snapshot the new full row into framework_config_snapshots
├─ Write audit row (separate autocommit conn)
└─ Return new full config
│
▼
Dashboard updates local state + shows toast
│
│ ~30 s pass
▼
Next quiz visitor lands on quiz.thesynergygroup.ch
│
▼
moneyquiz-app: loadResolvedFramework(req, "money_archetypes")
GET /api/v1/assess/framework/money_archetypes/resolved?client=<tenant>
│
│ (Next.js fetch cache, revalidate: 30 s)
▼
Gateway router
├─ Load platform defaults JSON
├─ Load tenant override row
├─ Per-column merge → resolved view
├─ Apply _canonicalize_mode_keys() to resolved.modes (defense-in-depth)
├─ Inject _tenant_urls + _canonical_quiz_url + _slug_prefix
└─ Return resolved
│
▼
moneyquiz-app caches for 30 s; renders quiz with new config
Worst-case latency from save → visible: ~30 seconds (the cache TTL). Best-case: immediate (if cache is cold for that tenant + framework).
Optimistic locking
PATCH accepts expected_version to prevent lost-write races. If the row's current version doesn't match, the gateway returns 409 Conflict with a body containing the current state.
The dashboard handles 409 by:
- Showing a "Someone else saved while you were editing" banner.
- Refusing to overwrite until the admin reloads + retries.
In practice this is rare — most tenants have a single admin editing at a time. The lock is there for safety, not for high-throughput concurrent editing.
Per-column merge (recap)
| Column | Merge type |
|---|---|
archetype_overrides | Per-dimension shallow merge |
| All others | Wholesale replace |
See 02 Tenancy model §Override merge rules for the full table + practical implications. The most common dashboard editor mistake is sending a sparse partial to a wholesale-replace column — that silently drops every unincluded field.
Snapshot lifecycle
Every PATCH writes a snapshot to framework_config_snapshots:
snapshot_at= NOWsnapshot_by= the actor's user id (orseed:...for migration-time writes)reason= the optional reason string from the PATCH bodyfull_config= the entire NEW state (post-merge)diff= compact JSON diff vs the previous snapshot (lazy — often NULL today)
Cap: 50 per (tenant, framework). Oldest are deleted on PATCH.
Restore
POST /api/v1/assess/framework/{id}/restore?client=<tenant>&snapshot=<snapshot_id>
Atomic: writes the snapshot's full_config back as the live config + creates a new snapshot for the restore action itself (so the restore is itself reversible).
Use cases:
- An admin makes a mistake and wants to roll back.
- A dashboard bug ships a malformed PATCH and we restore last-known-good.
- Quarterly review: compare current vs N versions ago.
Cache TTL (30 seconds)
The 30-second cache lives in the consumer (moneyquiz-app) via Next.js fetch({next: {revalidate: 30}}). The gateway itself does NOT cache /resolved — every gateway request hits Postgres.
Why 30 seconds:
- Short enough that admin edits land within a coffee-sip window.
- Long enough to absorb traffic bursts without hammering Postgres.
- A real session is typically 5–10 minutes long, so a single user mid-quiz won't see a config change mid-flight (the route caches at start; subsequent calls within the session use the same cached value).
Cache is per-tenant per-framework per-pod. With 2 moneyquiz-app pods, a publish takes up to 30 s on each pod to land.
Forcing fresh
The dashboard's preview (B8 / live brand preview card) does NOT go through the gateway — it renders client-side from the form state directly, so admins see changes instantly without waiting for cache invalidation.
For server-side preview (Phase 7 — not yet built), the planned approach is GET /resolved?client=<tenant>&draft_id=<id> returning the draft state + a ?config=draft:<id> URL parameter for the public quiz to honour.
What gets cached vs what doesn't
| Surface | Cached? | TTL |
|---|---|---|
/api/v1/assess/framework/{id}/resolved | Yes (Next.js fetch cache) | 30 s |
/api/v1/assess/tenant/{id}/brand | Yes (Next.js fetch cache) | 30 s |
/api/v1/assess/framework/{id}/config (dashboard PATCH target) | No | — |
/api/v1/assess/framework/{id}/snapshots | No | — |
/api/v1/assess/framework/{id}/calibrate | No (compute-on-call) | — |
framework_definitions/*.json (baked into image) | Yes (image build time) | until next deploy |
Configuration → consumers map
What field in framework_configs affects what surface in the running quiz:
| Override column | Public quiz consumer | Where read |
|---|---|---|
archetype_overrides | Archetype descriptions, colours, micro-insights | [archetypes.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/archetypes.ts) |
voice_overrides | LLM system prompt + tone directives | [quiz-prompt.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/quiz-prompt.ts), [score-analyser.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/score-analyser.ts) |
scoring_overrides.engine | Calibration rules + scoring engine knobs | [score-analyser.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/score-analyser.ts)::analyseScores, agent [scoring_calibration.py](../../../Agent%20Zero/repos/agent-zero-agents/agents/adaptive-assessment/scoring_calibration.py), gateway [scoring_calibration.py](../../../Agentic%20Starter/api-gateway/scoring_calibration.py) |
mode_overrides.likert | 112 Likert traits | WP [traditional-quiz-data.js](../../../MMC/assets/js/traditional-quiz-data.js) (today still file-based — Phase 4 will migrate) |
mode_overrides.qa | Q&A question bank | [question-bank-strategy-a.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/question-bank-strategy-a.ts) |
mode_overrides.game | Game rounds + interactions | [round-definitions.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/round-definitions.ts) |
mode_overrides.deal | Deal deck | [deal-engine/index.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/deal-engine/index.ts) |
landing_overrides | Persona landing pages | [landing-content.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/landing-content.ts), [app/lp/[personaSlug]/page.tsx](../../../MoneyQuiz/moneyquiz-app/src/app/lp/%5BpersonaSlug%5D/page.tsx) |
communications | Email templates + booking URL + follow-ups | [/api/send-results/route.ts](../../../MoneyQuiz/moneyquiz-app/src/app/api/send-results/route.ts), agent nurture_scheduler.py |
results_display | Spider chart, ordering, depth thresholds | [components/results/ResultsPage.tsx](../../../MoneyQuiz/moneyquiz-app/src/components/results/ResultsPage.tsx) |
providers | Model selectors, voice id, rate limits | [/api/quiz/results/route.ts](../../../MoneyQuiz/moneyquiz-app/src/app/api/quiz/results/route.ts), [/api/voice/route.ts](../../../MoneyQuiz/moneyquiz-app/src/app/api/voice/route.ts) |
Common pitfalls
- PATCHing with
{}does NOT clear. Falls through to current. See 02 Tenancy model §Clearing an override. - PATCH shallow-merge cannot DELETE JSONB keys. Use direct SQL
UPDATE ... SET col = col - 'subkey'. See feedback_alias_first_multi_phase_rename.md. - Wholesale-replace columns require the FULL bundle. Editors that ship sparse partials silently drop fields. Load defaults from a TS module / JSON asset.
- The 30 s cache means deploys can hide changes. If you publish then immediately curl
/resolved, you might still see the OLD value for up to 30 s.
Next
→ 06 Scoring + calibration — the rule engine, three implementations, per-format coverage.