Skip to content

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

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

feat(event): refined event subscription support (management, consume, encryption)#2064
leave330 wants to merge 210 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 — built on a hexagonal event-subsystem architecture with a single owner per domain fact. Draft for review; CI is red pending an upstream oapi-sdk-go release (see Test Plan).

Feature 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: 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.

Architecture

The event subsystem follows a hexagonal / clean layering — the domain is inner, adapters implement inward, the command layer is thin. The boundary is enforced by tests, not convention:

  • internal/event/model — SDK-free value objects (RemoteSubscription, Filter, OwnerRef, …), each with a deep Clone so a value crossing a decision boundary stops being the caller's to change.
  • internal/event/catalog — the EventKey registry. Compile(events.All()) is the one explicit build step: it registers every business declaration, validates the whole catalog (cross-declaration defects — a shadowed template path, a business event_type colliding with a reserved lifecycle meta-event — are only visible once every declaration is present), freezes it, and returns an immutable Snapshot projecting each key into its declaration view, its runtime hooks and its delivery capability. It is called from the single assembly function every entry point funnels through, so no entry point can run on an empty or unvalidated catalog.
  • internal/event/subscription — the domain owner: it defines the outbound Gateway port and its request specs, and owns Observe → Plan → Apply (the only remote-write path). A SubscriptionPlan is unforgeable and self-sufficient: its fields are unexported, it carries the policy and request it was classified against, and Apply(ctx, plan) takes nothing else — so what gets written and what was validated cannot come apart.
  • internal/event/binding — the DeliveryBinding aggregate: the facts belonging to one remote Subscription, which every consumer bound to it shares. They have one writable home instead of a copy per connection, mutations apply as one versioned step, and a decision made from an earlier read is rejected rather than allowed to overwrite newer state.
  • internal/event/adapter/** — the outward edge, grouped so the dependency direction is visible: adapter/lark/{websocket,subscription} for the platform, adapter/localbus/** for the local-bus IPC. No kernel package imports an adapter, and each host that legitimately does is pinned to an explicit allowlist.
  • internal/event/app — consume/status/subscription use-cases; internal/event/session — the fail-closed owner/current identity gate; plus health, routing, delivery, recovery.
  • Recovery suggestions — the domain names what to do next as a semantic action and never spells a command line; cmd/event/render is the single place that turns one into a runnable lark-cli command carrying the resolved --profile + --as. Renaming a flag or moving a command is one edit there instead of a hunt through the domain. Status recovery is owner-first: it maps a consumer's owning app → profile and only emits an executable command when that profile currently resolves to the consumer's owner (else a reason-only hint), never falling back to the invocation's profile.

Correctness / hardening

  • Fail-closed by default: consume runs the owner/current identity gate before any remote write; renew/reactivate reject out-of-policy remote states rather than issuing a blind write; update requires --yes when a filter change affects — or, if the local-bus scan/query is incomplete, cannot be confirmed not to affect — a running local consumer. Subscription listing fails closed on a broken pagination chain rather than silently truncating.
  • Canonical event metadata has one authority. Event identity, the upstream event time and the local observation time, the app and tenant identity, and the refined delivery tuple are carried across the IPC boundary and restored by the consumer; domain handlers never re-derive them from the push envelope, and an architecture test keeps it that way. Where the payload's own header also states one of those facts it is verified against the canonical value: a header claim the canonical side cannot corroborate — including one lost in transit — drops the event rather than emitting it with that field silently blank. Every consumer additionally requires the bus to advertise canonical-metadata support on the delivery connection itself, so an older bus is refused with a typed precondition error instead of feeding blank fields into handlers.
  • The upstream event time and the local observation time never substitute for one another, and neither is backfilled from the local clock — a missing time fact stays visibly missing.
  • Concurrency: a shared subscription's facts are written as one atomic, versioned mutation, so a status read can never observe a lifecycle reduction half-applied — no failure without the recovery beside it, and no new remote state still paired with the previous suspension code. Any conclusion drawn before a blocking remote call is applied conditionally on nothing having changed since, and dropped if it lost the race; re-observing is left to the next lifecycle event. Conditional clears are evaluated inside the aggregate's lock, so the condition and the act cannot come apart.
  • --dry-run mirrors the real outcome (would-block vs runnable), --quiet keeps the preview, scopes_ok never claims an unverified green light, and renew/reactivate/consume previews plan from observed remote state.
  • Atomic dedup on the globally-unique subscription_event_id; ordered per-id lifecycle reduction; per-dimension health facts + recovery actions.

Test Plan

  • Unit + golden tests green (go test ./internal/event/... ./cmd/event/... ./events/... ./cmd, 34 packages), race-clean under -race.
  • go build clean for darwin, GOOS=linux and GOOS=windows; go vet ./... clean.
  • Catalog goldens (event list / event schema, JSON and text) byte-identical — never regenerated. A separate reflection test asserts no runtime-only field can reach that output, because a field with omitempty and an empty value would leave the golden unchanged.
  • A full-catalog output snapshot pins canonical metadata for every registered EventKey, so a change to any domain's projection shows up as a diff.
  • Architecture boundaries enforced by tests: no kernel package imports an adapter; each host's adapter set is allowlisted; no domain package builds a CLI command string; no domain package re-derives canonical metadata.
  • Full acceptance (sandbox E2E + acceptance review) — pending the upstream SDK release.

⚠️ 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 (pinned to v3.7.2) will be bumped to the release that includes them before this PR leaves Draft.

Separately, make quality-gate reports three example_dry_run REJECTs against skills/lark-event/SKILL.md. These predate this branch's latest work and reproduce on the branch point — the flagged lines are event consume examples, not dry-run ones — so they look like a rule/doc mismatch to resolve on its own rather than inside this change.

Related Issues

  • None

🤖 Generated with Claude Code

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 and others added 30 commits July 29, 2026 23:03
Locks the red line that matters: a domain must not re-derive event_id, event_type
or create_time from the shared push-envelope header, because the ingress already
resolved them onto the canonical event and the consumer boundary fails closed if
the two disagree. An AST check (not a grep) flags those tags nested inside a
json:"header" block, so a domain body owning its own create_time is unaffected.

Deliberately scoped to those three facts rather than banning header access
outright: events/ carries long-running legacy projections, and forcing every one
of them off the header wholesale would be a wide, risky change with no
correctness gain.
…d bool

'Refined' bundles several independent facts that happen to move together today: a
remote Subscription must be provisioned, events are addressed by that
subscription's id instead of broadcast by event_type, and the EventKey must carry
a resource selector. Threading one boolean through catalog, consume and status
means a third delivery mode has to edit all of them.

Introduce a derived SubscriptionCapability naming those axes and repoint the two
genuine decision sites at it: the resolver asks 'does this key require a
selector?' and DescribeConsume asks 'how is delivery provisioned?'. The resolver
populates the capability on its result, so it is the single interpreter of the
declaration and no reader has to guess a default for a missing definition.

Derived from the existing declaration rather than added as a field, so no domain
under events/ changes and no JSON/wire shape gains a member — the list/schema
goldens are untouched. Only two values exist today; the win is that the next mode
is additive, which the extension test pins.
…consume

Domain handlers no longer re-parse the envelope header, so every consumer now
depends on the bus carrying the full canonical metadata on each delivered frame:
the identity facts, BOTH time facts kept separate, and the tenant identity. A bus
from an older build omits those fields, so a new consumer talking to it would emit
events with event_id / event time / tenant identity silently empty instead of
failing.

The bus advertises canonical_metadata_v1, and BOTH the classic and the refined
consume path validate it on the ack of the REAL delivery connection (a separate
status probe reads a different connection and would leave a TOCTOU). Missing it is
a typed failed_precondition telling the user to run 'event stop' and retry; the
classic path fails before PreConsume, and the refined path carries the apply-ok
receipt because the remote Subscription may already exist. Tests decode real
old-bus ack frames, and one gate pins that what this bus advertises satisfies what
this consumer requires.
The header-vs-canonical check compared event_id, event_type and create_time and
silently ignored the rest: the tenant identity and the entire refined delivery
tuple (remote_subscription_id / target_resource / authority /
subscription_event_id) could disagree without being noticed. Fixing that by adding
more if-branches would keep the same failure mode, so the comparison is now a table
covering every canonical fact, plus a reflection test that fails when a RawEvent
field is neither verified nor explicitly declared unverifiable — with a reason.

Authority needs both sides in one vocabulary, so the formatter moves into the
kernel and the ingress delegates to it; otherwise a formatting difference would
masquerade as a data conflict. A separate case drives a real mismatch through every
row so none can be present-but-inert.
…ugh a provisioner registry

Reworks the capability boundary so it is trustworthy rather than merely tidy.

The capability is now COMPILED AND VALIDATED at registration and stored immutably
on the registry entry, instead of being derived lazily at each call site. Its zero
value is invalid on every axis and the mode sets are closed, so an unset or
unimplemented mode fails registration rather than reaching runtime. The exported
accessor that could be handed a nil definition is gone — with it the silent
'unknown means classic' fallback, which was the dangerous direction because classic
reads as no-remote-write and read-level risk. Callers get a capability from the
compiled entry or from ResolveEventKey; an unknown key reports not-found.

Dispatch moves from an interface with one method per mode to a
ProvisioningKind -> Provisioner registry. Adding a delivery mode now means adding
the value to the catalog's declarable set and registering a Provisioner; the use
case's dispatch does not grow a branch and existing provisioners are untouched.
Each Provisioner receives the descriptor including DryRun, so preview semantics
belong to the mode rather than to a separate no-setup pseudo-mode. Unknown or
unregistered modes fail closed at both ends, and Risk reports write for anything not
KNOWN to be write-free.

The catalog owns which modes may be DECLARED; the registry owns which are
IMPLEMENTED. Dispatch checks the latter, so a mode can be implemented without
editing the dispatcher. The open-closed test now registers a third mode and asserts
the existing use case routes to it — replacing a test that only compared unused
string constants.
…side effect

Registration was an init() in the events package: importing it — even blank —
mutated a global registry, and the freeze happened separately in main. Nothing
stated where the catalog came from, and any entry point that forgot the import got
an empty one.

events.All() now returns the declarations and catalog.Compile registers and freezes
them. Compile is called from cmd.buildInternal, the one assembly function every
entry point funnels through (main, the plugin harness, Build callers), so no entry
point can miss it and the build step is explicit. Compile is idempotent, so a
process assembling several root commands is fine.

Test binaries that need the real catalog now say so in a TestMain, via
CompileForTest — which registers without freezing, because tests legitimately add
synthetic keys that a frozen catalog would rightly reject. That is a separate,
explicitly named entry point so production cannot reach the unfrozen path by
accident. The blank imports that no longer registered anything are removed.
…omain

The event domain suggests a "next step" on almost every failure it can
produce, and it spells those suggestions as `lark-cli ...` command lines. That
couples the domain to one specific front end: the domain cannot be reused
behind another caller without lying about how to recover, and a renamed flag or
relocated command silently rots hint text spread across nine files.

Introduce the vocabulary the domain will state instead: a Kind plus the
operands a presenter needs, built through constructors so an action can never
be half-populated, and a Context carrying the renderer a caller injects. With
no renderer the action presents itself semantically rather than guessing at a
command line — a library caller must see the action it was handed, not a
command for a front end that may not exist.

Kinds() is the enumeration a presenter's completeness test drives over, and an
AST check keeps it in step with the declared constants, so a new action cannot
ship with no presentation at all.

This commit only adds the package; nothing depends on it yet.
… in the CLI

Every `lark-cli ...` command the event domain used to spell is now a semantic
recovery.Action, and cmd/event/render is the single place that turns one into a
runnable command line. A recovery suggestion is a decision about WHAT to do
next, which the domain owns; how to type it is the front end's business, which
the domain had been guessing at in nine different files.

The renderer is injected, not imported: CommandContext gained the render seam it
already had the profile/identity for, and the domain entry points that produce
suggestions without one (the catalog resolver, filter validation, bus startup,
the bus-eligibility probe, the subscription planner) now take that seam as an
argument. cmd/event builds it once per invocation through
render.NewCommandContext, so a suggestion still targets the same app and
identity the failure happened under.

The rendered text is unchanged, deliberately: the renderer reproduces the exact
strings the domain used to build, including which forms carry the --profile head
and --as and which are bare app-independent lookups. The tests that assert that
operator-facing text now compose the domain with the production renderer, so
they still assert what a user sees rather than a copy of it.

Two sites keep the semantic form because neither of their callers is a front
end: the platform adapter's missing-user-token error (constructed by both the
CLI and the bus daemon) and the subscription controller the bus's lifecycle
action builds, whose output goes to a log. Threading a renderer into a transport
adapter to spell one command would be the coupling this change removes.
The recovery split is only worth its churn if it cannot silently regress: adding
one `lark-cli ...` string back into a hint is the easiest thing in the world to
do while fixing an unrelated bug.

Two checks. A source scan over every production file under internal/event fails
on the binary name, with one allowlisted exception (a Windows named-pipe address,
which is an IPC endpoint, not something a user can run) that has to state its
reason. And a seam test in cmd/event asserts the exact command text an operator
sees for the resolver and filter rejections, which is where the byte-for-byte
claim about this refactor actually lives — the domain names the action, the
render layer spells it, and the two together must still produce what they
produced before.

The scan is a source scan rather than an AST/string-literal scan on purpose: a
command line can be assembled from a raw string, a concatenation, or documented
in a comment, and all of those are the same violation.
The recovery seam threads a renderer in per invocation, but a handful of
adapter constructors are reached from several entry points and are handed
no context at all: platform/lark's subscription client is built from
eleven call sites by both the CLI and the bus daemon it forks. Its
missing-UAT hint therefore fell back to the semantic form, degrading
shipped operator text from `lark-cli auth login` to a bare `auth_login`
token.

Give the domain a process-wide fallback renderer that a front end installs
once at composition time, next to the event catalog compile, and resolve
those context-less sites through it. The per-invocation renderer still
wins wherever one was threaded in -- it is the only one that knows the
profile and identity the invocation resolved. A process that installs none
(a library embedding the domain, and any test that does not opt in) still
gets the semantic form, so the domain remains free of command text.

Covered both ways: the unit tests pin fallback, precedence and restore;
cmd asserts that building the root command makes those actions render as
the shipped commands, and the assertion was mutation-checked against a
build that skips the install.
…tore

The facts that describe one remote Subscription -- the lifecycle summary, the
lifecycle action outcome, the decryption accounting, and the subscription and
decryption health dimensions -- are shared by every consumer connection bound to
that subscription. They were copied into each connection, so every writer had to
walk the connection list and update each copy, and any path that missed one left
two consumers of the same subscription disagreeing about its state.

Give them one owner. binding.DeliveryBinding holds them behind named mutators,
reads leave through an immutable Snapshot, and no mutable pointer -- in
particular no *health.Facts -- crosses the package boundary. binding.Store owns
every live binding keyed by remote_subscription_id and reference-counts it, so it
outlives any single connection but is dropped once the last one goes; an empty id
is never a key, since a classic consumer has no remote Subscription to share.

health gains a value-typed Record so a consumer's dimensions can have more than
one owner: each projects exactly the dimensions it owns (RecordOf) and Merge
combines them, leaving the ordered Snapshot/NextAction projections identical to
the single-owner case. Facts.Snapshot/NextAction/Any now go through it.

Nothing references the aggregate yet -- this commit only adds it.
Conn was four objects in one. The third group -- the facts about the remote
Subscription rather than about the connection -- is now referenced instead of
copied: Conn keeps its transport, consumer-session and identity state and points
at the shared DeliveryBinding for its remote_subscription_id. Every read of a
subscription-scoped fact projects that binding, and the connection stores none of
them.

Health is split by ownership rather than duplicated: identity, source and
delivery stay on the connection; subscription and decryption move to the binding.
healthRecord merges the two owners back into one dimension-ordered view, so
HealthSnapshot and the next_action projection behave exactly as they did when one
record held everything -- a per-connection identity rebind still outranks a
shared subscription recovery.

Every writer now writes that one binding instead of iterating connections:

  - the lifecycle reducer and its effect runners (summary, suspension code,
    action outcome, subscription health). lifecycle.Conn is narrowed to the
    connection's OWN identity state and the new lifecycle.Binding carries the
    rest, so a lifecycle action can no longer reach a per-connection copy at all;
  - the executor's queue-full degrade;
  - the management-plane MarkLocalUpdate signal;
  - the decrypt accounting, which now counts one upstream failure ONCE for the
    subscription rather than once per watching consumer.

A reduction's scope no longer selects which copies to write -- there is one fact
-- but it still decides whether the decision applies: an eligible-scoped
decision needs an owner==current consumer, an all-scoped one needs any consumer.

Lifetime is reference counted by the Hub's store: the Hello-time attach and the
registration attach are idempotent, and Conn.shutdown -- the one funnel every
close path including a handshake reject goes through -- gives the reference back,
so the binding outlives any single connection but not all of them. A classic
consumer has no remote_subscription_id, so it gets no binding: none is
fabricated, its per-connection health is untouched, and the subscription-scoped
reads report the zero values they always reported.

Behavior is unchanged: event status text and --json, the ready marker, exit
codes, typed errors and the lifecycle semantics (terminal protection,
tombstones, per-id FIFO) all stay as they were. Only WHERE the state lives moves.
Three invariants the refactor stands or falls on, none of which the existing
suite could state before because there was no aggregate to state them about:

  - two consumers of one remote_subscription_id share ONE binding -- stored once,
    referenced twice, the same object -- so a single lifecycle reduction and a
    single management-plane signal are both visible in BOTH consumers' status
    projections, and one upstream decrypt failure counts once rather than twice.
    This is the anti-drift gate: with a binding per connection the reduction
    lands on one of them and the two consumers disagree;
  - a classic consumer (no remote_subscription_id) gets NO binding: none is
    fabricated for the empty id, its per-connection identity health still
    round-trips through the next_action projection, its subscription-scoped reads
    stay at their zero values, and it shares nothing with a refined consumer
    registered alongside it or with a second classic consumer;
  - the binding is released when its LAST referencing connection closes. It
    survives an earlier close with its state intact, a repeated close cannot
    double-release and drop an unrelated subscription's binding, a connection
    rejected mid-handshake still gives its reference back, and the store returns
    to empty once every connection has closed.
internal/event presented 20 sibling directories with no visible layering, so
nothing in the tree said which of them are kernel domain code and which speak
to the outside world. Five of them implement the local-bus IPC: the wire types
and codec (protocol), the per-OS socket/named-pipe mechanics (transport), the
control operations a client issues over it (busctl), the bus-endpoint discovery
and orphan detection (busdiscover), and the local-bus read model (buslocal).

Move all five under internal/event/adapter/localbus/ so the parent directory
states the boundary. This is a path change only: each package keeps its name,
its files and every exported identifier, and the dependency graph is unchanged
(protocol/transport/busdiscover stay leaves, busctl depends on protocol and
transport, buslocal on all four).

They stay five packages rather than one flat localbus package on purpose: the
names document a real internal layering, and merging them would risk identifier
collisions for no dependency-graph benefit. The visible adapter boundary is what
this step is after, and a parent directory delivers it.
The two packages that talk to Lark itself sat in the same flat namespace as the
domain: source drives the WebSocket long connection through the SDK's ws client,
and platform/lark owns the remote Subscription management plane. Move them to
internal/event/adapter/lark/websocket and internal/event/adapter/lark/subscription
so the tree names them as the outbound edge they are, and so adapter/ collects
every package that leaves the process.

Path change only — files, package names and exported identifiers are untouched,
and the dependency direction is unchanged (websocket depends on the subscription
gateway, never the reverse). Doc comments that cited a moved package by its old
import path are retargeted so they still point at real code.

Because the package clauses are unchanged, the two directory names no longer
match their package identifiers (websocket declares package source, subscription
declares package lark). Every import site names that identifier explicitly, so
the binding is visible where it is used. Renaming the clauses is deliberately not
part of this step: package subscription would collide with the kernel's own
internal/event/subscription in the files that import both, and it would rewrite
every call-site qualifier in a change that is otherwise a pure relocation.
The relocation left 44 comments across 32 files naming platform/lark, a
directory that no longer exists, so a reader following the reference found
nothing. Point them at lark/subscription instead, and spell the two
fully-qualified citations with their new import path.

Comment text only — no code, no string literal and no test expectation is
touched. Two sentences that read badly after the substitution ("a
lark/subscription type", "lark/subscription's subscription client") are reworded
to say what they mean.
The adapter/ grouping makes the boundary visible, but nothing yet stops the
dependency direction from inverting: a domain package importing the local-bus
wire format or the Lark gateway still compiles, still passes every test, and
quietly welds the domain to one transport. Add an AST gate that fails instead.

The gated set is derived from the directory tree — every directory under
internal/event except the four documented outward-facing ones (adapter itself,
the bus daemon, the consume client, and the transport test double) — so a package
added tomorrow is covered the day it appears rather than when someone remembers
to enrol it. That currently means the eight kernel packages the design names plus
app, binding, delivery and the event facade.

Two tripwires keep the gate from decaying into a no-op: the design-named kernel
list must still exist in the tree (so a rename, or an exemption added to silence
a failure, fails loudly instead of shrinking the gated set), and the adapter
detector is run against a synthetic violating file (so a path constant that stops
matching after a future move is caught).

Production files only. A kernel package's own tests may drive a real adapter on
purpose — internal/event's integration tests start the WebSocket source — and a
test edge is not what ships.
The events/<domain> dependency gate named the forbidden packages by their old
top-level directory (protocol / transport / busctl / buslocal / busdiscover /
source). Those all moved under internal/event/adapter, so the check's leaf
lookup started resolving to "adapter" and matched nothing: a domain importing
the local-bus wire format passed the gate silently. Verified — the same
violating import is green against the old list and red against this one.

Key on "adapter" instead, which covers the whole outward edge in one entry and
extends to any adapter added later, and keep bus / consume. Comment updated to
describe the layer rather than enumerate packages that will move again.
…tories

The relocation moved the packages but left their package clauses behind:
adapter/lark/websocket declared `package source` and adapter/lark/subscription
declared `package lark`. Opening either directory showed a package name that no
longer described it, which is most of what the grouping was meant to fix.

Renaming them costs nothing at the call sites: every importer already aliases
these two, and a Go import alias is independent of the declared name, so the
existing aliases keep compiling untouched. The only substantive edit is inside
the websocket adapter, which reached its sibling through a now-misleading `lark`
alias and now names it directly.

Verified: build clean for darwin, windows and linux; vet clean; 33 packages green;
-race clean; both goldens untouched; gofmt clean.
The check required BOTH sides to be non-empty before comparing, so it only
caught contradiction, never loss. A header claiming app_id whose canonical
counterpart arrived empty compared nothing, passed, and the event was emitted
with app_id silently blank -- the exact outcome the canonical-metadata
authority exists to prevent, reached through the check meant to enforce it.

The asymmetry must run one way only: header silence is still no claim (a
classic payload carries no subscription block, and treating that absence as a
mismatch would drop every classic event), but once the header claims a fact the
canonical value must MATCH it, empty included. An empty canonical against a
claiming header is not "nothing to compare" -- it means the fact was lost
between the ingress that wrote it and this consumer.

Gated in the opposite direction from the existing mismatch test: instead of
corrupting the header, blank the canonical value and assert the drop, driven
over all nine rows. Mutation-checked -- restoring the old condition fails every
one of the nine with the field named.
The kernel gate says the domain must not know its adapters. It says nothing
about the hosts that legitimately do: "bus and consume may import adapters"
read as "any adapter, and more of them over time", so the exemption could widen
indefinitely while the kernel gate stayed green. That is how a boundary decays
into a bucket -- a runtime host accumulating adapters is a composition root that
was never collected.

Pin each exempt host to exactly the adapters it imports today. The list is a
ceiling recorded so growth is a decision made here with a reason, not a side
effect of an import statement. It fails in both directions: an unexpected import
names the file and points at injecting from cmd/event instead, and an entry
nobody imports any more is reported as a ceiling drifted above reality -- an
allowlist wider than the truth permits a re-introduction nobody reviews.

The consume entry carries the sharpest case: it lists only localbus, so a
consumer growing a lark/* import fails. A consumer reaching Lark directly has
stopped going through the bus, which is the reason the bus exists.

Mutation-checked both ways: adding adapter/lark/subscription to consume fails
with the file named; adding an unused entry fails as stale.
…ient

SubscriptionPlan is a claim that a specific remote state was observed and
classified. With all fields exported, anyone could write
SubscriptionPlan{Action: ActionReuse, Before: someRemote} and Apply would reuse a
subscription nothing had checked. Unexport the fields, read the classification
through accessors, and route every path through one constructor so no plan can
name an action without the facts behind it.

Apply took (plan, policy, req) as three arguments that could disagree. The
refined consume path re-derived its Request at the call site, so a plan
classified against one request could be applied with another -- the plan's own
include_resource_data and filter conflict checks would then not describe the
write they gated. The plan now carries the policy and request it was classified
against, and Apply(ctx, plan) takes nothing else, so the classified thing and the
written thing cannot come apart.

Two guards, both mutation-checked:
- Apply rejects a plan it did not produce. Unexported fields stop most forgery at
  compile time, but a zero value inside the package still gets there, and an
  empty Action must not read as a default.
- NewPlanForTest exists for fakes in other packages that have no observation to
  classify. An arch test fails if production code calls it -- verified red by
  adding a call, and it counts the files it walked, because its own first draft
  resolved the repo root to ".." and silently walked nothing.

The test rewrite preserved each call's original policy and request rather than
defaulting them: passing Request{} made the encrypted-create tests build a plan
whose request contradicted the assertion, which is the same defect one layer up.
… land

Giving the shared subscription facts one owner removed the drift between
connections, but left two gaps inside the aggregate itself.

A multi-fact write was a sequence of independently-locked setters, so a reader
could catch it half-applied. A lifecycle reduction records what happened, the
resulting remote state, the suspension code, and whether the subscription is
degraded -- and `event status` landing mid-sequence reported a combination that
never held: degraded with no reason yet, or a new remote state still paired with
the previous suspension code. Mutation now applies a group under one lock, health
dimensions included (the lock order is mu then health, never the reverse), and
Snapshot is taken as one consistent cut so the revision it carries describes every
fact in it.

Worse, a reduction that consults the remote before writing had no way to notice
that newer state arrived while it waited. runReconcileGet reads the state, issues
a Get that can take arbitrarily long, then writes what it concluded -- so a
suspended_v1 landing mid-flight was silently overwritten by a conclusion drawn
before it. It now reads the revision before the round trip and writes through
CompareAndApply; losing the race drops the conclusion rather than applying it,
and is not retried, because the correct response to losing it is to re-observe.

The action result is folded into that same mutation rather than written first.
That keeps the write atomic AND keeps the revision test free of arithmetic -- an
earlier draft guessed "beforeGet+1" and was already wrong, since markActionResult
wrote two facts.

Every setter now routes through Apply, so no write can leave the revision behind
and let a later CompareAndApply succeed against a value that no longer describes
the binding; a test drives all ten write paths to hold that. The atomicity test
is mutation-checked: restoring the split write makes it report the exact tear
("state=suspended but degraded=false").
Compile registered declarations into a package-level map, set a frozen flag,
and returned nothing. There was no compiled artifact anyone could hold, and
the whole-catalog Validate — which exists precisely for the defects a
per-declaration check cannot see — was never called by anything.

Compile now returns a Snapshot: the compiled catalog, projected per key onto
a Descriptor (the declaration/display facts), a RuntimeBinding (the four
hooks as the same function values, plus the delivery knobs), and the already
compiled SubscriptionCapability. KeyDefinition stays exactly as it is —
`events/` keeps declaring it and the render path keeps consuming it — but it
is now a projection of the compiled model rather than the model itself, and
Entry.Definition reassembles it so the split cannot silently drop a fact.

Compile also runs the FULL Validate before freezing and panics on failure. A
duplicate template path segment, a schema that parses but declares no
fields, an event_type colliding with a reserved lifecycle meta-event, a
filter meta naming an operator its event_type does not publish: each of
those was reaching runtime as quietly wrong behaviour instead of a startup
error. CompileForTest stays lenient on purpose, because test binaries
register deliberately malformed keys to prove a gate bites.

A Snapshot is immutable from outside: unexported map, accessors that hand
back values or independent deep copies, never a pointer into the snapshot —
the same ownership discipline as internal/event/binding.

The package-level registry is untouched; every existing reader still goes
through Lookup. Moving them onto the snapshot would touch 15+ files for no
correctness gain and is a separate step.
ResolveEventKey and CompiledCapability reached into KeyDefinition's
unexported capability field, so two call sites were interpreting a
declaration alongside the compiler that already had.

Both now read the compiled Entry. Only the compiler writes that field and
only the projector reads it, which is what makes it an artefact of the
compile step rather than a member of the declaration. ResolveEventKey also
takes its Definition and its Capability from the same entry, so the pair it
returns is one projection of one declaration and cannot come apart.

No behaviour change: entryFor derives from the live registry, so a synthetic
key registered by a test after CompileForTest still resolves.
…leak

`event list --json` and `event schema --json` are an external contract: AI
callers and scripts parse them. The only thing standing between that contract
and a new field was the golden files, and a golden cannot carry this
guarantee — the moment someone adds a field and regenerates the golden, the
comparison agrees with the new output.

The gate reflects over the two structs the commands actually marshal and
requires every exported member to be either named in a written-out
declaration allowlist or explicitly `json:"-"`. Embedded structs are
followed, because that is how KeyDefinition's members reach the output, and
the walk asserts it actually reached them — a traversal that inspected only
the four wrapper fields would pass while a leak sat in the promotion.

The two marshalled shapes move from function-local types to named ones so
they can be reflected over. Same fields, same tags, byte-identical output.
…onsume path

PreConsume is how a classic EventKey arranges delivery it cannot arrange
locally — a server-side subscribe — and its cleanup is the matching
unsubscribe. Both are gated on facts only the bus knows: setup on the
handshake's FirstForKey, cleanup on the pre-shutdown ack's LastForKey. Both
gates had only unit coverage of the pieces, and neither gate's timing was
asserted anywhere.

The consequences of getting either wrong are remote and shared. Lark keys a
subscription record by (app, user, event_type) and overwrites rather than
reference-counts it, so a second setup subscribes twice over one record, and
a cleanup that fires while co-consumers are attached unsubscribes the record
they are still receiving on — with no local symptom at all.

Two consumers now attach to a real bus over a fake transport and are detached
in a controlled order, asserting exactly one setup and one cleanup. Calling
the hook directly would only prove the closure counts; what needs proving is
when production invokes it.

The second scenario is separate on purpose: in the first ordering the
consumer that leaves early holds no cleanup, so deleting the LastForKey check
outright leaves that test green. There the setup owner is the one leaving
early, and the bus's "you are not last" answer is the only thing between it
and a remote unsubscribe.

Each bus gets a unique app id: bus.alive.lock is keyed by app id and held on
an fd the OS releases only at process exit, so sharing one across buses in a
test binary fails a `-count=N` run as a bogus "bus did not come up".
Making Mutation atomic only made an atomic write possible; both lifecycle callers
still issued the pair as two setters. degradeSubscription and
applySubDecision(subSet) each wrote SetSubscriptionDegraded then
SetSubscriptionNextAction, so a status query landing between them saw a degraded
subscription with no next_action -- a failure printed with no step beside it,
which is the exact state pairing them exists to prevent.

Both now write one binding.Mutation through a shared degradeMutation helper.

Take the two setters off the lifecycle.Binding port as well. They are one fact in
two fields, and offering them separately is an invitation to sequence them again;
production lifecycle code can no longer do so, while the concrete binding keeps
them for tests that set arbitrary prior state.

Gated three ways, all mutation-checked against the restored two-setter form: a
concurrent sampler per caller (the defect is a window, so asserting the final
state would pass against the old code), plus a deterministic check that one
degrade advances the binding's revision by exactly one -- two writes advance it by
two, the same defect without needing a race to catch it.
All four were reachable, and three were introduced by the work that was meant
to prevent exactly this class of defect.

Seed ran after publication. Acquire inserted the binding into the store and the
caller seeded it afterwards, but Seed overwrites every scalar, so it is only
correct on a binding nobody else can reach. In between, a second Acquire could
take it and BindingFor -- the lifecycle and management-plane write path -- could
resolve it out of the same map; the bus handles connections concurrently and a
refined key allows several consumers, so anything either wrote was then erased.
The store now seeds under its own lock before publishing, and the caller hands
in the seed value instead of applying it.

Reactivate and Renew cleared a degradation newer than their own conclusion. Both
clear on success, but that conclusion describes the state the remote call started
from, and MarkLocalUpdate can write remote_subscription_conflict/get while the
call is in flight. Both now capture the revision before the call and land the
action result and the clear as one CompareAndApply, dropping the conclusion when
something else got there first.

A plan could be edited after it was made. Before() returned the internal
*RemoteSubscription, so a caller could change the ID and have Apply reactivate a
different subscription than the plan was a claim about -- verified: the test
reactivates "sub_ATTACKER" without the fix. Request.Filter was shared by the
struct copy, so mutating the tree after planning made create() send a filter the
plan never validated while still reporting it as compatible. newPlan now deep
copies every mutable input, and the accessors return copies; Filter and
RemoteSubscription grew Clone methods in the model package that owns them.

One status response could mix binding revisions. The row was assembled from a
dozen per-field getters, each taking its own snapshot, so a concurrent write
between two of them put facts from different revisions in one response. Conn.Status
projects the whole row from ONE snapshot, with NextAction derived from the same
Health value so it cannot disagree with its reason. On the write side Handle folded
its summary, suspension and health decision into one mutation instead of three.

Each fix has a regression test driving the real path -- the reactivate one uses a
mid-call hook on the fake client so the conflict lands exactly where the race is,
rather than asserting the binding's CAS in isolation -- and each was shown red
against the restored production behaviour and green after.
Three follow-ons from the previous round, each a place the mechanism was right
and a path did not use it.

Only the SUCCESS path was conditional. Reactivate, Renew and reconcile-Get all
kept two unconditional writes on failure -- the action result, then the
degradation -- so a MarkLocalUpdate conflict arriving during the call was
overwritten by a conclusion drawn before it, replacing the real reason the
subscription is unusable with a recovery that cannot help. A failure conclusion
is no less stale than a success one. All six outcome paths now go through one
applyActionOutcome that folds the result and the health effect into a single
CompareAndApply, and the revision is captured above the client build as well as
the call, since identity resolution can block too.

The conditional clear was a genuine TOCTOU, and the "idempotent" comment I wrote
defending it was wrong. mergeSubDecision read the current reason, decided, and
left the caller to Apply later; a conflict landing in between was erased by a
clear that had never been evaluated against it. A conditional clear is not
idempotent under a concurrent writer -- the condition and the act have to be
atomic with EACH OTHER, not merely each atomic alone. The test now travels with
the mutation as ClearSubscriptionDegradedIfReason and is evaluated inside the
binding's lock.

The receipt was a second route into the plan. reuseReceipt returned plan.before
as both Before and After -- one object, aliased twice and shared with the plan --
so editing the receipt edited the plan, and re-applying it acted on a different
subscription. Verified: without the fix a second Apply of the same plan resolves
to "sub_ATTACKER". Receipts now carry independent deep copies.

Each fix has a test on the real path -- the two lifecycle ones drive Handle with
a mid-call hook on the fake client -- and each was shown red against the restored
behaviour and green after.
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