Root cause. The admin storePrompt persisted manifests to the git-backed state backend at prompts/{i}{name}.prompt.json (auto-incrementing index prefix, JSON), while the workflow LLM handler loaded {workflow.path}/{ref}.prompt.yaml from local disk. Different store, different naming scheme, different extension — every manifest written to state was unreachable at runtime.
All verified. Final response follows.
Root cause. The admin storePrompt persisted manifests to the git-backed state backend at prompts/{i}_{name}.prompt.json (auto-incrementing index prefix, JSON), while the workflow LLM handler loaded {workflow.path}/{ref}.prompt.yaml from local disk. Different store, different naming scheme, different extension — every manifest written to state was unreachable at runtime.
The fix (implemented in ~/promptfix, module promptfix):
1. Canonical state location — prompts/{name}.prompt.json (state.go). One deterministic path per prompt name; the index prefix that made lookups unpredictable is gone:
// ManifestPath returns the canonical state location for a prompt.
func ManifestPath(name string) string {
return filepath.Join("prompts", name+".prompt.json")
}
2. AppID added to workflow.ExecutionContext (workflow.go). The workflow now knows which application it runs for; the loader uses it to scope lookups and reject cross-app manifests:
type ExecutionContext struct {
AppID string // application the workflow belongs to
WorkflowPath string // dir holding legacy local YAML prompt files
Ref string // prompt reference, e.g. "agent-reply"
}
3. Optional state-backed prompt loader injected into llmWorkflowHandler, with local-file YAML fallback (loader.go, workflow.go). newStatePromptLoader prefers state (prompts/{name}.prompt.json), and on ErrPromptNotFound reproduces the legacy loader exactly ({workflow.path}/{ref}.prompt.yaml), so file-only workflows keep working unmigrated:
type StatePromptLoader struct {
state StateBackend
root string
appID string // from ExecutionContext (per-run copy)
workflowPath string
}
func (l *StatePromptLoader) LoadPrompt(ctx context.Context, ref string) (*PromptManifest, error) {
m, err := l.state.ReadPrompt(ctx, promptName(ref)) // canonical: prompts/{name}.prompt.json
if err == nil {
if l.appID != "" && m.AppID != "" && m.AppID != l.appID {
return nil, fmt.Errorf("%w: manifest %q belongs to app %q, not %q",
ErrPromptNotFound, m.Name, m.AppID, l.appID)
}
return m, nil
}
if !errors.Is(err, ErrPromptNotFound) {
return nil, err
}
return l.loadLegacyYAML(ctx, ref) // fallback: {workflow.path}/{ref}.prompt.yaml
}
The handler takes the loader as an optional constructor argument and binds execution context per run (copy-on-write, so one injected instance is safe to share):
func (h *llmWorkflowHandler) resolvePrompt(ctx context.Context, ec ExecutionContext) (*PromptManifest, error) {
if h.promptLoader == nil {
return nil, fmt.Errorf("resolve prompt %q: no prompt loader injected", ec.Ref)
}
if sp, ok := h.promptLoader.(*StatePromptLoader); ok {
return sp.forExecution(ec).LoadPrompt(ctx, ec.Ref) // binds AppID + WorkflowPath
}
return h.promptLoader.LoadPrompt(ctx, ec.Ref)
}
4. JSON tags on PromptManifest make decode trivial (manifest.go). One tagged struct is the on-disk format, so json.Unmarshal is all it takes:
type PromptManifest struct {
Schema string `json:"schema"`
Name string `json:"name"`
Version int `json:"version"`
AppID string `json:"app_id,omitempty"`
Format string `json:"format"`
Body string `json:"body"`
Params map[string]any `json:"params,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
admin.storePrompt (admin.go) now writes to ManifestPath(name), stamps AppID, and reports the exact path — while a not-found error names both sources (prompts/x.prompt.json and .../x.prompt.yaml) for one-line debugging.
go vet clean; go test -race clean; 9/9 tests pass (go test -v, 0.004s). Files: manifest.go, state.go, loader.go, workflow.go, admin.go, promptfix_test.go.
| Test | What it proves |
|---|---|
TestStorePromptWritesCanonicalLocation |
Writes go to prompts/agent-reply.prompt.json; old prompts/{i}_agent-reply.prompt.json files do not exist |
TestManifestJSONDecodeTrivial |
Raw state bytes round-trip through plain DecodeManifest (name, app_id, body all intact) |
TestEndToEndStateBackedResolution |
Headline regression: admin writes to state → handler resolves it, with no legacy YAML file present |
TestLegacyYAMLFallback |
Existing {workflow.path}/{ref}.prompt.yaml workflows still resolve when state is empty |
TestStateWinsOverLegacy |
Canonical state takes priority over a stale legacy file with the same name |
TestAppIDScopingRejectsCrossApp |
ExecutionContext.AppID blocks a workflow from reaching another app's manifest |
TestLegacyRefVariants |
Legacy loader accepts agent-reply, agent-reply.prompt.yaml, and chat/agent-reply ref shapes |
TestNotFoundReportsBothSources |
Error names both prompts/missing.prompt.json and missing.prompt.yaml |
TestNoLoaderConfigured |
Loader injection is optional; nil loader fails explicitly, not silently |
Live run — the stored artifact and what the handler resolves (previously unreachable):
{
"schema": "prompt.v1",
"name": "agent-reply",
"version": 1,
"app_id": "app-chat",
"format": "yaml",
"body": "You are a helpful agent.",
"updated_at": "2026-08-03T05:30:39.254364815Z"
}
handler resolved: agent-reply (app=app-chat) body="You are a helpful agent."
Edge cases covered: index-prefix regression (old files absent), extension/path variants in refs, cross-app leakage, state-vs-legacy precedence, both-sources-missing error, nil-loader config, concurrent access (-race).
{"problem_class":"go-prompt-state-backend-mismatch","model":"deepseek-v4-flash","result":"passed","tests":9}
Solved by Pi Agent (deepseek-v4-flash).
All verified. Final response follows.
Root cause. The admin storePrompt persisted manifests to the git-backed state backend at prompts/{i}_{name}.prompt.json (auto-incrementing index prefix, JSON), while the workflow LLM handler loaded {workflow.path}/{ref}.prompt.yaml from local disk. Different store, different naming scheme, different extension — every manifest written to state was unreachable at runtime.
The fix (implemented in ~/promptfix, module promptfix):
1. Canonical state location — prompts/{name}.prompt.json (state.go). One deterministic path per prompt name; the index prefix that made lookups unpredictable is gone:
// ManifestPath returns the canonical state location for a prompt.
func ManifestPath(name string) string {
return filepath.Join("prompts", name+".prompt.json")
}
2. AppID added to workflow.ExecutionContext (workflow.go). The workflow now knows which application it runs for; the loader uses it to scope lookups and reject cross-app manifests:
type ExecutionContext struct {
AppID string // application the workflow belongs to
WorkflowPath string // dir holding legacy local YAML prompt files
Ref string // prompt reference, e.g. "agent-reply"
}
3. Optional state-backed prompt loader injected into llmWorkflowHandler, with local-file YAML fallback (loader.go, workflow.go). newStatePromptLoader prefers state (prompts/{name}.prompt.json), and on ErrPromptNotFound reproduces the legacy loader exactly ({workflow.path}/{ref}.prompt.yaml), so file-only workflows keep working unmigrated:
type StatePromptLoader struct {
state StateBackend
root string
appID string // from ExecutionContext (per-run copy)
workflowPath string
}
func (l *StatePromptLoader) LoadPrompt(ctx context.Context, ref string) (*PromptManifest, error) {
m, err := l.state.ReadPrompt(ctx, promptName(ref)) // canonical: prompts/{name}.prompt.json
if err == nil {
if l.appID != "" && m.AppID != "" && m.AppID != l.appID {
return nil, fmt.Errorf("%w: manifest %q belongs to app %q, not %q",
ErrPromptNotFound, m.Name, m.AppID, l.appID)
}
return m, nil
}
if !errors.Is(err, ErrPromptNotFound) {
return nil, err
}
return l.loadLegacyYAML(ctx, ref) // fallback: {workflow.path}/{ref}.prompt.yaml
}
The handler takes the loader as an optional constructor argument and binds execution context per run (copy-on-write, so one injected instance is safe to share):
func (h *llmWorkflowHandler) resolvePrompt(ctx context.Context, ec ExecutionContext) (*PromptManifest, error) {
if h.promptLoader == nil {
return nil, fmt.Errorf("resolve prompt %q: no prompt loader injected", ec.Ref)
}
if sp, ok := h.promptLoader.(*StatePromptLoader); ok {
return sp.forExecution(ec).LoadPrompt(ctx, ec.Ref) // binds AppID + WorkflowPath
}
return h.promptLoader.LoadPrompt(ctx, ec.Ref)
}
4. JSON tags on PromptManifest make decode trivial (manifest.go). One tagged struct is the on-disk format, so json.Unmarshal is all it takes:
type PromptManifest struct {
Schema string `json:"schema"`
Name string `json:"name"`
Version int `json:"version"`
AppID string `json:"app_id,omitempty"`
Format string `json:"format"`
Body string `json:"body"`
Params map[string]any `json:"params,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
admin.storePrompt (admin.go) now writes to ManifestPath(name), stamps AppID, and reports the exact path — while a not-found error names both sources (prompts/x.prompt.json and .../x.prompt.yaml) for one-line debugging.
go vet clean; go test -race clean; 9/9 tests pass (go test -v, 0.004s). Files: manifest.go, state.go, loader.go, workflow.go, admin.go, promptfix_test.go.
| Test | What it proves |
|---|---|
TestStorePromptWritesCanonicalLocation |
Writes go to prompts/agent-reply.prompt.json; old prompts/{i}_agent-reply.prompt.json files do not exist |
TestManifestJSONDecodeTrivial |
Raw state bytes round-trip through plain DecodeManifest (name, app_id, body all intact) |
TestEndToEndStateBackedResolution |
Headline regression: admin writes to state → handler resolves it, with no legacy YAML file present |
TestLegacyYAMLFallback |
Existing {workflow.path}/{ref}.prompt.yaml workflows still resolve when state is empty |
TestStateWinsOverLegacy |
Canonical state takes priority over a stale legacy file with the same name |
TestAppIDScopingRejectsCrossApp |
ExecutionContext.AppID blocks a workflow from reaching another app's manifest |
TestLegacyRefVariants |
Legacy loader accepts agent-reply, agent-reply.prompt.yaml, and chat/agent-reply ref shapes |
TestNotFoundReportsBothSources |
Error names both prompts/missing.prompt.json and missing.prompt.yaml |
TestNoLoaderConfigured |
Loader injection is optional; nil loader fails explicitly, not silently |
Live run — the stored artifact and what the handler resolves (previously unreachable):
{
"schema": "prompt.v1",
"name": "agent-reply",
"version": 1,
"app_id": "app-chat",
"format": "yaml",
"body": "You are a helpful agent.",
"updated_at": "2026-08-03T05:30:39.254364815Z"
}
handler resolved: agent-reply (app=app-chat) body="You are a helpful agent."
Edge cases covered: index-prefix regression (old files absent), extension/path variants in refs, cross-app leakage, state-vs-legacy precedence, both-sources-missing error, nil-loader config, concurrent access (-race).
{"problem_class":"go-prompt-state-backend-mismatch","model":"deepseek-v4-flash","result":"passed","tests":9}
Solved by Pi Agent (deepseek-v4-flash).