Skip to content

feat(event): compile the catalog and harden the consume pipeline - #2142

Merged
liangshuo-1 merged 64 commits into
mainfrom
feat/event-arch-refactor
Aug 5, 2026
Merged

feat(event): compile the catalog and harden the consume pipeline#2142
liangshuo-1 merged 64 commits into
mainfrom
feat/event-arch-refactor

Conversation

@leave330

@leave330 leave330 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR pays down long-standing technical debt in the event module without rewriting it: it gives event metadata a single source of truth, turns EventKey registration into a compile-time step, separates the consume decision from its side effects, draws an explicit kernel/host/adapter boundary, and locks all existing behavior behind compatibility tests before any of that happened.

The resulting main line: the platform WebSocket ingress parses each envelope once into a canonical event; the local bus carries it in full; EventKey declarations compile at startup into an immutable catalog snapshot; the consume command forms a structured decision first and only then acts on it; domain processors touch only their business payload and public output.

The strategy throughout: strict contracts on the shared pipeline, restraint on legacy domain code; byte-identical normal output, explicit failure for error and conflict cases.

What was wrong

  • No single source for event facts. The ingress parsed event_id / event_type / create_time, but consumers restored only a subset from the IPC frame, so 13 domain processors re-parsed payload.header themselves — multiple copies of the same facts with no authority when they disagreed.
  • A global mutable registry. Domains registered via init() side effects; list/schema/consume/bus each read global state; validation ran per-registration, so whole-catalog invariants were never checked.
  • Declaration, runtime behavior, and output contract were entangled. KeyDefinition carried display fields, schema, delivery knobs, and function hooks all at once; a malformed payload could push the raw V2 envelope into a processed key's stdout — a shape its schema never described.
  • Decision and side effects were interleaved. Validation, identity, preflight, bus startup, consumer registration, preparation, and streaming all lived in one command path; callers had no way to preview a consume before running it.
  • No enforced dependency direction. The bus constructed the platform WebSocket source itself; protocol/transport/platform code sat next to kernel code.

Changes

The work is staged in phases, so every migration lands behind a gate that proves it changed nothing it should not have.

Architecture

Layer Directories Responsibility
Event kernel internal/event/{model,catalog,processing,application/consume} Canonical event value type; catalog compilation; processing outcome contract; consume decision. No cobra, platform SDK, network, or adapter imports.
Domain declarations events/<domain> EventKey declarations, business payload projection, public outputs; existing Process/Match/NormalizeParams/PreConsume hooks unchanged.
Runtime hosts internal/event/{bus,consume} Daemon, connections, concurrency, backpressure, handshake, workers, sinks, lifecycle.
Adapters internal/event/adapter/{lark,localbus} Feishu WebSocket ingress; local IPC protocol, transport, discovery, control plane.
Composition root cmd/event Compiles the catalog, constructs and injects the source, maps flags to application requests.

Architecture tests pin the direction: kernel packages cannot import hosts, adapters, cobra, or the platform SDK; host→adapter imports sit behind an exact two-way allowlist (unexpected imports fail, stale entries fail); the detectors verify themselves against synthetic violations.

Key mechanisms

  • Canonical event, parsed once. The ingress is the only place that parses the shared header (event_id, event_type, create_time, app_id, tenant_key); the IPC frame carries every field (additive, with observed_at as a fixed RFC3339Nano string), consumers restore them in full, and a table-driven arbiter drops any event whose payload header contradicts the canonical facts — including the case where a canonical fact went missing.
  • Catalog compiled at startup. events.All() aggregates declarations explicitly; catalog.Compile canonicalizes defaults, validates the whole catalog (duplicate keys, schema placeholders, dangling field overrides, processless custom schemas, unresolvable strategies), resolves output schemas, and projects each key into Descriptor / OutputContract / Capability / RuntimeBinding inside an immutable snapshot. The runtime registry (RegisterKey/Lookup/ListAll) is gone.
  • Decision before side effects. event consume now forms an immutable decision (identity, normalized params, scope, precondition statuses, would-read/would-write sets) before anything is started, registered, prepared or written; a gate proves that separation across all 25 keys. Execution then acts on that same decision, and existing PreConsume hooks run through a compatibility strategy with unchanged first/last semantics.
  • Schema-closed output. A processed payload that cannot be decoded is dropped with a diagnostic (event id, type, reason only — never payload content) instead of leaking the raw envelope into stdout. Native keys still deliver the raw envelope by contract; the (nil, nil) business-filter convention is unchanged.
  • Bus capability negotiation. A bus daemon outlives CLI upgrades, so consumers check canonical_metadata_v1 on the real delivery handshake rather than decoding missing fields as empty values. The answer is decided once per connection: a key whose subscription identity is one-dimensional degrades into the compatibility mode described in the upgrade notes, while a key that subscribes per resource is refused explicitly, before any preparation side effect, with a recovery hint.

