Working with packages/contracts

The single source of truth for every event and DTO that crosses a service boundary. If the API speaks to the edge in TypeScript while the edge speaks Python and the supervisor speaks Go, something has to keep all three telling the same story. This package is that something.

What’s in here

packages/contracts/
├── proto/wiqaia/                  → .proto SCHEMAS (the source of truth)
│   ├── common/v1/types.proto         shared enums, BoundingBox, TenantContext, etc.
│   └── events/v1/                    one .proto per event family
│       ├── frame.proto               FrameEvent
│       ├── analysis.proto            AnalysisEvent + AnalysisType / EscalationDecision enums
│       ├── tier2.proto               Tier2AnalysisEvent (cloud LLM verdict)
│       ├── alarm.proto               AlarmEvent (fire panel)
│       ├── alert.proto               Alert + AlertAction + EvidenceItem
│       ├── telemetry.proto           EdgeTelemetryEvent
│       └── audit.proto               AuditLogEntry (hash-chained)
├── ts/gen/                        ← TypeScript bindings (gitignored, regenerated)
├── ts/dist/                       ← compiled JS + .d.ts (gitignored, published)
├── ts/test/roundtrip.test.ts      ← vitest tests
├── py/gen/                        ← Python bindings (gitignored)
├── py/test/test_roundtrip.py      ← pytest tests
├── go/gen/                        ← Go bindings (gitignored)
├── go/test/roundtrip_test.go      ← `go test` tests
├── buf.yaml                       buf module manifest (lint rules etc.)
├── buf.gen.yaml                   codegen config (which plugins → which outputs)
└── package.json                   build / test / lint scripts

What a .proto looks like

message FrameEvent {
  string event_id = 1;
  wiqaia.common.v1.TenantContext tenant = 2;
  string camera_id = 3;
  bytes jpeg_data = 5;
  int32 width_px = 6;
  int32 height_px = 7;
  float motion_score = 8;
  optional bytes reference_frame_hash = 9;
}

It looks like a TypeScript interface had a child with a Zod schema. The numbers (= 1, = 5) are field tags — what protobuf uses on the wire to identify each field. They are load-bearing and permanent. If you rename a field, the wire format is unchanged because tag 5 still means “jpeg bytes”. If you renumber a field, every consumer that previously decoded that message will silently misinterpret the new bytes — don’t renumber. Append new fields with new tag numbers; never reuse a tag that’s been retired.

The everyday workflow

Edit a message, regenerate, test

# 1. Edit the .proto file
$EDITOR packages/contracts/proto/wiqaia/events/v1/frame.proto

# 2. Lint + regenerate all three languages + recompile TS
pnpm -F @wiqaia/contracts build

# 3. Run the 12 round-trip tests (4 per language)
pnpm -F @wiqaia/contracts test

If you only changed the proto, the bindings update automatically and any consumer with stale types gets a typecheck error on next build. That’s the point.

Add a new message

  1. Create or extend a .proto file under proto/wiqaia/events/v1/ (or common/v1/ if it’s a shared type).
  2. pnpm -F @wiqaia/contracts build regenerates everything.
  3. Add a round-trip test for the new message in all three test files. The pattern is identical: construct, encode, decode, assert equal, assert re-encode produces identical bytes.
  4. Add the cross-language identity sample below if it’s a new event family.

Add a new field to an existing message

  1. Pick the next unused tag number.
  2. Make it optional if it can ever be absent — that’s the safe default for new fields, since old clients won’t know to send it.
  3. Regenerate, run tests, commit.

Rename a field

Safe — the wire format uses tag numbers, not names. But every consumer’s code references the old name and won’t compile until updated, so plan the rename as a coordinated PR across workspaces.

Remove a field

  1. Mark the tag as reserved in the proto so it can’t be reused:
    message FrameEvent {
      reserved 5;
      reserved "jpeg_data";
      // ...
    }
  2. Delete the field.
  3. Run breaking-check (pnpm -F @wiqaia/contracts breaking-check) to confirm what you’re doing is intentional.

The toolchain — buf, bindings, what does what

buf is the modern compiler for protobuf schemas. Same role as tsc for TypeScript or protoc (the original Google CLI) for protos — except buf has saner defaults, real linting, breaking-change detection, and remote plugin support. Installed as @bufbuild/buf in this package’s devDependencies (no system install needed; pnpm handles it).

The plugin chain is in buf.gen.yaml. When you run buf generate, buf reads the .proto files and farms each one out to a per-language plugin:

