Skip to content

A Go provider that finds nothing cannot say so, and its error codes go missing #155

Description

@macanderson

The problem

Four divergences between the Go SDK and the Rust reference. Two of them make an
ordinary Go provider unusable against the reference host; none of them is
visible to CI, because the one example provider the sdk-go job runs happens to
avoid all four.

1. An empty result serialises as "frames": null, which the reference host rejects

sdk/go/contextgraph/types.go:

type ContextQueryResult struct {
	Frames          []ContextFrame `json:"frames"`
	Truncated       bool           `json:"truncated"`
	DroppedEstimate *uint32        `json:"dropped_estimate,omitempty"`
}

encoding/json marshals a nil slice to null, not []. Proved:

$ go run …   // same struct shape
nil slice marshals to: {"frames":null,"truncated":false}

Nothing in provider.go's handleLine or http.go's RespondToBody
normalises it. On the Rust side, contextgraph-types/src/query.rs declares:

pub struct ContextQueryResult {
    pub frames: Vec<ContextFrame>,

with no #[serde(default)] and no custom deserializer, so "frames": null is a
deserialisation error rather than an empty result.

The trigger is the most ordinary case there is: a provider that finds no matches
and returns cg.ContextQueryResult{}, or builds var frames []cg.ContextFrame
and never appends. "I have nothing relevant" is an explicitly permitted answer —
check_frames in contextgraph-conformance/src/lib.rs returns pass with
"provider returned 0 frames (permitted — nothing relevant to the probe)" — and
in Go it is the one answer that cannot be delivered.

The same hazard applies to VerifyResponse.Verdicts for a user-supplied
Verifier. The SDK's own no-verify fallback is safe because it uses
make([]FrameVerdict, len(...)).

2. errors.As silently drops the error code for the idiomatic Go spelling

sdk/go/contextgraph/provider.go's handleLine, and the same block in
http.go's handleEnvelope:

			reply := errorReply{Type: "error", Message: err.Error(), ID: envelope.ID}
			var pe ProviderError
			if errors.As(err, &pe) {
				reply.Code = pe.Code
			}

ProviderError.Error() has a value receiver, so both ProviderError and
*ProviderError satisfy error and both compile as a Query return. But
errors.As with a *ProviderError target only matches a value. Proved:

errors.As(value):   true   code: bad_request
errors.As(pointer): false  code: ""

return nil, &cg.ProviderError{Code: "bad_request", …} — the spelling most Go
authors reach for, and the one go vet will not question — produces a codeless
error envelope. check_embedding_fingerprint reads that as "not
bad_request" and fails §E1. The author sees a conformance failure with no
indication that the & is the cause.

3. ContextFrame has no embedding field at all

The Go ContextFrame carries every other frame field — content_ref,
transform, minimum_content_fidelity, canonical_token_cost,
tokenizer_ref, relations, and the rest — and has no Embedding. There is no
FrameEmbedding type in the package.

contextgraph-types/src/frame.rs has pub embedding: Option<FrameEmbedding>,
schema/contextgraph-envelope.schema.json defines FrameEmbedding and puts it
on the frame, and both the TypeScript and Python SDKs expose it. So a Go
provider can declare Capabilities.EmbeddingsFingerprint — the bundled example
does — and can never attach a vector to a frame it returns.

4. Go's §E1 check accepts a present-but-empty embedding the other three reject

sdk/go/examples/example-docs/main.go:

	if n := len(query.Embedding); n > 0 && n != embeddingDimensions {

Rust's embedding_dimension_error treats Some(vec![]) as 0 ≠ 384 and rejects
it. TypeScript uses embedding !== undefined && embedding.length !== …; Python
uses embedding is not None and len(embedding) != …. Only Go lets an empty
vector through.

The root cause is structural rather than a slip: ContextQuery.Embedding []float64 with omitempty cannot distinguish absent from empty, so a Go
provider has no way to implement the rule as the other three do. Worth deciding
whether the fix is a *[]float64, or whether §E1 should be reworded so an empty
vector and an absent one mean the same thing everywhere.

Why CI does not see any of it

.github/workflows/ci.yml's sdk-go and sdk-go-http jobs run
sdk/go/examples/example-docs, which returns two hard-coded frames on every
query (never empty), returns a value ProviderError (never a pointer), declares
an embedding fingerprint it never attaches a vector for, and is probed with a
384-length embedding (never an empty one). Each defect sits exactly outside the
one path the example exercises.

What I verified vs. inferred

Verified by executing Go: the nil-slice-to-null marshalling and the
errors.As value-vs-pointer miss, both reproduced with a standalone program
using the same type shapes and the same value-receiver Error() method.

Verified by reading, on origin/main at a01ca64: the four Go source sites
quoted; ProviderError's value receiver in sdk/go/contextgraph/provider.go;
the absence of any embedding field in Go's ContextFrame; the Rust
ContextQueryResult declaration; the three languages' §E1 predicates; and the
sdk-go / sdk-go-http job definitions.

Inferred, not verified: that serde rejects "frames": null for a
Vec<ContextFrame> without #[serde(default)]. That is serde's documented
behaviour for a sequence type, but I did not run the round-trip. A fixer should
confirm it with one test before deciding whether item 1 needs a Go-side fix, a
Rust-side #[serde(default)], or both.

What "done" looks like

  • A Go provider returning no frames produces "frames": []. The cheapest
    durable fix is for handleLine and RespondToBody to normalise a nil
    Frames (and Verdicts) to an empty slice before marshalling, so no provider
    author has to remember. Whether the Rust side should also accept null
    defensively is a separate call — accepting it is more forgiving, rejecting it
    keeps the wire unambiguous.
  • The code survives a *ProviderError. Either give Error() a pointer
    receiver and match on *ProviderError, or check both targets. Document which
    spelling is supported in the type's doc comment, because right now nothing
    tells an author the & matters.
  • Go's ContextFrame carries embedding, or the SDK's README says plainly that
    it does not and why.
  • The empty-vector §E1 divergence is resolved in one direction, in the SPEC or
    in the SDK, so all four implementations agree.
  • Witnesses: a Go test that marshals an empty ContextQueryResult and asserts
    "frames":[]; a Go test that returns &ProviderError{Code: "bad_request"}
    and asserts the reply carries the code. Both belong in sdk/go's own tests,
    not only in the conformance run.
  • Consider making the bundled Go example return zero frames for a query it
    cannot serve, so the conformance run exercises the empty case at all.

Constraints

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1This cycle

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions