Guidance for Claude Code when working on gobdotnet. Read this before making changes.
- Read
PROGRESS.mdfirst. It tracks which phase is active, what's done, and what the next session should start with. Work sessions may be interrupted by quota limits or handoffs — the progress file is how continuity is maintained. - Skim
PRD.mdsections relevant to the current phase. - Before ending a session, update
PROGRESS.md:- Update "Current state" at the top.
- Check off completed acceptance items in the active phase.
- Append a session handoff entry at the bottom.
- If you discovered work that doesn't fit a phase, log it under "Discovered work".
Do not skip ahead of the current phase. Each phase depends on the previous one being solid. If Phase 1 codec tests aren't passing, don't start Phase 2 wire types.
Pure C# port of Go's encoding/gob binary serialization format. Sister library to pygob (Python). Full PRD lives in PRD.md — read it before making architectural decisions.
- Target: .NET 10, no external runtime dependencies, AOT-compatible.
- Test runner: xUnit.
FsCheck.Xunitfor property tests.Xunit.SkippableFactfor Go cross-validation. - Wire format version: Go 1.22. New wire types (rare) go in minor versions.
- Performance target: within 2× of Newtonsoft.Json. We do NOT compete with MessagePack-CSharp.
GobDotNet/ # The library
GobDotNet.SourceGenerators/ # [GobStruct] → compile-time schema (netstandard2.0)
GobDotNet.Tests/ # xUnit, property tests, Go cross-validation
testdata/ # .gob + .json sidecars — copied from pygob, evolves here
go_verify/main.go # Copied from pygob, evolves here
generate_testdata.go # Copied from pygob, evolves here
GobDotNet.Benchmarks/ # BenchmarkDotNet vs. Newtonsoft.Json
PRD.md # Design doc — authoritative
PROGRESS.md # Phase tracker — read and update every session
CLAUDE.md # This file
The testdata/ and go_verify/ files were copied from pygob at project start and evolve independently. Do not try to sync them back.
These are non-negotiable. If a change seems to require violating one, stop and ask.
- Wire fidelity is the top priority. Any byte stream Go's encoder produces must decode correctly; any byte stream we produce must decode in Go. If a round-trip test passes but
go_verifyfails, the bug is ours. - Thread safety is expected.
GobEncoderandGobDecoderserialize concurrent calls via an internal lock. Don't add APIs that assume single-threaded use. - Caller owns streams.
GobEncoderandGobDecoderdo NOT implementIDisposable. Do not addDispose()methods. EndOfStreamExceptionfor EOS.Decode()throws;TryDecodereturnsfalse. Never swallow EOS silently.- No external runtime dependencies. BCL only for the main library. Test and benchmark projects can depend on xUnit, FsCheck, BenchmarkDotNet, Newtonsoft.Json.
- No
BinaryReader/BinaryWriter— they default to little-endian. Use the manualGobReader/GobWriterbuilt onSpan<byte>. - Source generator is the primary path for
[GobStruct]. Reflection is the documented fallback.
Bugs here are silent and expensive. Every change that touches encoding or decoding needs to keep these in mind:
- First field delta is 1, not 0. Field indices start at -1. Delta=0 is the struct terminator.
- Collection types have empty
CommonType.Name. FieldIdarrives with delta=2 (skipping the omittedName). - Zero-valued fields are omitted on the wire and pre-populated with zero values on decode.
- Floats are byte-reversed IEEE 754 then encoded as unsigned int. This is for trailing-zero compression.
- Top-level non-struct values are singleton-wrapped:
0x00 value. The0x00precedes the value. - User type IDs start at 65. Go's pre-decrement allocates the first type as 64; we match Go's stated constant for determinism.
- Interface inline type defs end on a positive type ID, not EOF and not
0x00. The positive int IS the concrete value's type ID. - Interface concrete values have an inner
uint Nbyte-count wrapper inside the message payload. Different from top-level struct framing. - Nested struct fields are unwrapped — no type-def, no byte-count prefix, just raw delta-encoded bytes +
0x00. BinaryMarshalertype names on the wire are unqualified ("Time","UUID") — from Go'sreflect.Type.Name(). This is distinct frominterface{}concrete-type registration, which uses qualified names ("main.Point").- Go map iteration is non-deterministic. Never byte-compare map-containing gob output. Compare decoded values structurally.
When in doubt, re-read the "Lessons Learned" section of PRD.md.
- Enable
<Nullable>enable</Nullable>and annotate every public API. - Use
recordtypes for wire type structs (value equality is useful in tests). - Use
sealedon all internal implementation classes and on public API classes where extension makes no sense. - Use
ReadOnlySpan<byte>for decode paths in codec interfaces. - Use
MemoryStreamto build outgoing gob messages — byte counts fall out naturally when you flush. - Use
(string Name, GobFieldType Type)value tuples for schema fields. No dedicatedGobFieldclass. - Collection expressions
[]and[..]where they read naturally. ArgumentNullException.ThrowIfNull(...)for parameter validation.- Exceptions:
GobDecodeException/GobEncodeExceptionfor format errors;EndOfStreamExceptionfor EOS;InvalidCastExceptionfor type mismatches inDecode<T>();GobEncodeExceptionat schema derivation if a user tries to useBigIntegeror other unsupported types.
Four layers, in order of authority:
- Go → C# decoder tests — parametrized over
testdata/*.gobwith JSON sidecar expectations. - C# → C# round-trip tests — catches asymmetric bugs.
- C# → Go
go_verifycross-validation — authoritative proof of wire compatibility. Skipped (not failed) when Go isn't onPATH. - Property-based tests via FsCheck — catches edge cases example-based tests miss.
Plus stress tests for thread safety and a dedicated benchmark suite.
dotnet test # run everything
dotnet test --filter "Category=GoVerify" # only cross-validation
dotnet test --logger "console;verbosity=detailed" # verbose
dotnet run -c Release --project GobDotNet.Benchmarks
go run tests/generate_testdata.go # regenerate fixtures
echo "" | go run ./tests/go_verify struct_simple # manual verifier checkRun go_verify tests early and often during encoder work. They catch symmetric bugs that C#-only round-trips will happily pass.
- New feature or wire-format behavior: update
PRD.mdin the same PR. The PRD is the source of truth. - Bug fix in the encoder or decoder: add a regression test under the appropriate layer AND a
go_verifytest if the bug affects cross-language output. - Changes to the source generator: run tests with both the generator path and the reflection fallback. The fallback must stay behaviorally equivalent.
- Public API additions: think about thread safety. If the new method isn't idempotent under the internal lock, say so in the xmldoc.
- Performance work: run benchmarks before and after. Commit results under
GobDotNet.Benchmarks/results/. - Any substantive session: update
PROGRESS.mdbefore finishing.
- Don't add
Dispose()to encoders/decoders. - Don't add convenience APIs that silently truncate data.
BigIntegermust fail loud. - Don't change type ID assignment strategy — byte-level test stability depends on starting at 65.
- Don't use
BinaryReader/BinaryWriter. - Don't accept a "round-trip passes in C#" as sufficient evidence — always verify with
go_verifyfor encoder changes. - Don't add async APIs (
EncodeAsync,DecodeAsync) — explicitly out of scope. If you think the library needs them, open an issue first. - Don't reintroduce a
GobUIntwrapper type. Nativeulongis sufficient. - Don't quietly "fix" observed wire-format quirks. If the Go source does it, we do it — that's the point.
- Don't skip phases in
PROGRESS.md. If you think a phase needs reordering, surface the choice rather than silently rearranging.
PRD.md— authoritative design doc.PROGRESS.md— phase tracker, updated every session.encoding/gobin the Go source tree — ultimate source of truth for wire format.pygobrepo — sister library, useful for comparing design choices and test coverage.