New user-facing capabilities

  • event list --domain <d> — filter the catalog by domain at the snapshot query layer; unknown domains fail with the valid set listed.
  • event consume <key> --dry-run — reports the decision instead of acting on it, with three-state preconditions (ok / unknown / blocked) and sensitive parameter values redacted. The skill documentation deliberately does not cover it; it is a diagnostic surface, not part of the agent-facing flow.

Behavior changes and upgrade notes

Normal, well-formed event output is byte-identical (verified by frozen goldens, per-key stdout baselines, and a dual-binary byte-for-byte comparison across all 25 keys). The changes below tighten error semantics only:

  1. Malformed processed payloads and metadata conflicts are dropped with a stderr diagnostic instead of passing the raw envelope through to stdout.

  2. A missing upstream create_time stays empty instead of being backfilled from the local clock; the local observation time travels separately as observed_at.

  3. board.whiteboard.updated_v1 consumers are scoped per whiteboard_id (bug fix: distinct whiteboards previously shared one subscription scope, so a second consumer's server-side subscription never happened and an exiting consumer could unsubscribe a live one). Its schema now marks the parameter as a subscription key, and each consumer's stdout is filtered to the whiteboard it asked for — scoping the subscription alone would have left every consumer receiving every subscribed board's events, since the local bus fans out by event type. An event whose whiteboard cannot be read is dropped rather than attributed to the wrong board. Upgrade note: restart all whiteboard consumers after upgrading and avoid mixing old and new CLIs on the same whiteboard during the transition window.

  4. Attaching to a bus started by an older CLI: the connection enters a compatibility mode instead of failing. app_id and tenant_key are then read from the event payload — the same bytes the older ingress parsed them from — and a one-line notice on stderr names the mode. The payload's app_id is checked against the app this consumer is configured for, so it cannot be forged; tenant_key has no second source on such a connection, which is the one accepted cost. Everything the older frame does carry stays authoritative and stays arbitrated.

    board.whiteboard.updated_v1 is the exception and still exits with failed_precondition plus a recovery hint: it subscribes per whiteboard, and its scope is hashed here but bare on an older bus, so old and new consumers of the same board would each act as first and last for their own scope and unsubscribe one another. Stopping the consumers attached to the older bus and running event stop is a prerequisite for that key. Note that the bus's 30-second idle exit only applies while it has no connections — one long-running consumer keeps it alive indefinitely.

  5. Error precedence in combined failure cases is now validation-first: invalid parameters are reported before missing scopes (exit 2 rather than 3 when both apply). Single-error outputs are unchanged.

  6. An event body close to the 1 MiB ingress limit is now delivered instead of dropped. The frame limit used to equal the payload limit, so the metadata a frame adds pushed the top of the accepted range over it: the bus wrote such a frame and the consumer refused to read it, warning and moving on. Long transcripts reach that band.

  7. A run refused for an unmet precondition no longer prints stdin closed — shutting down on stderr. The watcher that emits it now starts with the stream, so the line means what it says instead of naming the wrong cause for the exit.

Known limitation — --quiet and dropped events. --quiet suppresses per-event drop diagnostics along with the rest of stderr, so a run that loses events to backpressure, an oversized frame, a metadata conflict or a malformed payload produces no signal at all: stdout is simply short. This is pre-existing behaviour, not a change in this PR, and it is now stated in the flag's help text and in the skill documentation so the trade-off is an informed one. Unattended runs that must notice data loss should omit the flag. Giving those drops a channel --quiet does not silence is deliberately left to a follow-up: doing it properly means correcting how the drop paths count (sequence reordering, fragmented frames, backpressure attribution) and touches the bus daemon, which does not belong in this PR.

Unrelated CI fix carried along

internal/qualitygate/config/allowlists/fixture-domains.txt gains two test-only hostnames (cdn.example.com, open.feishu.cn.example.com) used by pre-existing unit tests in internal/cmdutil and internal/core. The unapproved-domain guard landed after those tests' last green run, so lint currently fails on every pull request whose diff window includes them — including this one, on files it does not touch. Verified on the merge tree: the guard passes with the two entries present and reports six rejections with them removed.

Deliberate non-goals

Legacy EventKeys are not rewritten (public output types, field order, and JSON tags untouched; deduplication only in private helpers); Bus/Conn/Hub internals are not redesigned; compatibility aliases (KeyDefinition, RawEvent, APIClient) keep declaration sites unchanged; heavier lifecycle models were intentionally left out.

Test Plan

  • make unit-test passed
  • build, vet, unit and integration suites pass
  • container-sandbox E2E passed (4/4 scenarios)
  • AI-agent evaluation of the updated skill docs passed (5/5 cases)
  • independent acceptance review passed (10/10 scenarios, including credential-degraded dry-run behavior)
  • manual verification: lark-cli event list --domain vc, lark-cli event list --domain bogus, lark-cli event consume vc.note.generated_v1 --dry-run — filter, typed error with the valid domain set, and the side-effect-free preview verified against a live tenant

Verification depth behind those boxes:

  • Frozen baselines first: an explicit 25-key catalog baseline, byte-level list/schema goldens, per-key processed stdout snapshots, a real-bus PreConsume first/last contract (including the pre-existing owner-exits-first cleanup gap, pinned as-is), and a reflection allowlist over the rendered JSON — all landed before the first behavior change and never regenerated (the single whiteboard golden change is the declared fix).
  • Self-proving gates: whole-catalog compile validation, snapshot immutability, lossless projection with per-field routing, metadata-authority arbitration with reflection completeness, schema closure over every processed key, baseline outputs validated against their declared schemas, dry-run zero-side-effect spies with a control group, capability-gate byte replays of old bus acks, redaction checks with sentinel controls, and dependency-direction detectors with positive/negative self-checks. Every gate went through a red-then-green verification.
  • go build, go vet, full unit/integration suites, and go test -race across all event packages; incremental golangci-lint clean; go mod tidy produces no changes (zero new dependencies).
  • Container-sandbox E2E (domain filter, schema smoke, dry-run envelope, unknown-key regression; a live WebSocket bounded-run case is env-gated opt-in) and independent acceptance reviews across ten scenarios.

Related Issues

N/A

leave330 added 30 commits July 31, 2026 22:46
The whiteboard schema golden gains the subscription-key marker on
whiteboard_id; that visible diff is the intended outcome of this fix.
…shot

The runtime registry is gone: declarations aggregate through events.All,
compile once at the command tree's assembly point, and every reader
(list, schema, consume, suggestions, bus) works from the immutable
snapshot. Byte-identical golden output pins the migration.
…tion seam

The stream host keeps its connect/prepare/stream sequence and panic-safe
cleanup exactly as before; what changed is who supplies the preparation:
the application layer injects the decided strategy, and the declaration's
own hook remains the fallback for direct library callers.
…ctors

Kernel packages are derived from the directory tree so new packages are
gated the day they appear; host adapter imports are pinned by a two-way
allowlist (unexpected imports fail, stale entries fail); the detectors
prove themselves on synthetic violations. Header re-parsing in domain
packages is pinned by a shrink-only baseline until the residue is gone.
…d headers

Domain processors now take event id, type, source time, and tenant
identity from the canonical event the pipeline restored; the per-file
header envelope blocks are gone and the header re-parse gate is
zero-tolerance. The per-key stdout baseline is byte-identical.
Comment thread internal/event/consume/loop.go
Extending the default generic-api-key rule appends the allowlist to
that rule alone, and the AND condition additionally bounds it to the
event catalog golden fixtures, so the dotted-identifier exemption can
no longer mask a match from any other rule or any other path.
Comment thread .gitleaks.toml
The unapproved-domain guard rejects cdn.example.com and
open.feishu.cn.example.com in internal/cmdutil and internal/core unit
tests. Both are RFC 2606 example subdomains used only as fixtures, and
the reserved-name exemption covers the bare example.com names only, so
they belong in the fixture list next to the attacker/evil entries.

The tests predate the guard and their last green run predates it too,
so every pull request whose diff window includes them currently fails
lint on files it did not touch.
The vc and minutes PreConsume implementations were byte-identical copies,
so the register/unregister pair moved to a shared helper. Deleting both
domain files to get there read as if those domains had lost their
PreConsume, and it took away the place a reader looks for how a domain
subscribes.

Each domain keeps its own preconsume.go and its own documented
subscription semantics, delegating only the shared OAPI dance. The key
declarations are unchanged.

Also covers the shared helper directly: nine keys across three domains
now depend on it, including the bounded cleanup that lets an exiting
consumer unsubscribe under a cancelled consume context.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@events/internal/subscribeprep/subscribeprep_test.go`:
- Around line 117-157: Strengthen the error-path tests for Hook and
SubscribeWithCleanup by using a sentinel client error and asserting the returned
API failure’s typed metadata via errs.ProblemOf, while also verifying errors.Is
preserves the sentinel cause; ensure the stub/API failure is classified through
errclass.BuildAPIError or runtime.CallAPITyped. In
TestHook_RejectsMissingAPIClient, assert the typed internal error category and
errs.SubtypeUnknown in addition to the existing cleanup checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 190d97e7-e080-407b-a29c-5a76effdb750

📥 Commits

Reviewing files that changed from the base of the PR and between faf31a3 and da3dd77.

📒 Files selected for processing (3)
  • events/internal/subscribeprep/subscribeprep_test.go
  • events/minutes/preconsume.go
  • events/vc/preconsume.go

Comment thread events/internal/subscribeprep/subscribeprep_test.go
The error paths only checked for a non-nil error, so a rewrap that
dropped the client's category, subtype or retryable flag would have
passed. They now assert the client's typed problem reaches the caller
unchanged, and that a missing API client yields the typed internal
error this package documents.
Comment thread events/internal/subscribeprep/subscribeprep_test.go
leave330 added 12 commits August 3, 2026 20:15
Decoding the payload header into typed strings fails as soon as one
field carries the wrong JSON type, and the arbiter treated that error as
"no conflict". Because encoding/json still populates the fields it can
decode, a header could pair one type-flipped field with forged app_id
and tenant_key values and reach a native key's stdout unchallenged --
the opposite of the fail-closed contract this check documents.

Header values are now decoded one at a time, so a badly-typed field no
longer discards the claims beside it. The envelope declares every one of
these facts as a string, so a non-string assertion is itself a conflict;
JSON null still means absent and stays silent.
Scoping the subscription per whiteboard made the server-side setup run
for every board, but the local bus still fans out by event type, so each
consumer received every subscribed board's events -- including the first
consumer, which saw only its own board before.

The key now declares a Match filter on the payload's whiteboard_id, the
existing pre-Process hook the bus already calls. An event whose board
cannot be read is dropped rather than delivered: a consumer that asked
for one board must not be handed an event that cannot be attributed to
it. Routing per board on the bus would additionally save the IPC hop and
stays open as a follow-up; it needs the handshake to carry the filter and
the hub to read payload contents.
…data

Refusing every key on an older bus left no upgrade path. The bus is a
long-lived daemon on the user's machine and its 30-second idle exit only
applies while it has no connections, so one supervised consumer keeps an
older bus alive indefinitely; the refusal also shares an exit code with
"bad parameters", which reads to an orchestrator as "do not retry".
Together that turns an upgrade into a silent stall rather than the one
recovery round the notes promised.

A connection whose handshake advertises no canonical metadata now enters
a compatibility mode, decided once from the ack and read-only after
that. A per-event fallback was rejected deliberately: it would let a
forged header override canonical facts on a bus that does supply them.

The older frame already carries event_id, event_type and source_time, so
those stay authoritative and stay arbitrated. Only app_id and tenant_key
are restored, from the payload header the older ingress parsed them out
of. app_id is checked against the app this consumer is configured for, so
it cannot be forged; tenant_key has no second source on such a
connection and is the one accepted cost. Both are refused unless the
header spells them as strings.

Keys with a SubscriptionKey param keep refusing: their scope is hashed
here and bare on an older bus, so old and new consumers of the same
resource would each act as first and last for their own scope and
unsubscribe one another, and the older bus can never grow a guard. That
narrows the break from every key to one. The refusal still happens
before any preparation runs.

A per-key matrix over the shipped catalog asserts every key has a
defined answer and that a restored legacy event matches what a current
frame carries; the frozen output baseline then pins the rendered bytes.
The compatibility tests asserted on the restored canonical event and
argued the rendered output followed from that plus the frozen baseline.
The argument did not hold: a legacy frame has no observation clock, so
its canonical event legitimately differs from a current one, and the
control was built from another legacy frame -- so the one field the two
formats cannot agree on was never compared. Any future handler reading
that field would have kept both tests green while the output diverged.

Output equivalence is now asserted directly: an old-format frame and an
old hello_ack are replayed through the real consumer -- handshake, frame
decode, compatibility restore, arbitration, Match, Process, sink -- and
stdout is compared with the frozen baseline for every Processed key. The
key that refuses a legacy bus is asserted on the same path.

The field-level matrix keeps its place, now comparing against a frame
that does carry an observation clock and stating that difference as the
single recorded divergence.

The refusal regression is also bounded: it ran on a background context,
so a negotiation that wrongly degraded entered the consume loop and hung
until the package timeout, reporting a timeout instead of the missing
refusal. It now fails in about two seconds with the real reason.
The ingress accepted event bodies up to 1 MiB and the frame limit was the
same 1 MiB, but a frame carries the canonical metadata on top of its
payload. A body in the top few hundred bytes of the accepted range framed
into something over the limit: the bus wrote it, the consumer refused to
read it and warned, and the event was gone. Long transcripts reach that
band.

The frame limit is now the payload budget plus a stated overhead
allowance, so anything the ingress accepts can be framed and read back. A
boundary test frames a maximum-size payload with long metadata and round
trips it, and a second test pins the ingress cap against the wire
format's budget -- they are declared in separate packages because the
platform ingress must not depend on the local framing, so a test is what
keeps them in step.
The stdin EOF watcher was armed before the decision was executed, so a
run refused for an unmet precondition still announced "stdin closed --
shutting down" on stderr. For a subprocess caller that reads stderr to
find out why a consumer exited, that names the wrong cause: nothing had
closed stdin, and the run never reached the stream.

It now lives inside the runner, which a blocked decision never invokes.

The blocked path also gains the tests it lacked. One pins the preview
contract an orchestrator reads: ok and dry_run stay true and the exit code
stays 0 because the preview itself succeeded, so status and the named
blocked precondition with its detail are the only things that say a real
run would refuse. The other pins the invariant that makes the watcher move
correct -- executing a blocked decision returns the preflight's own error,
hint intact, and never starts the stream.
Two properties lived only in the wiring, where each side looks correct
alone and only the pair is wrong.

The normalizer must run exactly once per consumer: deciding runs it to
compute the subscription identity and tells the host to skip its own
call. Both directions of that flag failed silently -- a second run for a
hook that is not idempotent, or a host normalizing values the bus was
never told about -- and nothing asserted the count across the two layers.

The preparation the decision chose must be the one that runs. The
injected path and the declaration's own hook call the same function
today, so a broken injection would have produced identical behavior; the
test counts a spy the declaration cannot see, which distinguishes them.

Both were verified by mutation: dropping the skip makes the normalizer
run twice, and ignoring the injected preparation drops its count to zero.
A blocked precondition rendered only the error's sentence. The preview
exists to be read before acting -- it is the surface an agent consults to
decide whether to run at all -- yet it was the one surface with no way
forward: the classification and the recovery hint the same failure puts in
a real run's error envelope were dropped on the floor.

Each precondition now carries subtype, hint and, where the failure is a
missing grant, the concrete scopes, all omitted when the check passed so a
healthy preview stays as terse as before. The blocking error already held
all of it; only the view discarded it.

The renderer converts the view struct directly, so the exported model and
the JSON model cannot drift apart without breaking the build.
The seam tests asserted that the stream host honours the normalized-params
flag and the injected preparation, but they built those options themselves.
Deleting either line from the command left every test green -- verified by
deleting them -- so the wiring the tests were meant to protect was not
covered at all.

The three assignments the command alone can get wrong now live in a small
named function, and the tests call that function. Each of the three was
confirmed to turn the suite red when removed. The fixture key takes a
parameter and the incoming options carry a value the decision must
overwrite; with a parameterless key the parameter assignment had nothing
to observe and stayed unpinned on the first attempt.

The function does one thing: transfer the decided parts onto the options.
Signal handling, the stdin watcher, the transport and the runtime stay
where they were.
The bus stub has to live in a non-test file so other packages can import
it, which subjects it to the framework bans that test files are exempt
from. It now goes through vfs for its temp directory, like the rest of
internal/, so it needs no exemption at all. A redundant conversion of an
aliased function type is gone too.
The skill no longer documents --dry-run: the preview earns little for an
agent this cycle, and describing a path agents rarely take costs budget
in a document that has to stay short.

--domain now lists the eight domains inline instead of leaving the agent
to guess one and learn from the rejection, matching what the flag's own
help already prints.
# Conflicts:
#	cmd/build.go
#	cmd/event/consume.go
@liangshuo-1
liangshuo-1 merged commit fcdef49 into main Aug 5, 2026
28 of 42 checks passed
@liangshuo-1
liangshuo-1 deleted the feat/event-arch-refactor branch August 5, 2026 18:44
zkh-bytedance pushed a commit that referenced this pull request Aug 6, 2026
Co-authored-by: TRAE CLI <traecli@bytedance.com>
@coderabbitai coderabbitai Bot mentioned this pull request Aug 7, 2026
6 tasks
@liangshuo-1 liangshuo-1 mentioned this pull request Aug 7, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size/XL Architecture-level or global-impact change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants