Adding a New Program Type — Developer Playbook
Audience: Engineering (planning a second program on the TAP LMS engine)
Companion docs: reusable-components.md (what's reusable) · hardcoded-logic-audit.md (§A4 program_type, §A6 channels, §D3 tier curve) · architecture.md
Date: 2026-06-16 · Reference doc (not a PRD/CR)
1. The idea
The system already anticipates more than one program: Batch.program_type is a Select with Summer / Regular. The intent is that you add a new program type and give it its own logic, while the existing Summer logic — gated on program_type == "Summer" — keeps ignoring the new batches. That gate is the isolation seam: build the new program's behaviour without touching Summer.
This playbook documents how to actually do that, using two concrete examples: a program without binge pause, and a program with different week-advancement logic.
2. The honest current state — what program_type isolates, and what it does NOT
This is the single most important thing to understand before you start. As of 2026-06-16 there is exactly one program_type check in the whole SP codebase.
| Subsystem | Scoped by program_type today? |
Consequence for a new program |
|---|---|---|
Whitelisted content APIs (get_next_content, complete_content, …) via _get_active_bpr_for_student (student_progression_sp.py:2378) |
✅ Yes — if batch.program_type != "Summer": continue |
A non-Summer batch's students simply don't resolve content through the SP APIs. Genuinely isolated. |
Per-minute dispatcher process_program_actions (pe_dispatcher.py:64) |
❌ No — selects all active/paused PEs with next_action_at due; docstring: "there is no batch-level partition… partitioned by action type, not by Batch." |
A Regular PE with next_action_at armed is picked up and run through Summer's handlers. |
Handlers via HANDLER_MAP (handle_week_advancement, handle_escalation, …) |
❌ No — keyed by next_action_type only |
Same handler code runs for every program. |
Inbound Glific callback router update_flow_status → _get_handler (flow_callback.py:206) — audit §B1 |
❌ No — keyed by the SP_* flow name |
A flow name not in the SP_* map → no handler → silently acknowledged, no transition fires. A second program's differently-named flows would all silently no-op: the engine looks alive (callbacks return 200) but no student advances. Highest-consequence seam. |
State-machine t-functions (t14 advance, t15 binge, t16 complete, …) |
❌ No | Summer's week/binge/tier logic applies to any PE that reaches them. |
Difficulty ramp TIER_BY_WEEK (constants.py:315) — audit §D3 |
❌ No — one module-global map for all programs | Sets current_tier per week (wk1-2 Basic, 3-4 Intermediate, 5+ Advanced) → drives the content lookup (course_level, week, tier). A new program shares Summer's ramp; a different ramp means editing the shared constant (or deriving it from the curriculum — see §5). |
Weekly content + escalation triggers (weekly_content_delivery_trigger, sweep) |
❌ No — iterate active BPRs of any type | A Regular BPR's main collection would also get the Tuesday content flow. |
| Config DocTypes (Batch, BPR, ArchetypeConfig→EscalationStep/WeekRule) | ✅ per-batch | Already per-batch; a new program authors its own rows. |
Takeaway: the program_type gate isolates content resolution (the API surface Glific calls), but the engine — both the outbound path (dispatcher → handlers → t-functions → weekly triggers) and the inbound Glific callback router (update_flow_status → _get_handler, §B1) — is program-type-agnostic. So "just add a Regular type and implement new logic" is only half-true today: a new program is isolated at the API layer but would run through Summer's engine logic unless you make the engine program-type-aware. The rest of this doc is how to close that gap.
3. The three extension mechanisms
When the new program's behaviour differs, pick the lightest mechanism that fits:
| Mechanism | Use when | Cost |
|---|---|---|
| A. Config flag on Batch/BPR | The difference is ON/OFF of an existing behaviour (e.g. "no binge pause") | Smallest — one field + one if in the shared handler |
B. program_type branch in the shared handler |
The difference is different logic, and only one or two handlers differ | Quick, but pollutes shared code with per-program branches (the thing isolation is meant to avoid) — acceptable for 1–2 spots |
| C. Per-program handler registry | The new program differs in many behaviours, or you want true isolation | The clean end-state: make dispatch program-type-aware so each program registers its own handlers. Bigger refactor — see §6 |
Rule of thumb: flag for omissions, branch for one-off logic differences, registry when a whole program diverges. Don't build the registry speculatively (L-027) — build it when the second program is real and actually pulls on it.
4. Worked example — a program WITHOUT binge pause
Where binge pause lives: handle_week_advancement (pe_dispatcher.py:505) is reached via HANDLER_MAP[ACTION_WEEK_ADVANCEMENT]. It branches three ways:
if next_week > total_weeks: # → t16 program complete
elif next_week > max_allowed: # → t15 BINGE PAUSE (max_allowed = pe.max_allowed_week
else: # or batch.current_calendar_week + 1)
t14_week_advance(...) # → normal advance
Binge pause = the middle branch: a student who tries to run more than one week ahead of the batch calendar is paused until the calendar catches up. There is no toggle for this today (no binge field on Batch).
To remove it for a new program — Mechanism A (config flag), recommended:
1. Add Batch.binge_pause_enabled (Check, default 1 — so Summer is unchanged).
2. In handle_week_advancement, when the flag is off, skip the binge branch and let the student advance freely (effectively treat max_allowed as unbounded):
python
binge_on = frappe.db.get_value("Batch", pe.batch, "binge_pause_enabled")
if next_week > total_weeks:
t16_program_completed(pe, ...)
elif binge_on and next_week > max_allowed:
t15_binge_pause(pe, ...)
else:
t14_week_advance(pe, next_week, ...)
3. The new program's batches set binge_pause_enabled = 0; Summer's stay 1.
This is behaviour-preserving for Summer (default on) and needs no engine refactor. (If many behaviours differ, prefer Mechanism C instead of accumulating flags.)
5. Worked example — different week-advancement logic
"Different week advancement" is different logic, not an on/off flag — e.g. a non-weekly cadence, a different tier curve, or no "one-week-ahead" cap. The Summer logic in handle_week_advancement + t14_week_advance bakes in: the 7-day week, the +1-week-ahead cap, the TIER_BY_WEEK difficulty curve (audit §D3), and reset-to-Core.
Mechanism B (branch) — quick, for one handler:
def handle_week_advancement(pe_row):
program_type = frappe.db.get_value("Batch", pe_row.batch, "program_type")
if program_type == "Regular":
return handle_week_advancement_regular(pe_row) # new program's own logic
... # existing Summer logic
Mechanism C (registry) — clean, when the program diverges broadly: make the dispatcher resolve handlers by program_type:
# instead of one global HANDLER_MAP keyed by action_type:
HANDLERS_BY_PROGRAM = {
"Summer": { ACTION_WEEK_ADVANCEMENT: handle_week_advancement, ... },
"Regular": { ACTION_WEEK_ADVANCEMENT: handle_week_advancement_regular, ... },
}
# dispatcher: program_type = batch.program_type; handler = HANDLERS_BY_PROGRAM[program_type][action_type]
Summer registers its handlers (with binge + Summer week logic); Regular registers its own (without binge, different advancement). The engine stays generic; each program owns its layer. This is the true form of the isolation the program_type gate implies — and the point at which the gate genuinely protects the engine path, not just the API path.
The same registry applies to the inbound side (§B1). flow_callback._get_handler routes Glific callbacks off the hardcoded SP_* flow-name map, so a flow name it doesn't recognise silently no-ops (acknowledged, no transition). A new program's callbacks need routing too, so this becomes a program-keyed flow-handler map (FLOW_HANDLERS_BY_PROGRAM[program_type][flow_name]). There are two engine entry points that must become program-type-aware — the outbound dispatcher (HANDLER_MAP) and the inbound callback router (_get_handler) — plus the channel→vendor fork (§A6) as a third. One registry refactor covers all three; that's the single "per-program handler registry" PRD.
5b. Worked example — a program with a different difficulty ramp
Connected to §5 (week advancement): t14 sets current_tier = TIER_BY_WEEK[week], and content is then looked up by (course_level, week, difficulty_tier). So TIER_BY_WEEK is a module-global week→difficulty map — every program shares it. Out of the box, a Regular student gets Summer's exact ramp.
The three mechanisms (§3) apply, but here there's a fourth, cleaner option — derive it from data:
The curriculum already encodes the ramp — every LearningUnit is tagged with its difficulty_tier per week. So the week→tier mapping lives in two places today (the TIER_BY_WEEK constant and the curriculum tags), kept in sync by hand (audit §D3 — the "double-encoded ramp"; a mismatch silently yields no_content_for_week). The clean fix is to read current_tier from the curriculum instead of the constant — look up the week's LearningUnit and use whatever tier it's tagged with.
That yields two wins at once: - Per-program for free — each program's ramp is just its own curriculum tagging. A new program defines a different ramp by tagging its content differently, with no shared code to edit; the isolation is automatic. - Eliminates the double-encoding — one source of truth (the curriculum), so the constant-vs-tags drift can't happen.
General lesson: when the data already encodes a program-specific decision, derive from the data rather than maintaining a parallel constant. That's strictly better than parameterizing the constant by program_type — it removes the constant (and the drift) entirely. (Contrast the handler registry in §5, which is the right tool when the difference is behavior, not a value the data already holds.)
6. What you reuse for free (the shared core)
A new program type does not rebuild any of this — it inherits it:
- The transition engine — atomic state moves + journey-label idempotency + audit log (
state_machine.transition). - The per-minute dispatcher loop —
FOR UPDATE SKIP LOCKED, jitter, retry; you only supply handlers. - The Glific transport — contact-field sync, collection membership, callbacks, the API-standard response contract.
- Reliability — retry + DLQ wrappers (Glific, Vocallabs, feedback pipeline).
- The config DocTypes — Batch, BatchProgramRun, ArchetypeConfig→EscalationStep/WeekRule; the new program authors rows, not code.
- Gamification counting — race-safe COALESCE counters (you set the scoring rules).
The work is the per-program layer: which handlers differ, which behaviours are on/off, and the program's own content + rules.
7. The gap to close for clean isolation
Today the program_type gate lives only at the API layer (§2). For the engine to honour program isolation, both dispatch entry points must become program-type-aware (Mechanism C): the outbound per-minute dispatcher (HANDLER_MAP) and the inbound Glific callback router (_get_handler, §B1). Recommended sequencing (per reusable-components.md strategy + L-027): don't build the registry now. When the second program is real, build its handlers and let that pull the registry out of the shared dispatcher — generalize from the second example, not speculatively. Until then, Mechanisms A/B cover incremental needs.
8. Checklist for adding a new program type
- Identity: use the new
program_typevalue (Regular, etc.). Ensure every Batch setsprogram_type(the API gate fails closed on NULL — audit §A4 fragility), and fix theprogram_type or "Summer"fallbacks so a new type isn't mislabeled Summer (audit §A4). - Config: author the program's
Batch(weeks, grace),BatchProgramRun(flow IDs), andArchetypeConfigrows (escalation ladders + week rules) — no code. - Behaviour deltas: for each difference, pick Mechanism A/B/C (§3). Flags for omissions (no binge), handlers for different logic (week advancement).
- Engine routing: make the engine entry points program-type-aware for the new type — the per-minute dispatcher (
HANDLER_MAP), the inbound Glific callback router (_get_handler, §B1), and the weekly triggers — else the new program runs Summer's handlers, or its Glific callbacks silently no-op (§2). - Glific: the channel/flow vocabulary is Summer-named (
SP_*) and the AI-verdict / collection topology are SP-specific (audit §B, §C) — the new program needs its own flows + routing. - Tests: a regression test that the new type's PEs hit the new logic and do not trigger Summer-only behaviours (e.g. no binge pause when disabled).
Related audit findings
This playbook is the constructive companion to the hardcoded-logic audit: §A4 (program_type identity), §A6 (escalation channel taxonomy), §B1/B2 (Glific flow names + collection topology), §D3 (tier curve), §D1 (content-type whitelist) are all per-program layers a new program would supply for itself.