# Resource Id Decoupling

> ResourceId Decoupling Pattern for Vocabulary-Driven Activities

- Skill: `davidortinau/resource-id-decoupling` (Agent Skill)
- Install (CLI): `npx skillmds@latest add davidortinau/resource-id-decoupling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/davidortinau/resource-id-decoupling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: davidortinau (https://skillmd.com/u/davidortinau)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/davidortinau/resource-id-decoupling

---

# ResourceId Decoupling Pattern for Vocabulary-Driven Activities

## When to Use This Pattern

Apply when an activity is **vocabulary-driven** (should load from full user vocab pool based on SRS/due status) but can be incorrectly filtered by LearningResource scope.

**Trigger symptoms:**
- Activity launched from Today's Plan shows "no words available" error
- Same activity works when launched from resource detail page
- User has many due words globally but activity fails on specific resource
- Insights dashboard shows X due words, but activity claims 0 available

**Vocabulary-driven activities:**
- VocabularyReview (Vocab Quiz)
- VocabularyGame (Vocab Matching)
- Cloze (when launched from plan — generates contextual sentences for due vocab)

**Resource-driven activities (DO NOT apply this pattern):**
- Reading, Listening, VideoWatching, Shadowing, Translation, Writing
- These require resource.Transcript, resource.AudioUrl, etc.

## The 4-Layer Fix

### Layer 1: DeterministicPlanBuilder.cs

Set `ResourceId = null` when creating the plan activity. This prevents NEW plans from carrying ResourceId.

**Example (VocabReview, line 460):**
```csharp
activities.Add(new PlannedActivity
{
    ActivityType = "VocabularyReview",
    ResourceId = null,  // VocabularyReview is vocabulary-driven, NOT resource-scoped
    SkillId = vocabReview.SkillId ?? skill?.Id,
    // ...
});
```

**Example (VocabGame, line 520):**
```csharp
activities.Add(new PlannedActivity
{
    ActivityType = "VocabularyGame",
    ResourceId = null, // Vocabulary games use skill context
    SkillId = skill.Id,
    // ...
});
```

**Example (Cloze, line 505):**
```csharp
activities.Add(new PlannedActivity
{
    ActivityType = outputActivity,
    ResourceId = outputActivity == "Cloze" ? null : resource.Id,  // Cloze is vocabulary-driven when from plan
    SkillId = skill?.Id,
    // ...
});
```

### Layer 2: PlanConverter.cs

Add activity-specific handling in `BuildRouteParameters` to:
1. Set `DueOnly = true` (SRS filter)
2. Skip passing ResourceId to route params

**Example (VocabReview, lines 126-131):**
```csharp
if (activityType == PlanActivityType.VocabularyReview)
{
    parameters["Mode"] = "SRS";
    parameters["DueOnly"] = true;
    // VocabularyReview is vocabulary-driven, NOT resource-driven
    // ResourceId is intentionally NOT passed to allow quiz to load from full user vocab pool
}
```

**Example (VocabGame, lines 133-139):**
```csharp
else if (activityType == PlanActivityType.VocabularyGame)
{
    parameters["DueOnly"] = true;
    // VocabularyGame is vocabulary-driven, NOT resource-driven (like VocabularyReview)
    // ResourceId is intentionally NOT passed to allow matching to load from full user vocab pool
    if (!string.IsNullOrEmpty(skillId))
        parameters["SkillId"] = skillId;
}
```

**Example (Cloze, lines 140-147):**
```csharp
else if (activityType == PlanActivityType.Cloze)
{
    parameters["DueOnly"] = true;
    // Cloze is vocabulary-driven when launched from plan, NOT resource-driven
    // ResourceId is intentionally NOT passed to allow loading from full user vocab pool
    if (!string.IsNullOrEmpty(skillId))
        parameters["SkillId"] = skillId;
}
```

### Layer 3: Index.razor LaunchPlanItem Guard

Prevent **persisted old plan items** from leaking ResourceId into the URL. This is defense-in-depth for plans generated before Layer 1 fix.

**Location:** `src/SentenceStudio.UI/Pages/Index.razor` lines 984-992

**Pattern:**
```csharp
// CRITICAL: For vocabulary-driven activities (VocabularyReview, VocabularyGame, Cloze),
// NEVER pass ResourceId (even if persisted on the plan item)
// These activities are vocabulary-driven, NOT resource-driven
if (item.ActivityType != PlanActivityType.VocabularyReview 
    && item.ActivityType != PlanActivityType.VocabularyGame 
    && item.ActivityType != PlanActivityType.Cloze 
    && !string.IsNullOrEmpty(item.ResourceId))
{
    if (multiResourceRoutes.Contains(route))
        query.Add($"resourceIds={item.ResourceId}");
    else
        query.Add($"resourceId={item.ResourceId}");
}
```

**Why this matters:** Even after Layer 1 fix ships, DB already contains persisted DailyPlan rows with the old ResourceId. Index.razor reading `item.ResourceId` directly bypasses PlanConverter entirely. The guard stops the leak at the UI boundary.

### Layer 4: Page-Level Defense (DueOnly Check)

Activity page ignores ResourceIds when `DueOnly=true`, even if upstream leak bypassed Layer 3.

**Example (VocabQuiz.razor, line 643):**
```csharp
// DEFENSE IN DEPTH: When DueOnly=true (SRS mode), ignore ResourceIds even if passed
// This ensures VocabularyReview always loads from full user vocabulary pool
var resourceIds = DueOnly ? Array.Empty<string>() : ParseResourceIds();
```

**Example (VocabMatching.razor, similar pattern):**
```csharp
// DEFENSE IN DEPTH: When DueOnly=true (plan-initiated SRS mode), ignore ResourceIds
// and load from full user vocabulary pool, same pattern as VocabQuiz
var resourceIds = DueOnly ? Array.Empty<string>() : ParseResourceIds();
```

**Example (Cloze.razor + ClozureService):**
```csharp
// In Cloze.razor LoadSentences method:
private async Task LoadSentences()
{
    // DEFENSE IN DEPTH: When DueOnly=true (plan-initiated SRS mode), ignore ResourceId
    // and load from full user vocabulary pool, same pattern as VocabQuiz
    var resourceId = DueOnly ? "" : (ResourceIdParam ?? "");
    var skillId = SkillIdParam ?? "";
    var result = await ClozureSvc.GetSentences(resourceId, 8, skillId, DueOnly);
    // ...
}

