Skip to content

Commit c34739a

Browse files
authored
docs(solutions): capture discovery-before-validation lifecycle pattern (#376)
* docs(solutions): capture discovery-before-validation lifecycle pattern Compound the durable patterns from PR #372 (v2.14.3) into docs/solutions/best-practices/: 1. Empty success is a kind of failure; collapse at the envelope boundary so consumers can rely on a single status check rather than a status + size combination. 2. Run capability discovery before user-config validation in plugin lifecycle hooks for diagnostic clarity and a forward-compatible seam for future availability-aware validators. 3. Watch for parallel bugs on parallel code paths; the API-empty collapse highlighted a structurally identical cache-empty case, tracked separately as #373. Includes anti-pattern/pattern code blocks, regression test examples, and cross-references to the v2.13.0 architectural-arc compound doc that introduced the ModelAvailability envelope this learning sharpens. * docs(solutions): normalize tag format to inline array per corpus convention Fro Bot non-blocking note on PR #376: every other doc in docs/solutions/best-practices/ uses inline YAML array syntax for tags (`tags: [foo, bar]`); this doc used multiline block style. Both are valid YAML but corpus consistency matters for grep workflows.
1 parent c4483eb commit c34739a

1 file changed

Lines changed: 247 additions & 0 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)