Skip to content

feat(event): refined event subscription support (management, consume, encryption)#2064

Draft
leave330 wants to merge 92 commits into
mainfrom
feat/oapi-event-subscribe
Draft

feat(event): refined event subscription support (management, consume, encryption)#2064
leave330 wants to merge 92 commits into
mainfrom
feat/oapi-event-subscribe

Conversation

@leave330

@leave330 leave330 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds refined (fine-grained) event subscription support to lark-cli end to end: managing server-side event Subscriptions, consuming events through the refined subscription lifecycle, server-side event Filter, and encrypted resource data. Draft for review; CI is red pending an upstream oapi-sdk-go release (see Test Plan).

Changes

  • Subscription management (cmd/event/subscription/): create, get, list, renew, reactivate, delete, update, status — full lifecycle, each with --dry-run, identity resolution, and scope preflight; state-changing mutations read-before-write, destructive ones confirmation-gated.
  • Refined consume (cmd/event/consume.go, internal/event/consume/): reconcile against the remote Subscription, conflict detection with typed recovery hints, the Hello/registration handshake.
  • Server-side Filter: a CLI-owned filter model + per-event_type meta + R1–R8 validation, --filter/--clear-filter on create/consume/update, Filter as a reconcile-reuse conflict dimension, remote-filter display in reads, and Filter as an updated_v1 bus-compat dimension.
  • Push-envelope parsing (internal/event/source/): the platform's general event envelope (header.subscription).
  • Resource-data encryption: --include-resource-data handling and the decrypt-key path.
  • Bus / lifecycle (internal/event/bus/): the local event bus, subscription lifecycle events, and the requested-vs-remote cross-check.
  • Docs / skill: event command help and the lark-event skill reference.

Test Plan

  • Unit tests pass (go test ./internal/event/... ./cmd/event/...)
  • Manual local verification of the event subscription / event consume / filter flows — in progress
  • Full acceptance (sandbox E2E + acceptance review) — pending

⚠️ CI is currently red. This branch calls event-subscription and resource-data-encryption OAPI methods that are not yet in the released oapi-sdk-go. go.mod will be bumped to the release that includes them before this PR leaves Draft.

Related Issues

  • None

leave330 added 30 commits July 21, 2026 20:16
Add additive KeyDefinition fields (RefinedSubscription, ResourceType,
KeyTemplates) and the new KeyTemplate type, plus RegisterKey validation:
when RefinedSubscription is true, KeyTemplates must be non-empty, each
template's PathSegment/SelectorKey must be non-empty, and each
template's AuthTypes must be a subset of {"user","bot"}. Panics on
violation, matching the existing RegisterKey panic style. Existing
KeyDefinition fields and RegisterKey validation are unchanged.
Adds a placeholder refined-subscription EventKey catalog for
im.message.created_v1 (chat-id/{chat_id} and owner/me templates, per
design spec section 2.2), registered through the normal events/register.go
aggregation alongside the other domain packages. internal/event stays
types/resolver/registry only. meta-swap: replace the embedded mock JSON
with the fetch_meta-driven catalog once available; consumers unchanged.
TestRefinedKeys only asserted RefinedSubscription==true and
len(KeyTemplates)==2, so a wrong template string/selector/auth_types in
refined_keys_mock.json would pass silently. Assert the im.message.created_v1
key's ResourceType, AuthTypes, and both KeyTemplates' Template/PathSegment/
SelectorKey/FixedValue/AuthTypes against design spec section 2.2, since this
mock is the contract the refined-key parser (a later task) consumes.
Adds internal/event/resolve.go with ResolveEventKey(input) and
ResolvedEventKey, the sole refined-aware EventKey entry point per design
spec section 2.3. Exact Lookup runs first (legacy keys exact-only; a bare
refined base key is rejected under R1); only on a miss does it split
base/path-segment/value, requiring the base to itself be a refined
definition. Selector values go through url.PathUnescape exactly once,
validated (malformed % escape rejected; a literal unescaped '/' rejected
rather than silently changing segment count), then re-escaped with
PathEscape/QueryEscape so %2F round-trips without double-encoding.
Lookup and RegisterKey are unchanged.
ResolveEventKey's ValidationError.Param carried the literal usage
placeholder <EventKey> instead of a canonical snake_case name.
errs/ERROR_CONTRACT.md's Validation parameters section requires
positional arguments to use the canonical name without dashes (e.g.
target_user_id), so rename the constant's value to event_key; this
propagates to every error site through paramEventKey.
Adds additive `event schema --json` output for RefinedSubscription keys
per design spec section 2.6/2.7: conditional_scopes (event:encrypt_key:read
when --include-resource-data + --as user), a static risk disclosure
(ordinary_event_key=read, effective=write, dry_run_recommended=true) since
the command framework only tracks static per-command risk, subscription
payload_options + dry_run contract info, and a next_action pointing at a
concrete dry-run command anchored on the first KeyTemplate example.

refined_subscription/resource_type/key_templates/auth_types/scopes were
already flowing for free via the KeyDefinition embed; only
conditional_scopes/risk/subscription/next_action are new fields, gated on
RefinedSubscription and using pointer/slice types so omitempty actually
omits them for legacy keys. Legacy key JSON output (im.message.receive_v1)
is verified unchanged.
Wire ResolveEventKey into the event consume entry: R1 (bare refined base
key), legacy+suffix, and unknown-base all get a typed invalid_argument;
a materialized refined key gets a typed failed_precondition (refined
consume runtime is Phase C, not built yet -- never starts the bus or
performs a remote write for it). Legacy exact-match keys are unaffected
(same *KeyDefinition pointer, same flow). The pre-existing "unknown
EventKey: <key>" message/hint contract is preserved by mapping
ResolveEventKey's differently-worded unknown-base error back onto it.
Add internal/event/subscription_client.go, a thin adapter over the
candidate SDK's typed client.Event.V1.Subscription service, aliased
larkeventv1 to avoid the larkevent package-name collision with the
core oapi-sdk-go/v3/event package.

SubscriptionClient binds one already-resolved CLI identity (user or
bot) to the correct per-call option for its lifetime: user appends
larkcore.WithUserAccessToken(uat); bot appends none, letting the SDK
mint its own tenant token from the appID/secret already on the
lark.Client. Construction fails closed with a typed error on an
unresolved identity or a missing UAT instead of guessing.

Exposes Create/Get/List/Patch/Renew/Reactivate/Delete for the
upcoming event subscription command tasks. Errors are classified
through the existing errs/errclass contract: SDK-boundary transport
errors go through client.WrapDoAPIError; a non-success response is
decoded and classified via errclass.BuildAPIError, so Subscription
never gets bespoke error codes.

A small subscriptionService interface, satisfied structurally by
lark.Client Event.V1.Subscription, lets tests capture exactly which
RequestOptionFunc values are passed per identity, with no real
network call.
Add cmd/event/subscription/ with the event subscription command group
root plus the read-only list/get pair, wired into cmd/event/event.go.
Both call through Task 7 SubscriptionClient, resolve --as per spec 2.8
(no per-template AuthTypes check), and run a local best-effort
event:subscription:read scope pre-check before constructing the SDK
client. The local field is reserved but always omitted pending the
Phase-C bus runtime.
resolveEffectiveIdentity (cmd/event/subscription/subscription.go) called
f.ResolveAs but never f.CheckStrictMode, so an explicit --as user/bot
could bypass an administrator's configured strict-mode identity policy
whenever a credential of the disallowed type was still resolvable (e.g.
strict-mode: bot configured, plus a cached user token, plus event
subscription list --as user would resolve and call the API as user).

Call f.CheckStrictMode right after f.ResolveAs, before CheckIdentity,
mirroring cmd/api/api.go's apiRun, cmd/service/service.go's
serviceMethodRun, and cmd/whoami/whoami.go's whoamiRun. Both list and get
route through this one helper, so the single change covers both.

cmd/event/consume.go has the same pre-existing gap but is intentionally
left untouched here; it is tracked as a separate follow-up.

Add TestResolveEffectiveIdentity_StrictModeRejectsCrossIdentity, mirroring
cmd/whoami/whoami_test.go's TestWhoami_StrictModeRejectsCrossIdentity: a
bot-only strict-mode account configured via cmdutil.TestFactory rejects an
explicit --as user with a typed *errs.ValidationError.
Implements `event subscription create <refined EventKey>`: resolves the
key via ResolveEventKey, enforces template-level identity AuthTypes on
top of the usual --as checks, hard-requires both subscription:read and
subscription:write scopes, gates --include-resource-data=true (E),
and reconciles against remote state (List) before writing - idempotent
reuse on an active+compatible match, typed failed_precondition on a
conflicting or suspended match, and a single bounded re-List reconcile
if Create itself fails (duplicate/timeout). --dry-run previews the plan
without ever writing. No confirmation/--yes: create is additive.
…/suspended branches

Add TestRunCreate_MissingWriteScope_ReturnsPermissionError, which the
existing doc comment already claimed existed alongside the missing-read
test, locking spec section 3.5's read/write independence invariant from
both sides.

Wire suspendedDetail's reason param into Suspension.Code instead of
hardcoding "authority_revoked", and tighten the suspended-branch
reconcile test to use a distinct reason and assert it flows through
suspendedError's message, proving the extraction is dynamic rather than
coincidentally matching a hardcoded value.

Add dry-run coverage for the conflict and suspended reconcile plan
shapes (previously only create/reuse were covered), asserting --dry-run
reports each state informationally per spec section 3.4 and never calls
Create.
Add the remaining four event subscription management commands, closing
Phase B's management plane: update (SDK Patch, payload_options only, with
a suspended-state guard guiding reactivate), renew, reactivate, and delete.
All four hard-require both event:subscription:read and
event:subscription:write, always read remote state first via Get, and
support --dry-run (parse/identity/scope/remote-read/impact-analysis only,
never a write). update and delete are high-risk writes on a shared remote
resource and return a typed ConfirmationRequiredError (exit code 10) unless
--yes is passed; renew and reactivate do not expose --yes.
Add optional, omitempty fields to Hello/Event/StatusResponse per spec
§4.2 so later Phase-C tasks can carry refined-subscription context
between the consume client and bus daemon over the existing
newline-JSON IPC. No logic changes (routing/identity/negotiation are
later tasks) — struct fields only, with doc comments.

- Hello: consumer_scope_id, remote_subscription_id, identity, profile,
  user_open_id, capabilities (v2 capability marker, symmetric with
  StatusResponse.Capabilities).
- Event: remote_subscription_id, target_resource, authority,
  subscription_event_id (normalized push-envelope subscription context).
- StatusResponse: protocol_version, capabilities,
  registered_event_types, for a future ProbeBusEligibility to read bus
  capability over status_query/status_response.

The existing wire subscription_id stays the local parameter fingerprint
(frozen meaning) — remote_subscription_id is a new, distinct field.
Codec is unchanged; old frames without the new keys still decode with
the new fields left zero-valued.
Move the subscription create/reuse/conflict/suspended reconcile
classification (state table + authority matching) out of
cmd/event/subscription/create.go into exported internal/event/reconcile.go
(ReconcileExisting/ReconcilePlan/AuthorityMatchesIdentity/PlanAction*),
alongside subscription_client.go, so it can be shared with the refined
`event consume` startup chain's PlanRemoteSubscription stage (Task 15b)
instead of staying package-private to `create`.

create.go keeps its own reconcilePlan/planAction*/reconcileExisting names
as aliases/a forwarding wrapper to the new shared implementation, so its
behavior is unchanged and create_test.go passes with zero modification —
the correctness gate for this extraction.

Also defines SubscriptionLister (List-only) and SubscriptionCreateAPI
(List+Create) seam interfaces in internal/event, which *SubscriptionClient
satisfies structurally, for 15b's future write-path use.
Populate protocol.StatusResponse's v2 fields (ProtocolVersion,
Capabilities, RegisteredEventTypes), which existed on the wire shape but
were never set. handleStatusQuery now reports ProtocolVersion v2 and a
Capabilities list (refined_routing, hello_v2) via new consts in
protocol/messages.go (the one place both the bus and a later prober can
reference the same identifiers), plus a new Hub.RegisteredEventTypes()
aggregator over currently registered subscribers' EventTypes().

An old (pre-this-change) bus still reports all three fields empty; that
absence is itself the incompatibility signal a future probe (Task 15b's
ProbeBusEligibility) checks for. NewStatusResponse's own signature and
call sites are untouched - the fields are set on the constructed value
inside handleStatusQuery instead.
Implement Task 15b: the refined event consume <EventKey> startup chain
(design spec section 4.1/4.2/4.9). In strict order: ProbeBusEligibility
(read-only) -> PlanRemoteSubscription (List/Get) -> [--dry-run exits here]
-> ApplyRemoteSubscriptionPlan (the ONLY remote write: Create/Reactivate)
-> StartOrConnectBus -> HelloV2 -> consumeLoop.

- internal/event/consume/probe.go (new): ProbeBusEligibility reuses
  CheckRemoteConnections unchanged for the single-bus guard, plus a new
  local-bus capability check via status_query/status_response. An old
  (pre-refined) bus has an absent protocol_version/capabilities and fails
  closed with a prompt to run event stop; no local bus yet is fine.
- internal/event/consume/refined.go (new): RunRefined plus the injectable
  refinedDeps seam (probe/plan/apply/startBus/hello) so order and the
  dry-run short-circuit are assertable with fakes, no network. Apply
  (create/reactivate/reuse) is the only write; a conflicting plan fails
  closed before Apply; an Apply-ok-but-bus-start-fail never auto-deletes
  the remote subscription. HelloV2 populates Identity/Profile/UserOpenID/
  RemoteSubscriptionID/ConsumerScopeID (a SHA-256 hash of base key,
  materialized key, authority type, app_id, and user_open_id).
- internal/event/consume/handshake.go: extract doHello's wire dance into
  a shared sendHello helper; add doHelloV2 for the v2-populated Hello.
  doHello's own signature and behavior are unchanged.
- cmd/event/consume.go: add --dry-run; replace the refined-key stub with
  runRefinedConsume, which resolves identity, the SubscriptionClient, and
  signal handling, then calls consume.RunRefined. The legacy (non-refined)
  path is untouched.
- cmd/event/consume_test.go: update the one pre-existing test that locked
  the now-removed "runtime not yet available" stub to instead lock that a
  refined key is routed into the real chain and fails safely offline.
when a local bus already answered

