Technical design

Scope: one new connector repository. No existing CLI or kit behavior is changed. The initial deliverable is a bounded broker-backed CLI, not an agent runtime.

Components

The CLI's canonical resource/action tree and published compatibility paths are defined in command responsibilities. Those routes do not change wire operation names or listener-bound authority. Client configuration, operator policy, proposals, observation streams and relay services have distinct state owners. Compatibility commands use the same factories and emit structured stderr migration notices; they are excluded from generated discovery surfaces.

flowchart LR
  W[WeCom] -->|Signed encrypted callback| B[Credential-holding broker]
  B -->|Bounded HTTPS polling reads| W
  B -->|Fixed HTTPS APIs| W
  A[Sandboxed agent] -->|Agent Unix socket| B
  O[Trusted operator] -->|Private operator socket| B
  B --> P[Immutable policy]
  B --> L[Private event and proposal ledger]

cmd contains the executable and documentation generator. internal/app owns Cobra, output and local socket requests. internal/config loads strict operator policy; internal/broker enforces that policy and owns the HTTP/Unix listeners; internal/state provides atomic durable transitions with an exclusive file lock. pkg/apiclient contains typed WeCom reads, curated response models and the shared write builder. pkg/callback implements the documented crypto/event protocol. pkg/safejson rejects duplicate keys, excessive depth, invalid UTF-8 and trailing JSON. Generic output, errors, transport and argv correction come from agent-cli-kit.

The executable itself has no supported arbitrary URL/method flag, external command hook, media downloader, remote path handler or credential export. The Go API client is a protocol library, not the OS security boundary: downstream library users must enforce their own policy. In this CLI, only operator entry points construct that client and hold the Secret: explicit channel list --bot-id for setup and broker serve for the long-running deployment.

Authentication and callbacks

A deployment has one configured bot identity and receives its Secret through an operator-managed environment. It signs the documented token request with a cryptographically random nonce and caches the one-day token in memory with a one-minute expiry margin. Requests are fixed HTTPS POSTs. The transport inherits neither proxy environment nor redirect handling and performs no POST retries. Raw upstream errmsg, response bodies and URL-bearing network errors are never used as diagnostics. Known authentication material is redacted from curated read content if reflected by the service.

Webhook signature verification precedes decryption. AES-256-CBC and SHA-1 are protocol requirements, not newly chosen cryptographic primitives. Check the five-minute URL timestamp window, full PKCS#7 padding, payload length and empty receive ID. Require the decrypted bot identity to match configuration. Use the bot identity plus msgid to derive the durable event ID. Only supported event types and the configured channel reach the inbox. Human mentions require exact sender IDs. Explicit observe_all policy additionally admits post/reply observation records from any human or bot without granting request authority. Callback processing does no remote read, model invocation or reply.

Events receive a monotonically increasing admission sequence in the same durable transaction. Inbox pages and --after-sequence use it, so a delayed callback cannot be skipped merely because its remote creation timestamp is older than the last processed event. Consumers retain their own bot-bound checkpoints; the broker does not acknowledge, launch, or monitor agent jobs automatically. Checkpoints ahead of the ledger and missing retained/policy-visible sequences produce explicit conflicts rather than an apparently complete empty result.

Fresh callback delivery and retained event context are different windows: callback timestamps have five-minute skew tolerance; event creation times are at most 24 hours old and at most five minutes in the future; mention reads and approvals are bounded by the event's 24-hour age. An event retained for audit does not extend WeCom's permission window.

Credential-first bootstrap

channel list is the sole channel listing command. An explicit --bot-id selects the operator's WECOM_CHANNEL_BOT_SECRET environment for first-time setup. It invokes the existing channel.list reader (get_topic_list, type 1) before any channel policy, state directory, sockets or client contexts exist. The CLI deadline bounds token acquisition and the single page read together. Dry-run validates and builds the same query without reading credentials or performing I/O. Output retains the kit list envelope, pagination notices, curated channel model and trust metadata. Empty results carry membership/API-access recovery guidance, not an invented ID.

The explicit credential mode does not load client configuration, select a saved context, persist credentials, change policy or expose a new socket operation. Without --bot-id, the same command uses the existing broker path and enforces its agent/operator scope. Credentials in the environment never select direct access; failed or denied broker calls never fall back. Explicit socket/context flags conflict with --bot-id. Broker-mode dry-run resolves public connection coordinates offline and never calls the broker. Credential mode uses the same operator credential/OS boundary as broker startup. The operator selects an ID from the result, uses policy example --bot-id ... --channel ... to fill the policy template, and provisions the deployment before configuring the client. Missing channel IDs in client setup point back to discovery. Existing client contexts and existing broker-based discovery retain their behavior.

Reads and data provenance

The agent can enumerate only its context's single channel. Version 2 deployments route authenticated callbacks by channel into separate instances of the same broker logic. Named client contexts add expected context/bot/channel identities to each request; the listener checks them before dispatch. Client-side selection never grants server authority. See context design. The operator can discover joined channels even with no allowlist, then populate it. General post/reply/member/search reads require read_all for the agent. A valid mention instead grants only event context for that post and exact triggering reply; it does not grant arbitrary IDs or channel traversal.

Curated models preserve content, identity variants, timestamps, recalled/edited flags and attachment filenames/counts. They omit download URLs/cookies and unknown upstream fields. Every external record carries a fixed trust envelope; no field projection or raw/table mode can remove it. This helps interpretation but does not replace the host sandbox.

All reads are one page with an explicit limit. NDJSON pagination uses the kit's stderr notice and actual cursor. A missing pagination contract, non-advancing cursor, malformed identity or oversized response fails instead of claiming complete coverage. Filtering a post/reply page to zero records preserves its upstream continuation. Search uses one allowed channel and exposes the upstream job ID for its second step.

Bounded watch delivery

event watch follows the family's streaming pattern (Jenkins build-log follow and OpenObserve search tail): data-only stdout, cancellable requests, and retained continuation. The channel domain adds an explicit whole-channel/thread scope, a finite session duration/event count and a broker-bound checkpoint. Confluence's page watch is a remote subscription, so it is intentionally not reused; the other four siblings have no equivalent live stream to port. No sibling code changes.

The broker's event.scan scans at most 100 admitted sequences per request, then filters post IDs and change kinds. The returned sequence advances over skipped records, while missing/expired/inaccessible sequences fail before delivery. Checkpoints bind a durable random ledger ID, context/bot/channel, policy and normalized filters. A reset or rebinding cannot silently reuse a sequence.

Self exclusion is mandatory across event reads, watches and relays. Authenticated callback actors equal to the configured bot ID are acknowledged without admission, regardless of which client sent the message. Retained self events are filtered while scan progress advances; absent, expired or unauthorized records still fail as gaps. The watch binding includes the exclusion revision, so old checkpoints and queued relay batches stop before delivery until explicitly reconciled.

The client resolves its socket once, emits NDJSON through the kit, and saves only successful output progress. Limits are 5m/1000 events by default, up to 24h/100000; the interval is 1s–1m, default 2s. The total context bounds all requests and waits. A stalled downstream writer may need a host deadline. State files are private, atomically replaced and exclusively locked. Output success is not downstream processing acknowledgment; crash windows can replay records and downstream processing failures require consumer-owned acknowledgments. See watching.

The checkpoint is local consumer state; broker read-only mode still permits observation. The CLI's local read-only posture gates checkpoint writes, and dry-run reads coverage without remote reads, state changes, a checkpoint or lock. No watch sends messages or launches tools. Polling reads message content inside the broker for comparison; it persists fingerprints and emits metadata only.

Polling and callback adapters

The operator selects auto, poll or callback. Auto preserves callback intake when configured and otherwise selects polling. event.scan accepts the same scope and returns the same inbox contract for either backend. The client honors the polling interval reported by the broker; its own interval cannot shorten it. Backend/policy changes invalidate the existing scope-bound checkpoint.

internal/broker/poll.go serializes refreshes per context, rate-limits remote reads and caches scope attempts for the minimum interval. It reads complete, bounded post/reply snapshots using the existing apiclient.Client.Read path. Whole-channel scopes discover all posts; selected scopes read only their exact posts. Creation-time pagination cannot serve as a change cursor, so old posts and replies are revisited. The request and record budgets fail explicitly when the snapshot is too large. Retained inbox pages can drain without remote I/O.

internal/state/poll.go atomically commits scope baselines, resource fingerprints and derived events in the existing ledger transaction. Overlapping scopes share fingerprints, including when a new scope observes a resource for an already initialized scope. Initial snapshots suppress historical records; failed pages, cursor cycles, capacity errors and cancellation commit nothing. A failed disk fsync after rename retains the committed in-memory state, as for existing events.

Polling records are observation-only and never become mention authorization. They identify their transport and inference basis; their change actor is unknown. Polling separately normalizes creator/creator_info and aibotid/botid into resource creator metadata. Missing, malformed or conflicting creators fail the entire snapshot with POLL_CREATOR_UNKNOWN. Self-authored resource fingerprints advance atomically without creating events, including edits and recalls; their reply lists are still scanned for other authors. This resource filter does not attribute edits to creators. Older polling events lacking creator evidence cannot be replayed; see the explicit migration in watching. A returned recall flag is evidence, but absence is not. Intermediate changes, reactions and membership events are not reconstructed. No background job runs without an active watch. See watching for coverage and limits.

Durable consumer relay

relay run implements a consumer outside the broker and model worker through internal/relay. It uses existing event.scan requests; no broker endpoint, authorization policy or remote WeCom write is added. Fixed destination config and receiver credentials stay in that service. The shared transport performs one POST attempt without redirects/proxies; request-body rewind is disabled so net/http cannot infer retry permission from Idempotency-Key. Durable retries belong to the consumer state machine. Existing event watch keeps its no-reconnect contract.

The versioned private JSON state commits collected sequence and queued metadata atomically with file/directory fsync and an exclusive writer lock. It binds socket/scope/config through a consumer digest and policy/context/ledger through the watch binding. Uninitialized state persists startup retries before the first handshake without claiming coverage or permitting delivery. A 16 MiB file cap and bounded queue prevent unbounded growth; a full queue pauses collection without eviction. State validation checks trust metadata and queue counters. Persistence failures stop before another network operation, including ambiguous rename/fsync failures. relay status reads the atomic file without locking or remote requests and exposes counts/timestamps.

Deterministic filters run before enqueueing; skipped progress commits with accepted events. Unknown polling actors cannot be replaced by resource creators. After a bounded window or batch size, events are grouped by post into an immutable batch. Its digest binds consumer identity, coverage and exact payload. New events never alter an attempted batch. Attempts and retry deadlines persist before HTTP; the default explicit_ack mode dequeues only on a 2xx with the exact JSON ack. Retry budgets survive restart and require explicit reset after repair. Permission, protocol, binding and gap errors stop; only known transient failures have opt-in bounded retries.

Optional scan_retry_limit and delivery_retry_limit independently override the legacy retry_limit; omitted fields preserve legacy JSON/config digests. Before testing the scan budget, save a bounded last_scan_error with a fixed sanitized cause, phase, timestamp, attempt and effective limit. The same schema records last_delivery_error without replacing the terminal-error latch or uncertainty barrier. Retain this history after success/reset; counters and outcome state control recovery. Exhaustion errors and notices expose the original diagnosis, including after restart, without copying arbitrary messages or error details.

Scan backoff uses equal jitter under the five-minute exponential cap, with the effective poll interval and validated Retry-After metadata as lower bounds. The upstream adapter accepts delta seconds or HTTP dates and hashes optional request correlation; the existing broker error details carry only this curated metadata. Local rate limits supply a conservative 60-second wait. No transport retry is introduced and delivery retry timing remains unchanged. CLI request and session contexts bound scans and waits. Status preserves queue age through sealing with queued_at on delivery metadata, outside the immutable batch digest.

Explicit-ACK delivery is at least once. Its receiver must durably deduplicate before launching a model job. Acknowledgment means processing completed or responsibility passed to the receiver's durable queue, not proof that a Bot finished. http_accepted instead follows the documented Grok routine contract: exactly HTTP 200 accepts the batch, regardless of response body. Acceptance retains the batch and receipt locally; acknowledged and accepted counters differ. Other received HTTP statuses reject it. Only configured transient rejections may retry. A transport error or restart with sending becomes unknown and stops, even with remaining retry budget. relay resolve requires a stopped sender and verified operator evidence; --retry-failed cannot clear uncertainty. Saved minimum request spacing limits notifications without invoking a model to debounce.

