Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions docs/adr/custom-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
- **Supersedes:** Sections of [ADR: LINE Adapter](./line-adapter.md) (v2 Target Architecture)
- **Superseded by:** [ADR: Separate Binaries with Opt-In Unified Build](./unified-binary.md)

> **⚠️ This ADR is partially superseded by the unified binary architecture.**
> OpenAB now supports a single unified binary mode for simplified deployment
> (see [ADR: Separate Binaries with Opt-In Unified Build](./unified-binary.md)).
> While the unified binary is the recommended path for standard setups, the
> standalone gateway architecture remains fully supported for deployments
> requiring strict outbound-only network isolation for the OpenAB core.
> **Standing: historical reference only.** This ADR records the original
> standalone-gateway proposal and is not operative implementation instruction.
> Unified mode is the standard deployment path; standalone Gateway remains
> supported when Core must stay outbound-only. For operative protocol and delivery
> requirements, use [Separate Binaries with Opt-In Unified Build](./unified-binary.md)
> and [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md).
> Every requirement, rollout step, and compliance statement below describes this
> superseded proposal unless a non-superseded ADR explicitly adopts it.

---

Expand All @@ -22,6 +24,7 @@ As an OpenAB operator, I want to connect webhook-based platforms (LINE, Telegram
As a platform integrator, I want to write a plugin/adapter for my platform and register it with the gateway, so that any webhook source can drive an OAB agent session without upstream code changes.

Requirements:

- OAB core must remain outbound-only — no inbound ports, no TLS, no K8s Service
- The gateway is a separate, independently deployable service
- Adding a new webhook platform requires only a gateway plugin, zero OAB changes
Expand Down Expand Up @@ -101,7 +104,7 @@ Outbound (OAB → platform):

---

## 3. Internal Event Schema
## 3. Historical Internal Event Schema

The contract between the gateway and OAB. All platform-specific details are normalized away before crossing this boundary.

Expand Down Expand Up @@ -136,6 +139,7 @@ The contract between the gateway and OAB. All platform-specific details are norm
```

Key fields in the base schema:

- **`channel.thread_id`**: thread identifier for platforms that support threads (Discord thread ID, Slack `thread_ts`). `null` for platforms without threads (LINE). OAB uses this for session key construction — without it, per-thread session isolation cannot work through the gateway.
- **`mentions`**: array of mentioned entity IDs (users, bots). Required for @mention gating — the gateway adapter parses platform-specific mention formats and normalizes them here. Without it, OAB cannot determine whether the bot was mentioned, breaking the primary mitigation for LINE group chat noise and Discord/Slack trigger logic.

Expand All @@ -157,7 +161,10 @@ Key fields in the base schema:
```

Key fields in the outbound reply:
- **`reply_to`**: the `event_id` of the inbound `GatewayEvent` that triggered this reply. The gateway can use this for reply correlation — e.g., looking up a cached LINE reply token to prefer the free Reply API over the quota-consuming Push API. Empty string if the reply is not associated with a specific inbound event (e.g., cron-triggered messages).

- **`reply_to`**: the `event_id` of the inbound `GatewayEvent` that triggered this reply. The gateway can use this for reply correlation — e.g., looking up a cached LINE reply token to prefer the free Reply API over the quota-consuming Push API. Empty string if the reply is not associated with a specific inbound event (e.g., cron-triggered messages). Legacy command peers may still overload it with a platform target.
- **`quote_message_id`**: optional platform message selected for visual reply/quote behavior; it is distinct from origin correlation.
- **`target_message_id`**: additive optional target for edit/delete/reaction commands. Core uses it only when the negotiated capability advertises support; otherwise it copies the target into legacy `reply_to`.

### Design Principles for the Schema

Expand All @@ -178,9 +185,11 @@ The following fields/concepts are known to be needed but are not fully defined i
| `reply_context` | Reply token, quote target, original message reference — not all platforms only need `channel.id` to deliver a reply |
| `tenant` / `gateway_instance` | Multi-tenancy routing if a shared gateway serves multiple OAB instances |

The capability and delivery-result portion is resolved by [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md). Teams process-local route and duplicate-suppression behavior is resolved by [Teams Ephemeral Ingress Route and Duplicate Suppression](teams-ephemeral-ingress-state.md), its event correlation plus real send ACK by [Teams Real Send Acknowledgement and Reply Correlation](teams-real-send-acknowledgement.md), and its explicit command target plus ownership enforcement by [Teams Bot-Owned Message Mutations](teams-owned-message-mutations.md). The other rows remain deferred.

---

## 4. Gateway Adapter Interface
## 4. Historical Gateway Adapter Interface

Each platform adapter implements a common interface:

Expand Down Expand Up @@ -305,7 +314,7 @@ GitHub shows the gateway handling a non-chat event. The adapter maps repo → ch

---

## 7. Open Design Questions
## 7. Historical Open Design Questions

| Question | Options | Impact |
|---|---|---|
Expand All @@ -317,11 +326,11 @@ GitHub shows the gateway handling a non-chat event. The adapter maps repo → ch

---

## 8. Rollout Plan
## 8. Historical Rollout Plan

| Phase | Scope | Deliverable |
|---|---|---|
| **v1 (now)** | LINE adapter inside OAB | PR #521 — unblocks LINE users |
| **v1 (2026-04-22 baseline)** | LINE adapter inside OAB | PR #521 — unblocks LINE users |
| **v2** | Standalone gateway + OAB generic gateway adapter + LINE migrated out | Gateway service, LINE adapter, internal event schema, OAB connects via WebSocket |
| **v3** | Multi-platform gateway | Telegram, GitHub, custom adapters |
| **v4** | Plugin / distribution model | Third-party adapters without forking gateway |
Expand Down Expand Up @@ -386,10 +395,13 @@ The gateway holding all platform credentials is the correct architectural choice

---

## Compliance
## Historical Compliance Proposal

These clauses governed the superseded proposal; non-superseded ADRs named in the
standing notice above take precedence.

1. **OAB outbound-only**: after adoption of the custom gateway architecture, new platform integrations must not add inbound platform-traffic handling to OAB core unless explicitly approved by a superseding ADR.
2. **Event schema stability**: the `openab.gateway.event.v1` schema is currently a draft envelope for v2 development. The protocol spec must finalize all required fields (including deferred concerns in Section 3) before the schema is declared stable. Once declared stable, breaking changes require a version bump (`v2`) and a migration path.
2. **Event schema stability**: at ADR version 0.2 (2026-06-29), `openab.gateway.event.v1` was a draft envelope for v2 development. Current wire stability is defined by the implementation and [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md); this historical clause has no independent authority.
3. **Credential isolation**: platform credentials (tokens, secrets) must reside in the gateway, not in OAB. OAB must not hold or access platform-specific authentication material.
4. **Adapter interface compliance**: all gateway adapters must implement `validate`, `parse`, `send`, and `health`. Adapters that skip signature validation must be explicitly flagged as insecure.
5. **Webhook correctness**: all adapters must validate signatures against exact raw request body bytes, per the constraints defined in [ADR: LINE Adapter](./line-adapter.md) Compliance #1.
Expand Down
241 changes: 241 additions & 0 deletions docs/adr/gateway-capabilities-and-delivery-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# ADR: Gateway Capabilities and Delivery Semantics

- **Status:** Proposed
- **Date:** 2026-08-06
- **Author:** @NeoHsu
- **Related:**
- [Custom Gateway](custom-gateway.md)
- [Unified Binary](unified-binary.md)
- [Multi-Platform Adapters](multi-platform-adapters.md)
- [Teams bot-owned message mutations](teams-owned-message-mutations.md)
- [Teams message reactions preview](teams-message-reactions-preview.md)

---

## Context

OpenAB has two paths for webhook-based chat platforms:

1. **Unified:** platform adapters and Core run in one process.
2. **Standalone:** Core connects to `openab-gateway` through `/ws`.

Before this decision, Core inferred behavior from adapter-wide methods and platform-name allowlists. That caused three classes of error:

- a shared Unified adapter could apply Telegram streaming settings to Teams;
- Standalone Core could not know whether a Gateway acknowledged send, edit, or delete operations;
- a transport timeout could not be distinguished from an explicit platform rejection.

The Standalone event channel is a bounded in-process broadcast channel. It has no durable inbox, replay, shared deduplication store, or consumer-group semantics. Multiple Core consumers therefore receive duplicate events rather than distributed work.

## Decision

### 1. Proposed baseline product decisions

The following decisions define this proposed single-process baseline:

| ID | Decision |
| --- | --- |
| D1 | The baseline supports one process replica and one active Standalone Core consumer per platform. Ingress after local enqueue is best-effort; there is no crash replay or exactly-once claim. A second consumer is warned at high severity and reported as unsupported, but is not rejected in this backward-compatible release. |
| D2 | Standalone uses an optional, client-initiated capability handshake. A peer that does not complete a supported handshake remains in legacy mode; missing ACKs are not delivery failures in legacy mode. |
| D3 | `GatewayEvent.event_id` is correlation metadata, never a platform activity ID. New↔new create/send returns the real platform message ID. Teams client presentation is outside transport acknowledgement semantics. |
| D4 | Teams supports Microsoft commercial public cloud only. Sovereign-cloud and custom-proxy endpoints require an explicit future cloud profile. |
| D5 | User-visible status and content streaming are independent capabilities. Teams processing status must not reuse a streaming placeholder implicitly. |

### 2. Platform-aware capability contract

Each adapter exposes capabilities for the actual `ChannelRef.platform`:

```rust
struct AdapterCapabilities {
send_ack: bool,
edit_ack: bool,
delete_ack: bool,
supports_target_message_id: bool,
supports_reactions: bool,
can_edit: bool,
can_delete: bool,
streaming_mode: StreamingMode,
show_streaming_placeholder: bool,
message_limit: MessageLimit,
status_backend: StatusBackend,
}
```

Capability defaults fail closed:

- no required ACK;
- no additive command-target field or native reaction support;
- no edit or delete support;
- streaming disabled;
- status side effects disabled;
- a conservative 4,096-character message limit.

Direct adapters derive a backward-compatible capability view from their existing methods. Unified and Standalone shared adapters override it by platform.

A valid negotiated hello is authoritative. If a platform is omitted from a valid hello, Core uses fail-closed defaults rather than optimistic legacy behavior. Legacy behavior is used only before a supported hello is accepted.

### 3. Optional Standalone hello exchange

Core sends this additive control frame immediately after connecting:

```json
{
"schema": "openab.gateway.client_hello.v1",
"protocol_version": 1,
"client_name": "openab-core/<version>",
"requested_platforms": ["teams"]
}
```

A new Gateway responds:

```json
{
"schema": "openab.gateway.hello.v1",
"protocol_version": 1,
"capabilities": {
"teams": {
"send_ack": true,
"edit_ack": true,
"delete_ack": true,
"supports_target_message_id": true,
"supports_reactions": false,
"can_edit": true,
"can_delete": true,
"streaming_mode": "disabled",
"show_streaming_placeholder": true,
"message_limit": { "unit": "characters", "max": 4096 },
"status_backend": "none"
}
},
"topology": {
"active_consumers": 1,
"supported": true,
"delivery_mode": "best_effort_broadcast"
}
}
```

Rules:

- unknown JSON fields are additive and may be ignored;
- an empty `requested_platforms` list requests all configured adapters; the stock Core uses this because one Standalone socket can carry events from several platforms;
- protocol version mismatch, malformed hello, or no hello keeps Core in legacy mode;
- Gateway continues to accept `openab.gateway.reply.v1` as the first frame, so an old Core works with a new Gateway;
- a new Core may send `client_hello` to an old Gateway; the old Gateway may log it as an invalid reply but must keep the connection usable;
- operations emitted before a valid hello is processed use legacy semantics;
- control frames are prioritized over broadcast events once received.

Recommended Standalone rollout order remains Gateway first, then Core, but either side may be upgraded first.

### 4. Structured write outcome

Gateway keeps the existing `openab.gateway.response.v1` fields and adds optional fields:

- `outcome`: `delivered`, `rejected`, or `unknown`;
- `error_code`;
- `retry_after_ms`.

The internal result is:

```rust
enum WriteOutcome {
Delivered { message_id: Option<String> },
Rejected {
code: String,
message: String,
retry_after_ms: Option<u64>,
},
Unknown { code: String, message: String },
}
```

Semantics:

- create/send delivery requires a non-empty real message ID when that operation advertises required ACK support;
- edit/delete delivery does not require a message ID in its ACK;
- explicit platform refusal is `Rejected`;
- an ambiguous POST timeout or disconnect is `Unknown` and must not be retried blindly;
- legacy responses without `outcome` map from the existing `success`, `message_id`, and `error` fields;
- Core waits only for an operation whose capability advertises the corresponding ACK;
- Teams advertises `send_ack = true` only after its event-route send path emits a terminal structured response with a non-empty Bot Framework activity ID on delivery;
- Teams advertises edit/delete ACK and `supports_target_message_id` only after bot-owned mutation enforcement emits a terminal response on every command path;
- Teams advertises `supports_reactions = true` and `status_backend = reactions` only under the explicit public-preview `reactions_enabled` opt-in; the default remains false/`none`;
- `supports_reactions` is independent from the selected progress backend so permanent batch receipts can coexist with a processing message; new Core normalizes an old peer's `status_backend = reactions` to reaction support;
- configured Teams processing messages are selected Core-side only after a valid hello advertises required send/edit/delete ACKs, additive command targets, and bot-owned edit/delete; no valid hello means no message status;
- negotiated required ACK timeout defaults to 12 seconds and is configurable as `[gateway].gateway_ack_timeout_secs`;
- configuration rejects zero, a budget at or above `pool.prompt_hard_timeout_secs`, and a Teams budget at or below the 10-second Connector timeout;
- legacy response waits preserve their previous best-effort behavior.

The 12-second Gateway budget must remain greater than the Teams Bot Connector request timeout (10 seconds) and less than the ACP turn hard timeout.

### 5. Topology guardrail

Each Gateway process counts active `/ws` Core consumers:

- one consumer: `topology.supported = true`;
- more than one: emit an error-level log and return `topology.supported = false` with `delivery_mode = "best_effort_broadcast"`;
- disconnect decrements the count through a drop guard, including task cancellation paths.

This detects unsupported fan-out inside one Gateway process. It cannot detect multiple independent Gateway replicas because the baseline deliberately has no shared state. The Helm deployments therefore remain fixed at `replicas: 1` with `Recreate` strategy. External deployments must follow the same constraint.

Rejecting the second consumer would be a breaking change and is deferred.

## Compatibility Matrix

| Core | Gateway | Behavior |
| --- | --- | --- |
| old | old | Existing protocol and fire-and-forget behavior. |
| old | new | Gateway accepts a reply without hello; additive response fields are ignored. |
| new | old | Core sends optional hello, receives none, and stays in legacy mode; missing ACK is not failure. |
| new | new | Valid hello enables platform-aware capabilities, operation-specific required ACKs, structured outcomes, and topology reporting. |

## Security and Reliability Boundaries

- Capability negotiation is not authentication or authorization. Existing WebSocket token, platform webhook authentication, tenant checks, L2 scope, and L3 identity gates remain authoritative.
- Hello frames contain no platform credentials, service URLs, route records, or user identifiers.
- Advertising a capability does not make an operation safe by itself; the adapter must emit the corresponding ACK on every terminal path before the flag is enabled.
- `Unknown` preserves ambiguity instead of creating duplicates through automatic retry.
- This baseline does not claim durable enqueue, replay, duplicate-safe multi-consumer operation, or exactly-once delivery.

## Consequences

### Positive

- Teams no longer inherits generic Gateway or Telegram streaming/status behavior; reaction availability, processing-message selection, and progressive content each require their own explicit opt-in and capability gate.
- Core no longer needs write-path platform allowlists such as `EDIT_RESPONSE_PLATFORMS`.
- New platform features can be introduced additively without forcing a lockstep Core/Gateway deployment.
- Operators and Core can identify unsupported multi-consumer topology.
- Delivery uncertainty is represented explicitly and can be handled without unsafe retry.

### Negative

- Capability DTOs are mirrored in Core and Gateway and require wire-compatibility tests.
- The first operation may use legacy behavior if it races ahead of hello processing.
- Existing adapters that cannot return a stable message ID must advertise conservative send-once behavior until their delivery path is upgraded.
- Cross-replica topology remains undetectable without a shared coordination system.

## Alternatives Rejected

1. **Platform-name allowlists in Core.** Rejected because they drift whenever an adapter changes behavior and cannot represent deployment-specific support.
2. **Mandatory hello before accepting replies.** Rejected because it breaks old Core deployments during rolling upgrade.
3. **Treat every timeout as rejection.** Rejected because the platform may have committed an ambiguous POST.
4. **Retry ambiguous POST automatically.** Rejected because it can duplicate user-visible activities.
5. **Reject the second consumer immediately.** Deferred because this is a breaking operational change.
6. **Claim HA from multiple broadcast consumers.** Rejected because broadcast fan-out is not work distribution and has no shared idempotency state.

## Verification

Automated verification must cover:

- capability DTO defaults and wire round trips;
- all three structured outcomes plus legacy response decoding;
- old Core→new Gateway reply without hello;
- new Core→old Gateway legacy fallback;
- new↔new capability selection;
- requested-platform filtering;
- second-consumer unsupported topology and disconnect decrement;
- Unified Teams isolation from Telegram streaming settings;
- additive reaction-support decoding and processing-message fail-closed capability selection;
- Teams progressive-response selection only under explicit opt-in plus every required write primitive, in Standalone and Unified modes;
- configurable 12-second ACK default.
Loading
Loading