CheckRemoteConnections counts ALL of this app's active WebSocket
connections, including the local bus's own connection once it is up.
ProbeBusEligibility was calling it unconditionally, ahead of the local
bus reachability check, so every second (and later) refined consumer
attaching to an already-healthy, already-running local bus would see
online_instance_cnt=1 (that bus's own connection) and fail closed as if
"another event bus" were connected -- breaking the ordinary
N-consumers-share-one-bus model.

EnsureBus (startup.go) already avoids exactly this: it calls
CheckRemoteConnections only inside the branch where probeAndDialBus
failed (no local bus dialable at all) -- see its own "local bus not
found; checking remote connections..." log line. ProbeBusEligibility now
mirrors that same condition: check the local bus first, and only fall
through to the remote check when no local bus answered. A local bus that
does answer is checked for its v2 capability markers only.

Caught via a design-review question about the apparent duplication
between startup.go and probe.go; added
TestProbeBusEligibility_HealthyLocalBusAlreadyOnline_RemoteCheckNotAppliedToItsOwnConnection
as a regression test (RED against the previous implementation, GREEN
after the fix) that also asserts the remote check makes zero calls once
a local bus already answered.
Review fixes for the refined event consume startup chain (Task 15b):

1. (write-safety) runRefinedConsume only checked the base EventKey's
   AuthTypes, never the matched KeyTemplate's own narrower AuthTypes,
   so "event consume im.message.created_v1/owner/me --as bot" reached
   Plan/Apply (a real remote write) for a user-only template, a write
   that subscription create already rejects client-side. Moved
   checkTemplateAuthTypes out of cmd/event/subscription/create.go into
   the new exported internal/event.CheckTemplateAuthTypes (create.go
   keeps a thin same-named wrapper; create_test.go is unchanged) and
   call it in runRefinedConsume before any client construction or
   Plan/Apply.

2. (correctness) prodRefinedDeps's hello closure fed a bot consumer's
   real user_open_id into computeConsumerScopeID even though
   buildHelloV2 correctly drops it from the wire for a bot, making a
   bot's consumer_scope_id vary with whoever is currently logged in.
   Now forces an empty string for a bot identity's scope-id
   computation; user identity behavior is unchanged.

3. (correctness) A post-Apply HelloV2 transport error or bus-rejected
   hello_ack returned an error without the remote_subscription_id,
   created_by_this_attempt, and next_action recovery hint that the
   startBus-fail path already carries, even though both leave the same
   remote-subscription-behind situation. Extracted the shared hint
   builder and reused it in both paths, appending (not replacing) the
   bus-rejection path's own single-consumer guidance.

TDD throughout: a RED test was added per fix before each implementation
change. Full report in .superpowers/sdd/task-15b-report.md.
Additive, strictly read-only enhancement to `event status` for refined
(remote-subscription-backed) consumers, spec §4.6. Legacy consumer output
is unchanged; every new field is omitempty.

- protocol.ConsumerInfo gains refined_subscription, remote_subscription_id,
  owner_{identity,app_id,user_open_id}, stale_identity, degraded_reason,
  remote_state, a remote_subscription{state,expire_time,
  include_resource_data} sub-struct, and last_lifecycle_event (added now,
  populated later by Task 18). TestDecode_OldStatusResponse_BackwardCompat
  extended to pin all of them zero-valued on an old wire frame.

- Hub.Consumers() populates the new fields: RemoteSubscriptionID/OwnerAppID/
  OwnerUserOpenID come straight off the Subscriber interface (so OwnerAppID
  is populated for every consumer on a Phase-C bus, not only refined ones);
  OwnerIdentity/StaleIdentity/DegradedReason use the same s.(*Conn)
  type-assert the Publish delivery gate already relies on, so the
  Subscriber interface and its mocks are untouched.

- cmd/event/status.go: runStatus now threads cmd (needed to resolve its own
  identity). Local current_profile_match is computed against a freshly
  loaded (never Factory.Config()-cached) current identity, mirroring
  bus/identity.go's resolveCurrentIdentity, and is guarded to consumers with
  a real owner user (OwnerUserOpenID != "") so bot/legacy consumers are
  never flagged. Text output gets a per-consumer advisory sub-line under
  each refined row (stale/degraded are informational only, never "dead");
  JSON gets the same via a consumerView wrapper that embeds ConsumerInfo.
  A weak, optional remote supplement (SubscriptionClient.Get) fills
  remote_subscription/remote_state for the current app's refined consumers
  only when identity resolves, a valid unrefreshed token is on disk
  (auth.GetStoredToken, never f.Credential.ResolveToken), and
  event:subscription:read is held — any failed precondition or remote
  error silently falls back to local-only. status never refreshes a UAT,
  binds a user, or writes remotely.

TDD throughout, bottom-up (protocol -> bus -> cmd), RED confirmed at each
layer before implementing. Full report in .superpowers/sdd/task-16-report.md
(gitignored, local artifact only).
Address Task 16 code review findings for `event status`'s refined
additive fields (spec §4.6), display-only:

- Derive a degraded advisory from the remote-supplement's
  remote_state for the two grounded values ("suspended"/"expired");
  any other (open-vocabulary/healthy) state stays advisory-free.
  Appended alongside any existing bus-side degraded_reason/
  stale_identity advisory, never clobbering it.
- Add `,omitempty` to RemoteSubscriptionInfo.IncludeResourceData for
  consistency with its siblings.
- Reframe the stale_identity advisory as an earlier-snapshot note
  (instead of asserting a current mismatch) when the freshly
  computed current_profile_match is true, resolving a
  textually-contradictory pair of adjacent lines.

Strictly read-only/display-only: no bus write, no Conn mutation, no
new remote call. Legacy consumer output unchanged.
Task 17 (infra only, spec section 5.1/5.2): FeishuSource registers the 6
typed event.subscription.*_v1 handlers on the same dispatcher, before
cli.Start, diverting them away from business-consumer delivery into a new
bounded, single-shot in-memory executor (worker=2/slots=32, dedup via a
third DedupFilter domain, in-flight/pending merge, full-to-degraded,
cancel/timeout). The executor's action is summary-only for this task: it
records lastLifecycleEvent/remoteState on the matched consumer via a new
Hub.connsByRemoteSubscriptionID, behind a pluggable action seam that a
later task will swap remote actions into.
…stone

Task 18 (spec section 5.3/5.4/5.5/8): replaces Task 17's summary-only
lifecycleAction with the real per-event behavior, plugged into the same
bounded executor unchanged.

- New subscriptionLifecycleAction (internal/event/bus/lifecycle.go):
  per-event switch for activated/updated/suspended/expiration_reminder/
  expired/deleted, a single owner==current eligibility gate (spec 8) reused
  by every remote action (Reactivate/Renew/Get) and by BindUser, at-most-one
  remote call per event, and a TTL in-memory tombstone so a deleted_v1
  blocks a later activated_v1/updated_v1 resurrection for the same
  remote_subscription_id. Only the confirmed suspension.code value
  (authority_revoked) auto-Reactivates; any other value takes section 5.4's
  default branch (single Get reconcile, never a guessed recovery action).

- identityGate (identity.go) now memoizes the latest connID/bindUser from
  onConnReady and exposes bindConsumer(ctx, c), factored out of onConnReady's
  own loop body, so the lifecycle action can (re)bind a specific consumer
  independently of a fresh WS ready/reconnect event.

- Bus gains SetSubscriptionClient(sdk *lark.Client), mirroring
  SetIdentityProviders's shape; cmd/event/bus.go wires it via f.LarkClient()
  with a non-fatal (summary-only) fallback. NewBus now constructs
  subscriptionLifecycleAction instead of summaryLifecycleAction.

- Conn gains suspensionReason/lastAction/lastActionError/nextAction (spec
  section 5.5), surfaced via Hub.Consumers()/protocol.ConsumerInfo (new
  omitempty fields). Fixes 2 stale Task-17 doc comments that said these
  fields were populated by a future Task 18 when Task 17 already populated
  them (review Minor 1), and has the executor's queue-full path also set an
  explicit next_action (review Minor 2).

The section 8 red line (never recover/BindUser for a historical/non-current
identity) is covered explicitly: an owner-mismatched consumer triggers zero
Reactivate/Renew/Get/BindUser/resolveUAT calls across every event type,
verified in internal/event/bus/lifecycle_dispatch_test.go.

TDD throughout; go test ./internal/event/bus/ -race -count=1 passes (154
tests, 0 races). Full report in .superpowers/sdd/task-18-report.md
(gitignored, local artifact only).
Task 19 (final task) of the refined event-subscription feature: document
what Tasks 1-18 shipped, with no product-logic changes.

- Add skills/lark-event/references/refined-subscription.md: cross-cutting
  reference for key_templates/materialization/--dry-run, TTL/renew, the
  --as identity gate (owner vs current, stale_identity), the 6-event
  lifecycle control plane, the event subscription management plane
  (scope/confirmation table keyed by remote_subscription_id), the stop
  chain (local consume vs remote delete), and the E-deferred boundary on
  --include-resource-data.
- Modify skills/lark-event/SKILL.md: add a refined-subscription routing
  section (list/schema as source of truth -> key_templates -> --dry-run
  -> event subscription management -> stop chain) linking to the new
  reference; additive, existing content unchanged.
- Modify cobra Long/Example/Short help text (no flag/logic changes) for
  the event root, event consume, event status, and the event subscription
  group + its 7 subcommands, giving each the USAGE/allowed values/identity
  semantics/Scope/EXAMPLES/OUTPUT/NEXT STEP/SAFETY structure, verified
  against the actual implemented flags and behavior.
event consume shipped with no --include-resource-data flag at all
(Task 19's own report flagged this as a concern): passing it produced
cobra's generic unknown-flag error instead of the spec-required typed
rejection (design spec section 4.7:325/328/330, section 9:403). Adds the
flag (default false, no behavior change) and gates it before any side
effect: true on a refined EventKey returns the same typed
failed_precondition (reason resource_data_encryption_deferred) that
subscription create/update already return; true on an ordinary EventKey
returns a typed invalid_argument instead, since the flag has no
remote-Subscription concept to apply to there. Corrects the consume
--help text and skills/lark-event/references/refined-subscription.md,
both of which documented the flag as absent.
runRefinedConsume resolved identity via resolveIdentity (ResolveAs +
CheckIdentity) but never called CheckStrictMode, unlike every sibling
--as write command (subscription.go's resolveEffectiveIdentity,
api.go's apiRun, service.go's serviceMethodRun, whoami.go's whoamiRun).
Since ResolveAs deliberately preserves an explicit --as through strict
mode so the caller can reject it, an explicit --as of the disallowed
type on a strict-mode account could slip past both AuthTypes tiers and
reach PlanRemoteSubscription/ApplyRemoteSubscriptionPlan under the
wrong authority, bypassing the configured identity policy on a
remote-write path. Adds one CheckStrictMode call in runRefinedConsume,
mirrored exactly from resolveEffectiveIdentity, right after identity
resolution and before any remote write. Scoped to the refined write
path only; resolveIdentity and the legacy consume path are untouched.
leave330 added 9 commits July 27, 2026 11:06
…rce)

A refined consumer (a non-empty RemoteSubscriptionID) always builds its
target_resource from the resolved selector key, so a refined Hello that omits
it is a malformed registration. handleHello now fails closed on it before
acking — mirroring the encrypt-key and identity-bind pre-ack gates: a key-free
WARN, a HelloAckRejected carrying the new fixed
RejectReasonIncompleteRefinedHello token, then Close. The consumer never
registers or readies. Legacy (non-refined) Hellos, which carry no
RemoteSubscriptionID, are untouched.
RawEvent.Resource/RawEvent.Authority and protocol.Event.Authority still named
the old resource / authority{type,principal_id} envelope keys. Update them to
the current 通用事件信封 shape: header.subscription.target_resource plus
authority{type, app_id, open_id}. Comment-only, no behavior change.
identity_bind_failed and incomplete_refined_hello now map to their own
typed errors (failed_precondition / internal) with actionable hints,
instead of falling through to the generic single-consumer rejection hint
("EventKey allows only one consumer") that misrepresented them.
The SDK's event authority is now {type, open_id, app_id} (no union_id);
FormatLifecycleAuthority uses open_id for a user authority.
The subscription update API only accepts a new event filter now; it can no
longer change include_resource_data (which is decided at create time).
update --include-resource-data now returns a pure-local failed_precondition
guiding delete + recreate, and the include_resource_data update machinery is
removed. Filter-update support is not implemented yet.
The dedicated decrypt/bind/incomplete Hello-reject errors now append
applyOkRecoveryHint, so an Apply that already created/reactivated a remote
subscription before the reject still surfaces created_by_this_attempt and
next_action=retry (retry reconciles/reuses, never duplicates).
event subscription update no longer changes payload_options, requires --yes,
or supports --dry-run — it is a pure-local reject of --include-resource-data
(the update API is filter-only). Fixes the group --help text, the shared
mutation-helper comments, refined-subscription.md, and the update cross-refs
in renew/reactivate/delete (incl. a dangling errUpdateConfirmationRequired ref).
Introduce a CLI-owned filter domain model (Filter/FilterNode/FilterCond)
aligned to the platform filter wire shape but independent of the SDK's
type spelling. Canonicalize produces deterministic, order-sensitive wire
JSON for equality and the byte-size limit; Equal compares filters via that
canonical form. filterToSDK/filterFromSDK project the model across the SDK
boundary, with the empty/cleared filter mapping to {"filter":{}}.
@github-actions github-actions Bot added the size/XL Architecture-level or global-impact change label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: refined event subscription support across management, consume, and encryption.
Description check ✅ Passed The description follows the required template and clearly covers summary, changes, test plan, and related issues.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oapi-event-subscribe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

leave330 added 2 commits July 27, 2026 16:41
Add a single-source, per-event_type filter capability table (seeded with a
demo mock and a clearly marked seam for the real meta) and
ParseAndValidateFilter, which enforces the filter DSL: logic_op in
{and,or}, op in {eq,in,contains}, per-operand operator whitelists, open_id
operands, in/list_value vs eq/contains/value, a 2-level nesting cap, and the
10-condition, per-operand list, and 1 KiB size limits. Violations are typed
invalid_argument errors on --filter that name the failing rule and never
echo the raw filter or its values.
Extend event schema --json and its text output to disclose, for a
Filter-supporting event_type, a filter block under subscription: the
accepted logic ops and operators, the depth/condition/size limits, the
filterable operands with their value shape and list caps, and a wire-valid
example built through the SDK projection. JSON and text read one shared
builder so they cannot drift; keys without filter support are unchanged.

@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: 7

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (17)
cmd/event/bus.go-32-41 (1)

32-41: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make the bus UAT sentinel errors typed.

cmd/event/bus.go is under the active cmd/ scope of errs-no-bare-wrap, so errUATUserMismatch/errUATUnverifiable created with bare errors.New and line 75 fmt.Errorf("%w", errUATUnverifiable) will fail the pinned lint. Use typed error constructors from github.com/larksuite/cli/errs and preserve the cause with .WithCause(err).

🤖 Prompt for 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.

In `@cmd/event/bus.go` around lines 32 - 41, Replace the bare errors.New
definitions of errUATUserMismatch and errUATUnverifiable with typed constructors
from github.com/larksuite/cli/errs. Update the error path around
verifyUATBelongsToUser that currently uses fmt.Errorf("%w", errUATUnverifiable)
to preserve the underlying cause via the typed error’s WithCause(err), while
retaining the existing sentinel identities and messages.

Source: Coding guidelines

internal/event/bus/bus.go-445-455 (1)

445-455: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Guard the encrypted path on a non-empty RemoteSubscriptionID.

The completeness gate above only fires when RemoteSubscriptionID != "". A Hello with IncludeResourceData=true and an empty RemoteSubscriptionID therefore reaches fetchAndSet with an empty subscription id — protocol.Hello's own contract says IncludeResourceData is "Only meaningful alongside a non-empty RemoteSubscriptionID" (internal/event/protocol/messages.go lines 144-151). Fail closed on that combination rather than issuing a GetEncryptKey("").

🛡️ Proposed guard
-	if hello.IncludeResourceData && b.encryptKeyProvider != nil {
+	if hello.IncludeResourceData && bc.RemoteSubscriptionID() == "" {
+		b.logger.Printf("WARN: rejecting encrypted consumer pid=%d key=%q: include_resource_data without a remote_subscription_id",
+			hello.PID, hello.EventKey)
+		if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonIncompleteRefinedHello)); werr != nil {
+			b.logger.Printf("WARN: reject hello_ack (incomplete_refined_hello) write to pid=%d key=%q failed: %v",
+				hello.PID, hello.EventKey, werr)
+		}
+		bc.Close()
+		return
+	}
+	if hello.IncludeResourceData && b.encryptKeyProvider != nil {
🤖 Prompt for 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.

In `@internal/event/bus/bus.go` around lines 445 - 455, Update the
encrypted-resource handling around b.encryptKeyProvider.fetchAndSet to require a
non-empty bc.RemoteSubscriptionID() in addition to IncludeResourceData. When
IncludeResourceData is true but the subscription ID is empty, reject the Hello
and close the connection using the existing decrypt-key-unavailable response
path, without calling fetchAndSet or issuing a key lookup with an empty ID.
internal/event/bus/status_query_test.go-61-66 (1)

61-66: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the exact v2 markers, not just non-emptiness.

A regression that sets ProtocolVersion = "v1" or drops CapabilityHelloV2 from the list still passes these checks. protocol.ProtocolVersionV2, protocol.CapabilityRefinedRouting, and protocol.CapabilityHelloV2 are exported constants — compare against them directly.

As per coding guidelines, "contract tests must assert the changed field or behavior directly so reverting the implementation causes failure."

💚 Proposed assertions
-	if resp.ProtocolVersion == "" {
-		t.Error("ProtocolVersion is empty, want a non-empty v2 marker")
-	}
-	if len(resp.Capabilities) == 0 {
-		t.Error("Capabilities is empty, want a non-empty capability list")
-	}
+	if resp.ProtocolVersion != protocol.ProtocolVersionV2 {
+		t.Errorf("ProtocolVersion = %q, want %q", resp.ProtocolVersion, protocol.ProtocolVersionV2)
+	}
+	for _, want := range []string{protocol.CapabilityRefinedRouting, protocol.CapabilityHelloV2} {
+		if !slices.Contains(resp.Capabilities, want) {
+			t.Errorf("Capabilities = %v, want it to contain %q", resp.Capabilities, want)
+		}
+	}
🤖 Prompt for 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.

In `@internal/event/bus/status_query_test.go` around lines 61 - 66, Update the
response assertions in the status query test to compare ProtocolVersion directly
with protocol.ProtocolVersionV2, and verify Capabilities contains both
protocol.CapabilityRefinedRouting and protocol.CapabilityHelloV2. Replace the
non-empty checks while preserving the existing failure reporting style.

Source: Coding guidelines

cmd/event/consume.go-70-74 (1)

70-74: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Help text cites refined EventKeys that aren't in the shipped catalog. The only registered refined base key is im.message.created_v1 (events/refined/refined_keys_mock.json), yet the event command tree's help documents two other names; copy-pasted examples fail with "unknown EventKey".

  • cmd/event/consume.go#L70-L74: replace im.message.example_v1 with im.message.created_v1 in the REFINED EVENTKEYS paragraph, and in the Example block at lines 102-105.
  • cmd/event/event.go#L21-L24: replace im.message.receive_v2/chat-id/oc_xxx with im.message.created_v1/chat-id/oc_xxx, matching the same file's own example at line 45.
🤖 Prompt for 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.

In `@cmd/event/consume.go` around lines 70 - 74, Update the refined EventKey
examples to use the registered base key im.message.created_v1: change the
REFINED EVENTKEYS paragraph and Example block in cmd/event/consume.go, and the
example in cmd/event/event.go, replacing im.message.example_v1 and
im.message.receive_v2 respectively while preserving the existing
resource-selector format.
cmd/event/subscription/subscription_test.go-230-237 (1)

230-237: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Isolate config state for the cmdutil.TestFactory case.

This test builds a real factory but doesn't pin LARKSUITE_CLI_CONFIG_DIR, so it can read the developer's/CI host config dir.

As per coding guidelines: "Use cmdutil.TestFactory(t, config) for test factories and set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() with t.Setenv to isolate configuration state."

🛡️ Proposed fix
 func TestResolveEffectiveIdentity_StrictModeRejectsCrossIdentity(t *testing.T) {
+	t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
 	// Bot-only account -> strict mode bot (SupportedIdentities bit 2). A real
🤖 Prompt for 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.

In `@cmd/event/subscription/subscription_test.go` around lines 230 - 237, Isolate
the TestFactory configuration in
TestResolveEffectiveIdentity_StrictModeRejectsCrossIdentity by setting
LARKSUITE_CLI_CONFIG_DIR to a fresh t.TempDir() via t.Setenv before calling
cmdutil.TestFactory. Keep the existing factory configuration and test behavior
unchanged.

Source: Coding guidelines

cmd/event/subscription/update.go-57-58 (1)

57-58: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Example won't parse: pflag boolean flags require =.

--include-resource-data false keeps false as a second positional arg, so cobra.ExactArgs(1) rejects the command with an arg-count error instead of the intended typed rejection.

📝 Proposed fix
-		Example: `  lark-cli event subscription update sub_xxx --include-resource-data false`,
+		Example: `  lark-cli event subscription update sub_xxx --include-resource-data=false`,
🤖 Prompt for 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.

In `@cmd/event/subscription/update.go` around lines 57 - 58, Update the Example
command for the subscription update Cobra command to use equals syntax for the
boolean flag, ensuring false is parsed as the flag value and the command still
receives exactly one positional argument under Args: cobra.ExactArgs(1).
cmd/event/subscription/list.go-146-159 (1)

146-159: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A negative --page-size is silently dropped.

o.pageSize > 0 quietly ignores negative input rather than reporting it. Reject it with a typed validation error in runList so the user isn't silently given server-default paging.

🛡️ Proposed fix (in `runList`, before building the request)
if o.pageSize < 0 {
    return errs.NewValidationError(errs.SubtypeInvalidArgument,
        "--page-size must not be negative").
        WithParam("page-size").
        WithHint("omit --page-size (or pass 0) to use the server default")
}

As per coding guidelines: "never silently coerce unsupported inputs, ignore unhonored options... Return a typed validation error when a requested behavior cannot be honored."

🤖 Prompt for 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.

In `@cmd/event/subscription/list.go` around lines 146 - 159, The runList
validation flow must reject negative o.pageSize values before constructing the
subscription request. Return the established typed invalid-argument validation
error, identifying the page-size parameter and explaining that zero or omission
uses the server default; preserve the existing request-building behavior for
nonnegative values.

Source: Coding guidelines

cmd/event/status.go-39-58 (1)

39-58: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the help-text grammar and trailing whitespace.

Line 47 ends with a trailing space, and "a missing scope silently fall back" should be "a missing scope silently falls back".

✏️ Proposed fix
 SCOPE: the local view (bus in-memory state) needs no scope and is always
 shown. For a refined consumer, remote_state/expire_time/
 include_resource_data are additionally supplemented from a live 'subscription
-get' call, but ONLY as a weak, optional dependency: it requires event:subscription:read; 
-a missing scope silently fall back to the local-only view — this never fails the command.
+get' call, but ONLY as a weak, optional dependency: it requires
+event:subscription:read; a missing scope silently falls back to the
+local-only view — this never fails the command.
🤖 Prompt for 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.

In `@cmd/event/status.go` around lines 39 - 58, Update the `Long` help text in the
event status command to remove the trailing whitespace after
`event:subscription:read;` and correct “a missing scope silently fall back” to
“a missing scope silently falls back.”
cmd/event/subscription/delete_test.go-179-182 (1)

179-182: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unchecked type assertion can panic instead of failing the test.

If local_impact is present but note is missing or non-string, localImpact["note"].(string) panics. Use the comma-ok form so the assertion fails with a readable message.

🛡️ Proposed fix
-	localImpact, ok := generic["local_impact"].(map[string]interface{})
-	if !ok || !strings.Contains(localImpact["note"].(string), "not a substitute for stopping local consumers") {
+	localImpact, ok := generic["local_impact"].(map[string]interface{})
+	note, noteOK := localImpact["note"].(string)
+	if !ok || !noteOK || !strings.Contains(note, "not a substitute for stopping local consumers") {
 		t.Errorf(`"local_impact.note" = %v, want it to mention "not a substitute for stopping local consumers"`, generic["local_impact"])
 	}
🤖 Prompt for 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.

In `@cmd/event/subscription/delete_test.go` around lines 179 - 182, Update the
local_impact validation in the test to extract localImpact["note"] with a
comma-ok string assertion before calling strings.Contains. Treat a missing or
non-string note as a test failure and retain the existing readable error message
instead of allowing a panic.
cmd/event/subscription/get_test.go-124-134 (1)

124-134: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the typed metadata, not just "some typed error".

This error-path test only checks that errs.ProblemOf succeeds. Pin Category and Subtype (invalid_response) so a subtype regression actually fails the test.

💚 Proposed fix
-	if _, ok := errs.ProblemOf(err); !ok {
-		t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
-	}
+	p, ok := errs.ProblemOf(err)
+	if !ok {
+		t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
+	}
+	if p.Subtype != errs.SubtypeInvalidResponse {
+		t.Errorf("Subtype = %s, want %s", p.Subtype, errs.SubtypeInvalidResponse)
+	}

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param)".

🤖 Prompt for 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.

In `@cmd/event/subscription/get_test.go` around lines 124 - 134, Update
TestGetSubscription_SuccessWithNoData_ReturnsTypedInternalError to inspect the
metadata returned by errs.ProblemOf(err) and assert Category and Subtype are
both "invalid_response"; retain the existing non-nil and typed-error checks.

Source: Coding guidelines

internal/event/consume/refined_test.go-58-59 (1)

58-59: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

gofmt will reformat this line.

boolPtr has extra padding before {; gofmt does not align consecutive one-line func declarations, so gofmt -l . will flag this file.

As per coding guidelines: "Run gofmt; Go code must be formatted with no gofmt -l . output."

🎨 Formatting fix
 func strPtr(s string) *string { return &s }
-func boolPtr(b bool) *bool     { return &b }
+func boolPtr(b bool) *bool    { return &b }
🤖 Prompt for 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.

In `@internal/event/consume/refined_test.go` around lines 58 - 59, Run gofmt on
the helper declarations near strPtr and boolPtr, removing the extra spacing
before boolPtr’s opening brace so the file produces no gofmt output.

Source: Coding guidelines

internal/event/bus/conn.go-437-469 (1)

437-469: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

No recovery path out of decrypt-degraded state.

Once decryptFailCount reaches the threshold, degradedReason stays decrypt_failed forever: a later successful decrypt calls SetDecryptState(decryptStateDecrypted), which touches neither the counter nor degradedReason. Three transient failures (e.g. a key rotation window) permanently mark the consumer degraded in event status. Consider clearing both on a successful decrypt.

🛠️ Proposed fix sketch
 func (c *Conn) SetDecryptState(state string) {
 	c.identityMu.Lock()
 	defer c.identityMu.Unlock()
 	c.decryptState = state
+	// A successful decrypt supersedes an earlier persistent-failure
+	// degradation: reset the streak and clear the decrypt-owned reason.
+	if state == decryptStateDecrypted {
+		c.decryptFailCount = 0
+		if c.degradedReason == decryptStateFailed {
+			c.degradedReason = ""
+		}
+	}
 }
🤖 Prompt for 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.

In `@internal/event/bus/conn.go` around lines 437 - 469, Update SetDecryptState to
restore healthy state when called with decryptStateDecrypted: reset
decryptFailCount and clear degradedReason, while preserving the existing state
update and locking. Leave failure recording in RecordDecryptFailure unchanged.
internal/event/bus/identity.go-43-56 (1)

43-56: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use typed precondition error for missing app config

loadCurrentAppProfile should return a typed precondition error for the missing app config case instead of a bare fmt.Errorf, unless internal config sentinels are intentionally exempt.

🤖 Prompt for 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.

In `@internal/event/bus/identity.go` around lines 43 - 56, Update
resolveCurrentIdentity’s missing-current-app branch to return the project’s
typed precondition error, reusing the established constructor or sentinel rather
than fmt.Errorf. Preserve the existing message/context and leave the no-user
validation unchanged; only exempt this case if internal config sentinels are
explicitly handled that way elsewhere.

Source: Coding guidelines

internal/event/bus/lifecycle_test.go-217-223 (1)

217-223: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Possible flake: this waitForCalls(..., 1) can consume the first run's token.

started is buffered, and the first Handle's token was already drained at line 207 — but the merged run's token is the only one left only if timing cooperates; on a slow worker the call at line 218 returns immediately after the queue re-send and the 50ms settle window is all that guards the callCount() != 2 assertion. Waiting explicitly for the second call (waitForCalls(t, action, 1) after release is fine, but assert with a bounded poll to 2) removes the timing dependency.

🤖 Prompt for 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.

In `@internal/event/bus/lifecycle_test.go` around lines 217 - 223, Make the
post-release assertion in the lifecycle test wait explicitly for the merged
second Handle invocation using a bounded poll until action.callCount() reaches
2, rather than relying on the fixed 50ms settle window. Keep the existing
release and first-call synchronization, and retain the failure diagnostics from
action.allCalls().
cmd/event/subscription/reactivate_test.go-83-90 (1)

83-90: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Error-path tests only check that the error is typed, not which typed error it is. Both no-data tests copy the same errs.ProblemOf(err); !ok shape, so a subtype/category regression in doReactivateSubscription/doRenewSubscription would go unnoticed.

  • cmd/event/subscription/reactivate_test.go#L83-L90: capture the *errs.Problem and assert Subtype == errs.SubtypeInvalidResponse (plus category).
  • cmd/event/subscription/renew_test.go#L82-L89: apply the same assertion for the renew no-data path.

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param)".

🤖 Prompt for 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.

In `@cmd/event/subscription/reactivate_test.go` around lines 83 - 90, The no-data
error tests only verify that an error is typed, not its metadata. In
cmd/event/subscription/reactivate_test.go lines 83-90, capture the *errs.Problem
returned by errs.ProblemOf(err) and assert the expected category,
errs.SubtypeInvalidResponse subtype, and parameter; apply the same metadata
assertions to the no-data test in cmd/event/subscription/renew_test.go lines
82-89.

Source: Coding guidelines

internal/event/source/feishu.go-310-333 (1)

310-333: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

UnionId is never consulted despite the doc promising a preference order.

The comment says "OpenId is preferred over UnionId when a 'user' authority carries both", which implies a fallback; the code only reads OpenId, so a user authority carrying only union_id normalizes to the bare "user". That value feeds the hub's authority cross-check, where a bare "user" will not match an owner keyed by open_id, and the event is silently dropped. Either add the fallback or reword the doc to state that a union_id-only authority is deliberately not resolvable here.

🐛 Proposed fix (if the fallback is intended)
 	principalID := ""
 	if a.OpenId != nil {
 		principalID = *a.OpenId
+	} else if a.UnionId != nil {
+		principalID = *a.UnionId
 	}
🤖 Prompt for 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.

In `@internal/event/source/feishu.go` around lines 310 - 333, Update
FormatLifecycleAuthority to use UnionId as the principalID fallback when OpenId
is absent, preserving OpenId’s precedence when both identifiers are present.
Keep the existing nil handling and formatSubscriptionAuthority normalization
unchanged.
internal/event/subscription_client_test.go-181-192 (1)

181-192: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the typed metadata, not just "some typed error".

errs.ProblemOf(err) is checked only for ok here (and at Lines 205-207, 268-270, 344-346), so any typed error passes — an unresolved-identity failure classified as, say, a network error would keep this green. Assert Category and Subtype on the returned Problem (and Param via errors.As on *errs.ValidationError where applicable), as the neighbouring TestNewSubscriptionClient_UserIdentity_MissingUAT_FailsClosed already does.

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param) and verify cause preservation rather than relying only on message substrings."

💚 Proposed assertion shape
-	if _, ok := errs.ProblemOf(err); !ok {
-		t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
-	}
+	p, ok := errs.ProblemOf(err)
+	if !ok {
+		t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
+	}
+	if p.Subtype != errs.SubtypeInvalidArgument {
+		t.Errorf("Subtype = %s, want %s", p.Subtype, errs.SubtypeInvalidArgument)
+	}
🤖 Prompt for 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.

In `@internal/event/subscription_client_test.go` around lines 181 - 192,
Strengthen the error assertions in
TestNewSubscriptionClient_UnresolvedIdentity_FailsClosedRatherThanGuessing and
the corresponding cases around the other referenced tests. Extract the Problem
from errs.ProblemOf(err) and assert the expected Category and Subtype, then use
errors.As to validate the expected Param on *errs.ValidationError where
applicable, following
TestNewSubscriptionClient_UserIdentity_MissingUAT_FailsClosed. Also verify the
underlying cause is preserved instead of accepting any typed error.

Source: Coding guidelines

🧹 Nitpick comments (22)
internal/event/bus/hub_test.go (1)

128-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

mustNotReceive's 50ms window can hide a real delivery.

Negative assertions here back the fail-closed drop paths (cross-check, dedup, identity gate). Since Hub.Publish enqueues synchronously before returning, the wait can be replaced with a non-blocking drain — deterministic and faster than a 50ms sleep per call.

♻️ Deterministic negative assertion
 func mustNotReceive(t *testing.T, ch chan interface{}, label string) {
 	t.Helper()
-	if _, _, msg := recvEvent(ch, 50*time.Millisecond); msg != nil {
-		t.Fatalf("%s: should not have received a message, got %#v", label, msg)
-	}
+	// Publish enqueues synchronously, so anything destined for ch is already
+	// there by the time Publish returns.
+	select {
+	case msg := <-ch:
+		t.Fatalf("%s: should not have received a message, got %#v", label, msg)
+	default:
+	}
 }
🤖 Prompt for 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.

In `@internal/event/bus/hub_test.go` around lines 128 - 134, Update mustNotReceive
to perform a non-blocking receive check after Hub.Publish has synchronously
enqueued any delivery, instead of waiting 50ms through recvEvent. Preserve the
existing failure message and assert that no message is immediately available on
ch.
cmd/event/bus_test.go (1)

74-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a cause-preservation assertion (and preserve the cause in the source).

verifyUATBelongsToUser wraps the prove failure as fmt.Errorf("%w: %v", errUATUnverifiable, err) (cmd/event/bus.go line 77), so the original proveErr is flattened into text and errors.Is(err, proveErr) is false. Diagnosing a real user_info failure then loses the typed/wrapped cause.

As per coding guidelines, error-path tests must "verify cause preservation rather than relying only on message substrings."

♻️ Preserve both errors, then assert it

In cmd/event/bus.go:

-		return fmt.Errorf("%w: %v", errUATUnverifiable, err)
+		return fmt.Errorf("%w: %w", errUATUnverifiable, err)

In the test case:

 		{
 			name:         "no stored token + verification failure is rejected (fail-closed)",
 			userOpenID:   "ou_alice",
 			token:        "tok-unknown",
 			stored:       nil,
 			proveErr:     errors.New("user_info API returned HTTP 401"),
 			wantErr:      true,
 			wantSentinel: errUATUnverifiable,
+			wantCause:    true, // assert errors.Is(err, tc.proveErr)
 		},
🤖 Prompt for 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.

In `@cmd/event/bus_test.go` around lines 74 - 82, Update verifyUATBelongsToUser so
failures from the user-info verification preserve both errUATUnverifiable and
the original proveErr as wrapped causes rather than formatting proveErr into
plain text. Extend the “no stored token + verification failure is rejected
(fail-closed)” test to assert errors.Is(err, proveErr) in addition to the
existing sentinel assertion.

Source: Coding guidelines

cmd/event/subscription/subscription_test.go (1)

365-375: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead continue after t.Fatalf, and a missing subcommand aborts the whole loop.

t.Fatalf never returns, so the continue is unreachable; using t.Errorf + continue also lets the second subcommand still be checked.

♻️ Proposed refactor
 		sub, ok := found[name]
 		if !ok {
-			t.Fatalf("subscription command group missing %q subcommand", name)
+			t.Errorf("subscription command group missing %q subcommand", name)
 			continue
 		}
🤖 Prompt for 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.

In `@cmd/event/subscription/subscription_test.go` around lines 365 - 375, In the
subcommand validation loop, remove the unreachable continue after t.Fatalf and
change the missing-subcommand handling so the test records an error without
aborting the loop, allowing both “list” and “get” to be checked. Preserve the
existing risk validation for found subcommands.
cmd/event/subscription/create.go (1)

274-282: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Returning the shared package slice on the false path leaves an aliasing hazard.

createRequiredScopes(false) hands back subscriptionMutationScopes itself, and that value is embedded into createDryRunResult.RequiredScopes. A future append on the result field would write into the shared backing array — the exact bug the true branch already defends against. Returning a copy on both paths makes the guarantee unconditional.

♻️ Make both paths allocate
 func createRequiredScopes(includeResourceData bool) []string {
-	if !includeResourceData {
-		return subscriptionMutationScopes
-	}
 	scopes := make([]string, 0, len(subscriptionMutationScopes)+len(subscriptionEncryptKeyReadScopes))
 	scopes = append(scopes, subscriptionMutationScopes...)
+	if !includeResourceData {
+		return scopes
+	}
 	scopes = append(scopes, subscriptionEncryptKeyReadScopes...)
 	return scopes
 }
🤖 Prompt for 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.

In `@cmd/event/subscription/create.go` around lines 274 - 282, Update
createRequiredScopes so the !includeResourceData path returns a newly allocated
copy of subscriptionMutationScopes rather than the shared package slice.
Preserve the existing true-path combination and ensure both branches return
independently owned slices, preventing later append operations on RequiredScopes
from mutating shared backing storage.
internal/event/consume/refined_test.go (1)

1359-1361: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two config fixtures write with os.WriteFile inside an internal/ test. Both sites bypass the repository's filesystem abstraction; the shared fix is to route them through internal/vfs.

  • internal/event/consume/refined_test.go#L1359-L1361: replace os.WriteFile(filepath.Join(dir, "config.json"), ...) with vfs.WriteFile(...) and drop the now-unused os import if nothing else needs it.
  • internal/event/consume/refined_test.go#L1435-L1437: apply the same vfs.WriteFile replacement inside runOnce.

As per coding guidelines: "Use internal/vfs filesystem APIs instead of os filesystem APIs." Based on learnings: for any Go test file under internal/, use internal/vfs including fixture/setup helpers.

🤖 Prompt for 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.

In `@internal/event/consume/refined_test.go` around lines 1359 - 1361, Replace
both os.WriteFile calls in internal/event/consume/refined_test.go at lines
1359-1361 and 1435-1437, including the runOnce helper, with vfs.WriteFile using
the existing path and contents; remove the os import if no other references
remain.

Sources: Coding guidelines, Learnings

internal/event/reconcile_test.go (1)

554-566: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the typed metadata on this fail-closed error.

The test only checks err != nil, so it would still pass if the fail-closed branch degraded to an untyped error. Asserting errs.ProblemOf(err)'s category/subtype (SubtypeUnknown, internal) locks the contract.

As per coding guidelines: "Error-path tests must assert typed metadata through errs.ProblemOf (category, subtype, and param) and verify cause preservation rather than relying only on message substrings."

🤖 Prompt for 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.

In `@internal/event/reconcile_test.go` around lines 554 - 566, Update
TestReconcileExisting_EncryptedBothTrue_NoProberSupplied_FailsClosed to inspect
errs.ProblemOf(err) and assert the fail-closed error’s internal category,
SubtypeUnknown subtype, and expected param metadata; also verify the underlying
cause is preserved. Keep the existing non-nil and no-reuse assertions.

Source: Coding guidelines

internal/event/consume/refined.go (1)

623-637: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use typed error constructors instead of bare fmt.Errorf here.

resolveCurrentProfileIdentity returns a raw fmt.Errorf for the "no current app config" case, and the caller at Line 182 wraps it with another fmt.Errorf. Both eventually land inside a typed errApplyOkHelloFailed, but the inner values carry no category/subtype of their own. A errs.NewValidationError(errs.SubtypeFailedPrecondition, ...) (missing profile) and .WithCause(err) on the caller side keeps the classification intact.

As per coding guidelines: "Use the prescribed typed error constructors for validation, failed preconditions, API failures, network failures, file I/O failures, and unknown lower-layer errors".

🤖 Prompt for 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.

In `@internal/event/consume/refined.go` around lines 623 - 637, Update
resolveCurrentProfileIdentity to return
errs.NewValidationError(errs.SubtypeFailedPrecondition, ...) when
CurrentAppConfig returns nil instead of using fmt.Errorf. At the caller around
the existing error wrapping, preserve the typed classification by attaching the
returned error with .WithCause(err) rather than embedding it in another
fmt.Errorf.

Source: Coding guidelines

internal/event/reconcile.go (1)

98-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"Mutually exclusive" options aren't actually enforced.

WithDeferredEncryptKeyConfirmation's doc says it is mutually exclusive with WithEncryptKeyProber, but supplying both silently takes the prober branch. Given the whole block is fail-closed by design, rejecting the ambiguous combination the same way the default case does would keep behavior and documentation aligned.

Also applies to: 237-257

🤖 Prompt for 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.

In `@internal/event/reconcile.go` around lines 98 - 109, The reconcile
configuration must reject supplying both WithDeferredEncryptKeyConfirmation and
WithEncryptKeyProber instead of silently selecting the prober path. Update the
option validation or requestedIncludeResourceData=true handling in
ReconcileExisting to detect the conflicting flags and route it through the
existing fail-closed internal-error/default behavior.
cmd/event/subscription/list_test.go (1)

176-190: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer errors.Is over message-string equality for the pass-through assertion.

A sentinel makes the "unchanged, unwrapped" contract explicit and survives message rewording.

♻️ Proposed refactor
 func TestListSubscriptions_TransportError_PropagatesTyped(t *testing.T) {
-	fake := &fakeListAPI{err: errors.New("boom: connection reset")}
+	wantErr := errors.New("boom: connection reset")
+	fake := &fakeListAPI{err: wantErr}
 
 	_, err := listSubscriptions(context.Background(), fake, listOpts{})
 	if err == nil {
 		t.Fatal("expected an error, got nil")
 	}
-	if err.Error() != "boom: connection reset" {
+	if !errors.Is(err, wantErr) {
 		// listSubscriptions must pass the error through unchanged — it is
 		// *eventlib.SubscriptionClient's job (already tested) to
 		// wrap it into a typed error; this command layer must not swallow
 		// or double-wrap it.
 		t.Errorf("err = %v, want it passed through unchanged", err)
 	}
 }
🤖 Prompt for 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.

In `@cmd/event/subscription/list_test.go` around lines 176 - 190, Update
TestListSubscriptions_TransportError_PropagatesTyped to use a sentinel error for
fakeListAPI and assert the returned error with errors.Is instead of comparing
err.Error() to a message string, preserving the pass-through and unwrapped
contract.
internal/event/bus/identity_test.go (1)

376-381: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Fake panics with index-out-of-range if resolveCurrent is ever called more than twice.

A future change to the gate (e.g. an extra resolve) would fail as a panic rather than a readable assertion. Clamping to the last entry keeps the failure mode legible.

♻️ Proposed refactor
 	resolveCurrent := func() (currentIdentity, error) {
-		id := identities[resolveCalls]
+		idx := resolveCalls
+		if idx >= len(identities) {
+			idx = len(identities) - 1
+		}
+		id := identities[idx]
 		resolveCalls++
 		return id, nil
 	}
🤖 Prompt for 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.

In `@internal/event/bus/identity_test.go` around lines 376 - 381, Update the
resolveCurrent test helper to clamp resolveCalls to the final identities entry
when resolving beyond the available entries, while preserving sequential
selection for the initial calls. Keep the existing call-count tracking and
returned identity behavior intact, but prevent index-out-of-range panics so
later assertions report the actual failure.
internal/event/protocol/messages_test.go (1)

195-236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing the omitempty counterpart for Event's v2 fields.

Hello and ConsumerInfo both get a "zero-valued v2 field must not reach the wire" test; Event only gets round-trip + old-frame decode. A legacy consumer parsing strictly would benefit from the same pin.

💚 Proposed test
func TestEvent_V2FieldsOmittedWhenZero(t *testing.T) {
	e := NewEvent("im.message.receive_v1", "e1", "111", 1, json.RawMessage(`{}`))
	data, err := json.Marshal(e)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	for _, key := range []string{
		`"remote_subscription_id"`, `"target_resource"`,
		`"authority"`, `"subscription_event_id"`,
	} {
		if bytes.Contains(data, []byte(key)) {
			t.Errorf("zero-valued v2 Event field leaked onto wire: %s in %s", key, data)
		}
	}
}
🤖 Prompt for 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.

In `@internal/event/protocol/messages_test.go` around lines 195 - 236, Add a
TestEvent_V2FieldsOmittedWhenZero test alongside TestEvent_V2FieldsRoundTrip
that marshals an Event created with NewEvent and verifies the zero-valued v2
fields remote_subscription_id, target_resource, authority, and
subscription_event_id are absent from the JSON wire data. Use the existing event
constructors and encoding conventions, and fail with the field name and
serialized data when any key is present.
internal/event/bus/conn.go (1)

258-268: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

degradedReason is a single slot written by three independent producers.

The identity gate (SetBoundConnID/SetDegraded), the lifecycle action (ClearActionDegraded), and the decrypt path all write the same field, so a successful rebind silently erases a decrypt/lifecycle degradation (and vice versa) even though the underlying condition still holds. If status accuracy matters here, consider per-domain reason slots with a derived rollup.

Also applies to: 297-305, 459-469

🤖 Prompt for 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.

In `@internal/event/bus/conn.go` around lines 258 - 268, Replace the shared
degradedReason slot in Conn with separate reason fields for identity, lifecycle
action, and decrypt domains. Update SetBoundConnID, SetDegraded,
ClearActionDegraded, and the decrypt-path writers to modify only their
respective slots, then derive the exposed degradation reason from the active
domain reasons so clearing or rebinding one condition does not erase others.
internal/event/bus/conn_test.go (1)

333-343: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Orphaned doc comment: it documents TestConn_IdentityGateState_ConcurrentAccessRace (defined at Line 570) but sits above the Task-17 section header.

Move it down to the function it describes so the section banner reads cleanly.

♻️ Proposed fix
-// TestConn_IdentityGateState_ConcurrentAccessRace exercises every new
-// identity-gate mutator/getter concurrently: in production this state is
-// written from TWO independent goroutines (identity.go's onConnReady, driven
-// by the WS ready/reconnect callback, and Hub.Publish's per-event delivery
-// gate, driven by the source's emit goroutine) and read by a future status
-// command from yet another — hence its own dedicated mutex rather than the
-// zero-lock convention used for the write-once owner fields above. Run with
-// -race (mirrors hub_publish_race_test.go's style for the Hub side).
 // --- Task 17: lifecycle summary state (spec §5.1/§5.5) --------------------

and re-attach it directly above func TestConn_IdentityGateState_ConcurrentAccessRace (Line 570).

🤖 Prompt for 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.

In `@internal/event/bus/conn_test.go` around lines 333 - 343, Move the multi-line
documentation comment describing TestConn_IdentityGateState_ConcurrentAccessRace
from above the Task-17 lifecycle section to directly above that test function.
Leave the Task-17 section header and TestConn_LifecycleSummary_DefaultEmpty
declaration together without the orphaned comment.
internal/event/bus/hub_consumers_refined_test.go (1)

20-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Discarded net.Pipe() peers across the new Hub tests. Both files use conn, _ := net.Pipe() and close only one end, unlike the rest of the package's Hub/Conn tests which keep and close both.

  • internal/event/bus/hub_consumers_refined_test.go#L20-L21: bind the second return value and defer its Close() in all three tests.
  • internal/event/bus/hub_lifecycle_test.go#L19-L20: same for every net.Pipe() call site in this file (Lines 19, 25, 31, 64, 94, 119, 142, 176).
🤖 Prompt for 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.

In `@internal/event/bus/hub_consumers_refined_test.go` around lines 20 - 21, The
Hub tests discard one peer returned by net.Pipe(), leaving resources unclosed.
In internal/event/bus/hub_consumers_refined_test.go lines 20-21, update all
three tests to retain and defer-close both pipe connections; apply the same
change at every net.Pipe() call site in internal/event/bus/hub_lifecycle_test.go
lines 19, 25, 31, 64, 94, 119, 142, and 176.
internal/event/bus/lifecycle_dispatch_test.go (1)

1104-1111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the expected outcome instead of accepting both.

The err == nil → t.Log branch makes this assertion vacuous: any behavior passes. Decide the contract for "no subscription client configured" (typed error vs. nil) and assert it directly, keeping the summary-recording check.

🤖 Prompt for 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.

In `@internal/event/bus/lifecycle_dispatch_test.go` around lines 1104 - 1111, The
test around action.Handle must assert the defined contract for a missing
subscription client instead of accepting either outcome. Update the err == nil
branch to require the expected typed error or nil result, while preserving the
LastLifecycleEvent assertion that verifies the summary is recorded.
internal/event/bus/lifecycle_ports.go (1)

56-58: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Prefer a checked type assertion here.

The invariant holds today, but an unchecked assertion in a lifecycle worker goroutine turns any future non-*Conn implementation into a daemon-killing panic; returning an error degrades just that consumer instead.

♻️ Proposed change
 func (g *identityGate) BindConsumer(ctx context.Context, c lifecycle.Conn) error {
-	return g.bindConsumer(ctx, c.(*Conn))
+	conn, ok := c.(*Conn)
+	if !ok {
+		return fmt.Errorf("bind consumer: unexpected lifecycle.Conn type %T", c)
+	}
+	return g.bindConsumer(ctx, conn)
 }
🤖 Prompt for 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.

In `@internal/event/bus/lifecycle_ports.go` around lines 56 - 58, Update
identityGate.BindConsumer to use a checked type assertion when converting the
lifecycle.Conn argument to *Conn. If the assertion fails, return an appropriate
error instead of panicking; otherwise preserve the existing bindConsumer flow.
internal/event/bus/lifecycle/action.go (1)

187-208: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Unsynchronized post-construction wiring read from the executor goroutine.

gate/newSubClient/encryptKeyRemover are plain fields written by these setters and read by Handle on the executor's worker goroutine. Today all wiring happens in NewBus/SetIdentityProviders/SetSubscriptionClient before Run starts, so it's safe — but nothing enforces it, and Hub.SetCurrentResolver guards the equivalent state with mu (internal/event/bus/hub.go Lines 131-139). Consider atomic.Pointer/mutex, or at least a doc line stating these must be wired before the executor starts.

🤖 Prompt for 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.

In `@internal/event/bus/lifecycle/action.go` around lines 187 - 208, Synchronize
access to the wiring fields used by the executor: gate, newSubClient, and
encryptKeyRemover in SubscriptionAction, including reads from Handle and writes
through SetIdentityGate, SetNewSubscriptionClient, and SetEncryptKeyRemover.
Prefer the existing synchronization approach used by Hub.SetCurrentResolver, or
document and enforce that all wiring completes before the executor starts if
synchronization is intentionally not added.
internal/event/bus/encrypt_key.go (2)

29-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc contradicts the declaration.

The comment says "A field on the provider (not a const)", but this is a const. The tunable field is encryptKeyProvider.fetchTimeout; this const is only its default.

♻️ Proposed comment fix
 // defaultEncryptKeyFetchTimeout bounds the ONE Hello-time GetEncryptKey call.
-// A field on the provider (not a const) so tests can shrink it; deliberately
-// not env-configurable — this is control-plane housekeeping, run once per
-// encrypted consumer registration, never a hot path.
+// It only seeds encryptKeyProvider.fetchTimeout, which tests can shrink;
+// deliberately not env-configurable — this is control-plane housekeeping, run
+// once per encrypted consumer registration, never a hot path.
🤖 Prompt for 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.

In `@internal/event/bus/encrypt_key.go` around lines 29 - 33, Correct the comment
above defaultEncryptKeyFetchTimeout to describe it as the default value for the
provider’s encryptKeyProvider.fetchTimeout field, not as a provider field
itself. Preserve the existing timeout, test-tuning, environment-configuration,
and control-plane context.

100-106: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

gate is written unguarded while newClient is mutex-guarded.

setIdentityGate mutates p.gate with no lock, yet resolveOwnerIdentity reads it on the fetchAndSet path — the exact concurrency window newClientMu exists to cover for newClient. Production wires both before Run(), so this is latent rather than live, but the asymmetry invites a race under -race if a test (or future code) sets the gate concurrently. Either guard both or document that only newClient can be set late.

🔒 Proposed fix
-	newClientMu sync.RWMutex
-	newClient   func(as core.Identity, uat string) (encryptKeyClient, error)
+	// mu guards the post-construction wiring below (gate + newClient), which
+	// may be set slightly before Run() while a test drives fetchAndSet.
+	mu        sync.RWMutex
+	gate      *identityGate
+	newClient func(as core.Identity, uat string) (encryptKeyClient, error)
func (p *encryptKeyProvider) setIdentityGate(g *identityGate) {
	p.mu.Lock()
	p.gate = g
	p.mu.Unlock()
}
🤖 Prompt for 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.

In `@internal/event/bus/encrypt_key.go` around lines 100 - 106, Protect the gate
assignment in setIdentityGate with the same synchronization used when
resolveOwnerIdentity reads p.gate on the fetchAndSet path. Add or reuse the
provider mutex around both the write and corresponding read, preserving the
existing newClientMu handling separately.
internal/event/bus/encrypt_key_test.go (1)

350-360: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Classification table misses errEncryptKeyBotUnsupported.

encryptKeyFailureClass has a resource_data_requires_user branch that no case covers, so a rename/removal of that token would go unnoticed.

💚 Proposed test addition
 		{errEncryptKeyOwnerMismatch, "owner_mismatch"},
+		{errEncryptKeyBotUnsupported, "resource_data_requires_user"},
 		{errEncryptKeyNoGate, "no_identity_gate"},
🤖 Prompt for 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.

In `@internal/event/bus/encrypt_key_test.go` around lines 350 - 360, Add
errEncryptKeyBotUnsupported with the expected "resource_data_requires_user"
classification to the cases table covering encryptKeyFailureClass. Keep the
existing classifications unchanged so the test explicitly exercises every
branch, including the resource-data-requires-user outcome.
internal/event/registry_test.go (1)

314-332: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Only one of the four new panic branches is covered.

validateRefinedSubscription also panics on empty PathSegment, empty SelectorKey, and out-of-set AuthTypes; none of those are asserted, so removing any of them would keep this test green.

💚 Proposed additional cases
for _, tc := range []struct {
	name string
	tmpl KeyTemplate
}{
	{"empty path segment", KeyTemplate{SelectorKey: "z_id"}},
	{"empty selector key", KeyTemplate{PathSegment: "z-id"}},
	{"bad auth type", KeyTemplate{SelectorKey: "z_id", PathSegment: "z-id", AuthTypes: []string{"nope"}}},
} {
	t.Run(tc.name, func(t *testing.T) {
		def := KeyDefinition{Key: "x.y.bad_v1", EventType: "x.y.bad_v1", ResourceType: "x.y",
			Schema: nativeSchema(), RefinedSubscription: true, KeyTemplates: []KeyTemplate{tc.tmpl}}
		assertPanics(t, func() { RegisterKey(def) })
	})
}
🤖 Prompt for 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.

In `@internal/event/registry_test.go` around lines 314 - 332, Expand
TestRegisterKey_RefinedValidation to assert panics for the remaining
validateRefinedSubscription branches: a template with an empty PathSegment, one
with an empty SelectorKey, and one containing an out-of-set AuthTypes value. Add
separate test cases using invalid refined KeyDefinitions and RegisterKey, while
preserving the existing missing-template and valid-registration checks.
internal/event/registry.go (1)

110-133: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

ResourceType is required for a refined key but not validated.

internal/event/resolve.go Line 143 builds TargetResource: baseDef.ResourceType + "?" + tmpl.SelectorKey + "=" + .... A refined key registered without ResourceType therefore yields a malformed "?chat_id=oc_x" that flows straight into subscription create/update, instead of failing loudly at registration like the other refined-key contract violations here. Template/Example have the same "declared required, silently optional" shape — Example is what the empty-value hint at resolve.go Line 133 renders.

🛡️ Proposed validation
 	if len(def.KeyTemplates) == 0 {
 		panic(fmt.Sprintf("EventKey %s: RefinedSubscription requires non-empty KeyTemplates", def.Key))
 	}
+	if def.ResourceType == "" {
+		panic(fmt.Sprintf("EventKey %s: RefinedSubscription requires a non-empty ResourceType", def.Key))
+	}
 	for i, tmpl := range def.KeyTemplates {
+		if tmpl.Example == "" {
+			panic(fmt.Sprintf("EventKey %s: KeyTemplates[%d] Example must not be empty", def.Key, i))
+		}
 		if tmpl.PathSegment == "" {
🤖 Prompt for 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.

In `@internal/event/registry.go` around lines 110 - 133, The
validateRefinedSubscription function must also reject refined keys with an empty
ResourceType, Template, or Example. Add registration-time validation for these
required fields, using the existing def.Key context and consistent panic
behavior so malformed refined definitions fail before resolution.

Comment thread cmd/event/bus.go
Comment on lines +65 to +70
if stored := getStoredUAToken(appID, userOpenID); stored != nil {
if stored.AccessToken != token {
return errUATUserMismatch
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Stale stored UAT can cause a false "belongs to another user" rejection.

The fast path treats stored.AccessToken != token as proof of a different owner. But ResolveToken is documented as uncached for UAT ("may be refreshed between calls"), so a freshly refreshed token for the same user can differ from the keychain copy if the refresh hasn't been persisted yet (or races with this read). That yields errUATUserMismatch — a misleading, fail-closed rejection of a legitimate consumer.

Consider making the mismatch fall through to the positive proveOpenID check instead of rejecting outright; only reject when ownership is actually disproven or unprovable.

🛠️ Suggested fallback instead of immediate rejection
 	if stored := getStoredUAToken(appID, userOpenID); stored != nil {
-		if stored.AccessToken != token {
-			return errUATUserMismatch
+		if stored.AccessToken == token {
+			return nil
 		}
-		return nil
+		// A differing token is NOT proof of a different owner: the UAT may
+		// have just been refreshed and not yet written back. Fall through to
+		// the positive open_id proof below.
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if stored := getStoredUAToken(appID, userOpenID); stored != nil {
if stored.AccessToken != token {
return errUATUserMismatch
}
return nil
}
if stored := getStoredUAToken(appID, userOpenID); stored != nil {
if stored.AccessToken == token {
return nil
}
// A differing token is NOT proof of a different owner: the UAT may
// have just been refreshed and not yet written back. Fall through to
// the positive open_id proof below.
}
🤖 Prompt for 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.

In `@cmd/event/bus.go` around lines 65 - 70, Update the stored-token fast path in
the event-bus token resolution flow so an AccessToken mismatch does not
immediately return errUATUserMismatch. Fall through to the existing proveOpenID
ownership check, allowing refreshed tokens to validate successfully; retain
rejection only when ownership is disproven or cannot be proven.

@@ -0,0 +1,71 @@
package refined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

Missing license header — this is the CI license-header failure.

Every other new file in this PR carries the standard two-line header.

🐛 Proposed fix
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
 package refined
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
package refined
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package refined
🧰 Tools
🪛 GitHub Actions: CI / 2064 / 12_license-header.txt

[error] 1-1: license-eye header check failed: file does not have a valid license header.

🪛 GitHub Actions: CI / 2064 / license-header

[error] 1-1: license-eye header check failed: file does not have a valid license header.

🤖 Prompt for 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.

In `@events/refined/catalog_test.go` at line 1, Add the repository’s standard
two-line license header at the top of catalog_test.go, before the package
refined declaration, matching the format used by other new files in the pull
request.

Source: Pipeline failures

Comment on lines +64 to +77
func (p *CredentialProvider) VerifyUATOpenID(ctx context.Context, brand core.LarkBrand, uat string) (string, error) {
if p == nil || p.httpClient == nil {
return "", fmt.Errorf("credential: no HTTP client configured to verify a user access token")
}
hc, err := p.httpClient()
if err != nil {
return "", err
}
info, err := fetchUserInfo(ctx, hc, brand, uat)
if err != nil {
return "", err
}
return info.OpenID, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does the credential provider's httpClient factory set a Timeout?
rg -nP -C6 'httpClient\s*(:?=|func\(\))' internal/credential | rg -n -C6 'Timeout|http\.Client'
ast-grep run --pattern '&http.Client{$$$}' --lang go internal/credential

Repository: larksuite/cli

Length of output: 4963


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate usages of NewCredentialProvider / NewDefaultTokenProvider =="
rg -n "NewCredentialProvider|NewDefaultTokenProvider" --glob '*.go' .

echo
echo "== credential_provider relevant sections =="
sed -n '1,80p' internal/credential/http_client.go 2>/dev/null || true
sed -n '120,170p' internal/credential/credential_provider.go
sed -n '100,135p' internal/credential/default_provider.go

echo
echo "== ResolveUAT callers and timeout/ctx usages around bus =="
rg -n -C8 "ResolveUAT|verifyUATBelongsToUser|VerifyUATOpenID|handleHello|bindConsumer|context.Background" internal/event internal/credential --glob '*.go'

echo
echo "== client creation sites outside tests =="
rg -n -C5 'http.Client\{|Timeout:\s*time\.' --glob '*.go' --glob '!**/*_test.go' . | sed -n '1,220p'

Repository: larksuite/cli

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tracked files under internal/credential =="
git ls-files internal/credential | sed -n '1,120p'

echo
echo "== NewCredentialProvider/NewDefaultTokenProvider exact matches =="
rg -n -C4 'NewCredentialProvider\(|NewDefaultTokenProvider\(' --glob '*.go' .

echo
echo "== credential http client / request factory files and timeout mentions =="
git ls-files internal/credential | xargs rg -n -C4 'http\.Client|Timeout|user_info|openid|OpenID\(|VerifyUATOpenID|ResolveToken' || true

echo
echo "== identity gate resolveUAT implementation around live UAT resolution =="
rg -n -C12 'func .*ResolveUAT|verifyUATBelongsToUser|VerifyUATOpenID|fetchUserInfo\(' internal/event internal/credential --glob '*.go'

Repository: larksuite/cli

Length of output: 50370


Don’t rely on the caller’s context to bound UAT verification.

VerifyUATOpenID makes a live HTTP round-trip over ctx; internal/cmdutil/factory_default.go wires the credential provider through deps.HttpClient, and deps.HttpClient is not set by the existing Client construction paths with a guaranteed Timeout. When a context.Background() Hello path reaches this call, a hung user_info request can block that goroutine/pipe until the socket closes. Add an explicit context.WithTimeout around the HTTP call, or establish a guaranteed default http.Client.Timeout at the injection point.

🤖 Prompt for 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.

In `@internal/credential/user_info.go` around lines 64 - 77, The VerifyUATOpenID
flow must impose its own timeout instead of relying solely on the caller’s
context. Add an explicit bounded context around the fetchUserInfo call, or
guarantee a nonzero http.Client.Timeout where deps.HttpClient is injected, while
preserving the existing error propagation and OpenID return behavior.

Comment thread internal/event/bus/bus.go
Comment on lines +445 to +455
if hello.IncludeResourceData && b.encryptKeyProvider != nil {
if err := b.encryptKeyProvider.fetchAndSet(context.Background(), bc.RemoteSubscriptionID(), bc); err != nil {
b.logger.Printf("[encrypt-key] rejecting encrypted consumer pid=%d key=%q: key unavailable (%s)",
hello.PID, hello.EventKey, encryptKeyFailureClass(err))
if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonDecryptKeyUnavailable)); werr != nil {
b.logger.Printf("WARN: reject hello_ack (decrypt_key_unavailable) write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, werr)
}
bc.Close()
return
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Hello-time remote calls run with no deadline. Both remote operations in handleHello are driven with context.Background(). Each reaches a live API call (GetEncryptKey, and resolveUATVerifyUATOpenID/BindUser), so a hung or slow upstream pins this consumer's registration goroutine and its pipe indefinitely — the consumer neither acks nor gets rejected. Derive one bounded context at the top of handleHello and pass it to both.

  • internal/event/bus/bus.go#L445-L455: replace context.Background() in the encryptKeyProvider.fetchAndSet call with a context.WithTimeout-derived context created once at the top of handleHello (with its cancel deferred).
  • internal/event/bus/bus.go#L529-L530: pass that same bounded context to b.identityGate.bindConsumer instead of context.Background().
🛡️ Proposed bounded context
 func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
+	// Hello-time remote work (encrypt_key fetch, BindUser) must never outlive
+	// the handshake: a hung upstream would otherwise pin this goroutine and
+	// leave the consumer neither acked nor rejected.
+	helloCtx, helloCancel := context.WithTimeout(context.Background(), helloRemoteTimeout)
+	defer helloCancel()
 	subID := hello.SubscriptionID
-		if err := b.encryptKeyProvider.fetchAndSet(context.Background(), bc.RemoteSubscriptionID(), bc); err != nil {
+		if err := b.encryptKeyProvider.fetchAndSet(helloCtx, bc.RemoteSubscriptionID(), bc); err != nil {
-		if err := b.identityGate.bindConsumer(context.Background(), bc); err != nil {
+		if err := b.identityGate.bindConsumer(helloCtx, bc); err != nil {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if hello.IncludeResourceData && b.encryptKeyProvider != nil {
if err := b.encryptKeyProvider.fetchAndSet(context.Background(), bc.RemoteSubscriptionID(), bc); err != nil {
b.logger.Printf("[encrypt-key] rejecting encrypted consumer pid=%d key=%q: key unavailable (%s)",
hello.PID, hello.EventKey, encryptKeyFailureClass(err))
if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonDecryptKeyUnavailable)); werr != nil {
b.logger.Printf("WARN: reject hello_ack (decrypt_key_unavailable) write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, werr)
}
bc.Close()
return
}
}
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
// Hello-time remote work (encrypt_key fetch, BindUser) must never outlive
// the handshake: a hung upstream would otherwise pin this goroutine and
// leave the consumer neither acked nor rejected.
helloCtx, helloCancel := context.WithTimeout(context.Background(), helloRemoteTimeout)
defer helloCancel()
subID := hello.SubscriptionID
Suggested change
if hello.IncludeResourceData && b.encryptKeyProvider != nil {
if err := b.encryptKeyProvider.fetchAndSet(context.Background(), bc.RemoteSubscriptionID(), bc); err != nil {
b.logger.Printf("[encrypt-key] rejecting encrypted consumer pid=%d key=%q: key unavailable (%s)",
hello.PID, hello.EventKey, encryptKeyFailureClass(err))
if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonDecryptKeyUnavailable)); werr != nil {
b.logger.Printf("WARN: reject hello_ack (decrypt_key_unavailable) write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, werr)
}
bc.Close()
return
}
}
if hello.IncludeResourceData && b.encryptKeyProvider != nil {
if err := b.encryptKeyProvider.fetchAndSet(helloCtx, bc.RemoteSubscriptionID(), bc); err != nil {
b.logger.Printf("[encrypt-key] rejecting encrypted consumer pid=%d key=%q: key unavailable (%s)",
hello.PID, hello.EventKey, encryptKeyFailureClass(err))
if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonDecryptKeyUnavailable)); werr != nil {
b.logger.Printf("WARN: reject hello_ack (decrypt_key_unavailable) write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, werr)
}
bc.Close()
return
}
}
Suggested change
if hello.IncludeResourceData && b.encryptKeyProvider != nil {
if err := b.encryptKeyProvider.fetchAndSet(context.Background(), bc.RemoteSubscriptionID(), bc); err != nil {
b.logger.Printf("[encrypt-key] rejecting encrypted consumer pid=%d key=%q: key unavailable (%s)",
hello.PID, hello.EventKey, encryptKeyFailureClass(err))
if werr := bc.writeFrame(protocol.NewHelloAckRejected("v1", protocol.RejectReasonDecryptKeyUnavailable)); werr != nil {
b.logger.Printf("WARN: reject hello_ack (decrypt_key_unavailable) write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, werr)
}
bc.Close()
return
}
}
if b.identityGate != nil && bc.OwnerUserOpenID() != "" && b.identityGate.ready() {
if err := b.identityGate.bindConsumer(helloCtx, bc); err != nil {
📍 Affects 1 file
  • internal/event/bus/bus.go#L445-L455 (this comment)
  • internal/event/bus/bus.go#L529-L530
🤖 Prompt for 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.

In `@internal/event/bus/bus.go` around lines 445 - 455, In handleHello, create one
context.WithTimeout-derived context at the start and defer its cancel, using the
handler’s bounded hello timeout. Replace context.Background() in
internal/event/bus/bus.go lines 445-455 for encryptKeyProvider.fetchAndSet and
lines 529-530 for b.identityGate.bindConsumer with that shared context so both
remote calls are deadline-bound.

Comment on lines +90 to +95
switch {
case le.TargetResource == "":
unclear = true
case le.TargetResource != lead.TargetResource():
return updateIncompatible
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does internal/event import the bus/lifecycle packages (would a lifecycle -> event import cycle?)
rg -n --type=go '"github.com/larksuite/cli/internal/event/bus' internal/event --glob '!internal/event/bus/**'
# Where else is TargetResource compared?
rg -nP --type=go 'TargetResource\(\)\s*(!=|==)|TargetResourceEqual\('

Repository: larksuite/cli

Length of output: 321


🏁 Script executed:

#!/bin/bash
set -e

echo "== relevant files =="
git ls-files | rg 'internal/event/(target_resource|target_resource_test)\.go|internal/event/bus/(action|hub)\.go' || true

echo
echo "== action.go context =="
sed -n '60,115p' internal/event/bus/lifecycle/action.go

echo
echo "== hub.go context around TargetResourceEqual usage =="
sed -n '510,565p' internal/event/bus/hub.go

echo
echo "== target_resource.go =="
sed -n '1,220p' internal/event/target_resource.go

echo
echo "== imports in lifecycle packages =="
for f in internal/event/bus/lifecycle/*.go; do echo "--- $f"; sed -n '1,40p' "$f"; done

echo
echo "== behavioral probe from source code comments/conversions =="
rg -n "escape|unescape|plus|percent|selector|order|TargetResourceEqual" internal/event internal/event/bus -g '*.go'

Repository: larksuite/cli

Length of output: 31508


🏁 Script executed:

#!/bin/bash
set -e

echo "== action.go full imports =="
sed -n '1,80p' internal/event/bus/lifecycle/action.go

echo
echo "== event package files =="
git ls-files 'internal/event/*.go' | sed -n '1,120p'

echo
echo "== precise package imports by lifecycle files =="
python3 - <<'PY'
import re, pathlib
for p in pathlib.Path('internal/event/bus/lifecycle').glob('*.go'):
    txt=p.read_text()
    imports=re.findall(r'"([^"]+)"', txt)
    print(f'{p}: {imports}')
PY

echo
echo "== direct comparison with imported package in action.go =="
python3 - <<'PY'
import re
txt = pathlib.Path('internal/event/bus/lifecycle/action.go').read_text()
imports = re.findall(r'"([^"]+)"', txt)
has_event = any(importable == 'github.com/larksuite/cli/internal/event' for importable in imports)
uses_event_target_equal = bool(re.search(r'event\.TargetResourceEqual\(', txt))
print('imports github.com/larksuite/cli/internal/event:', has_event)
print('uses event.TargetResourceEqual:', uses_event_target_equal)
PY

Repository: larksuite/cli

Length of output: 39846


Use event.TargetResourceEqual for target_resource comparisons.

le.TargetResource != lead.TargetResource() treats percent/plus escaping and selector-order differences as incompatible, while the same comparison elsewhere uses the normalized form meant for the platform’s echoed value versus the locally built intent. Add the github.com/larksuite/cli/internal/event import already used by executor.go and use !event.TargetResourceEqual(...).

🐛 Proposed fix
 	switch {
 	case le.TargetResource == "":
 		unclear = true
-	case le.TargetResource != lead.TargetResource():
+	case !event.TargetResourceEqual(le.TargetResource, lead.TargetResource()):
 		return updateIncompatible
 	}
🤖 Prompt for 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.

In `@internal/event/bus/lifecycle/action.go` around lines 90 - 95, Update the
target_resource comparison in the lifecycle action switch to use
event.TargetResourceEqual, adding the internal/event import as needed. Replace
the direct inequality between le.TargetResource and lead.TargetResource() with
the normalized equality check while preserving the empty-target unclear case and
updateIncompatible result.

Comment on lines +30 to +48
// SubscriptionLister is the narrow read-only seam ReconcileExisting depends
// on: enough to classify existing remote Subscription state (event_type +
// target_resource + authority) without ever writing, so it is safe to call
// from a --dry-run / plan-only preflight (the
// PlanRemoteSubscription stage). *SubscriptionClient satisfies this structurally
// — no explicit "implements" declaration needed, Go interfaces are
// structural.
type SubscriptionLister interface {
List(ctx context.Context, req *larkeventv1.ListSubscriptionReq) (*larkeventv1.ListSubscriptionResp, error)
}

// SubscriptionCreateAPI extends SubscriptionLister with the write path a
// caller needs once it has decided, from a ReconcilePlan, to actually
// create a new remote Subscription (the PlanActionCreate row of the
// state table). *SubscriptionClient satisfies this structurally.
type SubscriptionCreateAPI interface {
SubscriptionLister
Create(ctx context.Context, req *larkeventv1.CreateSubscriptionReq) (*larkeventv1.CreateSubscriptionResp, error)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Both files reference larkeventv1 subscription symbols that the pinned SDK doesn't export, breaking go build ./... and govulncheck. One root cause: github.com/larksuite/oapi-sdk-go/v3@v3.7.2 has no service/event/v1 subscription request/response types.

  • internal/event/reconcile.go#L30-L48: ListSubscriptionReq/Resp, CreateSubscriptionReq/Resp, GetEncryptKeySubscriptionReq/Resp, SubscriptionDetail are all undefined — bump the SDK to a version exporting them before merge.
  • internal/event/subscription_client.go#L35-L36: CreateSubscriptionReq/Resp and GetSubscriptionReq fail identically; the same dependency bump resolves them, no local change needed.
🧰 Tools
🪛 GitHub Actions: CI / 2064 / 11_fast-gate.txt

[error] 38-38: go build ./... failed: undefined: larkeventv1.ListSubscriptionReq

🪛 GitHub Actions: CI / 2064 / 13_security.txt

[error] 38-38: govulncheck failed: undefined type 'larkeventv1.ListSubscriptionReq' at /home/runner/work/cli/cli/internal/event/reconcile.go:38:45

🪛 GitHub Actions: CI / 2064 / fast-gate

[error] 38-38: go build failed: undefined: larkeventv1.ListSubscriptionReq

🪛 GitHub Actions: CI / 2064 / security

[error] 38-38: govulncheck failed due to Go compilation error: undefined: larkeventv1.ListSubscriptionReq

🪛 GitHub Check: fast-gate

[failure] 47-47:
undefined: larkeventv1.CreateSubscriptionResp


[failure] 47-47:
undefined: larkeventv1.CreateSubscriptionReq


[failure] 38-38:
undefined: larkeventv1.ListSubscriptionResp


[failure] 38-38:
undefined: larkeventv1.ListSubscriptionReq

🪛 GitHub Check: security

[failure] 47-47:
undefined: larkeventv1.CreateSubscriptionResp


[failure] 47-47:
undefined: larkeventv1.CreateSubscriptionReq


[failure] 38-38:
undefined: larkeventv1.ListSubscriptionResp


[failure] 38-38:
undefined: larkeventv1.ListSubscriptionReq

📍 Affects 2 files
  • internal/event/reconcile.go#L30-L48 (this comment)
  • internal/event/subscription_client.go#L35-L36
🤖 Prompt for 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.

In `@internal/event/reconcile.go` around lines 30 - 48, Upgrade
github.com/larksuite/oapi-sdk-go/v3 from the pinned v3.7.2 to a version
exporting the subscription request, response, and detail symbols used by
SubscriptionLister, SubscriptionCreateAPI, and the related reconciliation code.
This dependency-only fix covers internal/event/reconcile.go lines 30-48 and
internal/event/subscription_client.go lines 35-36; no direct changes are needed
at either usage site.

Sources: Linters/SAST tools, Pipeline failures

Comment on lines +556 to 590
// subscriptionDecryptFailureRe matches the SDK's stable whole-envelope
// decrypt-failure error (event/dispatcher/dispatcher.go's
// decryptSubscriptionEnvelope: "subscription event decryption failed
// (subscription_id=%s): ...") and captures the subscription_id. This couples to
// the pinned SDK's error string — the only surface the WS path exposes for a
// per-message decrypt failure (Do returns the error; the ws client logs it via
// this logger). The capture stops at ')' so it never swallows the ": <detail>"
// tail (which carries no key, but also no useful routing info).
var subscriptionDecryptFailureRe = regexp.MustCompile(`subscription event decryption failed \(subscription_id=([^)]*)\)`)

// decryptFailureLogClass is the fixed classification used when a decrypt-failure
// SDK error line is rewritten before it reaches bus.log. The log must not carry
// the crypto/padding detail in the SDK's raw error tail, e.g. "illegal base64
// data", "cipher too short", or "ciphertext is not a multiple of the block
// size", because those details could become a decryption oracle. The token
// mirrors Conn.decryptStateFailed's "decrypt_failed" state.
const decryptFailureLogClass = "decrypt_failed"

// redactDecryptFailureLine detects a decrypt-failure SDK Error line
// (subscriptionDecryptFailureRe) and truncates it right after the
// "(subscription_id=...)" it already carries, discarding everything from
// there to the end of the line — event/dispatcher/dispatcher.go's
// decryptSubscriptionEnvelope always appends the raw crypto/padding detail as
// the LAST component of the wrapped ws-client log line ("...: <detail>"), so
// this is the ONLY place that detail appears on this path — and replacing it
// with a fixed classification instead. Any harmless prefix context the ws
// client adds (message_type/message_id/trace_id, etc.) is kept verbatim. A
// line that is not a decrypt failure is returned byte-for-byte unchanged.
func redactDecryptFailureLine(msg string) string {
loc := subscriptionDecryptFailureRe.FindStringIndex(msg)
if loc == nil {
return msg
}
return msg[:loc[1]] + ": classification=" + decryptFailureLogClass
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

oapi-sdk-go v3 subscription event decryption failed error message dispatcher

💡 Result:

The error message indicating a decryption failure in the Lark (Feishu) OpenAPI SDK for Go (v3) when handling event subscriptions is typically caused by a mismatch or incorrect configuration of the security credentials used by the EventDispatcher [1][2]. When you initialize the EventDispatcher in the Go SDK, you must provide the correct Verification Token and Event Encrypt Key if encryption is enabled in the Lark Developer Console [1][3][4]. If these values are missing, incorrect, or if encryption is enabled on the platform but the SDK is not configured to handle it, decryption will fail [1][2][5]. To resolve this issue, follow these steps: 1. Verify Encryption Settings: Go to the Lark Developer Console, navigate to your app, and check the Events and Callbacks > Encryption Strategy section [1][6][2]. Confirm whether Encryption is enabled [4]. 2. Update Dispatcher Initialization: When creating your EventDispatcher, ensure you are passing the exact Verification Token and Event Encrypt Key from the Developer Console [1][3][4]. Example configuration: handler:= dispatcher.NewEventDispatcher("your_verification_token", "your_event_encrypt_key") If encryption is enabled on the platform, passing empty strings or incorrect values will cause decryption errors [1][3][4]. 3. Check SDK Middleware: Ensure the http.HandleFunc used for receiving events is correctly wrapped with the handler using httpserverext.NewEventHandlerFunc [1][7][3]. This middleware handles the decryption and verification logic automatically [7][2]. 4. Debugging: If the error persists, you can enable debug logs by passing an option to the event handler to see more granular error details, which can help confirm if the issue is related to signature verification or the decryption process itself [3][8]: http.HandleFunc("/webhook/event", httpserverext.NewEventHandlerFunc(handler, larkevent.WithLogLevel(larkcore.LogLevelDebug))) If you have confirmed your keys are correct and are still encountering issues, verify that your server is correctly receiving the full, uncorrupted request body from the Lark platform, as any modification to the payload will cause signature and decryption verification to fail [2][5][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
fd -i 'go.mod|go.sum|feishu.go' . | sed 's#^\./##' | sort

echo
echo "Relevant occurrences:"
rg -n "subscriptionDecryptFailureRe|decryptSubscriptionEnvelope|redactDecryptFailureLine|DecryptFailure|decrypt_failed|oapi-sdk-go|larksuite/oapi" -S .

echo
echo "Module versions:"
if [ -f go.mod ]; then awk '/oapi-sdk-go|larksuite\/oapi|github.com\/larksuite\/oapi-sdk-go|go 1\./{print}' go.mod; fi
if [ -f go.sum ]; then rg 'oapi-sdk-go|larksuite/oapi' go.sum | head -20; fi

Repository: larksuite/cli

Length of output: 25442


🌐 Web query:

site:github.com/larksuite/oapi-sdk-go "subscription event decryption failed" v3.7.2 event dispatcher

💡 Result:

The error "subscription event decryption failed" (or related decryption/unmarshal errors) when using the Lark (Feishu) Open Platform Go SDK typically indicates a mismatch between the configuration in your application code and the settings in the Lark Developer Console [1]. To resolve this issue, ensure the following steps are taken: 1. Verify Encryption Configuration: In the Lark Developer Console under "Event Subscriptions," if "Encrypt Key" (Event Encrypt Key) is enabled, it must be correctly provided when initializing the EventDispatcher [1]. If the keys in your code do not match the keys configured in the developer console, decryption will fail [1]. Initialize your dispatcher as follows: dispatcher.NewEventDispatcher("your_verification_token", "your_event_encrypt_key") 2. Verify Token and Keys: Ensure both the "Verification Token" and "Event Encrypt Key" are copied exactly from the "Event Subscriptions" or "Credentials & Basic Info" section of your app in the developer console [1]. Even trailing or leading whitespace in these configuration strings can cause failures. 3. Check Event Protocol Version: Lark uses different event protocols (e.g., v1.0 vs v2.0). Ensure you are registering the correct event handler type (e.g., OnP1... for v1.0, OnP2... for v2.0) [1]. Using the wrong registration method can lead to unexpected unmarshaling errors or failure to process the decrypted payload [1]. 4. Inspect Request/Response Body: If you are getting JSON unmarshal errors (such as "invalid character"), inspect the raw body of the incoming request [2]. This often happens if the encryption key is incorrect (causing the SDK to attempt to decrypt garbage data) or if the server is receiving an unexpected format [2]. You can use the logger provided by the SDK to debug the incoming request: larkevent.WithLogLevel(larkcore.LogLevelDebug) [3][1] If the issue persists, ensure your SDK version is up to date, as issues regarding specific data type unmarshaling (e.g., string vs number for timestamps) have been addressed in later versions [4].

Citations:


🌐 Web query:

github.com larksuite oapi-sdk-go event dispatcher decryptSubscriptionEnvelope "subscription event decryption failed"

💡 Result:

The error "decryptSubscriptionEnvelope subscription event decryption failed" typically indicates a mismatch or misconfiguration in the event encryption settings when using the Lark/Feishu Open Platform SDK for Go [1][2][3]. When the Lark Open Platform pushes events with encryption enabled, the SDK must use the correct Encryption Key (AES Key) to decrypt the payload [4]. If decryption fails, it is almost always due to one of the following reasons: 1. Incorrect Encryption Key: Ensure the "Encrypt Key" configured in your application's "Event Subscriptions" page on the Lark/Feishu Developer Console matches exactly the key being passed to the SDK's dispatcher [4]. 2. Mismatch in Encryption Status: If you have enabled encryption in the Developer Console but are attempting to handle events in the SDK without providing the proper key, or vice-versa, decryption will fail [4]. 3. Handler Configuration: The EventDispatcher must be initialized with the correct verification token and encryption key if your app requires them [5][6]. If you are manually handling events (e.g., using OnCustomizedEvent), ensure you are passing the correct cipher data to the decryption methods [7]. Recommended troubleshooting steps: - Verify Encryption Settings: Go to the Lark/Feishu Developer Console, navigate to your app, and check the "Events & Callbacks" (or "Event Subscription") section. Confirm whether "Encrypt Key" is enabled and match that value against your code [5][4]. - Check SDK Initialization: When creating your event dispatcher, ensure you are passing the correct credentials [6]. For example: dispatcher.NewEventDispatcher("VERIFICATION_TOKEN", "ENCRYPTION_KEY") - Use Debug Logging: Enable debug logging in the SDK to inspect the incoming payload and the error details [5][6]. This will help determine if the payload itself is malformed or if the decryption process is rejecting it due to key mismatch. larkcore.WithLogLevel(larkcore.LogLevelDebug) If you are using the OnCustomizedEvent handler, ensure you are correctly parsing the event and calling handler.DecryptEvent(ctx, cipherEventJsonStr) as demonstrated in the official SDK samples [7].

Citations:


🌐 Web query:

raw GitHub github.com larksuite oapi-sdk-go event dispatcher dispatcher.go decryptSubscriptionEnvelope subscription event decryption failed

💡 Result:

If you encounter a subscription event decryption failure when using the Lark/Feishu Go SDK (larksuite/oapi-sdk-go), it typically indicates a mismatch between the encryption configuration in your application's settings and the values provided to the SDK's EventDispatcher [1][2][3]. Key troubleshooting steps include: 1. Verify Encryption Configuration: Ensure that the Encrypt Key (or Encryption Key) used in your code matches exactly with the one configured in the Lark Developer Console under "Event & Callback > Encryption Strategy" [1][2][3]. 2. Verify Dispatcher Initialization: When initializing the EventDispatcher, both the Verification Token and Encrypt Key must be passed correctly if encryption is enabled in the developer console [2][3]. Example initialization: handler:= dispatcher.NewEventDispatcher("your_verification_token", "your_encrypt_key") 3. Check Request Data: Ensure the raw request body is being passed correctly to the dispatcher. Decryption will fail if the payload is malformed, truncated, or if the wrong part of the HTTP request is being parsed [4][5]. 4. SDK Versioning: Ensure you are using the latest version of the SDK, as older versions may have different handling logic or bugs related to event decryption [6][7]. For further debugging, you can use the low-level event.EventDecrypt function provided by the SDK to isolate whether the failure is in the dispatcher's automatic handling or the decryption logic itself [6][8]. func EventDecrypt(encrypt string, secret string) ([]byte, error) If the decryption continues to fail, verify that no middleware or framework (e.g., Gin, standard net/http) is consuming or modifying the request body before it reaches the SDK handler [4][5].

Citations:


🌐 Web query:

raw.githubusercontent.com/larksuite/oapi-sdk-go v3.7.2 event/dispatcher/dispatcher.go subscription event decryption failed decryption failed

💡 Result:

The "decryption failed" error when using the larksuite/oapi-sdk-go event dispatcher typically indicates a mismatch between the configuration on the Lark Developer Platform and the credentials provided to the SDK [1][2][3]. When encryption is enabled in the Lark Developer Platform (under Event & Callback > Encryption Strategy), the SDK must be initialized with both the correct Verification Token and the correct Encrypt Key [1][2][3]. Troubleshooting steps: 1. Verify Credentials: Ensure that the Verification Token and Encrypt Key passed to dispatcher.NewEventDispatcher(verificationToken, encryptKey) exactly match those provided in the Lark developer console [2][3]. Even a slight mismatch will cause decryption to fail [1]. 2. Confirm Encryption Status: If you have not enabled "Encrypt Key" in the developer backend, ensure you are not passing a key to the dispatcher that the SDK attempts to use for decryption [2][3]. 3. Check SDK Initialization: Ensure the dispatcher is initialized correctly. In older or specific versions, incorrect parameter ordering or empty strings for disabled encryption can lead to unexpected behavior [2][3]. 4. Debugging: Use larkcore.WithLogLevel(larkcore.LogLevelDebug) when registering the event handler to see more detailed logs about the request processing [2][3]. The oapi-sdk-go performs decryption internally using the provided Encrypt Key [1]. If your application receives the event request but fails to decrypt it, the issue is almost certainly due to the Encrypt Key not matching the key used by the Lark server to encrypt the payload [1][2]. If the data itself is malformed or truncated before reaching the SDK, standard base64 decoding errors may also occur, which are often related to improper handling of character encoding or padding in the request body [4][5].

Citations:


🌐 Web query:

"subscription event decryption failed" "larksuite/oapi-sdk-go" "subscription_id"

💡 Result:

If you encounter a "subscription event decryption failed" error when using the Lark/Feishu Open API SDK for Go, it is almost certainly caused by a mismatch between the encryption settings on the Lark Developer Platform and the configuration passed to your SDK's event handler [1][2][3]. When encryption is enabled in the Developer Platform (under Event & Callback > Encryption Strategy), you must provide the correct Encrypt Key and Verification Token when initializing the EventDispatcher [1][4][3]. To resolve this, ensure the following steps are verified: 1. Validate Credentials: Confirm that the Encrypt Key and Verification Token in your code match exactly what is displayed on the Event & Callback > Encryption Strategy page of your application in the Lark Developer Console [2]. 2. Initialize Dispatcher Correctly: When creating your dispatcher, ensure both parameters are provided [1][4][3]: handler:= dispatcher.NewEventDispatcher("your_verification_token", "your_encrypt_key") 3. Check SDK Usage: If you are using the EventDispatcher, ensure you are not attempting to manually decrypt the event payload using a different method while the dispatcher is already configured to handle it automatically [1][5]. The SDK's handler automatically manages decryption if these keys are provided [1][3]. 4. Debugging: Enable debug logging in your event handler to inspect the raw request and identify if the decryption failure occurs during initial parsing or during the decryption phase [1][5][6]: http.HandleFunc("/webhook/event", httpserverext.NewEventHandlerFunc(handler, larkevent.WithLogLevel(larkcore.LogLevelDebug))) If you are using the low-level event.EventDecrypt function directly, ensure you are passing the correct encrypted string extracted from the request headers (often the X-Lark-Content-V1-Encrypted header) and the correct secret key [7][8]. Note that if you prefer to avoid the complexities of manual decryption and webhook configuration, you can use the SDK's persistent connection (WebSocket) mode, which encapsulates authentication and delivers plain text data directly, eliminating the need for decryption logic on your server [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Feishu source relevant section:"
sed -n '520,650p' internal/event/source/feishu.go

echo
echo "Feishu tests around decrypt failure fixtures:"
sed -n '450,525p' internal/event/source/feishu_test.go

echo
echo "Check whether tests import/pull live SDK sources or just own fixtures:"
rg -n 'TryDecryptEnvelope|decryptSubscriptionEnvelope|subscription event decryption failed|DecryptSubscription|oapi-sdk-go.*v3' internal/event/source/internal/event/source/feishu_test.go internal/event/source/feishu.go || true

Repository: larksuite/cli

Length of output: 10929


Gate decrypt-failure logs on a positive SDK-error match.

redactDecryptFailureLine only passes the raw crypto/padding tail through when the pinned SDK wording no longer matches, creating fail-open drift on a security control. Make SDK upgrades fail loudly (for example, assert the current SDK error text) and require the encrypt-mismatch prefix for decryption failures instead of any error containing encrypt mismatch/invalid token.

🤖 Prompt for 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.

In `@internal/event/source/feishu.go` around lines 556 - 590, Harden
redactDecryptFailureLine and subscriptionDecryptFailureRe so redaction occurs
only for the exact pinned SDK decrypt-failure format with the required
encrypt-mismatch prefix, not merely arbitrary “encrypt mismatch” or “invalid
token” text. Add an explicit assertion of the expected SDK error wording so SDK
changes fail loudly rather than silently passing raw crypto details through.

leave330 added 15 commits July 27, 2026 17:35
Rename filterToSDK/filterFromSDK to FilterToSDK/FilterFromSDK so the
command layer (create/consume/reads) and reconcile can project a CLI
filter to and from the SDK filter type at their own API boundaries
without duplicating the mapping. Behavior is unchanged; internal callers
and tests are updated to the exported names.
Add a WithRequestedFilter reconcile option carrying the requested
server-side filter. An active subscription may be reused only when the
requested filter equals the remote one; any difference (including
empty-vs-filtered either way) plans a conflict with a 'filter'
ConflictField, mirroring the fail-closed include_resource_data reuse
rule. The compare runs ahead of the plaintext and encrypted reuse paths,
and again inside the encrypted probe, so a filter mismatch blocks reuse
regardless of encryption. The conflict reason names only the dimension,
never the filter contents on either side.
Add a --filter flag to 'event subscription create'. The inline JSON
filter is validated against the event type's filter schema (a failure is
a typed invalid_argument on --filter, before any remote call), reconciled
against an existing subscription as a conflict dimension, and projected
into the Create body. With no --filter the field is omitted entirely
rather than sent as an empty clear form.
Add a --filter flag to 'event consume', mirroring subscription create.
On a refined key the filter is validated against the event type's filter
schema, reconciled against an existing subscription as a conflict
dimension (surfaced through the plan's conflict fields), and projected
into the Create body on apply; with no --filter the field is omitted. On
an ordinary (non-refined) key --filter is rejected as invalid_argument
rather than silently ignored, since it has no remote Subscription to
apply to. Local delivery is not yet filtered by it.
Surface a remote subscription's server-side filter as canonical JSON in
the read surfaces: list/get (subscriptionRow.filter, get text line) and
status (RemoteSubscriptionInfo.filter, JSON plus a status sub-line). The
field is present only when the remote subscription actually carries a
filter and omitted otherwise, consistent with how payload_options is
shown.
Rebuild `event subscription update` from a pure-local reject into a real
remote write whose only field is the server-side event filter: --filter
<json> sets or replaces it, --clear-filter removes it (exactly one is
required). It resolves an identity, requires event:subscription:read and
event:subscription:write, reads the subscription first (to learn the event
type --filter is validated against, since it carries no EventKey, and to
skip a no-op write when the requested filter already matches), supports
--dry-run, and is not confirmation-gated (a filter change is reversible).
include_resource_data stays an explicit, network-free reject.

Re-correct the subscription group help, the shared-helper comments, and the
refined-subscription reference that still described update as a reject-only
stub with no identity, scope, remote read, or --dry-run.
Carry the consumer's requested server-side event filter through the bus
Hello as declared intent (Hello.Filter -> Conn.listenFilter ->
FilterIntent()), and add the filter as the 4th dimension of the updated_v1
compatibility check so a remote filter change degrades the local consumer
via the existing remote_subscription_conflict / next_action=get path.

The platform applies the filter server-side, so the bus never filters on
it; it is tracked purely as intent, like include_resource_data, with three
differences: compared via event.Equal (a struct, not ==); a mandatory
FilterPresent flag distinguishes a genuine no-filter from an absent wire
value (absent -> unclear -> single Get); and capture-only at Hello (no
reject gate). A Get response always carries the authoritative filter.
A blank --filter (e.g. from an unset shell variable expanding to "" in a
script) passed the "--filter was set" gate, and ParseAndValidateFilter("")
returned an empty filter with no error — so update would silently issue a
{"filter":{}} clear on a currently-filtered subscription instead of
surfacing the caller's mistake. Reject it locally, before any network
call, alongside the existing --filter/--clear-filter mutual-exclusion
checks, directing the caller to --clear-filter instead.

Also make the real-run no-op message match what --dry-run already reports:
a --clear-filter no-op now says the subscription already has no filter,
instead of reusing --filter no-op's "already has the requested filter"
wording.
… --filter content

ReconcileExisting now applies the same fail-closed filter compare on the
suspended path that the active path already uses: a suspended match whose
remote filter differs from the requested filter is returned as a filter
conflict instead of a reactivate-and-reuse, so an incompatible suspended
subscription is never silently reused onto the wrong event stream. A
ConflictOnFilter helper and a shared field-name constant let callers detect
that dimension.

ParseAndValidateFilter now requires the input to be exactly one JSON value:
after decoding the object a second decode must return io.EOF. dec.More() did
not catch a trailing ] or } (it read them as an array/object terminator), so
inputs like {...}] or {...}} were wrongly accepted; trailing content of any
kind is now a typed invalid_argument on --filter, with no raw content echoed.
…; fix --filter help metavar

A reconcile filter conflict surfaced by create/consume now guides recovery
through get -> update --filter --dry-run -> update --filter -> re-run, because
a server-side filter is changeable in place and deleting a possibly-shared
subscription is destructive and unnecessary. The include_resource_data
conflict guidance (genuinely not updatable) is unchanged. Filter contents are
never named in the guidance.

The --filter usage strings on create/consume put the flag metavar in the first
back-quoted word, so pflag rendered "--filter event schema <key> --json". The
metavar is now "json" and the doc reference uses plain quotes.
…lumn; update --filter metavar

The shared mutation dry-run builder no longer hardcodes
local_consumer_affected=false. renew/reactivate/delete keep false (they do not
change the delivered stream); a real update filter change now reports true and
discloses that any local consumer receives the new stream and should be
re-synced (run event status, restart if needed), while a no-op update reports
false. No bus query and no new confirmation flag are introduced.

event list text output gains a REFINED column before DESCRIPTION (true for
refined-subscription base keys, false for legacy) and a footer explaining that
a REFINED=true key is a base key to inspect via event schema rather than
consume directly, replacing the weaker refined-subscriptions block. --json
output is unchanged.

The update --filter help metavar is corrected to "json" (same pflag back-quote
fix as create/consume).
The generic internal/event filter layer hardcoded im.message.created_v1's
FilterMeta in a package map, mixing a specific business event_type into the
framework that owns only the Filter type, validation, and SDK projection.

Keep the types and FilterMetaFor in internal/event and add a
RegisterFilterMeta registry API; the refined business layer registers the
im.message.created_v1 mock capability from an init, alongside the mock
catalog that owns that event_type. Behavior is unchanged: the event schema
filter block, operands, and limits are identical, and event_types without a
registered capability stay fail-closed.

Tests that relied on the seed being present at import of internal/event
alone now seed the capability through the registry API.
Help text, doc comments, and error-hint placeholders used the real catalog
key im.message.created_v1 as a generic stand-in, which misleads now that it
is a concrete shipped key. Switch those illustrative-only sites to the
deliberately non-existent im.message.example_v1, matching the convention the
consume help already follows.

Real references keep im.message.created_v1: contract/unit/e2e/skill-eval
tests, the mock catalog definition, the filter-meta registration seed, and
the refined-subscription skill docs, which walk through the real shipped key.
Extend the reconcile suspended-reuse path to compare include_resource_data via
a shared includeResourceDataConflictPlan helper (used by the active path too),
so a suspended subscription whose include_resource_data differs from the request
is a conflict a human resolves rather than being silently reactivated and
reused -- the same fail-closed rule already applied to the filter dimension.
Replace the real catalog key im.message.created_v1 with the illustrative
im.message.example_v1 throughout the lark-event skill, and point agents at
'event list' to discover real refined base keys. The concrete key stays in the
catalog and tests; the skill no longer hardcodes it as a generic example.
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

PR Quality Summary

CI did not complete successfully. Use the failed check links below to decide whether this PR needs a code change or a rerun.

Failed checks

  • license-header — failure — details
  • fast-gate — failure — details
  • results — failure — details

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Architecture-level or global-impact change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant