Skip to content

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_MISMATCH stays 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 unchangedOPTION_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)

  1. 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 === 1 only. Function comment updated to "co-start".
  2. occupancyKey sibling-collapse consumers: ~~use max(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 in checkStudentConflicts (: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 on occupancyKey documenting this; no gap/coverage code exists to change.
  3. checkOptionBlockChildMismatch + checkOptionBlockTeacherDuplicate: no change (see §1 sequencing).
  4. 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_MISMATCH then 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_CLASH stays 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_SYNC violation;
  • 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.ts green with reworded copy; generation.prechecks.spec.ts passes 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_SYNC card 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.