Option-block SYNC — relax co-slot to co-start¶
1. Problem distillation¶
OPTION_BLOCK_SYNC(ERROR, publish-gating) today requires pick-one (maxSelections === 1) block children to occupy identical (weekday, startTick, duration) interval sets per grade cohort. With per-subject period durations (4-level cascade), two perfectly reasonable alternatives with different durations can never satisfy it — the admin sees an unfixable ERROR for a configuration that is fine in practice.- Product decision (Fabio, 2026-07-23): the invariant that matters is co-start — children share the same (weekday, startTick) occurrence sets (hence the same weekly lesson count). Durations and scheduling windows may diverge. "Interchangeable time shape" is deliberately relaxed.
- Accepted consequences: a shorter alternative ends before its siblings (per-student gap — operational, not a correctness issue); window divergence surfaces as ordinary placement infeasibility at scheduling time instead of an upfront config rejection. Consistent with hour-budget having gone fully advisory (2026-07-23).
- Sequencing constraint: the external CP-SAT solver service still encodes identical-interval co-slotting for pick-one blocks. Feeding it duration-divergent configs it has never seen is the real risk — so the generation precheck
OPTION_BLOCK_CHILD_MISMATCHstays strict (identical hours/durations/window) until the solver's own change lands. Stricter-solver-than-gate is the safe direction: solver output always passes the relaxed publish gate. Until then, duration-divergent co-starts are hand-placed only. - Related final decision recorded for the team (no code):
OPTION_BLOCK_CLASH(maxSelections > 1) stays structural — the timetable never consumes enrollment data; band-splitting ("option columns") remains demand-driven-deferred.
Success criteria (observable behavior that proves this works):
- Two pick-one children sharing (weekday, startTick) pairs with durations 60′ vs 45′ → no OPTION_BLOCK_SYNC violation; a DRAFT with only that "violation" before the change now publishes.
- Same durations but one child missing a start, or one child with an extra weekly occurrence (3 starts vs 2) → violation, unchanged.
- A co-started 60′/45′ sibling pair raises no STUDENT_CONFLICT between the siblings (the occupancyKey suppression covers the partial overlap — see §4.2 audit finding; the anticipated gap/coverage "longest child" computation does not exist in the codebase).
- OPTION_BLOCK_TEACHER_DUPLICATE still fires when one teacher appears in two children (co-start still double-books them).
- generation.prechecks.spec.ts passes unchanged — OPTION_BLOCK_CHILD_MISMATCH still rejects mismatched hours/durations/window on the generation path.
- The OPTION_BLOCK_SYNC card copy (en+it) speaks of "same start times", not "same duration"; card drift specs green.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No solver-service change in this repo (separate repo/deploy; deferral below).
- No OPTION_BLOCK_CHILD_MISMATCH loosening — rides the solver change.
- No OPTION_BLOCK_CLASH change (structural rule is final).
- No curriculum-editor advisory for divergent block-children time-shapes (deferral).
- No schema, API, or RBAC change of any kind.
2. Patterns survey¶
Origin note (2026-07-25): the attendance-v2 design this was extracted from has been deleted; nothing below depends on it.
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/timetables/timetables.diagnostics.ts (checkOptionBlockSync :577-622, occupancyKey :65-67, buildBlockCohorts) |
The existing violation machinery (categories, severities, params, expanded solo∪combined occupancy) — this change is a rule edit inside it, no new primitives | Fits cleanly |
src/timetables/diagnostics-cards.ts + diagnostics-cards.drift.spec.ts + localized copy |
Card copy update path with drift enforcement | Fits cleanly |
src/timetables/generation/generation.prechecks.ts (checkOptionBlockChildMismatch, checkOptionBlockTeacherDuplicate) |
The strict generation-side guards — deliberately UNTOUCHED (sequencing) | The precheck is now intentionally stricter than the publish gate; the asymmetry is documented, not accidental |
COMBINED_CLASS_DURATION_MISMATCH (ch18 / combined classes) |
Contrast precedent: a combined class is ONE shared meeting and must keep duration agreement — unchanged by this spec | Option-block siblings are N parallel meetings; only their starts must agree |
On-axis / off-axis check (ch16 §4–§5): pure behavioral delta inside existing diagnostic machinery — on-axis, no new pattern invented.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | n/a | No storage change; diagnostics already tenant-scoped via snapshot | |
| Academic-year scope | n/a | Unchanged | |
| RBAC entity key | n/a | No grant surface touched | |
| Scopes | n/a | — | |
| Actions | n/a | — | |
| Service base | n/a | Pure functions in timetables.diagnostics.ts |
|
queries.ts shape |
n/a | Snapshot loaders unchanged | |
| Error codes | none | Violation category ids unchanged (OPTION_BLOCK_SYNC etc.) — only the comparison rule and card copy change |
FE contract stable |
| DTO conventions | n/a | Violation params shape unchanged (childPlacements[].slots keeps {weekday, startTick, durationMinutes} — durations still shown, no longer compared) |
|
| File-backed sub-resources | n/a | — | |
| Custom fields | n/a | — | |
| Profile completeness | n/a | — |
4. Data model plan¶
Schema deltas¶
- None.
Migration shape¶
- None.
Indexes and uniqueness¶
- None.
Behavioral delta (normative)¶
checkOptionBlockSync: slot identity key changes from`${weekday}:${startTick}:${durationMinutes}`to`${weekday}:${startTick}`. Comparison still runs over each child's expanded occupancy (solo ∪ combined), per grade cohort,maxSelections === 1only. Function comment updated to "co-start".occupancyKeysibling-collapse consumers: ~~usemax(durationMinutes)across co-started siblings~~ — implementation audit finding (2026-07-23): no duration-bearing consumer exists.occupancyKey's sole consumer is the sibling-pair suppression incheckStudentConflicts(:395), which is duration-agnostic — and under the relaxation that suppression becomes load-bearing (co-started siblings with divergent durations DO overlap each other and must not raise student conflicts). Resolved as a comment onoccupancyKeydocumenting this; no gap/coverage code exists to change.checkOptionBlockChildMismatch+checkOptionBlockTeacherDuplicate: no change (see §1 sequencing).- Card copy for
OPTION_BLOCK_SYNC(en + it): "children of a pick-one block must share the same start times"; remove duration phrasing.
5. API surface¶
None — no route, DTO, or wire-shape change. Violation payloads are byte-compatible.
Swagger considerations¶
- None.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Publish gate and generation prechecks encode the same strictness | Precheck (OPTION_BLOCK_CHILD_MISMATCH) now stricter than the relaxed publish gate |
External solver still assumes identical intervals; stricter-solver-than-gate is the safe sequencing direction | Duration-divergent co-starts are hand-placed only until the solver change lands; asymmetry documented in both files' comments |
| Diagnostics compare what the card shows | Durations remain in the card payload but are no longer part of the comparison | FE keeps rendering intervals; the rule is start-only | Reader of the card must not infer durations are enforced — copy reworded |
8. Pushback log¶
| Source says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| Original iteration-2 rule: "identical start ticks AND identical durations — a student flips between alternatives seamlessly" | Product reality: duration equality confuses admins over benign configs | Co-start only; seamless-swap presupposition explicitly dropped | Resolved (Fabio, chat 2026-07-23) |
9. Deferrals¶
- Solver-side co-start relaxation — external CP-SAT service (separate repo/deploy) relaxes pick-one co-slotting to co-start with per-child durations;
OPTION_BLOCK_CHILD_MISMATCHthen loosens to "same weekly lesson count" — follow-up: solver repo change + a one-line precheck edit here. - Curriculum-editor time-shape advisory — divergent durations/hours across block children now surface only at timetabling; an authoring-time warning would fail earlier — nice-to-have, revisit on admin feedback.
- Band-splitting ("option columns") for
maxSelections > 1— demand-driven only;OPTION_BLOCK_CLASHstays structural (final, scheduling never consumes enrollment).
10. Open questions¶
Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.
- None — all resolved in chat 2026-07-23 (rule, sequencing, occupancy collapse, CLASH finality).
11. Verification plan¶
- Unit specs (
src/timetables/timetables.diagnostics.spec.ts, updated): - co-started children with durations 60′/45′ → no
OPTION_BLOCK_SYNCviolation; - start-set mismatch (missing start / extra weekly occurrence) → violation;
- combined-expanded occupancy still honored (existing cases unchanged);
- sibling-collapse occupancy spans the longest child (gap/coverage case).
- Drift/regression:
diagnostics-cards.spec.ts+diagnostics-cards.drift.spec.tsgreen with reworded copy;generation.prechecks.spec.tspasses unchanged (strictness intact). - Manual verification: on a dev tenant, place a pick-one block's two children co-started with different durations → diagnostics clean → publish succeeds; shift one child's start →
OPTION_BLOCK_SYNCcard appears naming both placements.
Patterns: chapter 09 (testing). Docs follow-up on landing: docs/18-timetables.md sync-semantics section.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-23
- Chat reference: "lets pause also spec 2 for now and only implement the sync relaxation" — chat 2026-07-23 (all design points resolved in the same day's brainstorm; zero open questions at approval).
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.