← 1.0.0 scope SAPE-1

Build identity: version embedded, version command, product version in the handshake

Status
Done
Version
1.0.0
Component
server, cli, protocol
Priority
Highest — unlocks SAPE-2, 3, 4, 6
Blocked by
Blocks
SAPE-2, SAPE-3, SAPE-4, SAPE-6

Description

No sapedb binary can say which build it is. Embed the version at build time, add a sapedb version command that prints it, and have the daemon state its product version during the handshake so a connected client can record it.

Why now

When something breaks, the first question anybody asks is which version is running, and today nobody can answer — not the operator, not a bug report, not a log line. It also blocks four other tickets: a compatibility policy nobody can check is not a policy, a release workflow cannot stamp what does not exist, and the upgrade rehearsal in SAPE-6 needs two builds that can tell each other apart.

Measured 2026-09-20. Welcome carries a version field, but it is the protocol version, and that is the constant 1 in internal/protocol/frame.go:23. Two builds six months apart introduce themselves identically. There is no version command in cmd/sapedb at all.

Acceptance criteria

  1. Build the binary from two different tags. Each one's sapedb version prints the tag it was built from, and the two answers differ.
  2. A connected client can read the server's product version and record it.
  3. Measured by building twice and comparing the output — not by reading the code that formats it.

How it was verified

  1. internal/build holds Version as a variable, not a constant (internal/build/build.go:21), stamped by the linker at build.Path = "github.com/sapedb/sapedb/internal/build.Version" and never assigned from inside the program — the package doc says why: "a build that decides its own version at run time is a build that cannot be identified from the outside".
  2. Criterion 1, run rather than read: TestAStampedBuildSaysWhatItWasStampedWith (version_live_test.go:60) builds the real sapedb binary twice, from two hand-written stamps that are not derived from each other or from build.Version"1.4.0-first-of-two" and "2.9.1-second-of-two" — and asserts sapedb version prints each one back, distinctly. TestAnUnstampedBuildDoesNotClaimAVersion (version_live_test.go:79) is the negative case: a build with no -X flag says dev, a word no release will ever be tagged.
  3. The command itself: internal/cli/cli.go:325-337 registers version, refuses arguments (checkNoArguments("version")), and writes fmt.Fprintf(out, "sapedb %s\n", build.Version).
  4. Criterion 2: ProductVersion is a field of Welcome (internal/server/server.go:544, json:"productVersion"), filled from build.Version when the server is not told otherwise (server.go:149-150), and asserted onto the wire itself — TestTheWelcomeCarriesTheProductVersion (internal/server/product_version_test.go:50) checks both the decoded struct field and the raw frame payload for the literal substring "productVersion":"…". TestAConnectedClientReadsTheDaemonsProductVersion (version_live_test.go:126) is the same fact from a live client's side of a real socket.
  5. TestAClientThatPredatesTheProductVersionStillReadsTheWelcome (internal/server/product_version_test.go:126) decodes today's welcome into the struct shape that existed before this field, so adding it is measured as additive rather than assumed to be.

Out of scope