Files
deepdrft/PLAN.md
T
2026-06-13 08:44:42 -04:00

30 KiB
Raw Blame History

PLAN.md — DeepDrftHome forward roadmap

Forward-looking roadmap. Sits alongside CONTEXT.md (architecture orientation) and COMPLETED.md (history). Per CONTEXT.md §6, items move from here to COMPLETED.md when work lands; do not delete completed entries.

Organised by theme, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so.


0. Baseline — what just landed

A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on dev. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:

  • Index concurrencyVaultIndexDirectory no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
  • Repository semanticsTrackRepository.Update now fails-fast when an Id is not found instead of silently issuing an INSERT.
  • Streaming Criticals — concurrent-seek race in the client, dirty trailing bytes leaking out of the ArrayPool-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header.
  • 17 design and streaming Majors/Minors across all eight projects — format-validation alignment between processor/offset/decoder, IAsyncDisposable on the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings.

What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in TODO-V2.md that did not land are deferred as features, not bugs — they are captured below under Phase 1.


Phase 1 — Streaming features deferred from the audit

These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.

1.3 Preload / prefetch of the next track

  • What: No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
  • Why it matters: Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
  • Shape: A second HttpClient request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged StreamDecoder instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next.
  • Prerequisite: Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in IPlayerService or a passive "what was the next row in the gallery" inference.
  • Open question: Does a queue model belong in IPlayerService, or is the player a single-slot device that a future PlaylistService orchestrates above? Worth a design note before implementation. Capture in product notes when picked up.

1.4 Crossfade

  • What: Smooth A→B transition with overlapping fade-out / fade-in.
  • Why it matters: DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track."
  • Shape: Architecturally two simultaneous PlaybackScheduler instances suffice — each owns its own gain node, crossfaded via GainNode.gain.linearRampToValueAtTime. The wiring is the work, not the audio graph itself.
  • Prerequisite: 1.3 (Preload) — there is nothing to fade into without prefetch.

1.5 Gapless playback

  • What: Eliminate the inter-track silence that exists today.
  • Why it matters: Important for live-set rips, mix tapes, anything authored to flow continuously.
  • Shape: The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With PlaybackScheduler's existing 500 ms lookahead this is mechanically achievable — the next track's first AudioBufferSourceNode.start(t) is set to the previous track's end time.
  • Prerequisite: 1.3 (Preload). Also needs to play nicely with 1.2 because gapless across formats is hard (encoder padding/priming on MP3 in particular).
  • Constraint: Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands.

1.6 Track-skip on error

  • What: A failed processStreamingChunk aborts the entire load with no recovery path.
  • Why it matters: One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region.
  • Shape: Two-level response.
    • Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists.
    • Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once 1.2 lands.

1.7 Safari compatibility

  • What: Two known Safari edge cases.
    • webkitAudioContext.close() is async-but-not-Promise on older Safari (≤ ~14); await resolves immediately and the next initialize() can run against a not-yet-closed context.
    • iOS Safari < 15 had streaming-fetch quirks; HttpCompletionOption.ResponseHeadersRead behaviour is not guaranteed there.
  • Why it matters: Real listener share. iOS in particular is a primary listening surface for music.
  • Shape: For the close() race — detect webkitAudioContext and poll state === "closed" with a short timeout instead of trusting the await. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency.
  • Open question: What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely.

These follow from CONTEXT.md §5. Direction is strongly implied but no specific UI has been committed.


Phase 6 — CMS Enhancements (Completed)

See COMPLETED.md for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer) which landed on dev in June 2026.


6.2 Card-contextual filtering of the Tracks page — [superseded by §8]

  • What: Make the Album and Genre dashboard cards navigate into a filtered /tracks view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table.
  • Why: Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist.
  • Why deferred: The dashboard cards aggregate across all albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public AlbumsView/GenresView, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The GET api/track/page endpoint already accepts album= and genre= query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in TrackList.
  • Superseded: §8 (CMS Track Browser) builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode are the CMS analogue of AlbumsView/GenresView, and the filter plumbing into GetPagedAsync is part of §8's data contract. This item folds into §8; do not implement it separately.

Phase 3 — New content kinds

3.1 Live / session content

  • What: The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these.
  • Why it matters: Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output.
  • Shape: Speculative; no commitment yet.
    • Likely new entity table(s) sibling to TrackEntity (SessionEntity, VideoEntity?) — or a polymorphic MediaEntity with discriminator. The choice affects how much code in TrackService / TrackController can be reused.
    • New vault type(s). MediaVaultType.Media exists and is the obvious home for video; sessions are probably still Audio.
    • New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
  • Prerequisite: Probably 2.1 (vault wiring proof) and a decision on the entity model before any code lands.
  • [speculative] — direction inferred from home-page copy, not a Daniel-confirmed commitment.

Phase 4 — Infrastructure / delivery

4.3 Dual-write rollback / dead-letter log

  • What: If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.
  • Why it matters: A latent data-integrity issue. Materially riskier once web upload (2.4) exists.
  • Shape: Audit suggested a DeadLetterLog recording orphaned entryKeys for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us).
  • Prerequisite: None. Worth landing alongside or just before 2.4.

Phase 5 — Documentation backlog

5.1 Folder-level CLAUDE.md sweep

  • What: Eight folder-level CLAUDE.md files need writing/rewriting per the brief in DOC_PLAN.md. Five are rewrites (drift from the .NET 10 upgrade and structural moves); three are new (DeepDrftWeb.Services, DeepDrftContent.Services — the two libraries where most domain logic now lives — plus the open question on DeepDrftContent.Services/FileDatabase/README.md).
  • Why it matters: The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming .NET 9, referencing MediaPath that has been EntryKey for two migrations, describing a FileDatabase/ tree inside DeepDrftContent that has moved out, and missing entirely for the two *.Services libraries.
  • Shape: Doc-keeper executes against DOC_PLAN.md. Order of operations and the per-folder briefs are already specified there.
  • Prerequisite: None. Can run fully in parallel with any feature work.
  • Constraint: Wait on Daniel for the DeepDrftContent.Services/FileDatabase/README.md judgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.

Phase 7 — Shared UI Components