// In ClozureService.cs:
public async Task<List<Challenge>> GetSentences(string resourceID, int numberOfSentences, string skillID, bool dueOnly = false)
{
    // DEFENSE IN DEPTH: When dueOnly=true (plan-initiated), ignore resourceID and load from full vocab pool
    if (dueOnly || string.IsNullOrEmpty(resourceID))
    {
        return await GetSentencesFromDueWords(numberOfSentences, skillID);
    }
    // ... existing resource-driven path
}

private async Task<List<Challenge>> GetSentencesFromDueWords(int numberOfSentences, string skillID)
{
    // Load all user vocabulary (same pattern as VocabQuiz)
    var allWords = await _resourceRepository.GetAllVocabularyWordsWithResourcesAsync();
    // Filter to due words using SRS logic
    var dueWords = allWords.Where(w => {
        // Grace period exclusion, NextReviewDate checks, unseen word handling
    }).ToList();
    // Generate sentences from due vocab pool
    return await GenerateSentencesFromWords(dueWords, numberOfSentences, skillID, targetLanguage);
}
```

**Required page changes:**
1. Add query parameter: `[SupplyParameterFromQuery(Name = "DueOnly")] public bool DueOnly { get; set; }`
2. Check DueOnly flag in load method
3. When DueOnly=true: call `GetAllVocabularyWordsWithResourcesAsync()` or `GetDueWordsAsync()` (full user pool)
4. When DueOnly=false: preserve existing resource-filtered behavior (user-initiated path)

## Testing Checklist

After applying all 4 layers:

- [ ] Generate new plan → activity launches successfully from Today's Plan
- [ ] Old persisted plan item → activity still launches successfully (Layer 3 guard prevents leak)
- [ ] Direct resource-filtered launch → still works (DueOnly=false path)
- [ ] Activity loads due words globally when launched from plan
- [ ] Activity respects resource filter when launched from resource detail page
- [ ] No regression for other vocabulary-driven activities

## References

- VocabQuiz decoupling: commits 88a0272 (builder+converter), c081a63 (Index guard + page defense)
- VocabMatching decoupling: commit 0c8e197 (all 4 layers)
- Cloze decoupling: commit b3a2937 (all 4 layers, PR #201)
- Decisions log: `.squad/decisions.md` lines 7-16
- Memory: "DailyPlan items persisted in DB retain their original ResourceId field. UI code that reads item.ResourceId directly (e.g., Index.razor LaunchPlanItem) bypasses PlanConverter and can leak stale resource filters."

## Anti-Pattern: ResourceId Field Removal

**DO NOT** remove the ResourceId field from DailyPlanItem schema. Resource-driven activities (Reading, Shadowing, etc.) legitimately use it. The fix is selective filtering, not schema change.

