feat(schema): protobuf event registry + buf CI gate (#54)

- Add proto schemas in packages/shared-types/events/ (oo.events.v1):
  envelope.proto, signals.proto, integration.proto
- buf.yaml with STANDARD lint + FILE breaking-change rules
- .gitea/workflows/buf-check.yaml: lint + breaking check on every PR
  touching events/ (needs a Gitea Actions runner to execute)
- scripts/buf-check.sh: local equivalent of the CI check
- NormalizedEvent TS envelope gains eventId, schemaVersion, producer
  to align with the proto Envelope message
- ml/serving/schemas.py: pydantic models mirroring the v1 proto types
- nats_consumer.py: validate payloads via pydantic instead of raw .get()

A field-rename PR will now fail buf breaking with exit code 100 and
show the offending messages. To make a breaking change: keep the old
field reserved, add the new one, bump schema_version to v2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:48:24 +00:00
parent f48b5a7646
commit d539fde0c1
10 changed files with 213 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
version: v1
lint:
use:
- STANDARD
breaking:
use:
- FILE

View File

@@ -0,0 +1,25 @@
syntax = "proto3";
package oo.events.v1;
import "oo/events/v1/signals.proto";
import "oo/events/v1/integration.proto";
// Envelope wraps every event on the bus and on NATS JetStream.
// Wire format: proto3 JSON (camelCase field names).
// schema_version = "v1" — bump to "v2" only for breaking payload changes.
message Envelope {
string event_id = 1; // UUID assigned by bus on publish
string occurred_at = 2; // ISO 8601
string schema_version = 3; // "v1"
string producer = 4; // e.g. "services/api"
string subject = 5; // NATS-style subject: domain.entity.verb
uint64 seq = 6; // monotonic sequence from the bus ring
oneof payload {
TaskSyncedPayload task_synced = 10;
TipServedPayload tip_served = 11;
TipFeedbackPayload tip_feedback = 12;
TipRewardFailedPayload tip_reward_failed = 13;
IntegrationTokenExpiredPayload integration_token_expired = 14;
}
}

View File

@@ -0,0 +1,9 @@
syntax = "proto3";
package oo.events.v1;
// subject: signals.integration.token_expired
message IntegrationTokenExpiredPayload {
string user_id = 1;
string provider = 2;
string detected_at = 3; // ISO 8601
}

View File

@@ -0,0 +1,39 @@
syntax = "proto3";
package oo.events.v1;
// subject: signals.task.synced
message TaskSyncedPayload {
string user_id = 1;
string source = 2; // e.g. "todoist"
int32 count = 3;
string synced_at = 4; // ISO 8601
}
// subject: signals.tip.served
message TipServedPayload {
string user_id = 1;
string tip_id = 2;
string policy = 3;
string served_at = 4; // ISO 8601
}
// subject: signals.tip.feedback
// action: done | dismiss | snooze | helpful | not_helpful
message TipFeedbackPayload {
string user_id = 1;
string tip_id = 2;
string action = 3;
double reward = 4;
optional int64 dwell_ms = 5; // null when no dwell was recorded
string created_at = 6; // ISO 8601
}
// subject: signals.tip.reward_failed
message TipRewardFailedPayload {
string user_id = 1;
string tip_id = 2;
double reward = 3;
int32 attempts = 4;
string error = 5;
string failed_at = 6; // ISO 8601
}

View File

@@ -15,7 +15,9 @@
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"buf:lint": "buf lint events",
"buf:breaking": "buf breaking events --against '.git#branch=main,subdir=packages/shared-types/events'"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.4",

View File

@@ -1,6 +1,6 @@
/**
* NormalizedEvent — the durable envelope for all events flowing through
* the system. Today: in-process EventEmitter. Tomorrow: NATS JetStream.
* the system. Mirrors oo.events.v1.Envelope in packages/shared-types/events/.
*
* Subject taxonomy:
* signals.task.synced — Todoist (or other source) task list refreshed
@@ -10,10 +10,16 @@
* signals.integration.token_expired — OAuth token needs reconnect
*/
export interface NormalizedEvent<T = unknown> {
/** UUID assigned by bus on publish */
eventId: string;
/** NATS-style subject: domain.entity.verb */
subject: string;
/** ISO 8601 timestamp */
ts: string;
occurredAt: string;
/** "v1" — bump for breaking payload changes; see packages/shared-types/events/ */
schemaVersion: 'v1';
/** e.g. "services/api" */
producer: string;
/** Monotonically increasing sequence number (in-process ring; JetStream seq in prod) */
seq: number;
payload: T;