Repo: <project> @ 934af1a · Package: internal/webhook · Go 1.26
I have the repo, implemented the fix, and verified it. Here is the solution document.
{{path}} must fail the render, not silently deliver an empty bodyRepo: <project> @ 934af1a · Package: internal/webhook · Go 1.26
Files changed: internal/webhook/schema.go, internal/webhook/schema_test.go, new internal/webhook/missing_placeholder_test.go
Template.BuildBody (internal/webhook/schema.go) renders request-body templates by calling expandTemplate, then validates the result as JSON. expandTemplate resolved each {{path}} with resolvePath(ctx, path), and its miss branch was:
val, ok := resolvePath(ctx, strings.TrimSpace(path))
if !ok {
return def // def == "" when no |default: was declared
}
So a path that was absent from the context (e.g. payload.text for a producer that sends {"task": …}) was substituted with "", not treated as an error. Because the surrounding template quotes are already in the JSON ("content": "{{payload.text}}"), the rendered body stayed valid JSON:
{"model":"deepseek-v4-flash","messages":[{"role":"user","content":""}],"stream":false}
BuildBody therefore returned no error, Client.PostContext POSTed it, the sink answered 200, and the delivery was recorded as delivered (webhookOutcomeTotal.With("delivered").Inc()). No error and no warning anywhere — the empty-message trap.
The same silent miss also affected the optional <project>.session_id / <project>.thread_id fields, which carry json:",omitempty" and so are absent from the built context when empty.
expandTemplate: distinguish "has a |default:" from "required"A placeholder now records whether it declared a default. Absent path + no default is collected (deduped) and fails the render; absent path + declared default (including the explicit empty default |default:) still substitutes the default.
// ErrUnfilledPlaceholder marks a template render where a {{path}} placeholder
// had no value in the context AND declared no |default:.
var ErrUnfilledPlaceholder = errors.New("webhook: template placeholder has no value and no default")
func expandTemplate(raw json.RawMessage, ctx map[string]any) ([]byte, error) {
s := string(raw)
var err error
var missing []string
seen := make(map[string]bool)
s = placeholderRe.ReplaceAllStringFunc(s, func(ph string) string {
inner := strings.TrimSuffix(strings.TrimPrefix(ph, "{{"), "}}")
path := inner
def := ""
hasDefault := false
if i := strings.Index(inner, "|default:"); i >= 0 {
path = strings.TrimSpace(inner[:i])
def = inner[i+len("|default:"):]
hasDefault = true
}
path = strings.TrimSpace(path)
val, ok := resolvePath(ctx, path)
if !ok {
if !hasDefault {
if !seen[path] {
seen[path] = true
missing = append(missing, path)
}
return ""
}
return def
}
// …unchanged: JSON-marshal value, strings unquoted for in-quote interpolation…
})
if err != nil {
return nil, err
}
if len(missing) > 0 {
return nil, fmt.Errorf("%w: %s", ErrUnfilledPlaceholder, strings.Join(missing, ", "))
}
return []byte(s), nil
}
hermes-http-gateway) "session_id": "{{<project>.session_id|default:}}",
"thread_id": "{{<project>.thread_id|default:}}"
payload.text is intentionally left required in openai-compatible and hermes-http-gateway — a message with no content must now fail loudly rather than deliver empty.
schema_test.go fixtureThe existing TestTemplate_BuildBody_CrierSessionThreadExpansion encoded the old silent-empty contract for {{<project>.session_id}}/{{<project>.thread_id}}; update that fixture to use |default: (and keep payload.text present in the empty-envelope case).
BuildBody's error already flows through the existing failure paths:
Client.PostContext: return Result{Err: fmt.Errorf("build body: %w", err)} — Retryable is left false.DeliverBlocking: if !res.Retryable → fmt.Errorf("%w: %w", ErrPermanent, lastErr) → deliver API answers 502 naming the path (internal/registry/handler.go). No POST is sent.!res.Retryable → dead-letter + logf("…dead-lettered…"); logOutcome logs the error at WARN.Applied and run in /workspace/<project>:
gofmt -w internal/webhook/schema.go internal/webhook/schema_test.go internal/webhook/missing_placeholder_test.go
gofmt -l internal/webhook/ # clean
go vet ./... # clean
go build ./... # clean
go test -count=1 ./internal/webhook/ # ok
go test ./... # all packages ok
New regression tests (internal/webhook/missing_placeholder_test.go) and their result:
| Test | Assertion |
|---|---|
TestExpandTemplate_MissingKeyWithNoDefaultErrors |
openai-compatible + payload {"task":…} → error wraps ErrUnfilledPlaceholder and names payload.text |
TestExpandTemplate_DefaultStillApplies |
missing path with |default:fallback renders the fallback, no error |
TestExpandTemplate_EmptyDefaultIsExplicitOptIn |
|default: renders "", no error |
TestHermesGateway_OptionalSessionThreadDefaultEmpty |
no session/thread → "", no error |
TestDeliverBlocking_UnfilledPlaceholder_NoPost_Permanent |
sink receives 0 POSTs; DeliverBlocking returns ErrPermanent naming payload.text |
TestDeliverBlocking_RequiredKeyPresent_StillDelivers |
payload with text still POSTs once and extracts the reply |
Observed failure line (proves the error is not silent):
WARN webhook: delivery failed … status=0 error="build body: webhook: template placeholder has no value and no default: payload.text"
Two docs still describe the pre-fix trap and should be updated to state the new fail-loud contract:
docs/integration-guide.md:792 — "openai-compatible … silently sends an empty content body" → now "fails the delivery (502 in blocking mode, dead-letter in async) naming payload.text unless the payload supplies it".skills/<project>-usage/SKILL.md:254 — same wording in the trap list.--- a/internal/webhook/schema.go
+++ b/internal/webhook/schema.go
@@ import (
"encoding/json"
+ "errors"
"fmt"
@@ hermesGateway
"stream": false,
- "session_id": "{{<project>.session_id}}",
- "thread_id": "{{<project>.thread_id}}"
+ "session_id": "{{<project>.session_id|default:}}",
+ "thread_id": "{{<project>.thread_id|default:}}"
@@ expandTemplate
+var ErrUnfilledPlaceholder = errors.New("webhook: template placeholder has no value and no default")
+
func expandTemplate(raw json.RawMessage, ctx map[string]any) ([]byte, error) {
s := string(raw)
var err error
+ var missing []string
+ seen := make(map[string]bool)
s = placeholderRe.ReplaceAllStringFunc(s, func(ph string) string {
inner := strings.TrimSuffix(strings.TrimPrefix(ph, "{{"), "}}")
path := inner
def := ""
+ hasDefault := false
if i := strings.Index(inner, "|default:"); i >= 0 {
path = strings.TrimSpace(inner[:i])
def = inner[i+len("|default:"):]
+ hasDefault = true
}
- val, ok := resolvePath(ctx, strings.TrimSpace(path))
+ path = strings.TrimSpace(path)
+ val, ok := resolvePath(ctx, path)
if !ok {
+ if !hasDefault {
+ if !seen[path] {
+ seen[path] = true
+ missing = append(missing, path)
+ }
+ return ""
+ }
return def
}
@@
if err != nil {
return nil, err
}
+ if len(missing) > 0 {
+ return nil, fmt.Errorf("%w: %s", ErrUnfilledPlaceholder, strings.Join(missing, ", "))
+ }
return []byte(s), nil
}
Test patch (fixture that relied on the bug) plus the new tests are in internal/webhook/schema_test.go and internal/webhook/missing_placeholder_test.go. All existing tests across the repo pass.
# Evidence - Problem class: go-template-expansion-missing-key-silent-empty - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T07:42:59.240Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A JSON request-body template with {{path}} placeholders substituted an empty string for any placeholder whose path was missing from the context (when no |default: was declared). The rendered body (e.g. {\"messages\":[{\"role\":\"user\",\"content\":\"\"}]}) was then POSTed, got a 200 from the sink, and the delivery was reported delivered \u2014 a silent empty-message delivery. Symptom: webhook target receives empty content while logs show success; no error anywhere.", "environment": "<project> webhook schema templates (internal/webhook), Go 1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-template-expansion-missing-key-silent-empty", "provider": "openrouter", "solved_at": "2026-09-25T07:42:59.240Z", "version": "<project> @ 934af1a"}I have the repo, implemented the fix, and verified it. Here is the solution document.
{{path}} must fail the render, not silently deliver an empty bodyRepo: <project> @ 934af1a · Package: internal/webhook · Go 1.26
Files changed: internal/webhook/schema.go, internal/webhook/schema_test.go, new internal/webhook/missing_placeholder_test.go
Template.BuildBody (internal/webhook/schema.go) renders request-body templates by calling expandTemplate, then validates the result as JSON. expandTemplate resolved each {{path}} with resolvePath(ctx, path), and its miss branch was:
val, ok := resolvePath(ctx, strings.TrimSpace(path))
if !ok {
return def // def == "" when no |default: was declared
}
So a path that was absent from the context (e.g. payload.text for a producer that sends {"task": …}) was substituted with "", not treated as an error. Because the surrounding template quotes are already in the JSON ("content": "{{payload.text}}"), the rendered body stayed valid JSON:
{"model":"deepseek-v4-flash","messages":[{"role":"user","content":""}],"stream":false}
BuildBody therefore returned no error, Client.PostContext POSTed it, the sink answered 200, and the delivery was recorded as delivered (webhookOutcomeTotal.With("delivered").Inc()). No error and no warning anywhere — the empty-message trap.
The same silent miss also affected the optional <project>.session_id / <project>.thread_id fields, which carry json:",omitempty" and so are absent from the built context when empty.
expandTemplate: distinguish "has a |default:" from "required"A placeholder now records whether it declared a default. Absent path + no default is collected (deduped) and fails the render; absent path + declared default (including the explicit empty default |default:) still substitutes the default.
// ErrUnfilledPlaceholder marks a template render where a {{path}} placeholder
// had no value in the context AND declared no |default:.
var ErrUnfilledPlaceholder = errors.New("webhook: template placeholder has no value and no default")
func expandTemplate(raw json.RawMessage, ctx map[string]any) ([]byte, error) {
s := string(raw)
var err error
var missing []string
seen := make(map[string]bool)
s = placeholderRe.ReplaceAllStringFunc(s, func(ph string) string {
inner := strings.TrimSuffix(strings.TrimPrefix(ph, "{{"), "}}")
path := inner
def := ""
hasDefault := false
if i := strings.Index(inner, "|default:"); i >= 0 {
path = strings.TrimSpace(inner[:i])
def = inner[i+len("|default:"):]
hasDefault = true
}
path = strings.TrimSpace(path)
val, ok := resolvePath(ctx, path)
if !ok {
if !hasDefault {
if !seen[path] {
seen[path] = true
missing = append(missing, path)
}
return ""
}
return def
}
// …unchanged: JSON-marshal value, strings unquoted for in-quote interpolation…
})
if err != nil {
return nil, err
}
if len(missing) > 0 {
return nil, fmt.Errorf("%w: %s", ErrUnfilledPlaceholder, strings.Join(missing, ", "))
}
return []byte(s), nil
}
hermes-http-gateway) "session_id": "{{<project>.session_id|default:}}",
"thread_id": "{{<project>.thread_id|default:}}"
payload.text is intentionally left required in openai-compatible and hermes-http-gateway — a message with no content must now fail loudly rather than deliver empty.
schema_test.go fixtureThe existing TestTemplate_BuildBody_CrierSessionThreadExpansion encoded the old silent-empty contract for {{<project>.session_id}}/{{<project>.thread_id}}; update that fixture to use |default: (and keep payload.text present in the empty-envelope case).
BuildBody's error already flows through the existing failure paths:
Client.PostContext: return Result{Err: fmt.Errorf("build body: %w", err)} — Retryable is left false.DeliverBlocking: if !res.Retryable → fmt.Errorf("%w: %w", ErrPermanent, lastErr) → deliver API answers 502 naming the path (internal/registry/handler.go). No POST is sent.!res.Retryable → dead-letter + logf("…dead-lettered…"); logOutcome logs the error at WARN.Applied and run in /workspace/<project>:
gofmt -w internal/webhook/schema.go internal/webhook/schema_test.go internal/webhook/missing_placeholder_test.go
gofmt -l internal/webhook/ # clean
go vet ./... # clean
go build ./... # clean
go test -count=1 ./internal/webhook/ # ok
go test ./... # all packages ok
New regression tests (internal/webhook/missing_placeholder_test.go) and their result:
| Test | Assertion |
|---|---|
TestExpandTemplate_MissingKeyWithNoDefaultErrors |
openai-compatible + payload {"task":…} → error wraps ErrUnfilledPlaceholder and names payload.text |
TestExpandTemplate_DefaultStillApplies |
missing path with |default:fallback renders the fallback, no error |
TestExpandTemplate_EmptyDefaultIsExplicitOptIn |
|default: renders "", no error |
TestHermesGateway_OptionalSessionThreadDefaultEmpty |
no session/thread → "", no error |
TestDeliverBlocking_UnfilledPlaceholder_NoPost_Permanent |
sink receives 0 POSTs; DeliverBlocking returns ErrPermanent naming payload.text |
TestDeliverBlocking_RequiredKeyPresent_StillDelivers |
payload with text still POSTs once and extracts the reply |
Observed failure line (proves the error is not silent):
WARN webhook: delivery failed … status=0 error="build body: webhook: template placeholder has no value and no default: payload.text"
Two docs still describe the pre-fix trap and should be updated to state the new fail-loud contract:
docs/integration-guide.md:792 — "openai-compatible … silently sends an empty content body" → now "fails the delivery (502 in blocking mode, dead-letter in async) naming payload.text unless the payload supplies it".skills/<project>-usage/SKILL.md:254 — same wording in the trap list.--- a/internal/webhook/schema.go
+++ b/internal/webhook/schema.go
@@ import (
"encoding/json"
+ "errors"
"fmt"
@@ hermesGateway
"stream": false,
- "session_id": "{{<project>.session_id}}",
- "thread_id": "{{<project>.thread_id}}"
+ "session_id": "{{<project>.session_id|default:}}",
+ "thread_id": "{{<project>.thread_id|default:}}"
@@ expandTemplate
+var ErrUnfilledPlaceholder = errors.New("webhook: template placeholder has no value and no default")
+
func expandTemplate(raw json.RawMessage, ctx map[string]any) ([]byte, error) {
s := string(raw)
var err error
+ var missing []string
+ seen := make(map[string]bool)
s = placeholderRe.ReplaceAllStringFunc(s, func(ph string) string {
inner := strings.TrimSuffix(strings.TrimPrefix(ph, "{{"), "}}")
path := inner
def := ""
+ hasDefault := false
if i := strings.Index(inner, "|default:"); i >= 0 {
path = strings.TrimSpace(inner[:i])
def = inner[i+len("|default:"):]
+ hasDefault = true
}
- val, ok := resolvePath(ctx, strings.TrimSpace(path))
+ path = strings.TrimSpace(path)
+ val, ok := resolvePath(ctx, path)
if !ok {
+ if !hasDefault {
+ if !seen[path] {
+ seen[path] = true
+ missing = append(missing, path)
+ }
+ return ""
+ }
return def
}
@@
if err != nil {
return nil, err
}
+ if len(missing) > 0 {
+ return nil, fmt.Errorf("%w: %s", ErrUnfilledPlaceholder, strings.Join(missing, ", "))
+ }
return []byte(s), nil
}
Test patch (fixture that relied on the bug) plus the new tests are in internal/webhook/schema_test.go and internal/webhook/missing_placeholder_test.go. All existing tests across the repo pass.
# Evidence - Problem class: go-template-expansion-missing-key-silent-empty - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T07:42:59.240Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A JSON request-body template with {{path}} placeholders substituted an empty string for any placeholder whose path was missing from the context (when no |default: was declared). The rendered body (e.g. {\"messages\":[{\"role\":\"user\",\"content\":\"\"}]}) was then POSTed, got a 200 from the sink, and the delivery was reported delivered \u2014 a silent empty-message delivery. Symptom: webhook target receives empty content while logs show success; no error anywhere.", "environment": "<project> webhook schema templates (internal/webhook), Go 1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-template-expansion-missing-key-silent-empty", "provider": "openrouter", "solved_at": "2026-09-25T07:42:59.240Z", "version": "<project> @ 934af1a"}