Files
muzick/docs/architecture/v2-fix-plan.md
T

27 KiB
Raw Blame History

v2 Fix Plan — foolproof execution

This plan fixes the gaps between the overnight v2 work and docs/architecture/09-recommendation-and-identity-v2.md. Every step has exact file paths, exact old/new code, and a verification command. Execute steps 18 in order, then step 9 (build + deploy + verify).

Rules for the executing agent:

  • Do NOT edit or remove existing entries in the MIGRATIONS array in db.service.ts. The three v2 migrations (20260707_claim_fusion, 20260707_backfill_claims, 20260708_materialize_claim_fusion) have not applied to the live DB yet, but leave them as-is — they run cleanly on first boot.
  • Do NOT delete the v1 getNextVibeChunk CTE or vibe.routes.ts in this plan. The doc says v1 is deleted when D ships and is verified. That's a follow-up, not this plan.
  • After all code edits (steps 17), run npx tsc --noEmit and npx vitest run from backend/ before deploying.
  • All file paths are relative to /home/kami/apps/muzick/.

Step 1 — claim_fusion MV refresh consumer (blocker)

Problem: claim_fusion is a MATERIALIZED VIEW. It is populated at creation time (migration) and never refreshed again. A trigger fires NOTIFY claim_fusion_changed on claims changes, but nothing LISTENs. Every generator and compat view reads the frozen MV — new claims are invisible.

Fix: Add a refreshClaimFusion() method to DbService and a background interval in app.ts that calls it every 10 seconds. CONCURRENTLY won't block reads.

1a. Add method to backend/src/services/db.service.ts

Insert this method immediately before the closing } of the class (after seedDefaultDiversityBudgets, which ends at line 2062):


  /**
   * Refresh the claim_fusion materialised view. Called on a periodic
   * timer so the graph's read path stays current with new claims.
   * CONCURRENTLY requires the unique index (idx_claim_fusion_pk),
   * which the 20260708_materialize_claim_fusion migration creates.
   */
  async refreshClaimFusion(): Promise<void> {
    try {
      await this.pgClient.query('SELECT refresh_claim_fusion()');
    } catch (err) {
      // Non-fatal: the MV may not exist yet on first boot before
      // migrations run. Log and move on; the next tick will retry.
      console.error('[DB] refresh_claim_fusion failed:', err);
    }
  }

The oldString to match for the edit (the end of seedDefaultDiversityBudgets + the class closing brace):

    for (const d of defaults) {
      await this.upsertDiversityBudget({
        user_id: userId,
        dimension: d.dimension,
        budget_share: d.share,
        horizon_min: d.horizon,
      });
    }
  }
}

Replace with the same block + the new method inserted before the final }.

1b. Start the refresh interval in backend/src/app.ts

After the line await dbService.runMigrations(); (line 53), add:


  // Keep the claim_fusion materialised view fresh. The trigger on
  // `claims` fires NOTIFY on every change; rather than maintain a
  // LISTEN consumer (separate long-lived connection), we refresh on a
  // short interval. 10s staleness is well below any user-facing
  // latency for a homelab music player.
  const FUSION_REFRESH_MS = 10_000;
  const fusionTimer = setInterval(() => {
    dbService.refreshClaimFusion().catch(() => {});
  }, FUSION_REFRESH_MS);

