Skip to main content

Scheduling is a plugin, not a core layer

Vocabulary note. The text below is preserved as written and says Task throughout. That record was renamed to Entry by ADR 0003 — read every Task here as Entry. ADR 0003 exists in large part because this ADR's boundary kept being undone by this ADR's noun. Separately, ProjectPlugin below (the edit-extension contract design from issue #15) was renamed to DatasetPlugin by ADR 0004, following the same Project → Dataset rename applied to the class it names. PluginContext.layout.registerItemEmitter below was later renamed to registerItemProducer (Q16, S5.9). Read every registerItemEmitter here as registerItemProducer.

Superseded in one clause (2026-09-04, S5.10, D-S5-23). "A plugin may occupy it exclusively" below is now read as arity, never ownership: the hook has one occupant at a time, and a scheduling plugin is one candidate occupant with no special claim on it. Installing composes — ctx.edits.setExtender((next) => …) hands a plugin the current occupant, so a second plugin adds to the first's writes instead of evicting them (plans/s5-extensibility-and-editing/s5.10-dataset-plugins.md, closing plans/s2-data-core/OPEN-QUESTIONS.md OQ8). What exclusivity protected is intact: data/ still holds one field, calls it at one site, once per transaction. Everything else in this ADR stands.

D3/D4 (plans/00-overview.md) treated scheduling/ as one of the five fixed, universal, DOM-free core layers, with only its SchedulingPolicy pluggable. In practice this had already leaked further than the docs admitted: Task carried a required scheduling: 'auto' | 'pinned' field, Dependency/DependencyType/DependencyId were defined in model/ (the zero-dependency, supposedly domain-neutral layer), and the layer diagram had a structural data/ --> scheduling/ edge backing the "every transaction runs one scheduling pass, no exceptions" rule. This directly contradicted the project's own stated goal (00-overview.md): "no assumptions about the user's planning methodology... everything opinionated is a pluggable policy or extension, never a baked-in rule." A host building a pure resource/manpower view — no task-to-task dependencies at all — paid for and inherited Gantt-specific scheduling machinery it never used, and every future feature built against Task/data/ would have inherited the same assumption by default.

We pulled scheduling — the propagation engine, its policy, the Dependency entity, and the per-task pin flag — out of the mandatory core layers and into the plugin boundary. Core exposes one generic (not "scheduling"-named) synchronous extension hook that may add extra field writes to a proposed edit; a plugin may occupy it exclusively, and when nothing does, it adds nothing, decided once at setup rather than branched per call. Dependency and the pin flag move into scheduling-plugin-owned storage, not Task/model/. FreeGantt still ships an official bars+dependencies scheduler as a first-party default plugin — this is about where the boundary sits, not about de-prioritizing scheduling.

Considered options​

  • Keep scheduling as a fixed core layer, pluggable policy only (status quo, D3/D4 as originally written). Rejected: "fixed, universal engine" already privileges precedence-graph propagation as the methodology, which is exactly the assumption the project claims not to make, and doesn't explain why Task needs a scheduling-specific field regardless of whether scheduling is even used.
  • Move scheduling/ bodily into extensions/. Rejected: extensions/ is DOM-touching; scheduling's purity is load-bearing (speculative schedule() calls, once per animation frame during drag preview, must stay synchronous and side-effect-free, and the engine needs to keep running headless in Node and, later, in a worker). Relocating the directory would have broken that for no benefit — the fix needed is architectural (who's allowed to assume scheduling exists), not geographic (which folder the code sits in).
  • A second, bespoke "pure extension" contract, separate from GanttPlugin. Initially proposed, then retracted: the kind-seam precedent already in the spec (PluginContext.layout.registerItemEmitter — a pure, DOM-free function registered once through the DOM-side plugin host, then invoked repeatedly by pure code) is the exact shape scheduling needs. One plugin system, generalized, beats two.

Consequences​

  • Task's public shape lost the scheduling field; Dependency/DependencyType/DependencyId left model/. This touched D3, D4, the data/ --> scheduling/ edge in the plans/01 layer diagram, and S3's scope — all rewritten, not just the code (tracked and closed via #13).
  • Confirmed by grep before this ADR: nothing outside model/ referenced Dependency, DependencyId, or Task.scheduling — data/ and scheduling/ were still 3-line stubs (pre-S2/S3). The removal cost nothing at the time it landed. That window would have closed once transactions (S2) or the propagation engine (S3) were built on top of the old shape, so the cleanup was time-sensitive, not just correct-in-principle.
  • The generic extension hook's exact contract was deliberately not settled by this ADR: where per-plugin per-task data lives (a reserved store, not Task.meta, to avoid host/plugin collisions in the same field), and how the hot-path preview call and the commit-time call share one resolution so a user never sees an edit land in the "wrong" spot before a plugin corrects it moments later — both needed dedicated plugin-system design work. This ADR fixed the boundary (scheduling is not privileged core); the contract shape was tracked separately and landed as issue #15 (ProjectPlugin/EditExtender/reserved PluginStore — the design work opened by #12) and issue #16 (the re-scoped bars+dependencies default plugin spec, built on #15's contract — the design work opened by #14). #16 also surfaced one small follow-up against this ADR's own boundary — GeometryFrame.links (plans/01 §4) still needs a plugin-contributed link-emission seam, since its id field can no longer be a model/-owned DependencyId brand.
  • #16 is closed, superseded by #111 and #136 (2026-09-02). The S5.0 grill on issue #111 split #16's single bars+dependencies plugin into two: entryDependencies() (the Dependency store, the link-create gesture, link rendering) and scheduling() (propagation, pins, policy, diagnostics), one-way — scheduling() requires entryDependencies(), never the reverse (plans/s5-extensibility-and-editing/s5.0-grill-issue-111.md, s5.10-dataset-plugins.md D-S5-30/D-S5-31). #16's GeometryFrame.links follow-up carries forward at #136, now owned by entryDependencies(). #16's reusable design (the Dependency shape, its removal cascade, pin storage) also carries forward at #136, reassigned across the two plugins. ProjectPlugin/setResolver/declareStore are pre-ADR-0004 names for DatasetPlugin/setExtender/ctx.store.reserve().