PluginOutputWhy this one
buf.build/bufbuild/es:v2.2.0ts/gen/*.tsThe official protobuf-es TypeScript generator. Produces modern ESM, has the cleanest typing story.
buf.build/protocolbuffers/python:v28.2py/gen/*.py + *.pyiGoogle’s standard Python plugin. Generates the same _pb2.py files you’d see anywhere protobuf is used in Python.
buf.build/protocolbuffers/go:v1.36.0go/gen/*.pb.goGoogle’s standard Go plugin. Remote, not local — so buf generate doesn’t need a local Go install. (Running Go tests still does.)

All three are remote plugins: buf downloads them on demand from the Buf Schema Registry, no local protoc-gen-* binaries to manage.

The TS layout: ts/gen/ is what buf produces — raw TypeScript source. ts/dist/ is what tsc -p ts/tsconfig.build.json produces from it — the compiled .js + .d.ts that other workspaces actually import. The pnpm build script runs both steps. The package.json exports field points consumers at ts/dist/, so import { FrameEventSchema } from "@wiqaia/contracts/ts/wiqaia/events/v1/frame_pb" resolves correctly via the pnpm workspace symlink.

The float32 gotcha

Proto float fields are 32-bit. JavaScript numbers (and Python floats) are 64-bit. So this fails:

const original = { motionScore: 0.85 };
const decoded = fromBinary(Schema, toBinary(Schema, original));
expect(decoded.motionScore).toBe(0.85);  // FAILS — it's 0.8500000238418579

0.85 doesn’t have an exact float32 representation; the closest one is 0.8500000238418579. The encode step coerces to float32; the decode step gives you back the float32 value, which is not the JS literal you started with.

Fixes used in our tests:

// TS — Math.fround returns the closest float32
const f32 = (n: number) => Math.fround(n);
{ motionScore: f32(0.85) }  // both sides are now 0.8500000238...

// Python — struct.pack does the same
def f32(n: float) -> float:
    return struct.unpack("f", struct.pack("f", n))[0]

// Go — native float32 has no precision issue
MotionScore: 0.85  // (Go's float32 literal IS what protobuf stores)

When your test fails with “expected 0.85, received 0.8500000238…”, you’ve found this. Either use exact float32 values (0.5, 0.125, 0.875, …, any fraction with a power-of-2 denominator) or coerce literals through f32.

Testing

pnpm -F @wiqaia/contracts test       # all three: TS + Python + Go
pnpm -F @wiqaia/contracts test:ts    # vitest only
pnpm -F @wiqaia/contracts test:py    # pytest only
pnpm -F @wiqaia/contracts test:go    # go test only
pnpm -F @wiqaia/contracts test:watch # vitest in watch mode

Each language has the same 4 tests:

  1. FrameEvent — bytes payload + nested tenant + edge metadata survive round-trip. Constructs a fully-populated FrameEvent, serializes, deserializes, asserts deep equality, and asserts that re-serializing the decoded message produces byte-identical output. The bytes check is the strongest property: even if value-equality somehow passed by accident, re-encoding drift would catch it.
  2. FrameEvent — optional reference_frame_hash absence is preserved. Proto3 optional fields use field presence (vs. zero values); an absent optional must stay absent through a round-trip. If this fails, optional semantics are broken in the generator.
  3. AnalysisEvent — round-trips with enum values + nested detections. Covers enums (AnalysisType, EscalationDecision) and repeated fields (a list of detections).
  4. AuditLogEntry — hash-chain bytes survive round-trip. The audit log’s tamper-evidence depends on prev_hash and self_hash being preserved byte-for-byte. If a generator started silently re-encoding bytes fields (e.g. base64-ing them), the chain breaks. This test catches that.

When you add a new message type, add the same four kinds of test for it.

How a consumer imports

Apps import via the pnpm workspace symlink:

// apps/api/...
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
import { FrameEventSchema } from "@wiqaia/contracts/ts/wiqaia/events/v1/frame_pb";

const frame = create(FrameEventSchema, {
  eventId: "...",
  cameraId: "...",
  widthPx: 1920,
  heightPx: 1080,
  motionScore: Math.fround(0.5),  // float32 coercion
  jpegData: jpegBytes,
});
const wireBytes = toBinary(FrameEventSchema, frame);   // → Pub/Sub etc.
# apps/edge-app/...
from wiqaia.events.v1 import frame_pb2

frame = frame_pb2.FrameEvent(
    event_id="...",
    camera_id="...",
    width_px=1920,
    height_px=1080,
    motion_score=0.5,
    jpeg_data=jpeg_bytes,
)
wire_bytes = frame.SerializeToString()
// apps/edge-supervisor/...
import (
    eventsv1 "github.com/wiqaia/contracts/go/gen/wiqaia/events/v1"
    "google.golang.org/protobuf/proto"
)

frame := &eventsv1.FrameEvent{
    EventId: "...",
    CameraId: "...",
    WidthPx: 1920,
    HeightPx: 1080,
    MotionScore: 0.5,
    JpegData: jpegBytes,
}
wireBytes, _ := proto.Marshal(frame)

The point: same shape, same wire bytes, all three languages.

Breaking changes

Before merging a proto change, check that you didn’t accidentally break the wire format for existing consumers:

pnpm -F @wiqaia/contracts breaking-check

This runs buf breaking against the last released tag (or HEAD~1 if no tags). It flags renames-without-reserved, tag renumbering, type changes, required→optional flips, and other wire-incompatible edits. Treat any flag as a stop-and-think; we’re not running canary deploys yet.

When to add a runbook entry vs. update this one

  • New message type, new field, new test pattern → no new doc, just update the proto.
  • New generator plugin, new language target, change to the build pipeline → update this runbook.
  • A debugging adventure that taught you something nobody else knows → add a new section here, even one paragraph. The float32 section above started life as a debugging adventure.
WiqAIa+ Docs · Rizoma