Then in the onClose hook (around line 128), add clearInterval for the new timer. Find:

  fastify.addHook('onClose', async () => {
    try {
      await pgClient.end();

Insert before await pgClient.end();:

    clearInterval(fusionTimer);

Verify: npx tsc --noEmit in backend/ — 0 errors.


Step 2 — daily belief decay + nightly forgotten derivation (blocker)

Problem: listener_beliefs.last_decayed_at is set at insert and never advanced. No decay job exists. This violates the core axiom "everything decays unless reinforced" and reproduces the v1 failure mode (heavily-played artists win forever). Also, the forgotten profile is "derived nightly" per the doc but nothing populates it, so the revival generator always returns empty.

Fix: Add decayBeliefs() and deriveForgottenProfile() methods to DbService and periodic intervals in app.ts.

2a. Add decay method to backend/src/services/db.service.ts

Insert after refreshClaimFusion() (the method added in step 1a):


  /**
   * Decay all listener beliefs whose last_decayed_at is older than 1
   * hour. Implements the decay formula from spec §B.4:
   *   value *= 0.5 ^ (elapsed / halflife)
   *   confidence *= 0.5 ^ (elapsed / halflife)
   * Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d,
   * negative=180d, contextual=7d). The 'forgotten' profile is excluded
   * — it is fully derived nightly by deriveForgottenProfile(), not
   * decayed.
   */
  async decayBeliefs(): Promise<number> {
    const res = await this.pgClient.query(`
      WITH halflives AS (
        SELECT profile,
          CASE profile
            WHEN 'longterm'   THEN 365 * 86400
            WHEN 'obsession'  THEN 14 * 86400
            WHEN 'discovery'  THEN 30 * 86400
            WHEN 'negative'   THEN 180 * 86400
            WHEN 'contextual' THEN 7 * 86400
            ELSE 30 * 86400
          END AS halflife_sec
      )
      UPDATE listener_beliefs lb
      SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5,
              EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
          confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5,
              EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
          last_decayed_at = NOW()
      FROM halflives h
      WHERE lb.profile = h.profile
        AND lb.profile <> 'forgotten'
        AND lb.last_decayed_at < NOW() - INTERVAL '1 hour'
    `);
    return res.rowCount ?? 0;
  }

  /**
   * Derive the 'forgotten' profile nightly (spec §B.2):
   *   longterm affinity > 0.3 AND not reinforced in 90+ days.
   * Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed.
   */
  async deriveForgottenProfile(): Promise<number> {
    await this.pgClient.query(
      `DELETE FROM listener_beliefs WHERE profile = 'forgotten'`
    );
    const res = await this.pgClient.query(`
      INSERT INTO listener_beliefs
        (user_id, profile, entity_type, entity_id, dimension, value,
         confidence, evidence_count, last_reinforced_at, last_decayed_at)
      SELECT user_id, 'forgotten', entity_type, entity_id, dimension,
             value, confidence, evidence_count, last_reinforced_at, NOW()
      FROM listener_beliefs
      WHERE profile = 'longterm'
        AND dimension = 'affinity'
        AND value > 0.3
        AND last_reinforced_at < NOW() - INTERVAL '90 days'
      ON CONFLICT (user_id, profile, entity_type, entity_id, dimension)
      DO UPDATE SET
        value = EXCLUDED.value,
        confidence = EXCLUDED.confidence,
        evidence_count = EXCLUDED.evidence_count,
        last_reinforced_at = EXCLUDED.last_reinforced_at
    `);
    return res.rowCount ?? 0;
  }

2b. Start decay + forgotten intervals in backend/src/app.ts

After the fusionTimer block added in step 1b, add:


  // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches
  // beliefs whose last_decayed_at is >1h old, so frequent runs are safe.
  const DECAY_INTERVAL_MS = 60 * 60 * 1000;
  const decayTimer = setInterval(() => {
    dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e));
  }, DECAY_INTERVAL_MS);

  // Nightly 'forgotten' profile derivation (spec §B.2).
  const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000;
  const forgottenTimer = setInterval(() => {
    dbService.deriveForgottenProfile().catch((e) =>
      console.error('[DB] forgotten derivation failed:', e)
    );
  }, FORGOTTEN_INTERVAL_MS);

  // Run both once at boot so the first session benefits.
  dbService.decayBeliefs().catch(() => {});
  dbService.deriveForgottenProfile().catch(() => {});

In the onClose hook, add (after clearInterval(fusionTimer);):

    clearInterval(decayTimer);
    clearInterval(forgottenTimer);

Verify: npx tsc --noEmit in backend/ — 0 errors.


Step 3 — fix MB spine writer generated-column bug

File: workers/src/mb-spine-writer.ts

Problem: resolveArtist (line 128) tries to INSERT into normalized_name, which is GENERATED ALWAYS AS normalize_artist(name) STORED. PostgreSQL rejects this: ERROR: cannot insert a non-DEFAULT value into column "normalized_name". The stub-creation path is broken — the spine writer can only attach claims to existing artists; any newly-credited artist is dropped.

Fix: Remove normalized_name from the INSERT column list and the normalized value from the params. The generated column auto-computes from name.

Find (lines 127134):

    const result = await this.pgClient.query<{ id: string }>(
      `INSERT INTO artists (name, canonical_name, sort_name, mbid, normalized_name)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
       RETURNING id`,
      [creditName, artistName, sortName, mbid, normalized]
    );

Replace with:

    const result = await this.pgClient.query<{ id: string }>(
      `INSERT INTO artists (name, canonical_name, sort_name, mbid)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
       RETURNING id`,
      [creditName, artistName, sortName, mbid]
    );

Verify: npx tsc --noEmit in workers/ — 0 errors.


Step 4 — fix dislikeTrack evidence-in-catch bug

File: backend/src/services/db.service.ts

Problem: dislikeTrack (line 712) writes the hidden evidence row in the catch block — i.e. only when the transaction fails. A successful dislike writes no negative evidence via this path.

Fix: Move the evidence write out of the catch block to after the try/catch, so it runs only on success.

Find (lines 712748):

  async dislikeTrack(userId: string, trackId: string): Promise<void> {
    try {
      await this.pgClient.query('BEGIN');

      // Phase 1: hide the track in all active views
      await this.pgClient.query(
        "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
        [trackId]
      );

      // Phase 1: insert dislike row (idempotent — won't create duplicate)
      await this.pgClient.query(
        'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING',
        [trackId]
      );

      // Phase 1: log feedback signal for the Vibe learning loop
      await this.pgClient.query(
        "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')",
        [userId, trackId]
      );

      await this.pgClient.query('COMMIT');
    } catch (err) {
      await this.pgClient.query('ROLLBACK');
      // Write evidence: hidden → negative profile
      await this.recordEvidence({
        user_id: userId,
        entity_type: 'track',
        entity_id: trackId,
        signal: 'hidden',
        profile: 'negative',
        weight: -0.60,
      });
      throw err;
    }
  }

Replace with:

  async dislikeTrack(userId: string, trackId: string): Promise<void> {
    try {
      await this.pgClient.query('BEGIN');

      // Phase 1: hide the track in all active views
      await this.pgClient.query(
        "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
        [trackId]
      );

      // Phase 1: insert dislike row (idempotent — won't create duplicate)
      await this.pgClient.query(
        'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING',
        [trackId]
      );

      // Phase 1: log feedback signal for the Vibe learning loop
      await this.pgClient.query(
        "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')",
        [userId, trackId]
      );

      await this.pgClient.query('COMMIT');
    } catch (err) {
      await this.pgClient.query('ROLLBACK');
      throw err;
    }

    // Write evidence: hidden → negative profile (only on success)
    await this.recordEvidence({
      user_id: userId,
      entity_type: 'track',
      entity_id: trackId,
      signal: 'hidden',
      profile: 'negative',
      weight: -0.60,
    });
  }

Verify: npx tsc --noEmit in backend/ — 0 errors.


Step 5 — fix hardDeleteTrack missing manual_deleted evidence

File: backend/src/services/db.service.ts

Problem: hardDeleteTrack (line 810) writes a legacy feedback row but no evidence. The doc's strongest negative signal (manual_deleted → negative -0.90) is missing. Permanent deletion has no effect on listener beliefs.

Fix: Add a recordEvidence call between the feedback insert and the track DELETE. evidence.entity_id has no FK to tracks, so the evidence row survives the deletion.

Find (lines 813821):

    // Log the permanent deletion feedback event first (before the track is gone)
    await this.pgClient.query(
      "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
      [userId, trackId]
    );

    // Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
    // feedback, track_audio_features, track_lyrics, recommendation_batch_track)
    await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);