Worker processing uses a separate private versioned JSON file and the same atomic snapshot writer. relay claim locks, validates the complete trust envelope and pinned context/bot/channel/ledger, deduplicates batch digests and event IDs, and persists a random claim token before returning new work. Concurrent lock contention grants no work. relay consume reports processed/failed with that exact claim; repeated matching reports are idempotent. Claims never expire into auto-replay. No worker file needs sender credentials/configuration, and the sender cannot infer processing from host acceptance. relay status selects either local schema. Sender receipt history is capped at 10000 batches; worker records are capped at 10000 batches/20000 event IDs, with the shared 16 MiB file limit. Capacity stops without eviction; manual archival must preserve deduplication over replay windows.

--ephemeral replaces sender persistence with bounded memory and validation, mutually exclusive with --state. Each new process establishes a quiet current baseline; interruption loss is accepted. Broker persistence is unchanged. The existing file store meets this scope without a SQLite dependency, storage daemon, or extra HTTP receiver. The relay has no model client, content preview, history fetch loop or shell runner. Empty scans make no request. Local claims cannot prevent duplicated paid wakeups that already happened at the host. See the host contract and recovery guide.

The retry-diagnostics review is scoped to this repository:

Project Applicability to this change
WeCom Channel Implement durable scan diagnostics, independent retry limits and throttling waits here
WeCom Chat Its adapted relay has the same scan-exhaustion diagnostic gap; a follow-up port must preserve chat grants, epochs and deadlines. No sibling edits in this change
Jenkins / OpenObserve Streaming/cancellation patterns remain useful; no durable webhook retry policy to update
WeCom Calendar Durable-state precedent; mirror synchronization has different retry semantics
Confluence Remote watch subscriptions, not local durable dispatch
Bitbucket / Jira / Prometheus Bounded reads, not durable webhook dispatch
Agent CLI Kit Reuse errors/output/transport; no generic retry engine or dependency change

The pinned kit lacks the newer redirect-guidance helper described by the workspace standard; relay preserves this project's curated secret-free refusal without copying that helper locally.

Write state

proposal -> pending -> sending -> sent
                    \          -> uncertain
                     -> rejected

sending/uncertain -> resolved_sent | resolved_not_sent

sending also represents a crash after the durable transition. No terminal or uncertain state can be approved again. There is no automatic retry. Expiry and policy mismatch prevent pending approval without rewriting historical evidence.

The shared BuildWrite validates the exact JSON body for both previews and execution. A proposal binds bot, operator-policy digest, operation, channel, post/parent reply, triggering event, exact content and caller idempotency key.

Native reactions share this state machine. reaction propose binds an exact post or reply, numeric native type and explicit add/remove action; BuildWrite maps it to topic/post_emoji with no text body. allow_reactions is independent of text permissions. Mention scope requires a matching callback within 24 hours at proposal and approval time; explicit channel scope requires read_all and the operator's verified proactive bot access. Poll events cannot supply that authority.

The provider's successful reaction response has no resource ID. sent means the mutation was acknowledged, and remote_id remains absent. For an uncertain reaction, the operator inspects reaction list and resolves the immutable proposal without --remote-id; the CLI rejects fabricated reaction IDs. Neither an automatic retry nor an opposite action is a recovery mechanism. Curated emoji_list metadata is available on content reads; membership is excluded from poll fingerprints so it does not masquerade as a post/reply change feed. The SHA-256 digest is also its ID. The digest detects changes; Unix permissions and listener authority authenticate approval. A reused key with differing content, target or policy is a conflict.

The operator must read the entire proposal, provide its digest and confirm with --yes. The broker rechecks policy, expiry and event-target binding. It records sending durably before token/network I/O. Concurrency is serialized around approval, while an exclusive state lock prevents another process using the same ledger. A returned remote ID is retained on success; a later persistence failure reports it explicitly. Any request failure is conservatively uncertain, even when the underlying failure may have occurred before sending. Reconciliation records evidence; it never resends.

Each context has an event/proposal ledger: a single versioned JSON snapshot with 0600 atomic replacement, file and directory fsync, a private 0700 state directory and an exclusive OS lock. It is capped at 64 MiB, with separate event/proposal counts. Atomic replacement errors after rename retain the committed in-memory state so subsequent operations cannot replay an ambiguous transition. One deployment shares a trusted process across independently bound channel contexts. Each ledger is bound to its bot, context and channel. Horizontal replicas and cross-ledger exactly-once delivery are not claimed.

