| name | zfc-leveling-roadmap |
| description | ZFC Level-Up architecture alignment — file boundaries, API contracts, pre-flight checks, and milestone status. Read this BEFORE any level-up/rewards/XP code change. |
| scope | your-project.com/mvp_site |
ZFC Leveling Roadmap Alignment
Design Principle
This skill is the level-up/rewards specialization of the general ZFC contract.
Before applying these file boundaries, read
.claude/skills/zero-framework-cognition/SKILL.md for the repository-wide
rules: models make semantic decisions, new regex/keyword/heuristic semantic
routing is banned, and model contracts should request minimal semantic fields
instead of redundant aliases.
Before accepting a backend correction guard, also apply
.claude/skills/root-cause-first/SKILL.md: prove the selected model path
received the right prompt/schema, fix missing or ambiguous prompt/schema
instructions first, and treat backend correction as a last resort.
"Model Computes, Backend Formats" — the LLM decides level-up; rewards_engine.py formats it; world_logic.py wraps the formatted result with modal semantics.
"LLM Calculates, Backend Corrects" — the LLM owns XP arithmetic, target-level math, level-up availability, and choice/benefit selection. Backend code must not independently calculate those facts from thresholds as a primary path. It may only correct or suppress mechanically contradictory explicit model/state payloads.
Correction Exception Boundary: Correction guards that prevent degraded UX (stale-flag time-freeze, SSE soft-lock, already-applied level-up residue) are permitted only within rewards_engine.py. These guards must be correction-only: compare explicit model/state fields for contradictions, suppress or normalize invalid payloads, and avoid creating a competing backend level-up decision. Scattered heuristics across multiple files are strictly forbidden.
Opaque Choice Boundary: Planning choice IDs are opaque handles, not semantic signals. level_up_now, finish_level_up_return_to_game, and custom_action are migration/compatibility handles only. Level-up choice meaning must come from explicit model/schema fields on the resolved choice object, not from ID text, prefixes, labels, or backend allowlists.
File Responsibility Table (Mandatory)
Before modifying ANY file for level-up/rewards/XP work, verify against this table.
If your change puts logic in a "MUST NOT DO" column → STOP and redesign.
| File | OWNS | MUST NOT DO |
|---|
rewards_engine.py | Format explicit model signals, correction-only contradiction guards, canonicalize_level_up_signal(), XP/reward field normalization from explicit values, sync_level_up_signals(), SSE atomicity enforcement (_enforce_atomicity()) | Model calls, Firestore writes, text interpretation, primary XP/level calculation, threshold-derived level-up decisions |
world_logic.py | Modal locks, finish choice injection, wrap precomputed payloads only, thin delegation to rewards_engine.project_level_up_ui() | Recompute XP, synthesize choices, call canonicalize_level_up_signal() directly, build level_up_signal dicts, call resolve_level_progression() |
game_state.py | State storage, deterministic mechanical primitives (xp_needed_for_level, level_from_xp, coerce_int) | Decide level-up from state, replace model choices, own level-up decisions, contain level-up dataclasses or resolvers |
agents.py | Route to correct agent using rewards_engine.is_level_up_active() | Inline flag interpretation, XP extraction, stale-flag recovery logic |
llm_parser.py | Parse model output, call the pipeline once via canonicalize_rewards(), persist/deliver | Duplicate rewards formatting, inline false-positive mutations, call canonicalization separately |
narrative_response_schema.py | Preserve model output losslessly, contradiction detection on model output boundary (e.g., level_up=true but current_turn_exp < total_exp_for_next_level) | Class feature synthesis, state mutation, building UI payloads |
llm_service.py | LLM request/response pipeline, retry on SchemaRejectionError | Rewards formatting, level-up flag interpretation, modal state mutation |
structured_fields_utils.py | Extract non-empty structured fields |
Authorized Public API Surface
These are the only rewards_engine functions that external files may call:
| Function | Callers | Purpose |
|---|
canonicalize_rewards(structured_fields, game_state, original_state) | llm_parser.py (1 call), world_logic.py (polling paths) | Single convergence point for all rewards/level-up UI |
is_level_up_active(game_state) | agents.py, world_logic.py | Correction-guarded modal activity check over explicit state/model fields |
project_level_up_ui(game_state, original_state) | world_logic.py (via _project_level_up_ui_locally()) | Polling/projection path (transitional — delete when callers migrate) |
sync_level_up_signals(game_state, original_state) | world_logic.py (via ensure_level_up_rewards_pending() shim) | Idempotent rewards_pending sync |
format_model_level_up_signal(signal) | llm_parser.py (fallback path) | Explicit signal formatter (should become private _format_...) |
All other functions are private implementation details. Do not call _canonicalize_core(), _enforce_atomicity(), _apply_contradiction_guard(), resolve_level_up_signal(), or ensure_rewards_box() from outside rewards_engine.py.
API Contract Freeze
canonicalize_rewards() returns (rewards_box, planning_block) — a 2-tuple. Do NOT change this return signature. Use out_meta dict parameter to pass additional data out (e.g., canonical signal, false-positive flags).
canonicalize_level_up_signal() is called inside rewards_engine.py only. External files consume precomputed results.
is_level_up_active() returns bool. It performs correction-only stale-flag/contradiction suppression internally. It must not become a primary XP-threshold calculator. Callers must not second-guess its result.
ensure_rewards_box() returns tuple[dict | None, dict | None] (rewards_box, signal_metadata). Internal only.
Common Boundary Violations
These are the most frequently observed agent drift patterns. If you catch yourself doing any of these, STOP:
| If you're about to... | WRONG file | RIGHT approach |
|---|
Call canonicalize_level_up_signal() | world_logic.py, llm_parser.py | Only inside rewards_engine.py (via _canonicalize_core) |
Build a level_up_signal dict | world_logic.py | Build in rewards_engine.py, pass via return or out_meta |
| Compute XP progress, thresholds, target level, or level-up availability as the primary path | any backend file | Ask the LLM for explicit calculated fields; rewards_engine.py may only correct/suppress contradictions in those explicit fields |
Check level_up_pending / level_up_in_progress flags directly | world_logic.py, agents.py | Call rewards_engine.is_level_up_active() |
Call resolve_level_progression() | anywhere | Deleted. Use rewards_engine.is_level_up_active() or rewards_engine.project_level_up_ui() |
Use build_level_up_rewards_box() | anywhere | Deleted. Use rewards_engine.ensure_rewards_box() (internal) or project_level_up_ui() |
Change canonicalize_rewards() return type | anywhere | Don't. Use out_meta dict instead |
| Add signal normalization aliases | structured_fields_utils.py | Add in rewards_engine.canonicalize_level_up_signal() |
| Classify or scrub choices by ID text/prefix, label, enum name, or CSS class | any backend file | Resolve the submitted handle against persisted planning_block.choices, then read explicit structured fields; keep level_up_now, finish_level_up_return_to_game, and custom_action exact checks migration-only |
Pre-Flight Checklist
Run this before writing any code for a PR that touches level-up, rewards, XP, or canonicalization logic.
1. File boundary check
For each file you plan to modify, check the table above:
2. API contract check
3. Deleted function check
4. Scope check
5. PR hygiene check
Decision Flowchart
Is my change about level-up/XP/rewards logic?
├── YES → Put the logic in rewards_engine.py
│ ├── Does world_logic.py need the result?
│ │ └── Pass via return value or out_meta dict
│ ├── Does agents.py need to know if level-up is active?
│ │ └── Call rewards_engine.is_level_up_active() — never inline flag checks
│ └── Does llm_parser.py need the result?
│ └── Call canonicalize_rewards() once; don't call internal functions directly
├── Is my change about validating LLM output?
│ └── Contradiction detection → narrative_response_schema.py
│ └── Schema retry → llm_service.py SchemaRejectionError
└── NO → Normal file protocol applies
Milestone Status
| Milestone | Status | Description |
|---|
| M0 | ✅ Complete | Delete legacy paths, net LOC ≤ 0 |
| M1 | ✅ Complete | Model compliance probe — prompt contract + streaming evidence |
| M1b | ✅ Complete | Repeated-run reliability harness (Wilson score intervals) |
| M2 | 🟡 Near Complete | Legacy resolver deletion done; formatter narrowing partially done |
| M3 | 🟢 Partially Started | CI grep gates active via design-doc-gate.yml (8 gates enforced) |
M2 Completed Work (via PR #6641)
- ✅
game_state.py — all legacy resolvers and dataclasses deleted (422 LOC)
- ✅
world_logic.py — build_level_up_rewards_box() deleted, all resolve_level_progression() calls removed
- ✅
agents.py — _is_stale_level_up_pending() deleted, all routing uses is_level_up_active()
- ✅
rewards_engine.sync_level_up_signals() replaces 200-line ensure_level_up_rewards_pending()
- ✅
_enforce_atomicity() rewritten for semantic SSE validation (fail-closed)
- ✅ Canonical XP field names enforced:
current_turn_exp, total_exp_for_next_level
- ✅
ensure_rewards_box() returns tuple[dict|None, dict|None], field resolved_target_level → new_level
M2 Remaining Work
format_model_level_up_signal() → make private (_format_model_level_up_signal)
project_level_up_ui() → delete when no remaining callers (currently used by _project_level_up_ui_locally())
M3 Status — CI Enforcement Gates (Active)
The design-doc-gate.yml workflow enforces 8 grep-based boundary checks on every PR:
| Gate | Rule | Status |
|---|
world_logic 0 rewards_engine public API imports | No direct from rewards_engine import in world_logic | ✅ Enforced |
world_logic 0 resolve_level_up_signal calls | No direct resolver calls | ✅ Enforced |
constants get_xp_for_level | No duplicate XP helpers in constants | ✅ Enforced |
constants get_level_from_xp | No duplicate level helpers in constants | ✅ Enforced |
_is_state_flag_true 2 files | Helper exists in exactly 2 files | ✅ Enforced |
world_logic ≤1 project_level_up_ui | At most 1 non-wrapper call | ✅ Enforced |
non-rewards 0 direct level-up pair builders | No external pair builders | ✅ Enforced |
llm_parser canonicalize_rewards=1 | Exactly 1 canonicalize call | ✅ Enforced |
M3 Remaining Work
- Centralization ratchet: no new
def in world_logic.py whose name exists in rewards_engine.py
- Architecture test:
level_up_signal formatting stays in rewards_engine.py
Field Ownership Quick Reference
Every shared dict field must have exactly ONE authoritative writer.
| Dict / Field | Writer | Semantic |
|---|
level_up_signal.current_level | LLM | Int: level before applying the level-up |
level_up_signal.target_level | LLM | Int: target level now available; target_level > current_level is the model-owned level-up signal |
rewards_pending.level_up_available | Backend-derived compatibility/display field from accepted level_up_signal | Bool: deterministic UI alias; do not ask the model to emit it |
rewards_pending.xp_gained | LLM via state_updates; rewards_engine.sync_level_up_signals() only for transitional correction | Int: model-calculated XP delta for rewards box |
rewards_pending.new_level | Backend-derived compatibility/display field from level_up_signal.target_level | Int: deterministic UI alias; do not ask the model to emit it |
player_character_data.experience.current | LLM via state_updates | Int: cumulative XP (backend NEVER writes) |
custom_campaign_state.level_up_in_progress | Server-owned modal/session control state | Bool: level-up modal active; model must not write this on story/rewards turns |
custom_campaign_state.level_up_pending | Backend-derived compatibility/control field from accepted level-up signal or modal state | Bool: modal routing compatibility; model must not write this on story/rewards turns |
custom_campaign_state.level_up_complete | Server-owned level-up flow state | Bool: level-up flow finished (suppresses is_level_up_active()) |
custom_campaign_state.level_up_cancelled | Server-owned level-up flow state | Bool: level-up deferred (suppresses is_level_up_active()) |
For the full field registry, see .claude/skills/field-ownership-contracts.md.
Key Architectural Invariants (Post-M2)
-
Correction-only validation: is_level_up_active() validates explicit flags against explicit model/state fields only to suppress contradictions. A stale level_up_pending=True can be rejected when the persisted state proves the model-owned level-up signal is already applied or mechanically impossible, but this must not become a backend threshold-derived level-up decision.
-
SSE atomicity: _enforce_atomicity() enforces that level-up rewards_box and planning_block always ship together. If either is missing, both are dropped (fail-closed). This prevents UI soft-locks.
-
Completion/cancellation precedence: level_up_complete=True or level_up_cancelled=True always returns False from is_level_up_active(), regardless of other flags.
-
Level cap: Level 20 characters always return False from is_level_up_active() for pending flags, preventing stale signals at max level.
-
Single delegation: world_logic._project_level_up_ui_locally() is a 1-line call to rewards_engine.project_level_up_ui(). No independent computation.
Canonical References
- Design doc:
roadmap/zfc-level-up-model-computes-2026-04-19.md (repo) / ~/roadmap/zfc-level-up-model-computes-2026-04-19.md (local) (1,725 lines — read lines 219-241 for the file-responsibility table)
- Task specs:
roadmap/zfc-pr-ta[REDACTED_OPENAI_KEY] (repo) / ~/roadmap/zfc-pr-ta[REDACTED_OPENAI_KEY] (local) (per-PR scope locks)
- Postmortem:
roadmap/2026-04-21-level-up-zfc-loop-postmortem.md (repo) / ~/roadmap/2026-04-21-level-up-zfc-loop-postmortem.md (local)
ZFC Leveling Compliance Worker Prompt
When delegating PR refactors (such as PRs #6718, #6725, #6736, #6756, #6758) to autonomous workers, provide them with this exact prompt to prevent common architectural drift:
# ZFC Leveling Compliance Redesign Prompt
**Objective**: Refactor and redesign existing level-up, streaming orchestration, and infinite progression logic to strictly comply with the new Zero-Framework Cognition (ZFC) mandate: **"LLM Calculates, Backend Corrects."**
---
## 🛑 Core Architectural Mandate
The backend is no longer the source of truth for gameplay math. You must enforce the following invariant across your PR:
**The LLM owns all XP arithmetic, target-level math, and level-up availability.**
The backend code must **never** independently calculate these facts from thresholds as a primary path.
### 1. The "Backend Just Fixes" Rule (Correction Exception Boundary)
The backend is ONLY permitted to apply *correction guards* to prevent degraded UX (e.g., stale-flag time-freezes, SSE soft-locks, or already-applied level-up residues).
* **Where**: These guards are permitted **ONLY** within `$PROJECT_ROOT/rewards_engine.py`.
* **How**: Guards must be "correction-only." They may compare explicit model/state fields for contradictions and suppress or normalize invalid payloads.
* **Forbidden**: Guards must *never* synthesize a competing backend level-up decision or trigger a level-up prompt because backend XP math crossed a threshold. Scattered heuristics across multiple files are strictly forbidden.
### 2. Strict File Boundaries
Before modifying any file, verify your changes against these boundaries. If your logic violates these rules, **STOP and redesign.**
* **`rewards_engine.py`**
* **Owns**: Formatting explicit model signals, correction-only contradiction guards, `canonicalize_level_up_signal()`, and SSE atomicity enforcement.
* **Must NOT Do**: Primary XP/level calculations, threshold-derived level-up decisions, or model calls.
* **`world_logic.py`**
* **Owns**: Modal locks, injecting finish choices, wrapping precomputed payloads.
: Recomputing XP, synthesizing choices, directly calling , or making threshold-based level-up decisions.
Must NOT Do**
: Routing to the correct agent using .
: Inline flag interpretation, XP extraction, or stale-flag recovery logic.
If you are removing the Level 20 hard-cap or fixing streaming parity, you must ensure that the progression relies on the LLM's explicit (including , , , etc.).
Do not fallback to having the backend detect to trigger the level-up modal. The LLM must emit . If the LLM misses it, the backend fails closed (suppresses the level-up), it does fix it by synthesizing the level-up itself.
: Search your PR for any instances where the backend calculates XP progress, checks thresholds to trigger events, or builds level-up choices independently.
: Delete any code that tries to act as a "competing source of truth" against the LLM's .
: If your PR fixes a stale state bug, ensure the fix is a contradiction guard placed in that suppresses the stale payload, rather than recalculating the correct state.
: You must not check or directly in or . You must replace these with calls to , which contains the centralized correction guards.
: returns a 2-tuple . Do not change this signature. Use the dict parameter to pass out additional flags (like false-positives).
Related Skills
.claude/skills/field-ownership-contracts.md — full field writer/reader registry
~/.claude/skills/evidence-standards.md — evidence requirements for production PRs
~/.claude/skills/pr-green-definition.md — canonical /green (CI green + no merge conflicts) definition; quality gates live in the draft phase