Replace with:

    // Log the permanent deletion feedback event first (before the track is gone)
    await this.pgClient.query(
      "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
      [userId, trackId]
    );

    // Write evidence: manual_deleted → negative profile (strongest negative
    // signal, spec §B.3). entity_id has no FK to tracks, so the row survives.
    await this.recordEvidence({
      user_id: userId,
      entity_type: 'track',
      entity_id: trackId,
      signal: 'manual_deleted',
      profile: 'negative',
      weight: -0.90,
    });

    // Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
    // feedback, track_audio_features, track_lyrics, recommendation_batch_track)
    await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);

Verify: npx tsc --noEmit in backend/ — 0 errors.


Step 6 — switch session director artist reads to track_artists_v2

File: backend/src/services/session-director.service.ts

Problem: The session director reads the legacy track_artists table for artist fatigue, budget spend, repetition checks, seed resolution, and recent-play artist lookup. Doc D.2 requires artist fatigue to roll up via alias_of fusion (so DOOM / Madvillain / Viktor Vaughn collapse into one artist). By bypassing claim_fusion / track_artists_v2, the alias collapse cannot happen.

Fix: Replace all track_artists tatrack_artists_v2 ta and track_artists ta3track_artists_v2 ta3 in this file. The v2 view has the same track_id, artist_id, role columns, so it's a drop-in replacement. This depends on step 1 (MV refresh) being deployed so the view has data.

