|
| 1 | +--- |
| 2 | +title: Run capability discovery before user-config validation in plugin lifecycle hooks |
| 3 | +date: 2026-05-15 |
| 4 | +category: best-practices |
| 5 | +module: agent-overlay-resolution |
| 6 | +problem_type: best_practice |
| 7 | +component: tooling |
| 8 | +severity: medium |
| 9 | +applies_when: |
| 10 | + - Designing a plugin config hook where multiple things must happen (discovery, validation, resolution, emission) |
| 11 | + - A validator may throw before downstream code can attempt graceful degradation |
| 12 | + - A discriminated availability envelope already exists but consumers admit edge cases that should degrade |
| 13 | + - Diagnosing why a feature that "should work" silently produces wrong values when an upstream signal is empty |
| 14 | +related_components: |
| 15 | + - tooling |
| 16 | + - authentication |
| 17 | +tags: [lifecycle-ordering, discovery-before-validation, graceful-degradation, availability-envelope, empty-set-collapse, plugin-config-hook] |
| 18 | +--- |
| 19 | + |
| 20 | +# Run capability discovery before user-config validation in plugin lifecycle hooks |
| 21 | + |
| 22 | +## Context |
| 23 | + |
| 24 | +Systematic's `createConfigHandler` is a plugin lifecycle hook that runs at OpenCode startup. It loads Systematic config, builds a bundled-agent inventory, runs two validators, calls `getAvailableModels` to discover connected providers, and then emits the merged agent set. |
| 25 | + |
| 26 | +After v2.13.0 introduced the `ModelAvailability` envelope (with `status: 'api' | 'cache' | 'unknown'` so consumers could distinguish live answers from degraded modes), two correctness gaps surfaced as users adopted the new behavior: |
| 27 | + |
| 28 | +1. **An authoritatively-empty `'api'` response slipped through the downstream gate.** When OpenCode returned `200 OK` with `data.providers = []` (no providers connected), `getAvailableModels` returned `{ status: 'api', models: <empty Set> }`. The consumer gate `status !== 'unknown'` admitted it, source-default resolution iterated over the empty set, found no match, and fell through to a "last-resort: first provider's first model" path. Bundled agents got pinned to a provider the user had no auth for — the exact failure mode the envelope was supposed to prevent. |
| 29 | + |
| 30 | +2. **The validators ran before discovery.** Any user-config typo (e.g., misspelled agent name in an overlay) caused `validateAgentOverlays` to throw, exiting the config hook before `getAvailableModels` was called. The user saw an overlay validation error; OpenCode logs showed a `/config/providers` 200 response — but the 200 came from some other request, not Systematic. The "did Systematic actually reach discovery?" question was unanswerable from logs. |
| 31 | + |
| 32 | +Together: the consumer side was missing one collapse rule, and the lifecycle ordering made the symptom hard to diagnose. PR #372 (v2.14.3) fixed both. Two patterns are worth lifting out of the specific change. |
| 33 | + |
| 34 | +## Guidance |
| 35 | + |
| 36 | +### 1. Empty success is a kind of failure; collapse it at the envelope boundary |
| 37 | + |
| 38 | +A discriminated availability envelope is only as useful as its trigger thresholds. If `'api'` admits empty results (because the API call technically succeeded), every downstream consumer has to remember to combine the status check with a `size > 0` check. Miss that combination at one site, and the failure mode is silent. |
| 39 | + |
| 40 | +The fix is to push the collapse into the envelope construction itself: when the success path produces an empty set, return the `'unknown'` shape instead. Callers get one rule ("`status !== 'unknown'` is safe to use"), and adding new consumers is mechanical rather than thoughtful. |
| 41 | + |
| 42 | +**Anti-pattern (admits the edge case):** |
| 43 | + |
| 44 | +```ts |
| 45 | +if (response.error !== undefined || response.data === undefined) { |
| 46 | + return readFallbackCache() |
| 47 | +} |
| 48 | +return { |
| 49 | + status: 'api', |
| 50 | + models: buildSetFromProviders(response.data.providers), |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +```ts |
| 55 | +// Downstream consumer |
| 56 | +const availabilitySet = |
| 57 | + availability.status !== 'unknown' ? availability.models : undefined |
| 58 | +// BUG: an empty `'api'` set produces an empty `availabilitySet`, |
| 59 | +// which is admitted by the gate. Source-default pinning then |
| 60 | +// falls through to a wrong-model last-resort. |
| 61 | +``` |
| 62 | + |
| 63 | +**Pattern (collapse at the boundary):** |
| 64 | + |
| 65 | +```ts |
| 66 | +if (response.error !== undefined || response.data === undefined) { |
| 67 | + return readFallbackCache() |
| 68 | +} |
| 69 | + |
| 70 | +const models = buildSetFromProviders(response.data.providers) |
| 71 | + |
| 72 | +// An authoritatively-empty response is operationally identical to total |
| 73 | +// discovery failure — we cannot point bundled agents at any model the user |
| 74 | +// can call. Funneling the empty case through the same `'unknown'` path the |
| 75 | +// real failures take keeps the gate a single rule. |
| 76 | +if (models.size === 0) { |
| 77 | + return emptyAvailability() |
| 78 | +} |
| 79 | + |
| 80 | +return { status: 'api', models } |
| 81 | +``` |
| 82 | + |
| 83 | +The downstream consumer is unchanged. The fix lives at the envelope construction site, where the trigger threshold is decided once for every consumer. |
| 84 | + |
| 85 | +### 2. Run capability discovery before user-config validation in plugin lifecycle hooks |
| 86 | + |
| 87 | +When a plugin lifecycle hook needs to both discover runtime capabilities and validate user config, run discovery first. Two reasons: |
| 88 | + |
| 89 | +**Diagnostic clarity.** A validator throw leaves the question "did discovery happen?" unanswerable from external logs. Running discovery first guarantees an answer regardless of validation outcome: either discovery succeeded (the result is in scope when validation runs), or it fell back gracefully (the envelope's degraded shape is in scope). The user sees the right error class — overlay validation, not "we don't know if discovery worked." |
| 90 | + |
| 91 | +**Forward-compatible lifecycle seam.** Future validators that need to consult availability — "reject this overlay because its target model isn't in the connected set" — can assume the availability result is computed by the time validation runs. If discovery happens after validation, that future work either needs a refactor or has to repeat the discovery call. The reorder costs almost nothing today and prevents a refactor later. |
| 92 | + |
| 93 | +**Anti-pattern:** |
| 94 | + |
| 95 | +```ts |
| 96 | +const inventory = buildBundledAgentInventory(...) |
| 97 | + |
| 98 | +// Validate first — risks throwing before discovery has a chance to run |
| 99 | +assertSourceCategoryModelCoverage(inventory.categories) // can throw |
| 100 | +const validatedOverlays = validateAgentOverlays({...}) // can throw |
| 101 | +const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays) |
| 102 | + |
| 103 | +// Discovery only runs if validators didn't throw |
| 104 | +const availability = await getAvailableModels(deps.client) |
| 105 | +``` |
| 106 | + |
| 107 | +**Pattern:** |
| 108 | + |
| 109 | +```ts |
| 110 | +const inventory = buildBundledAgentInventory(...) |
| 111 | + |
| 112 | +// Discovery runs BEFORE validation. Two reasons: |
| 113 | +// |
| 114 | +// 1. Diagnostic clarity. If validation throws, the user sees the |
| 115 | +// validation error — and we know discovery already attempted (and |
| 116 | +// succeeded or fell back gracefully). Without this ordering, a |
| 117 | +// validator throw obscures whether discovery ever ran. |
| 118 | +// |
| 119 | +// 2. Forward-compatible lifecycle seam. Future validators that consult |
| 120 | +// availability (e.g., rejecting an overlay whose target model is not |
| 121 | +// in the connected set) can assume `availabilitySet` is already |
| 122 | +// computed by the time validation runs. Do not move discovery back |
| 123 | +// down on the grounds that current validators don't consume it; that |
| 124 | +// would reintroduce the same ordering bug class. |
| 125 | +const availability = deps.client |
| 126 | + ? await getAvailableModels(deps.client) |
| 127 | + : undefined |
| 128 | +const availabilitySet = computeAvailabilitySet(availability) |
| 129 | + |
| 130 | +// Validators come after — they can throw safely now |
| 131 | +assertSourceCategoryModelCoverage(inventory.categories) |
| 132 | +const validatedOverlays = validateAgentOverlays({...}) |
| 133 | +const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays) |
| 134 | +``` |
| 135 | + |
| 136 | +The reorder is small. The inline rationale is non-negotiable — future maintainers must not "tidy up" by moving discovery back down on the grounds that current validators don't consume the result. |
| 137 | + |
| 138 | +### 3. Watch for parallel bugs on parallel code paths |
| 139 | + |
| 140 | +When you collapse an edge case at one source of an envelope (e.g., the API success path), check whether the same edge case exists at the other sources (cache, fallback, etc.). Fro Bot's review on PR #372 flagged exactly this: the API-empty case was fixed, but the cache-empty case (`{ status: 'cache', models: <empty Set> }`) still bypasses the same downstream gate. Tracked as issue #373 for a parallel patch. |
| 141 | + |
| 142 | +The lesson isn't "always fix both paths in one PR" — sometimes splitting is correct. The lesson is **after fixing edge case X at source A, ask whether source B has the same edge case.** A 30-second check at PR-review time saves a separate bug-hunt cycle later. |
| 143 | + |
| 144 | +## Why This Matters |
| 145 | + |
| 146 | +The two patterns work together. The collapse in Pattern 1 only matters because the consumer side is gating on a single rule (`status !== 'unknown'`). The reorder in Pattern 2 only matters because the availability envelope already exists and downstream consumers can degrade based on it. Together they make graceful degradation the lazy default — consumers can be terse, validators can fail loudly, and the right thing happens without coordination. |
| 147 | + |
| 148 | +For Systematic specifically, the cost of getting this wrong was concrete: users with no provider auth got bundled agents pinned to `anthropic/claude-opus-4-7`, which then failed at first invocation with a confusing error far from the config hook. After v2.14.3, those users get bundled agents that inherit OpenCode's parent model — which they configured themselves and know is callable. |
| 149 | + |
| 150 | +## When to Apply |
| 151 | + |
| 152 | +- Building a plugin lifecycle hook that combines discovery, validation, and emission |
| 153 | +- Designing a discriminated envelope where one variant admits a "successful but useless" edge case (e.g., empty API response, empty cache, schema-valid-but-empty payload) |
| 154 | +- Diagnosing a bug where a downstream component "should have degraded" but didn't — the upstream envelope's trigger may admit the case |
| 155 | +- Working in any startup hook where the order of fallible operations affects what the user sees in logs after a failure |
| 156 | + |
| 157 | +The patterns are not specific to Systematic or OpenCode. Any plugin or lifecycle handler with comparable shape benefits. |
| 158 | + |
| 159 | +## Examples |
| 160 | + |
| 161 | +### Before/after: the empty-set collapse |
| 162 | + |
| 163 | +The before/after is in the Anti-pattern / Pattern blocks under Guidance #1 above. A regression test guarding the collapse: |
| 164 | + |
| 165 | +```ts |
| 166 | +test('returns status: unknown when API providers array is empty', async () => { |
| 167 | + process.env.XDG_CACHE_HOME = path.join(testDir, 'nonexistent') |
| 168 | + |
| 169 | + const client = makeMockClient({ |
| 170 | + data: { providers: [], default: {} }, |
| 171 | + error: undefined, |
| 172 | + }) |
| 173 | + |
| 174 | + const result = await getAvailableModels(client) |
| 175 | + |
| 176 | + expect(result.status).toBe('unknown') |
| 177 | + expect(result.models.size).toBe(0) |
| 178 | +}) |
| 179 | + |
| 180 | +test('returns status: unknown when providers list is non-empty but no models discovered', async () => { |
| 181 | + // Catches the edge case where SDK shape produces a non-empty providers |
| 182 | + // array but `buildSetFromProviders` still yields zero models. |
| 183 | + process.env.XDG_CACHE_HOME = path.join(testDir, 'nonexistent') |
| 184 | + |
| 185 | + const client = makeMockClient({ |
| 186 | + data: { |
| 187 | + providers: [{ id: 'fake', models: {} }], |
| 188 | + default: {}, |
| 189 | + }, |
| 190 | + error: undefined, |
| 191 | + }) |
| 192 | + |
| 193 | + const result = await getAvailableModels(client) |
| 194 | + |
| 195 | + expect(result.status).toBe('unknown') |
| 196 | + expect(result.models.size).toBe(0) |
| 197 | +}) |
| 198 | +``` |
| 199 | + |
| 200 | +### Before/after: the lifecycle reorder |
| 201 | + |
| 202 | +A regression test that catches a future refactor moving discovery back down: |
| 203 | + |
| 204 | +```ts |
| 205 | +test('discovery completes before user-overlay validation throws', async () => { |
| 206 | + // When `validateAgentOverlays` rejects a user overlay, the config hook |
| 207 | + // must have already invoked `client.config.providers()`. Protects the |
| 208 | + // lifecycle ordering: discover first, validate second. |
| 209 | + createCategorizedAgent('review', 'correctness-reviewer', { ... }) |
| 210 | + writeCustomSystematicConfig({ |
| 211 | + agents: { 'correctness-reviewer': { skills: ['missing-skill'] } }, |
| 212 | + }) |
| 213 | + |
| 214 | + const providersCalls: number[] = [] |
| 215 | + const trackingClient = { |
| 216 | + config: { |
| 217 | + providers: async () => { |
| 218 | + providersCalls.push(Date.now()) |
| 219 | + return { data: { providers: [], default: {} }, error: undefined } |
| 220 | + }, |
| 221 | + }, |
| 222 | + } |
| 223 | + |
| 224 | + const handler = createConfigHandler({ |
| 225 | + directory: projectDir, |
| 226 | + bundledSkillsDir, bundledAgentsDir, bundledCommandsDir, |
| 227 | + client: trackingClient, |
| 228 | + }) |
| 229 | + |
| 230 | + // The handler should still throw — but discovery must have happened first |
| 231 | + await expect(handler({})).rejects.toThrow(/missing-skill/) |
| 232 | + |
| 233 | + // Spy fired before the throw — proves discovery is no longer gated |
| 234 | + // behind user-overlay validation. |
| 235 | + expect(providersCalls.length).toBe(1) |
| 236 | +}) |
| 237 | +``` |
| 238 | + |
| 239 | +The spy doesn't just verify "called once eventually" — it asserts the call happened **before** the validator throw. A future refactor that defers discovery behind any validator-throw path would break this assertion. |
| 240 | + |
| 241 | +## Related |
| 242 | + |
| 243 | +- `docs/solutions/best-practices/provider-availability-source-defaults-2026-05-12.md` — the v2.13.0 architectural arc; introduced the `ModelAvailability` envelope this learning sharpens |
| 244 | +- `docs/solutions/best-practices/layered-trust-boundaries-overlay-config-2026-05-09.md` — the trust-boundary mechanism that determines which overlay fields project config can set; orthogonal to availability discovery but in the same config-hook lifecycle |
| 245 | +- Issue #373 — Cache-empty fallback bypasses source-default pinning gate (the parallel bug surfaced by Fro Bot's review on PR #372) |
| 246 | +- PR #358 (v2.13.0) — introduced the `ModelAvailability` envelope |
| 247 | +- PR #372 (v2.14.3) — this learning's source PR; empty-discovery collapse + lifecycle reorder |
0 commit comments