Add Pub/Sub schema validation using Protocol Buffers
Background
The webhook service currently publishes raw Slack event JSON bytes to Pub/Sub. There is no schema enforcement: any payload — valid or not — is accepted by the topic, and downstream consumers have no contract to rely on.
Google Cloud Pub/Sub supports native schema validation for topics. When a schema is attached to a topic, the service rejects publishes that don't conform before they reach any subscriber.
Goal
Define a Protocol Buffer schema that mirrors the Slack event envelope, register it with the Pub/Sub topic, and make the webhook publish messages that conform to it. Wire format is JSON-encoded proto (readable during development, still schema-validated server-side).
Scope
1. Proto source
- New file:
proto/threadops/v1/slack_event.proto
syntax = "proto3", package threadops.v1
go_package = "github.com/jonaskay/threadops/internal/gen/threadops/v1;threadopsv1"
- Messages mirror the Slack event envelope structs in
services/webhook/types.go:
SlackEvent (root, equivalent to the current OuterEvent)
InnerEvent
Authorization
- Same field names and types as the Go structs.
event_time stays int64.
2. Code generation with protoc
- Generated Go into
internal/gen/threadops/v1/slack_event.pb.go
- Add
Makefile target generate invoking protoc --go_out=...
- Commit the generated
.pb.go so day-to-day builds don't require protoc
- README: document prerequisites (
protoc, protoc-gen-go) and the make generate step
3. Publisher
- Change
internal/pubsub/publisher.go:18 so Publish takes a proto.Message instead of []byte
- Marshal to JSON via
google.golang.org/protobuf/encoding/protojson inside the publisher
- Promotes
google.golang.org/protobuf to a direct dependency (already indirect)
4. Webhook handler
services/webhook/handler.go: after Slack signature verification, parse the request body with protojson.UnmarshalOptions{DiscardUnknown: true} into a SlackEvent and hand the message to the publisher (replacing the raw pub.Publish(ctx, body) at services/webhook/handler.go:33)
DiscardUnknown: true is required — Slack sends many fields we don't model
- On JSON parse error: respond
400 Bad Request
- Delete
services/webhook/types.go — replaced by the generated proto types
5. Tests
services/webhook/handler_test.go: the request body becomes a real Slack JSON payload; the fake publisher's Publish takes proto.Message; assert on message fields (e.g. TeamID, Event.Type) instead of byte equality
- Add a new case: malformed JSON → 400
6. Terraform
- New
terraform/ directory:
google_pubsub_schema.slack_event with type = "PROTOCOL_BUFFER" and definition = file("../proto/threadops/v1/slack_event.proto")
google_pubsub_topic.slack_events with schema_settings { schema = ..., encoding = "JSON" }
variables.tf (at minimum project_id), versions.tf pinning the google provider
- No backend config — leave to the operator
- README: section on applying the Terraform
Acceptance criteria
Out of scope / notes
- E2E tests:
e2e/docker-compose.yml uses the Pub/Sub emulator, which does not enforce schemas. E2E tests will keep passing unchanged but won't exercise schema validation — real validation only happens against the live GCP topic. Consider documenting this caveat in e2e/README.md.
- Domain event abstraction: This issue deliberately mirrors the Slack envelope 1:1 rather than introducing a ThreadOps-native domain event type. Decoupling from Slack's shape can be a follow-up once a real processor exists.
- Processor service:
services/processor is still a placeholder. Updating it to consume the typed messages is a separate task.
References
- Publisher:
internal/pubsub/publisher.go:13
- Publish call site:
services/webhook/handler.go:33
- Slack event Go structs:
services/webhook/types.go:3
- Topic configuration:
PUBSUB_TOPIC env var read in services/webhook/main.go:34
Add Pub/Sub schema validation using Protocol Buffers
Background
The webhook service currently publishes raw Slack event JSON bytes to Pub/Sub. There is no schema enforcement: any payload — valid or not — is accepted by the topic, and downstream consumers have no contract to rely on.
Google Cloud Pub/Sub supports native schema validation for topics. When a schema is attached to a topic, the service rejects publishes that don't conform before they reach any subscriber.
Goal
Define a Protocol Buffer schema that mirrors the Slack event envelope, register it with the Pub/Sub topic, and make the webhook publish messages that conform to it. Wire format is JSON-encoded proto (readable during development, still schema-validated server-side).
Scope
1. Proto source
proto/threadops/v1/slack_event.protosyntax = "proto3",package threadops.v1go_package = "github.com/jonaskay/threadops/internal/gen/threadops/v1;threadopsv1"services/webhook/types.go:SlackEvent(root, equivalent to the currentOuterEvent)InnerEventAuthorizationevent_timestaysint64.2. Code generation with
protocinternal/gen/threadops/v1/slack_event.pb.goMakefiletargetgenerateinvokingprotoc --go_out=....pb.goso day-to-day builds don't requireprotocprotoc,protoc-gen-go) and themake generatestep3. Publisher
internal/pubsub/publisher.go:18soPublishtakes aproto.Messageinstead of[]bytegoogle.golang.org/protobuf/encoding/protojsoninside the publishergoogle.golang.org/protobufto a direct dependency (already indirect)4. Webhook handler
services/webhook/handler.go: after Slack signature verification, parse the request body withprotojson.UnmarshalOptions{DiscardUnknown: true}into aSlackEventand hand the message to the publisher (replacing the rawpub.Publish(ctx, body)atservices/webhook/handler.go:33)DiscardUnknown: trueis required — Slack sends many fields we don't model400 Bad Requestservices/webhook/types.go— replaced by the generated proto types5. Tests
services/webhook/handler_test.go: the request body becomes a real Slack JSON payload; the fake publisher'sPublishtakesproto.Message; assert on message fields (e.g.TeamID,Event.Type) instead of byte equality6. Terraform
terraform/directory:google_pubsub_schema.slack_eventwithtype = "PROTOCOL_BUFFER"anddefinition = file("../proto/threadops/v1/slack_event.proto")google_pubsub_topic.slack_eventswithschema_settings { schema = ..., encoding = "JSON" }variables.tf(at minimumproject_id),versions.tfpinning thegoogleproviderAcceptance criteria
proto/threadops/v1/slack_event.protoexists and matches the fields currently inservices/webhook/types.gomake generateregeneratesinternal/gen/threadops/v1/slack_event.pb.goproto.Messageand publishes JSON-encoded protoSlackEventand republishes it via the typed publisherterraform applyagainst a real GCP project creates the schema and a topic bound to itOut of scope / notes
e2e/docker-compose.ymluses the Pub/Sub emulator, which does not enforce schemas. E2E tests will keep passing unchanged but won't exercise schema validation — real validation only happens against the live GCP topic. Consider documenting this caveat ine2e/README.md.services/processoris still a placeholder. Updating it to consume the typed messages is a separate task.References
internal/pubsub/publisher.go:13services/webhook/handler.go:33services/webhook/types.go:3PUBSUB_TOPICenv var read inservices/webhook/main.go:34