There are 11 occurrences across the file. Do two replaceAll edits:

6a. Replace all track_artists ta with track_artists_v2 ta

Use replaceAll: true on the string track_artists tatrack_artists_v2 ta. This covers 10 occurrences (buildState, computeFatigue, calcBudgetSpent artist, calcBudgetSpent new_artist, checkRepetition, rankCandidates, buildPlan, replan, resolveSeedArtistId).

6b. Replace track_artists ta3 with track_artists_v2 ta3

Use replaceAll: true on the string track_artists ta3track_artists_v2 ta3. This covers 1 occurrence in calcBudgetSpent's new_artist case.

Note: Do step 6a first, then 6b. After 6a, the ta3 occurrence will still be track_artists ta3 (it wasn't matched by track_artists ta because that's a different string — ta3ta). So both replacements are needed.

Verify:

  • npx tsc --noEmit in backend/ — 0 errors.
  • grep -n "track_artists " backend/src/services/session-director.service.ts should return zero matches (all replaced). If any track_artists without _v2 remain, fix them.

Step 7 — correct the session log

File: SESSION-07-07-2026.md

Problem: The session log overclaims: says "Full Stack" and "replaces getNextVibeChunk" but nothing is deployed, v1 is intact, and frontend is not wired. Also miscounts files (7 not 8) and says noveltyGenerator was "skipped" when it's implemented.

Fix: Make these edits:

7a. Fix the headline (line 3)

Find:

## Implemented: v2 Recommendation Engine — Full Stack (Systems AE + Phase 4)

Replace with:

## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems AE + Phase 4)

7b. Fix the file count (line 8)

Find:

### Files created (7 new)

Replace with:

### Files created (8 new)

And add a row to the table after the image-enrichment.service.ts row (line 15). After:

| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline |

Add:

| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) |

7c. Fix the novelty generator claim (line 53)

Find:

- `noveltyGenerator`: skipped (no release_date column)

Replace with:

- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists

7d. Fix the "replaces getNextVibeChunk" claim (line 56)

Find:

**System D — Session Director (replaces getNextVibeChunk)**

Replace with:

**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)**

7e. Fix the Verification section (lines 8688)

Find:

### Verification
- `npx tsc --noEmit` — 0 errors
- No git repo — changes uncommitted

Replace with:

### Verification
- `npx tsc --noEmit` — 0 errors
- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state)
- No git repo — changes uncommitted
- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan.

7f. Fix the Next section (lines 8992)

Find:

### Next
- Wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls)
- Build yt-dlp worker for System E acquisition (download candidates)
- Runtime smoke-test after redeploy

Replace with:

### Next
- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy)
- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls)
- Build yt-dlp worker for System E acquisition (download candidates)
- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list

Step 8 — run typecheck + tests before deploying

cd /home/kami/apps/muzick/backend
npx tsc --noEmit
npx vitest run

Both must pass (0 ts errors, 30/30 tests). If any test fails, do not deploy — re-read the relevant step and fix.

Also typecheck the worker:

cd /home/kami/apps/muzick/workers
npx tsc --noEmit

Step 9 — build, deploy, and verify against the live DB

9a. Rebuild the backend + worker images

cd /home/kami/apps/muzick
docker compose build backend worker
docker compose up -d backend worker

Wait ~15 seconds for boot, then check the backend logs for migration output:

docker logs muzick-backend-1 --tail 80 2>&1 | grep -E "Migration|migration|ERROR|error"

You should see:

[DB] Running migration: 20260707_claim_fusion
[DB] Migration applied: 20260707_claim_fusion
[DB] Running migration: 20260707_backfill_claims
[DB] Migration applied: 20260707_backfill_claims
[DB] Running migration: 20260708_materialize_claim_fusion
[DB] Migration applied: 20260708_materialize_claim_fusion

If any migration fails, read the error, fix the SQL in a NEW migration (do not edit the failed one), rebuild, and redeploy.

9b. Verify v2 tables + views exist and are populated

docker exec muzick-db-1 psql -U user -d muzick -c "
SELECT 'migrations' AS check, COUNT(*) FROM schema_migrations
UNION ALL SELECT 'claims', COUNT(*) FROM claims
UNION ALL SELECT 'source_trust', COUNT(*) FROM source_trust
UNION ALL SELECT 'evidence', COUNT(*) FROM evidence
UNION ALL SELECT 'claim_fusion rows', COUNT(*) FROM claim_fusion
UNION ALL SELECT 'track_artists_v2 rows', COUNT(*) FROM track_artists_v2
UNION ALL SELECT 'recording_mbid set', COUNT(*) FROM tracks WHERE recording_mbid IS NOT NULL;
"

Expected after first boot:

  • migrations = 7 (4 old + 3 new)
  • source_trust = 7 (seed rows)
  • claims > 0 (backfilled from track_artists + artist_similar)
  • claim_fusion rows > 0 (populated by the materialize migration)
  • track_artists_v2 rows > 0 (view over claim_fusion)

If claims = 0, the backfill migration found nothing — check that track_artists and artist_similar have data in the live DB.

9c. Verify the v2 endpoints are live

curl -s http://localhost:3000/api/graph/sources | head -c 200
echo
curl -s -o /dev/null -w "v2/state: HTTP %{http_code}\n" http://localhost:3000/api/v2/state
curl -s -o /dev/null -w "graph/sources: HTTP %{http_code}\n" http://localhost:3000/api/graph/sources
curl -s -o /dev/null -w "graph/summary: HTTP %{http_code}\n" http://localhost:3000/api/graph/summary

Expected: v2/state: HTTP 200, graph/sources: HTTP 200, graph/summary: HTTP 200.

9d. Smoke-test the v2 session flow

# Start a v2 session (use any library track ID as seed)
SEED=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n')
echo "Seed track: $SEED"
curl -s -X POST http://localhost:3000/api/v2/vibe/start \
  -H 'Content-Type: application/json' \
  -H "x-user-id: 00000000-0000-0000-0000-000000000000" \
  -d "{\"seedTrackId\":\"$SEED\"}" | head -c 500
echo
# Get the next track from the plan
curl -s http://localhost:3000/api/v2/vibe/next \
  -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300
echo
# Check the plan
curl -s http://localhost:3000/api/v2/vibe/plan \
  -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300

If /v2/vibe/start returns an empty plan [], check the backend logs for generator errors. The most likely cause is claim_fusion being empty — verify step 9b showed claim_fusion rows > 0.

9e. Verify evidence is written on a completed play

USER="00000000-0000-0000-0000-000000000000"
TRACK=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n')

# Before
docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_before FROM evidence WHERE user_id='$USER'"

# Record a completed play via the v2 feedback endpoint
curl -s -X POST http://localhost:3000/api/v2/vibe/feedback \
  -H 'Content-Type: application/json' \
  -H "x-user-id: $USER" \
  -d "{\"trackId\":\"$TRACK\",\"action\":\"completed\"}"

# After
docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_after, signal, profile, weight FROM evidence WHERE user_id='$USER' GROUP BY signal, profile, weight ORDER BY created_at DESC LIMIT 5"

Expected: evidence_after > evidence_before, and you should see a playback_completed / longterm / 0.10 row.

9f. Verify the MV refresh is running

Wait 15 seconds after boot, then:

docker logs muzick-backend-1 2>&1 | grep -i "refresh_claim_fusion" | tail -3

You should see no errors (the method logs only on failure). If you see repeated refresh_claim_fusion failed errors, the MV or refresh function doesn't exist — re-check that the 20260708_materialize_claim_fusion migration applied.

9g. Verify belief decay runs

# Manually trigger decay and check it doesn't error
docker exec muzick-backend-1 node -e "
const { Client } = require('pg');
const c = new Client({ connectionString: process.env.DATABASE_URL });
(async () => {
  await c.connect();
  const r = await c.query('SELECT decay_beliefs()');
  console.log('decay result:', r.rows);
  await c.end();
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
" 2>&1 || echo "decay_beliefs() not a SQL function — that's OK, the method runs the UPDATE directly"

This is a soft check — the decayBeliefs() method runs raw SQL, not a stored function. The real verification is that last_decayed_at advances after 1 hour. Check:

docker exec muzick-db-1 psql -U user -d muzick -c "
SELECT user_id, profile, entity_type, last_decayed_at,
       EXTRACT(EPOCH FROM (NOW() - last_decayed_at))/3600 AS hours_since_decay
FROM listener_beliefs
ORDER BY last_decayed_at DESC LIMIT 5;
"

After 1+ hours of uptime, hours_since_decay should be < 1 for recently-decayed rows (the hourly job touched them).


Summary of what each step fixes

Step Doc section Problem Fix
1 A.4 claim_fusion MV never refreshed 10s interval calls refresh_claim_fusion()
2 B.4, B.2 No belief decay; forgotten never derived Hourly decay job + 24h forgotten derivation
3 A.5 #1 MB spine writer can't create new artists (generated column) Drop normalized_name from INSERT
4 B.3 dislikeTrack writes evidence only on failure Move evidence write to success path
5 B.3 hardDeleteTrack writes no manual_deleted evidence Add -0.90 evidence before track DELETE
6 D.2 Director reads legacy track_artists, bypassing alias fusion Switch to track_artists_v2
7 Session log overclaims Correct the wording
8 Pre-deploy gate tsc + vitest pass
9 A.7, B.6, D.11 Not deployed; acceptance unverified Build, deploy, verify against live DB

What is NOT in this plan (follow-ups, not blockers)

  • MB spine writer album + artist-relation claimswriteAlbumClaims and writeArtistRelationClaims in mb-spine-writer.ts are stubs (console.log + return 0). They require new MusicBrainzClient methods (release-group artist-credit, artist-relations ARs). Not a blocker for first deploy; the recording- claim writer is the critical path. Implement as a follow-up.
  • Frontend wiring — the frontend still calls /api/vibe/* (v1). After v2 is verified in production, wire /api/v2/vibe/* into frontend/src/services/vibeService.ts and the Vibe page.
  • v1 deletiongetNextVibeChunk, vibe.routes.ts, the feedback table, artist_similar table, genre.parent_id are all still present. The doc says delete them when D ships and is verified. That's a separate, careful release after v2 is confirmed good in production.
  • LISTEN-based MV refresh — the 10s interval in step 1 is the simple, foolproof approach. Upgrading to a LISTEN/NOTIFY consumer (immediate refresh on claim change) is a follow-up if 10s staleness ever becomes a problem.