Files
STTracker/docs/plans/001-phase1-sdd/ledger.md
T
Lucas WintherandClaude Opus 5.5 be04fab098 docs: phase 1 handoff, execution ledger and task reports
Captures the owner's requirements and decisions from the planning
conversation, current branch/worktree state, open tasks 17-19, pending
owner decisions and the controller's rulings so a new agent can resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:53:02 +01:00

23 KiB
Raw Blame History

SDD ledger — plan: docs/plans/001-phase1-own-your-data.md

Spec: docs/specs/001-sttracker-v1-design.md (approved). Branch: feat/phase1. Owner asked for parallel agents → parallel tasks run in isolated worktrees, merged into feat/phase1 after review.

Pre-flight scan

Tasks Shared file / interface Finding
T1 ↔ T3 packages/plugin-sdk/package.json (T1 creates stubs; T3 needs it) conflict if parallel
T1 ↔ T10–T14 plugins/*/package.json + bun.lock (new workspace members) lock conflicts if plugins create their own
T1 ↔ T2 packages/db/src/schema.ts placeholder → real sequential, consistent
T3 ↔ T13 plugin-sdk/src/csv.ts created by T13 T13 after T3, OK
T4 ↔ T5/T6/T7/T15 AuthError/ValidationError/CryptoError/ImportError used across parallel tasks no task owns errors.ts
T5 ↔ T6 acceptSuggestion (T5) calls moveEntryToWork (T6), parallel cross-dependency in a parallel wave
T8 ↔ T9 syncHandler (T9) calls runSync (T8); plan puts both in wave D T9 must follow T8
T13 ↔ T15 toCsv from @sttracker/plugin-sdk/csv T13 in earlier wave, OK
T16 ↔ T17 server serves apps/web/index.html, created by T17 T16 boot/tests need a placeholder
T17 ↔ T18 api.ts, theme.css, layout T18 after T17, as the plan says
T3 self rate-limit test needs a fake clock; signature has only sleep needs a now option
T14 self Last.fm error 29 comes in the JSON body, but the http client retries by HTTP status mechanism unclear
T2, T4–T7, T10–T13, T15–T19 self tests vs code vs files consistent

Rulings (pre-flight)

  • Ruling: Run T1 alone first, then T2 ∥ T3. Why: T1 owns all package.json stubs. Cost if wrong: a few minutes of lost parallelism.
  • Ruling: T1 also creates package.json stubs for plugins/{trakt,hardcover,mangabaka,csv,listenbrainz,lastfm} (dep @sttracker/plugin-sdk: workspace:*; listenbrainz also fflate) and runs bun install once. Plugin tasks never touch package.json or bun.lock. Why: avoids lockfile merge conflicts between parallel worktrees. Cost if wrong: a plugin needing another dep asks first.
  • Ruling: T1 creates packages/core/src/errors.ts with AuthError(code), ValidationError(field, reason), CryptoError, ImportError(code), NotFoundError, each with a code property. Why: shared by parallel tasks. Cost if wrong: trivial.
  • Ruling: acceptSuggestion/rejectSuggestion move from T5 to T6 (entries.ts). Why: they depend on moveEntryToWork, which removes the T5↔T6 cycle. Cost if wrong: none functionally.
  • Ruling: Wave D becomes T8 ∥ T15, then T9. Why: T9's syncHandler consumes runSync. Cost if wrong: serial time.
  • Ruling: T16 creates a minimal apps/web/index.html placeholder if absent; T17 replaces it. Why: the server must boot before the web app exists.
  • Ruling: Wave F becomes T17 ∥ T19, then T18.
  • Ruling: T3 createHttpClient also accepts now?: () => number for testable rate limiting.
  • Ruling: T14 Last.fm reads error 29 from the JSON body and retries itself up to 3 times, sleeping through the client's backoff (an injectable sleep option on the plugin's internal helper). Why: the API returns error 29 in the body. Cost if wrong: duplicated retry logic in one plugin.
  • Ruling: Every worktree task must run bun install --frozen-lockfile first and run DB tests with TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres as well as without it.

Tasks

Task 1: minor (deferred): no numAffectedRows regression test; SQLite has no busy_timeout; a crashed run leaks pg test DBs; insertId guard needs a comment Ruling: Every later dispatch must say "inside a transaction always use tx, never db.k, because the SQLite single-connection mutex deadlocks". Why: the Task 1 design (bun:sqlite has no reserve). Cost if wrong: hangs in tests, caught quickly. Ruling: AGENTS.md "ULIDs" → "UUIDv7" is folded into Task 2's dispatch. Task 1: complete (commits fb4ff36..27ec24e, review clean; ⚠️ pg verified by controller run) Ruling: The Agent tool's isolation: worktree branches from main, not feat/phase1. From Wave C on, the controller creates worktrees itself (git worktree add .worktrees/tN -b task/N feat/phase1) and dispatches without isolation, pointing the agent at that path. T3's branch was rebased onto feat/phase1 (27ec24e → a14c4e7; tests 24/24 still pass). T2 was messaged to rebase. Cost if wrong: none. Task 3: minor (deferred): burst/concurrency untested; datetime partial dates not normalized (string compare across offsets); no cap on Retry-After / no abort signal; 501/505 retryable; ratePerMinute 0 = unlimited; fixture key trailing-slash; seq helper; createTestContext.now uses the real clock Task 3: fix round 1 dispatched (2 Important: test ctx network fallback; HttpError leaks query) Task 3: fix round 1/5 (2 addressed, 0 open; commits a14c4e7..06a59e1) Task 3: minor (deferred): HttpError.body may echo secrets; createHttpClient itself still defaults to global fetch (intended for prod); fixture error includes query Task 3: complete (commits 27ec24e..06a59e1, review clean) — merged into feat/phase1 Wave C plugins dispatched: T10 trakt, T11 hardcover, T12 mangabaka, T13 csv, T14 listenbrainz+lastfm in .worktrees/t10..t14 (branches task/10..14, base 06a59e1) Task 2: minor (deferred): untyped Kind DSL in migration; redundant prefix indexes (brief-mandated); pg integer size cap 2GB and float4 score; schema test exercises few tables Task 2: fix round 1 dispatched (down migration; template-DB pg harness + 30s hooks, a controller-confirmed flake; EntryStatus union; missing report) Ruling: Plugin rate limits are declared by an optional rateLimit?: { perMinute: number; burst?: number } added to PluginManifest in Task 9. Task 9 wires it into makePluginContext's http client and sets it on each plugin manifest (trakt 100, hardcover 50, mangabaka 150, lastfm 240; listenbrainz relies on its headers, so 60). Until then plugins keep local constants. Why: the host builds ctx.http, and parallel plugin worktrees must not edit SDK types. Cost if wrong: a small follow-up edit. Ruling: Config fields of type "secret" are persisted only inside encrypted credentials_enc, never in plain config. Task 9's makePluginContext merges them back into ctx.config (decrypted) and also exposes them via ctx.credentials.get(), so plugins may read secrets from either place. Why: plugins were written both ways in parallel, and secrets must not sit in plaintext. Cost if wrong: one adapter function. Task 11: review: Approved with 1 Important (lookup untested) → fix round 1 dispatched. Minors deferred: merge negative test re-pulls; only book_series[0] used; duplicated max-cursor loop; GraphQL errors thrown as status-200 HttpError Task 12: review: Needs fixes (upsert without entry → deletion) → fix round 1 dispatched (+ hasOwn, key cross-check test). Minors deferred: cursor re-saved when unchanged; fake work hint on deletes; stale progress record when progress cleared; lookup casts through mapEntry; all non-comic types become manga Ruling: External-ID namespaces must be type-specific wherever an id space is shared across types: trakt_movie, trakt_show, trakt_season, trakt_episode; tvdb_show, tvdb_episode; tmdb_movie, tmdb_show (already); imdb stays shared (tt ids are global). Why: external_ids is unique on (namespace, value), so movie 1 and show 1 would collide. Cost if wrong: re-mapping namespaces in one plugin. Ruling (ext): MangaBaka namespaces anilist_manga, mal_manga, kitsu_manga, mangaupdates, mangabaka — sent to the T12 fix round. Task 5 (matching) must not assume namespace names. Task 13: review Needs fixes. Ruling: amend the plan-mandated CSV key formula with a per-file occurrence suffix #n — the plan's formula collides on duplicate rows and loses data (spec: never lose user data) — cost if wrong: keys change for duplicate rows only. Fix round 1 dispatched (keys, per-file mediaType, rating-0 only at scale 5). Task 13: minor (deferred): JSON parsed twice; nested JSON stringified as [object Object]; titleless rows skipped silently; unknown status → none; toCsv() does not round-trip; weak test assertions; no tests for an empty final field or a lone CR Task 14: review Approved, but ⚠️ LB /listens ordering confirmed as a real data-loss risk by the controller. Ruling: pullChanges walks backwards with max_ts (documented semantics), stops at the cursor, and dedupes the boundary second via idempotent keys — why: the min_ts ordering is unverified — cost if wrong: extra requests only. Fix round 1 dispatched (+ nowplaying test). Task 14: minor (deferred): export chosen by created, not the POST export_id; options.ts duplicated, with global mutable sleep; Last.fm cursor set before loved(); no RED run (process) Task 11: fix round 1 done (26d021a) → scoped re-review Task 10: review Needs fixes (namespaces per ruling; season/episode ids). Fix round 1 dispatched (+ failed-refresh test). Task 10: minor (deferred): no unparsable-expiresAt test; ctxRef declared late; duplicated full-pull sequence; 429 during device poll doesn't slow down; lists category extra (accepted). TDD process gaps (no RED run) in T10/T11/T12/T14 noted for the final review — tests themselves are behavioural. Task 2: fix round 1/5 (3 addressed, 0 open; commits da04adb..ff595c1) Task 2: complete (commits 27ec24e..ff595c1, review clean) — merged into feat/phase1; pg verified by the controller Task 11: fix round 1/5 (1 addressed; commits c099680..26d021a) — complete (06a59e1..26d021a, review clean), merged Task 12: fix round 1/5 (4 addressed; commits ddd9389..2573c83) — complete (06a59e1..2573c83, review clean), merged Task 10: fix round 1/5 (3 addressed; 59ccc90..9708fb5) — complete (06a59e1..9708fb5, review clean), merged Task 13: fix round 1/5 (3 addressed; a540c7d..4c08c16) — complete (06a59e1..4c08c16, review clean), merged Task 14: fix round 1/5 (2 addressed; 3eec2f2..0074293) — complete (06a59e1..0074293, review clean), merged Plugins minor (deferred): season-type trakt_season untested; a CSV with only zero ratings → scale 5; LB >1000 listens in one second skipped; LB one-second fallback untested Task 7: review Needs fixes (store/unlink race; non-atomic write) → fix round 1 dispatched. Ruling: exclude image/svg+xml and normalize mimes — why: SVG can carry script, and this is cheap — cost if wrong: users can't attach SVGs. Task 7: minor (deferred): storeAttachment doesn't verify link.entityId ownership — must be enforced in the API layer (Task 16) Task 4: review Needs fixes (first-user admin race on pg) → fix round 1 dispatched (+ invite-before-username, dummy-hash timing, hash/revoke tests). Minor deferred: Uint8Array.toBase64 needs recent Bun. Task 4: fix round 1/5 (4 addressed; e1faa74..fe3c9e4) — complete (d392a65..fe3c9e4, review clean), merged after owner approval; pg verified by the controller Task 5: review Needs fixes (3 Important: concurrent-create crash/duplicates; merge orphans children; ID match can return another user's private work). Ruling: add migration 0002 with a unique works(parent_id, position) index, a retry-on-unique-violation in matchWork, and on-conflict-do-nothing for external_ids — why: correctness under concurrent syncs on pg — cost if wrong: one extra migration. Ruling: private works can't carry external IDs. Fix round 1 dispatched. Task 5: minor (deferred): candidate LIKE misses diacritics (SQLite lower() is ASCII-only); conflicting hint IDs resolved by key order, not logged; ID-less drafts not deduplicated across syncs (Task 8 must map records via source_records to avoid re-matching)

PAUSED 2026-10-30 (owner asked to wind down until the usage limit resets)

Resume state:

  • feat/phase1 @ c7effb5 contains Tasks 1, 2, 3, 4, 10, 11, 12, 13, 14 (all reviewed and merged). Local only, not pushed.
  • Task 5: worktree .worktrees/t5 @ a46fb60, review Needs fixes. Fix round 1 was dispatched, but the agent was killed before committing anything (clean tree). On resume, dispatch a FRESH implementer with task-5-brief, task-5-report and the fix-round-1 findings (3 Important + minors, see the "Task 5: review" ledger line and rulings above).
  • Task 6: worktree .worktrees/t6 @ 56b86e2 (custom-fields committed). Uncommitted WIP: src/history.ts, test/entries.test.ts, test/history.test.ts. entries/engagements/lists/copies/suggestions are not done. On resume, dispatch a fresh implementer (opus) to continue from the WIP; no report file exists yet.
  • Task 7: worktree .worktrees/t7 @ 8f58ba4, fix round 1 dispatched but nothing committed (clean tree). On resume, dispatch a fresh implementer with the fix-round-1 findings (race mutex, atomic write, no SVG, tests).
  • Next waves after 5/6/7: T8 ∥ T15, then T9, T16, T17 ∥ T19, T18, final review.
  • The Postgres test container sttracker-pg-test (127.0.0.1:55432) is still running.

RESUMED 2026-10-30 (owner: "Continue")

The old agents could not be resumed (stopped). Fresh dispatches: T6 continuation (opus, from WIP), T5 fix round 1 (opus), T7 fix round 1 (sonnet). Task 7: fix round 1/5 (4 addressed; 8f58ba4..4acb368) — complete (d392a65..4acb368, review clean), merged; pg verified by the controller Task 7: minor (deferred): concurrency test is weak (ordering not forced); orphan file if the DB insert fails after the write; failure-cleanup path untested Task 5: fix round 1/5 (all addressed; a46fb60..437ca14); re-review found a new Important (a tombstone occupies its position slot → sync failure) → fix round 2 dispatched Task 5: minor (deferred): title/year parent reuse has no unique key (in TODO); legacy private works carrying ext IDs burn retries Task 5: fix round 2/5 (1 addressed; 437ca14..50c115b) — complete (d392a65..50c115b, review clean), merged Task 5: minor (deferred): finalWorkId silently returns the tombstone id on null; DECISIONS entry for "position lookup follows tombstones" missing (Task 19 docs pass should add it); hint display fields overwrite the surviving work (consistent with the shared metadata cache design) Ruling: Hierarchy visibility.

  • (a) Task 8's sync engine ensures that whenever it creates an entry on a child work (season/episode/track…), the user also has an entry on the ROOT ancestor work (status "none" if newly created, actor = plugin). Why: spec §6.3 "How deep imports go" says the show gets an entry too.
  • (b) T6's childSummary counts all descendant entries up to depth 3 (child, grandchild, great-grandchild via work parent_id joins), not just direct children.
  • (c) T6's listEntries parentId: null default lists entries whose work has no ancestor (up to depth 3) on which this user has an entry, so orphaned child entries (e.g. manual quick adds, or old data) still appear. Why: otherwise imported episodes disappear from the library. Cost if wrong: a little extra query complexity. Task 6: review Needs fixes (9 Important) → fix round 1 dispatched via task-6-fix1-findings.md. Rulings in that file: merging review/notes appends instead of dropping; an undo removes the alias; combined field_sources keep "user" if either side was user-owned. Task 6: minor (deferred): non-ASCII tag/q filters case-sensitive on SQLite; search/sort ignore override titles; plugin upsert revives a soft-deleted entry — Ruling: Task 8's change engine must not revive soft-deleted entries for plugin actors (removed/deleted entries stay deleted unless the user restores them); equal-value patches re-stamp field_sources — Task 8 must only send changed fields; history↔entries import cycle; duplication of T5 helpers (resolveVisibleWork, insertPrivateWork) to consolidate later; mutate bumps updated_at on no-op; pagination rating-tie tests Task 6: fix round 1/5 (9 + 3 minors addressed; 4c65939..00fc018). New Important: rollup vs drill-down mismatch → fix round 2 dispatched (+ restore onto a deleted entry throws). Task 6: parked/minor: an undo doesn't move copies/list items back or restore re-pointed aliases (in TODO); restoring a merge row swaps in the other rating (in DECISIONS); restoring a merged-away source keeps the D→S alias. Ruling: DECISIONS.md order is newest-first (per its header). Task 19's docs pass moves Task 5's bottom entry into order. Cost: cosmetic. Task 6: fix round 2/5 (2 addressed; 00fc018..c6865b2) — complete (d392a65..c6865b2, review clean), merged. Full suite: 310 pass on sqlite+pg. Task 6: minor (deferred): the show count includes season + episodes while the drill-down lists only the season (per ruling) Wave D dispatched: T8 (opus), T15 (sonnet). Task 15: review Needs fixes (5 Important: aliases/tombstones dropped; attachments after commit; non-transactional export; thin round-trip tests; docs) → fix round 1 via task-15-fix1-findings.md. Rulings: pre-validate attachments and write files before the tx, rows inside it; repeatable-read export; 2 GB unzip cap; 0600/0700 backups with tmp+rename. Minor deferred: concurrent-import race on the empty-account check; crafted bundles can create shared works with arbitrary metadata (Phase 2: import as private unless matched). Task 15: fix round 1/5 (all addressed; a047020..66b1558) — complete (33631ae..66b1558, review clean), merged Task 15: minor (deferred → Task 19 docs pass): stale DECISIONS sentence about mergedIntoId; orphan blobs if the tx fails; post-commit rewrite may throw after success; attachment-id remap in history; untested rejection paths (bundle_too_large, conflict, status/visibility) Task 8: implemented (33631ae..1975e97). Rulings on the implementer's open concerns, to be sent with the review findings:
  • Ruling: list records ARE applied in Phase 1. New → a user list (kind "list", name = record name, description "Imported from ") with items via matchWork. Changed → item and name diffs follow the field rules (a user-edited list is not overwritten: pending changed). Removed → pending removed. Why: otherwise imported lists are invisible, against the spec goal "everything in one place". Cost: moderate extra scope.
  • Ruling: accepting removed for an engagement with other engagement_sources only detaches this connection's source row; soft-delete happens only when no sources remain.
  • Ruling: a newer incoming value for the same (record, field) supersedes the older pending event (set status "ignored", payload.supersededBy = new event id) and creates the new pending event.
  • Ruling: listens are bulk-inserted in chunks of 500 with onConflict doNothing.
  • Concurrent runs of the same connection → Task 9 lock (as planned). Task 8: review Needs fixes (6 Important) → fix round 1 via task-8-fix1-findings.md (also carries the rulings on lists, multi-source removal, superseding). Minor deferred: identical rerun opens one tx per record to bump last_seen; snapshot hash edge-case tests. Task 9 dispatched in parallel with the T8 fix round (based on feat/phase1 + task/8 WIP; runSync interface stable). Ruling: rateLimit manifest field, secret split into credentials_enc, advisory-locked enqueue, weekly full — see task-9-rulings.md. Task 9: review Needs fixes (4 Important: error connections unscheduled; pre-runSync failures loop; unhandled rejection; reload misses non-index files) → fix round 1 via task-9-fix1-findings.md. Rulings: NonRetryableError + failed run row + clear STTRACKER_SECRET message; stale running-job reaper (1h); reload limitation documented. Minor deferred: per-plugin (not per-run) rate limiting → Phase 2; deleted connection = paused without creds (API layer must hide it). Task 8: fix round 1/5 (9 + minors addressed; b2e6e19..a003c3e) — complete (33631ae..a003c3e, review clean), merged Task 8: minor (deferred → TODO): stale removed/merged events follow merges to the survivor (prefer target_gone); two records → one entry after a move-merge; list-item title reuse is ASCII-only on SQLite and ignores creators; pending-hash records counted as removed; listen batch partial counts Task 9: fix round 1/5 (4 + minors addressed except the reaper re-queuing in-flight jobs) → fix round 2 dispatched (in-flight exclusion + heartbeat; shutdown re-queue without burning an attempt). Minor deferred: a reload drops a plugin whose edited entry fails to import (keep the old instance → TODO); a log-append failure masks the original error. Task 16 dispatched early on a task/9 base (839d969); merge feat/phase1 after T9 lands. Task 9: fix round 2/5 (2 addressed; 839d969..f0d41f2) — complete (207c92b..f0d41f2, review clean), merged Task 16: implemented (7f3d0d5..0a54020). Its edit to the permission files (.claude/settings.json, .gemini/settings.json) was DENIED by the harness. The controller will not work around it: surface it to the owner at the end. AGENTS.md Commands and PERMISSIONS.md are left unchanged (they must move together). Ruling (pending review): add migration 0003 with source_connections.deleted_at; deleteConnection sets it instead of overloading paused+no-creds; listConnections/scheduler exclude deleted rows. Why: a paused CSV source (no secrets) would vanish. Cost: one migration. Tasks 17 and 19 dispatched early on a task/16 base (0a54020); they merge feat/phase1 after T16 lands. T19 must not touch the agent permission files (denied earlier; owner decision). Task 16: review Needs fixes (paused credential-less connections unreachable; token history provenance) → fix round 1 via task-16-fix1-findings.md. Rulings: migration 0003 deleted_at; non-plugin actors → field_sources 'user', with history keeping token:; CSRF header + JSON content-type on all non-Bearer writes including login; production mode by default. Minor deferred: 50 MB multipart peak memory (TODO); per-day backup lockKey (optional). Task 16: fix round 1/5 (all addressed; 0a54020..0b34ede) — complete (7f3d0d5..0b34ede, review clean), merged after owner approval Task 16: minor (deferred → TODO): unauthenticated writes without the header get 403, not 401; the CSRF exemption is based on Bearer presence; quickAdd/restore/acceptSuggestion/resolveChange record "user", not the token actor; a delete can race a running sync's credential save Task 17: implemented (0a54020..439d138); not yet reviewed Ruling (pending T17 review): add 3 small server routes in the T17 fix round — GET auth/status {hasUsers}; GET attachments?entity=&entityId= (owner-scoped listAttachments); GET/PATCH me/preferences {ratingScale, defaultVisibility} stored in users.preferences. Why: the UI otherwise fakes them (first-run guess, session-only uploads, localStorage scale). Cost: small API additions. Task 17: review Needs fixes (4 Important: lost tag/custom-field updates; stale review undoes Restore; raw validation text; Gelasio missing) → fix round 1 via task-17-fix1-findings.md. Rulings: 3 API routes (auth/status, attachments list, me/preferences); dark text on --warn ratified (AA). T18 depends on T17's final api.ts/hooks. Task 18 dispatched on a task/17 base (5fa5931). Ruling (T18 scope): add GET /log (core log.ts, keyset over at/kind/id); listChanges adds title + intoTitle (batched, override-aware); listCopies adds workTitle. Why: the spec's Log and Changes views need them, and the UI-side fallbacks would be N+1 and vague. Profile PATCH, custom-field edit/delete and the invites list go to TODO. Task 17: fix round 1/5 (all addressed); new Important: a failed review save discards typed text → fix round 2 dispatched (+ was-capture, restore in the patch scope, prefs tx, auth-status invalidation, rating copy). Minor deferred: Gelasio TTF is inlined in the CSS bundle → woff2 TODO. Task 17: fix round 2/5 (items 2-6 addressed; item 1 partial: the saving flag can stick with overlapping saves) → fix round 3 dispatched.