Limits

Family applicability

All seven maintained siblings were inspected before implementation. The common reference points were internal/app/root.go, internal/app/skill.go, internal/auth, pkg/transport/http.go, and each available read-only decorator. WeCom Calendar's local meta write gate was checked separately.

Sibling Applicable shared pattern Channel-specific difference / change
Bitbucket CLI Kit errors/output/transport, command groups, read-only decorator, dry runs Existing service CLI; no channel intake or separate operator broker. No changes
Confluence CLI Same shared skeleton and write gates Existing service CLI; channel broker is outside scope. No changes
Jenkins CLI Kit transport and server-side write decorator Build operations remain under its existing contract. No changes
Jira CLI Kit errors/config/auth/output and read-only decorator Issue workflow is not a channel event gateway. No changes
OpenObserve CLI Kit transport/output and read-only client surface Read-oriented telemetry domain. No changes
Prometheus CLI Kit pagination and guarded admin writes Metric/admin domain. No changes
WeCom Calendar CLI Kit baseline plus domain-specific private local store CalDAV identity and calendar-only reads remain separate. No changes
Agent CLI Kit Reuse the published release pinned in go.mod for config, errors, output, transport, flags and Skill metadata No business-product code or new shared abstraction added

For polling, Jenkins internal/app/build_log.go and OpenObserve internal/app/search.go provide the cancellable streaming reference. WeCom Calendar internal/sync/sync.go provides the comparison-before-watermark lesson, but its server ETags do not exist in the channel API. Confluence page watching changes a remote subscription and is intentionally different. Bitbucket activity, Jira worklog history and Prometheus queries remain bounded reads, not a channel snapshot ledger. Their shared output/transport/time-bound contracts apply; this product-specific polling implementation requires no sibling changes.

The new broker deliberately strengthens authorization beyond a CLI-local --allow-writes: a separate operator must approve every send. These safeguards could inform a future family-wide untrusted-content project, but this task does not silently alter seven existing products. Existing sibling content can also contain prompt injection; their ordinary direct-CLI model should not be mistaken for host isolation.

Bootstrap follows all seven siblings' discovery-before-resource-selection and kit output/error conventions; their config set-context implementations remain offline. Bitbucket, Confluence, Jenkins, Jira, OpenObserve, Prometheus and WeCom Calendar authenticate directly to their own services and have no credential-held channel broker to bootstrap. Only this connector needs the explicit credential mode for channel listing; no sibling or kit changes are required.

Other deliberate differences:

Observed pre-existing documentation drift: parts of the shared construction guide still describe auth/config as local until extraction, while current siblings already delegate them to the kit. The workspace agent guide and current implementations were followed; shared prose was left unchanged within this single-project scope. Bitbucket also pins an older kit than the other inspected CLIs and has user-owned edits; those were not changed.

Validation

make lint test e2e docs-check exercises the protocol, policy, state and actual CLI process. Tests use synthetic credentials, fake HTTP clients and local test servers; they do not read the OS keychain or contact WeCom. The crypto test uses the published known-answer vector. Socket tests cover role separation, file modes and clean shutdown. Process tests exercise JSON/NDJSON pagination, proposal creation, denied agent approval and one-time operator execution.

Reaction coverage additionally includes native post/reply add/remove wire bodies, offline type discovery, acknowledgment without IDs, mention-target/expiry checks, explicit proactive scope, read-only and digest gates, terminal replay refusal, unknown-write reconciliation and redacted membership reads. The existing shared write/output patterns apply across the family; no maintained sibling currently exposes this native WeCom operation, so no sibling API or implementation changes are needed.

Before release, repeat with GOWORK=off, run the race detector and cross-build, then complete an explicitly authorized live tenant and isolated-worker check. Generated docs come from the same Cobra tree and can be checked without Git.

Terminal relay delivery failures also persist their error code in the inflight record. A process restart cannot retry a permission, redirect or invalid-ACK failure merely because attempts remain. Explicit --retry-failed after repair clears that latch; uncertain HTTP acceptance still requires evidence first.