Reusable presentational components in DeepDrftShared.Client (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose.


Phase 8 — CMS Track Browser

Three browse modes for the CMS /tracks page — Track, Album, Genre — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the TracksViewModel pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes are the intermediate browse surface that item was waiting on. Full spec: product-notes/phase-8-cms-track-browser.md (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions).

§8.0 landed on 2026-06-11 — a breaking TrackEntity normalization has been completed and is stable on dev. §8.1–§8.5 are now unblocked. The Waveform Pre-Processing tab is removed, folded into an in-grid status column + per-row/page-level generate actions (see §8.2).


A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.

  • Identity / accounts. Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. [speculative] until Daniel signals interest.
  • ITrackService interface. Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.
  • Test coverage outside FileDatabase. Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 14 land, test scope should expand — at minimum WavOffsetService, AudioProcessor, TrackService (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.

Phase 9 — Release Medium Types

Releases gain a top-level medium discriminator above the existing ReleaseType. Three media: Studio CUTS (Cut — the only medium that uses Single/EP/Album), Live SESSIONS (Session — a single live track with a distinct hero image), DJ MIXES (Mix — a single long track with a preprocessed high-resolution waveform datum). This touches the data model, the API, the CMS, and the public site.

The public home page already carries the three-medium framing as editorial cards (Studio / Live / DJ Mix — COMPLETED.md §8.6, landed 2026-06-12), but those cards have no destinations and nothing below the copy layer knows what a medium is. Phase 9 makes the medium real and gives those cards somewhere to point.

Architectural spine — discriminator enum + optional metadata table. ReleaseMedium is a plain enum column on ReleaseEntity. A medium that needs data beyond the base release (Session's hero image, Mix's waveform datum) gets its own 1:1 metadata table; a medium that needs nothing extra (Cut) is the base ReleaseEntity. This is Open/Closed at the schema level — a future medium (e.g. Video, §3.1) adds an enum value and optionally one metadata table, and changes zero existing tables. The alternatives (one wide nullable table; an EF type hierarchy) both collapse to the god-table the Phase 8 normalization moved away from — rejected. Full design, contracts, and the SOLID rationale: product-notes/phase-9-release-medium-types.md.

Design discipline throughout: extension, not modification. Where a per-medium mapping is unavoidable (card → browser, medium → API projection, medium → detail hero), keep it in one table per concern — never a scattered three-arm switch. Drive CMS cards and nav sub-items off Enum.GetValues<ReleaseMedium>() + a display-metadata lookup, so a new medium surfaces automatically.

The ReleaseType-only-for-Cut invariant. Single/EP/Album is meaningful only when Medium == Cut. Enforce as a domain rule (service layer ignores/resets ReleaseType for non-Cut; CMS hides the field unless Cut; ReleaseDto.ReleaseType is nullable, nulled at the single entity→DTO mapping point for non-Cut so one producer enforces and no consumer needs the rule), not a DB constraint — by choice, not necessity: EF Core supports check constraints first-class (HasCheckConstraint, versioned in migrations, Npgsql-supported), but the invariant is advisory ("meaningless," not "invalid") and the read model enforces it at one point. The column stays on ReleaseEntity as a named exception to the metadata-table pattern: a CutMetadata table was considered and rejected because the /cuts hot path reads ReleaseType on every card and Phase 8 §8.0 just landed the column (see spec §1). Future media must not copy this — the default remains the metadata table.

Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 24 the lettered tracks are parallel.

Dependency summary: 1 → 2 → 3 → 4. Wave 4 (public site) can begin once Wave 2's api/release family is stable; both Wave 4 build and acceptance are independent of Wave 3 (CMS) — the body-less POST api/release/{id}/mix/waveform trigger (9.2.B) can seed real waveform datum for acceptance testing without any CMS in existence, and hero images seed via a script against 9.2.B likewise.


9.5 Wave 5 — Gap Cleanup

Waves 14 are on dev. This wave fixes functional gaps discovered in the landed code: one disclosed by the Wave 3 engineer (medium is never written through the upload path), two structural issues flagged by review (fragile track resolution in the detail VM, browser duplication), and one nav gap (/tracks is unreachable from the public menu). Items are ordered: AB are blockers (data correctness); CE are correctness/nav gaps; F is a structural debt item worth landing when the browsers next need editing.

9.5.A — Medium write path: POST api/track/upload

  • What: The POST api/track/upload endpoint accepts no medium form field. CmsTrackService.UploadTrackAsync already sends medium in the multipart body (a forward-compatible no-op left by the Wave 3 engineer), but the API ignores it. Every uploaded release is created with Medium = Cut regardless of the CMS form selection. Sessions and Mixes uploaded through the CMS are silently mis-typed at the database level.
  • Why: This is the primary functional gap of the phase. A mix uploaded as Cut does not appear in the /mixes browser, does not trigger waveform generation on the correct release, and the public /mixes/{id} detail page will never find it. The bug is silent — no error surfaces; the track uploads cleanly into the wrong category.
  • Shape: Three layers, each minimal:
    1. TrackController.UploadTrack — add [FromForm] string? medium parameter. Parse it with Enum.TryParse<ReleaseMedium> (same defensive pattern as releaseType, defaulting to Cut with a logged warning on unrecognised values). Pass the parsed value into UnifiedTrackService.UploadAsync.
    2. UnifiedTrackService.UploadAsync — add ReleaseMedium medium parameter. Include it in the ReleaseDto passed to FindOrCreateRelease (the DTO already has the Medium field; it is simply not populated today).
    3. FindOrCreateRelease find-path: When the release already exists, the returned row's Medium is not updated to match the upload's intent. This is correct behaviour for the first track — the release was created with the right medium. It is potentially wrong for subsequent tracks uploaded to the same release with a corrected medium. No change required here: medium is a release-level property, and the first upload is authoritative. Document this explicitly in the service comment so future engineers do not try to "fix" it.
  • Acceptance criteria: A Session upload from the CMS creates (or links to) a release with Medium == Session; a Mix upload creates a release with Medium == Mix; a Cut upload is unchanged. The GET api/release?medium=session endpoint returns the Session release immediately after upload with no manual migration.
  • Open question: Should the upload path update an existing release's medium when it differs? Recommend no — a release's medium is set on creation and should not silently change on a subsequent track add. If an admin needs to change a release's medium, that is an edit operation (9.5.B). Capture this as a comment in the service, not a policy decision to re-open here.

9.5.B — Medium write path: PUT api/track/meta

  • What: UpdateTrackMetadataRequest carries no Medium field. PUT api/track/meta/{id} can update ReleaseType on a release but cannot change Medium. CmsTrackService.UpdateAsync sends no medium field. An admin who uploads a Session as Cut (due to the pre-9.5.A bug, or a future form mistake) has no way to correct the medium through the CMS after the fact.
  • Why: Without an edit path, the only remediation is a direct DB update or a delete-and-re-upload. Both are bad. The edit path should be complete.
  • Shape:
    1. UpdateTrackMetadataRequest — add ReleaseMedium? Medium (nullable: null = no change, matching the ReleaseType? pattern already on the request).
    2. TrackController.UpdateMeta — apply request.Medium to release.Medium when non-null, alongside the existing ReleaseType conditional (the same six-line pattern at line 394395 of the controller).
    3. CmsTrackService.UpdateAsync — add ReleaseMedium? medium = null parameter, include in the JSON body.
    4. ICmsTrackService — update the interface signature to match.
    5. TrackEdit.razor / BatchEdit.razor — wire the MediumFields selector (already present for upload via BatchUpload) into the edit submit path, passing the selected medium.
  • Acceptance criteria: An admin can open an existing release in TrackEdit or BatchEdit, change the medium selector, submit, and the release's Medium column updates in the DB. The browsers (CmsAlbumBrowser, CmsSessionBrowser, CmsMixBrowser) reflect the new medium after the edit.
  • Constraint: The ReleaseType-only-for-Cut invariant: when medium changes away from Cut, the controller should null (or ignore) ReleaseType on the release — the same enforcement the TrackConverter already applies on the read path. Mirror that logic on the write path: if request.Medium is non-null and not Cut, reset release.ReleaseType = ReleaseType.Single (the DB-level default) rather than leaving a stale studio-format value.

9.5.C — ReleaseDetailViewModel: replace fragile album-title track resolution

  • What: ReleaseDetailViewModel.Load resolves the playable track for a Session or Mix detail page by calling _trackData.GetPage(pageNumber: 1, pageSize: 1, album: release.Title). This is a string join on album title. If two releases share the same title (different artists — e.g., both have an untitled mix), the wrong track is returned. More fundamentally, filtering by album title relies on the Release.Title matching what was stored as the album string at upload time — a join that is fragile once releases can be renamed via the edit path (9.5.B).
  • Why: The correct join is by releaseId, not album title. The track-page endpoint already supports album= filtering; it needs an additional releaseId= filter, or the public API needs a GET api/track/by-release/{releaseId} endpoint. This is a correctness issue, not a cosmetic one — a collision silently plays the wrong track.
  • Shape (recommended): Add a releaseId query parameter to GET api/track/page in TrackController and thread it through ITrackService.GetPagedTrackRepository.GetPagedFilteredAsync as an additional WHERE release_id = @releaseId predicate. TrackFilter gains a long? ReleaseId field. ReleaseDetailViewModel.Load then calls GetPage(pageNumber: 1, pageSize: 1, releaseId: release.Id) — an exact join, no title string. The public IReleaseDataService and ReleaseClientDataService do not need changes if the track page is called directly via ITrackDataService.
  • Acceptance criteria: /sessions/{id} and /mixes/{id} resolve their playable track by releaseId, not by album title string. Two releases with identical titles return their own correct tracks on their respective detail pages.
  • Open question: Should TrackFilter.ReleaseId be exposed on the public unauthenticated GET api/track/page endpoint? Yes — it is a read-only filter on public data, same posture as album= and genre=. No auth change.

9.5.D — Public nav: /tracks route unreachable

  • What: Pages.MenuPages (the public nav model) contains ARCHIVE (with sub-items /cuts, /sessions, /mixes) and Genres. /tracks (the original track gallery at TracksView.razor) is not in the nav. The route is still live — typing /tracks in the address bar works — but there is no menu entry, no link from any existing page, and no redirect from any of the new medium surfaces.
  • Why it matters: The track gallery is a useful surface (flat cross-medium search, grid/list toggle, genre/album filter). Removing it from the nav without a replacement or deliberate deprecation is a nav gap. A listener who does not know about /cuts has no way to discover the flat track list.
  • Shape (three options — pick one):
    • Option A (recommended): Add /tracks back to the nav. Add a "Tracks" entry (flat, no children) to Pages.MenuPages alongside ARCHIVE and Genres. Zero risk; the page exists and works. Honest about what the site offers.
    • Option B: Retire /tracks explicitly. Add a redirect from /tracks/cuts (or /archive) and remove TracksView.razor. Requires confirming that /cuts is a complete replacement (it is not — /cuts shows only Cut releases; /tracks is a flat cross-medium list). Not recommended unless Daniel confirms the gallery is intentionally retired.
    • Option C: Make ARCHIVE the gallery. Repurpose /archive from the current three-card overview to the flat track gallery. Feels wrong — /archive is already a meaningful overview page, not a gallery.
  • Recommendation: Option A. The track gallery is valuable and distinct from the medium-specific browsers. Add "Tracks" to Pages.MenuPages. If Daniel later wants to retire the gallery, that is a separate explicit decision with a redirect. Do not silently leave a useful route off the nav.
  • Acceptance criteria: /tracks appears in the public navigation menu. Desktop and mobile nav both link to it. Existing functionality of TracksView is unchanged.

9.5.E — CmsSessionBrowser and CmsMixBrowser: missing Edit row action

  • What: The Wave 3 spec for 9.3.B says "row Edit + hero-image management" for the Session browser, and the Mix browser should similarly have an edit affordance. The landed CmsSessionBrowser and CmsMixBrowser provide the medium-specific action (hero upload / waveform generate) but no Edit button linking to the standard release edit page (/tracks/album/{name}/edit via BatchEdit).
  • Why: Without the Edit button, an admin cannot rename a session, change its artist, update its genre, or swap its cover art from the browser. The only path is navigating to /tracks, finding the session track, and editing it from there — which itself is now off the nav (9.5.D).
  • Shape: Add a MudButton (or MudIconButton) per row linking to /tracks/album/@Uri.EscapeDataString(context.Release.Title)/edit in both browsers, matching the CmsAlbumBrowser pattern. No new components or endpoints.
  • Acceptance criteria: Each row in CmsSessionBrowser and CmsMixBrowser has an Edit button that navigates to BatchEdit for that release. The edit page loads the release's tracks and release-level fields correctly.

9.5.F — CmsSessionBrowser / CmsMixBrowser structural duplication (DRY debt)

  • What: Both browsers share an identical structural skeleton: a LoadAsync method with _loading / _rows fields, an OnInitializedAsyncLoadAsync call, a ThumbUrl static helper, snackbar error handling, and a MudTable with cover-thumbnail + title + artist columns. Only the per-row action column and the row model differ. This is copy-paste, not composition. The Phase 9 intro promises "a new medium is one entry, one file" — with this structure, a new medium browser is instead two files of boilerplate plus one file of new logic.
  • Why: Manageable now at three media, but violates the open/closed discipline the phase established. The right fix is a MediumBrowserBase abstract base (or a parameterized CmsMediumBrowser component with an action-column slot), reducing each browser to its medium-specific action markup only.
  • Shape: Extract a CmsMediumBrowserBase class (analogous to MediumBrowseBase on the public site) carrying: _loading, _rows, OnInitializedAsync, LoadAsync, ThumbUrl. Subclasses supply the ReleaseMedium and the per-row action column. The table structure (cover, title, artist, actions) is rendered in the base or via a shared CmsMediumTable Razor component with an ActionContent RenderFragment parameter. A new medium browser is then a subclass that overrides the medium enum and implements the action fragment.
  • Acceptance criteria: CmsSessionBrowser and CmsMixBrowser no longer duplicate LoadAsync / ThumbUrl / the error-snackbar pattern. A third medium browser (hypothetical) would require only the medium-specific action markup, with zero structural boilerplate.
  • Note: This is structural debt, not a functional gap. Mark [nice-to-have] if Wave 5 is time-boxed. The functional items (AE) are the priority; F can defer to Wave 6 if needed.

Dependency summary for Wave 5: A and B are independent of each other (parallel tracks) and are the highest priority — both are data-correctness blockers for Session/Mix releases created since Wave 3 landed. C depends on A and B being stable (so the detail VM resolves tracks for correctly-typed releases). D and E are independent nav/UI fixes. F is independent structural debt.



Working with this file

  • Add items by extending an existing phase first; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
  • When something lands, move it to COMPLETED.md rather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome.
  • Mark genuinely uncertain items [speculative] so future readers can tell what is direction vs. commitment.
  • Open questions belong in the item that raises them, not in a separate "questions" list — they expire when the item does.