← 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:

  1. Showing a "Someone else saved while you were editing" banner.
  2. 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)

ColumnMerge type
archetype_overridesPer-dimension shallow merge
All othersWholesale 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 = NOW
  • snapshot_by = the actor's user id (or seed:... for migration-time writes)
  • reason = the optional reason string from the PATCH body
  • full_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

SurfaceCached?TTL
/api/v1/assess/framework/{id}/resolvedYes (Next.js fetch cache)30 s
/api/v1/assess/tenant/{id}/brandYes (Next.js fetch cache)30 s
/api/v1/assess/framework/{id}/config (dashboard PATCH target)No
/api/v1/assess/framework/{id}/snapshotsNo
/api/v1/assess/framework/{id}/calibrateNo (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 columnPublic quiz consumerWhere read
archetype_overridesArchetype descriptions, colours, micro-insights[archetypes.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/archetypes.ts)
voice_overridesLLM 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.engineCalibration 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.likert112 Likert traitsWP [traditional-quiz-data.js](../../../MMC/assets/js/traditional-quiz-data.js) (today still file-based — Phase 4 will migrate)
mode_overrides.qaQ&A question bank[question-bank-strategy-a.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/question-bank-strategy-a.ts)
mode_overrides.gameGame rounds + interactions[round-definitions.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/round-definitions.ts)
mode_overrides.dealDeal deck[deal-engine/index.ts](../../../MoneyQuiz/moneyquiz-app/src/lib/deal-engine/index.ts)
landing_overridesPersona 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)
communicationsEmail 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_displaySpider chart, ordering, depth thresholds[components/results/ResultsPage.tsx](../../../MoneyQuiz/moneyquiz-app/src/components/results/ResultsPage.tsx)
providersModel 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

  1. PATCHing with {} does NOT clear. Falls through to current. See 02 Tenancy model §Clearing an override.
  2. PATCH shallow-merge cannot DELETE JSONB keys. Use direct SQL UPDATE ... SET col = col - 'subkey'. See feedback_alias_first_multi_phase_rename.md.
  3. Wholesale-replace columns require the FULL bundle. Editors that ship sparse partials silently drop fields. Load defaults from a TS module / JSON asset.
  4. 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.