◐ Off-By-One · answer catalog

go-template-expansion-missing-key-silent-empty

2 answer(s)gogo1.26gogo1.26

Repo: <project> @ 934af1a · Package: internal/webhook · Go 1.26

📦 Source in repository (JSON)

Answer 1

I have the repo, implemented the fix, and verified it. Here is the solution document.


DF-CRIER-279 — A missing {{path}} must fail the render, not silently deliver an empty body

Repo: &lt;project&gt; @ 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


1. Root cause

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 &lt;project&gt;.session_id / &lt;project&gt;.thread_id fields, which carry json:",omitempty" and so are absent from the built context when empty.

2. The fix

2a. 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
}

2b. Declare the optional fields explicitly (hermes-http-gateway)

  "session_id": "{{&lt;project&gt;.session_id|default:}}",
  "thread_id":  "{{&lt;project&gt;.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.

2c. schema_test.go fixture

The existing TestTemplate_BuildBody_CrierSessionThreadExpansion encoded the old silent-empty contract for {{&lt;project&gt;.session_id}}/{{&lt;project&gt;.thread_id}}; update that fixture to use |default: (and keep payload.text present in the empty-envelope case).

2d. Why no separate plumbing is needed

BuildBody's error already flows through the existing failure paths:

3. Verification

Applied and run in /workspace/&lt;project&gt;:

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"

4. Follow-up (stale prose)

Two docs still describe the pre-fix trap and should be updated to state the new fail-loud contract:

5. Self-contained patch

--- a/internal/webhook/schema.go
+++ b/internal/webhook/schema.go
@@ import (
    "encoding/json"
+   "errors"
    "fmt"
@@ hermesGateway
   "stream": false,
-  "session_id": "{{&lt;project&gt;.session_id}}",
-  "thread_id": "{{&lt;project&gt;.thread_id}}"
+  "session_id": "{{&lt;project&gt;.session_id|default:}}",
+  "thread_id": "{{&lt;project&gt;.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 & signatures

# 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"}

Answer 2

I have the repo, implemented the fix, and verified it. Here is the solution document.


DF-CRIER-279 — A missing {{path}} must fail the render, not silently deliver an empty body

Repo: &lt;project&gt; @ 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


1. Root cause

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 &lt;project&gt;.session_id / &lt;project&gt;.thread_id fields, which carry json:",omitempty" and so are absent from the built context when empty.

2. The fix

2a. 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
}

2b. Declare the optional fields explicitly (hermes-http-gateway)

  "session_id": "{{&lt;project&gt;.session_id|default:}}",
  "thread_id":  "{{&lt;project&gt;.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.

2c. schema_test.go fixture

The existing TestTemplate_BuildBody_CrierSessionThreadExpansion encoded the old silent-empty contract for {{&lt;project&gt;.session_id}}/{{&lt;project&gt;.thread_id}}; update that fixture to use |default: (and keep payload.text present in the empty-envelope case).

2d. Why no separate plumbing is needed

BuildBody's error already flows through the existing failure paths:

3. Verification

Applied and run in /workspace/&lt;project&gt;:

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"

4. Follow-up (stale prose)

Two docs still describe the pre-fix trap and should be updated to state the new fail-loud contract:

5. Self-contained patch

--- a/internal/webhook/schema.go
+++ b/internal/webhook/schema.go
@@ import (
    "encoding/json"
+   "errors"
    "fmt"
@@ hermesGateway
   "stream": false,
-  "session_id": "{{&lt;project&gt;.session_id}}",
-  "thread_id": "{{&lt;project&gt;.thread_id}}"
+  "session_id": "{{&lt;project&gt;.session_id|default:}}",
+  "thread_id": "{{&lt;project&gt;.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 & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog