Skip to content

Pre-flight crashes when the checked material contains a brace literal #6

Description

@bweinzierl

Summary

extractJSONObject picks the first brace group in the model's response,
including braces that appear in prose. When the entry text (or the text of a
--closes target) contains a brace literal such as User{ID: userID}, the
model quotes it, extraction latches onto that group instead of the real JSON
object, and json.Unmarshal fails. Pre-flight then aborts with a JSON parse
error and the entry cannot be written at all.

This is not an LLM quality problem — the checker does its job. The extraction
step picks the wrong candidate.

Environment

  • sdd 0.16.2 (latest stable at time of report); defect also present in main
    (441 commits ahead of v0.16.2, verified by reading the source)
  • macOS 26.4, arm64
  • LLM provider anthropic, model claude-sonnet-4-6
  • Also reproducible with a plain --dry-run, so no write is needed to trigger it

Reproduction

Any brace literal in the entry description is enough:

  sdd new s ops "The config reads Options{Zebra: true}." \
    --kind done --closes <any-entry-id> --dry-run

  ✕ pre-flight error: parsing pre-flight result: parsing pre-flight JSON:
    invalid character 'Z' looking for beginning of object key string

Evidence that the wrong group is parsed

The character named in the error tracks the first character after the brace
in the input text — proof that the parsed group is the literal from the material
under review, not the checker's JSON object:

Brace literal in the text Reported error
User{ID: userID} invalid character 'I'
Options{Zebra: true} invalid character 'Z'

Control cases, same command shape, all fine — pre-flight runs and returns
proper findings:

Case Result
No brace literal anywhere, --closes a gap ✓ findings returned
No brace literal, --closes a plan (5 ACs) ✓ findings returned, ACs checked
No brace literal, --closes a plan (9 ACs) ✓ findings returned, ACs checked

Root cause

internal/llm/preflight.go

  // extractJSONObject returns the first balanced {...} in the input, skipping
  // any surrounding prose or code fences.
  func extractJSONObject(output string) (string, error) {
      ...
      start := strings.Index(output, "{")   // line 590

The brace matcher is string-escape aware, so {Zebra: true} balances cleanly
and is returned as the "JSON object". parsePreflightResult then unmarshals it
and fails at line 553.

The doc comment's promise — "skipping any surrounding prose" — does not hold for
prose that itself contains braces, which is exactly what happens when the
checker quotes code from the entry it is reviewing.

Per the comment, extractJSONObject is shared by every JSON-shaped LLM check
(pre-flight and writing guide), so the blast radius is wider than pre-flight.

Impact

Any entry whose own body — or whose closes/supersedes target's body —
contains a brace literal is unwritable through the normal path. Go composite
literals, JS/TS object literals and Rust struct literals all trigger it, which
makes it likely in exactly the entries a code-focused graph wants to record.

When the trigger sits in an already-written immutable entry — an acceptance
criterion quoting a struct literal, say — rewording is not an option: every
future entry closing that target fails deterministically. The only way out is
--skip-preflight, which discards the validation entirely for an entry that has
no actual problem.

Suggested fix

Try candidate brace groups until one unmarshals into the expected shape, instead
of committing to the first:

  • iterate over each { position, extract the balanced group, attempt
    json.Unmarshal into the target struct, and return the first that succeeds;
  • or prefer the last balanced group, since models put prose before the
    payload, not after;
  • or require the payload to carry the expected top-level key (findings) and
    skip groups that do not.

The third point matters on its own: a bare {} in the preamble decodes without
complaint, so a candidate loop that accepts the first decodable group would
report a clean pass and let the entry through unchecked. Requiring the key
avoids that.

Secondary: the failure is not diagnosable

Two things made this take much longer than it should have:

  1. The raw model response is nowhere. .sdd/stats/llm.jsonl records only
    op, provider, model, token counts and duration. --extra-verbose adds
    nothing either — the response body is never logged, so from the outside the
    error looks like a broken LLM or a broken key.
  2. The error surfaces as a bare parse error. parsing pre-flight JSON: invalid character 'Z' looking for beginning of object key string gives no
    hint that a candidate selection went wrong. Including the offending
    extracted snippet in the error would point straight at the cause:
    parsing pre-flight JSON: invalid character 'Z' ... (extracted candidate: "{Zebra: true}").

Both would also help when a checker genuinely misbehaves, which is
indistinguishable from this bug today.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions