This is an automated email from the ASF dual-hosted git repository.

dmsolr pushed a commit to branch docs/plugin-graph-contract
in repository https://gitbox.apache.org/repos/asf/shenyu.git

commit f2839049ca3873cde0b0c8fe6ff519c040dc905a
Author: Luke Haochao Zhuang <[email protected]>
AuthorDate: Fri Oct 23 11:35:13 2026 +0800

    docs: add plugin graph orchestration contract and compatibility audit
    
    Add the Phase-1 design artifacts for optional DAG-based plugin orchestration
    (parent proposal #7993):
    
    - docs/plugin-graph-orchestration.md: versioned graph schema, node/condition
      semantics, Phase-1 structural restrictions, graph limits, wrapper-scope
      classes, the dual-executor (LEGACY/GRAPH) contract with sequence diagrams,
      and explicit decisions for repeated continuations, mandatory dependencies
      (fail closed) and the no-legacy-fallback rule.
    - docs/plugin-graph-compatibility-audit.md: category-level classification of
      every PluginEnum-shipped plugin into wrapper-scope class + continuation
      behaviour + Phase-1 verdict, with representative deep-dives 
(global/context
      init, Sentinel-style wrapper, response owner/writer, proxy terminal + 
retry,
      fork() internal dispatch) and the validation rules derived for #7995.
    
    These are design documents for community review; no runtime behaviour 
changes.
    
    Fixes #7994
    Refs #7993
---
 docs/plugin-graph-compatibility-audit.md | 333 ++++++++++++++++++++++++++
 docs/plugin-graph-orchestration.md       | 397 +++++++++++++++++++++++++++++++
 2 files changed, 730 insertions(+)

diff --git a/docs/plugin-graph-compatibility-audit.md 
b/docs/plugin-graph-compatibility-audit.md
new file mode 100644
index 0000000000..1935f82605
--- /dev/null
+++ b/docs/plugin-graph-compatibility-audit.md
@@ -0,0 +1,333 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# Plugin Graph Orchestration — Compatibility Audit (Phase 1)
+
+Status: **design artifact for community review**, produced for issue #7994 
under the parent proposal
+#7993. This is the companion to 
[plugin-graph-orchestration.md](./plugin-graph-orchestration.md)
+(the contract). Where the contract fixes *semantics*, this audit fixes 
*placement*: it classifies every
+plugin currently shipped in `shenyu-plugin` into a wrapper-scope class, 
records how each plugin uses
+the `ShenyuPluginChain` continuation, and derives the concrete placement and 
validation rules that
+#7995 (validator/compiler) and #7996 (graph runtime) must enforce.
+
+The audit is **category-level with representative deep-dives**: §3 gives the 
full per-plugin matrix
+(one row per `PluginEnum` name), §4 digs into the five cases that actually 
decide the design
+(global/context init, Sentinel-style wrapper, response owner/writer, proxy 
terminal + retry,
+`fork()` internal dispatch), and §5–§6 turn the findings into 
machine-checkable rules.
+
+## 1. Method and evidence base
+
+Classification is grounded in the code as it exists on `master` at the time of 
writing:
+
+- Plugin identity and default ordering come from 
`org.apache.shenyu.common.enums.PluginEnum`
+  (`code` = sort key, `name` = the string a graph node binds via `pluginName`).
+- Continuation behaviour is read from each plugin's `execute`/`doExecute`: 
whether it calls
+  `chain.execute(exchange)` exactly once, decorates the returned `Mono`
+  (`.transform`/`.then`/`.onErrorResume`/`.doFinally`), resubscribes 
internally, or calls
+  `chain.fork()`.
+- Selector/rule matching is **out of scope** for placement: 
`AbstractShenyuPlugin` already owns it and
+  the graph never replaces it (contract §2 invariant). A graph edge is not a 
rule.
+
+This audit does **not** assert that a plugin is correct in LEGACY mode; it 
only asks whether the
+plugin's chain usage survives being placed on a graph node under the Phase-1 
restrictions
+(contract §7).
+
+## 2. Classification dimensions
+
+Each plugin gets three orthogonal labels.
+
+**Wrapper-scope class** (contract §9) — decides *where* a node may sit:
+
+| Class | Placement rule |
+|-------|----------------|
+| Global / context init | Mandatory **common prefix**; cannot be branched, 
skipped, or duplicated. |
+| Auth / security | **Common prefix**; mandatory deps fail closed (contract 
§12); never bypassed by graph config. |
+| Wrapper (observability / resilience / logging) | **Wrapper scope**: must 
fully enclose the sub-path it measures. A whole-pipeline wrapper belongs in the 
common prefix/suffix, not inside one branch. |
+| Response owner / writer | **Common suffix**; exactly one response owner per 
root→END path. |
+| Transform | **Branch-safe** if idempotent and not duplicated on a path; 
order-sensitive. |
+| Proxy / terminal | **Exclusive** per path; side-effecting (upstream calls) — 
must not be duplicated or re-executed on fallback. |
+| Short-circuit | **Branch-safe** terminal alternative (commits a response 
without an upstream). |
+
+**Continuation behaviour** (contract §11) — decides whether the GRAPH executor 
may cache/re-run a
+node's output `Mono`:
+
+| Code | Meaning | Executor constraint |
+|------|---------|---------------------|
+| (a) | calls the continuation **exactly once** | free placement within its 
class rules |
+| (b) | may **resubscribe** its own output (retry / failover) | executor must 
**not** insert a silent `cache()`; node output must stay re-subscribable |
+| (c) | **wraps / decorates** the continuation (`transform`/`then`/response 
decoration) | wrapper scope must be preserved; cannot be split across a branch 
boundary |
+
+**Phase-1 verdict**:
+
+| Verdict | Meaning |
+|---------|---------|
+| Supported | may appear on a graph node in Phase 1 under its placement rule. |
+| Prefix/Suffix-only | supported but constrained to the common prefix or 
suffix (cannot live inside a CHOICE branch). |
+| Restricted | supported only in a single-execution position; validator 
rejects duplicate placement on a path. |
+| Rejected (Phase 1) | referencing it in a graph node fails validation with an 
actionable error (contract §15); still fully usable in LEGACY. |
+
+## 3. Full per-plugin matrix
+
+Ordered by `PluginEnum.code` (the LEGACY sort key). "Cont." is the 
continuation behaviour from §2.
+
+| code | name | class | Cont. | Phase-1 verdict |
+|------|------|-------|-------|-----------------|
+| -1 | `global` | Global / context init | (a) | **Prefix-only** (mandatory, 
exactly once) |
+| 0 | `tracing` | Wrapper (observability) | (c) | Prefix/Suffix-only 
(whole-pipeline wrapper) |
+| 1 | `mock` | Short-circuit | (a) | Supported (branch-safe terminal) |
+| 10 | `cache` | Response owner / writer | (c) | Prefix/Suffix-only (wraps 
response write) |
+| 15 | `metrics` | Wrapper (observability) | (c) | Prefix/Suffix-only |
+| 20 | `sign` | Auth / security | (a) | **Prefix-only** (mandatory dep, fail 
closed) |
+| 30 | `jwt` | Auth / security | (a) | **Prefix-only** |
+| 40 | `oauth2` | Auth / security | (a) | **Prefix-only** |
+| 40 | `casdoor` | Auth / security | (a) | **Prefix-only** |
+| 50 | `waf` | Auth / security | (a) | **Prefix-only** |
+| 60 | `rateLimiter` | Wrapper (resilience) | (c) | Prefix/Suffix-only |
+| 65 | `aiRequestTransformer` | Transform (request) | (a) | Supported 
(branch-safe, no duplicate) |
+| 66 | `aiResponseTransformer` | Transform (response) | (c) | 
Prefix/Suffix-only (response side) |
+| 70 | `paramMapping` | Transform (request) | (a) | Supported |
+| 80 | `contextPath` | Transform (request) | (a) | Supported |
+| 90 | `rewrite` | Transform (request) | (a) | Supported |
+| 100 | `cryptorRequest` | Transform (request) | (a) | Supported |
+| 110 | `redirect` | Short-circuit | (a) | Supported (branch-safe terminal) |
+| 120 | `request` | Transform (request) | (a) | Supported |
+| 125 | `generalContext` | Transform (context) | (a) | Supported |
+| 125 | `mqtt` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 130 | `hystrix` | Wrapper (resilience) | (c) | Prefix/Suffix-only |
+| 140 | `sentinel` | Wrapper (resilience) | (c) | Prefix/Suffix-only (see 
§4.2) |
+| 150 | `keyAuth` | Auth / security | (a) | **Prefix-only** |
+| 150 | `basicAuth` | Auth / security | (a) | **Prefix-only** |
+| 160 | `loggingConsole` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 170 | `aiPrompt` | Transform (request) | (a) | Supported |
+| 170 | `loggingRocketMQ` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 171 | `loggingRabbitMQ` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 171 | `aiTokenLimiter` | Wrapper (resilience) | (c) | Prefix/Suffix-only |
+| 175 | `loggingAliyunSls` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 176 | `loggingTencentCls` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 177 | `loggingHuaweiLts` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 180 | `loggingKafka` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 180 | `mcpServer` | Proxy / terminal | (a) | **Restricted** (exclusive; uses 
`fork()`, see §4.5) |
+| 185 | `loggingPulsar` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 190 | `loggingElasticSearch` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 195 | `loggingClickHouse` | Wrapper (logging) | (c) | Prefix/Suffix-only |
+| 197 | `sensitiveWord` | Transform (request/response) | (c) | 
Prefix/Suffix-only |
+| 198 | `agentGateway` | Proxy / terminal | (a) | **Restricted** (exclusive; 
uses `fork()`, see §4.5) |
+| 199 | `aiProxy` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 200 | `divide` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting; see §4.4) |
+| 200 | `springCloud` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 200 | `websocket` | Proxy / terminal | (a) | **Restricted** (exclusive, 
long-lived stream) |
+| 205 | `uri` | Transform (routing) | (a) | Supported |
+| 210 | `webClient` | Proxy / terminal (HTTP client) | (b) | **Restricted** 
(internal resubscription; see §4.4) |
+| 210 | `nettyHttpClient` | Proxy / terminal (HTTP client) | (b) | 
**Restricted** (internal resubscription; see §4.4) |
+| 220 | `modifyResponse` | Transform (response) | (c) | Prefix/Suffix-only 
(response side) |
+| 300 | `paramTransform` | Transform (RPC request) | (a) | Supported |
+| 310 | `dubbo` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 310 | `sofa` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 310 | `tars` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 310 | `grpc` | Proxy / terminal | (a) | **Restricted** (exclusive, 
side-effecting) |
+| 310 | `resilience4j` | Wrapper (resilience) | (c) | Prefix/Suffix-only |
+| 320 | `tcp` | Proxy / terminal | (a) | **Restricted** (exclusive, long-lived 
stream) |
+| 410 | `cryptorResponse` | Transform (response) | (c) | Prefix/Suffix-only 
(response side) |
+| 420 | `response` | Response owner / writer | (c) | **Suffix-only** 
(mandatory single owner; see §4.3) |
+
+Notes on the matrix:
+
+- Several `code` values collide (e.g. `divide`/`springCloud`/`websocket` all 
200; the RPC proxies and
+  `resilience4j` all 310). LEGACY breaks ties by registration/insertion order; 
a graph makes the order
+  **explicit**, which is one of the concrete benefits of #7993 and removes a 
real source of ambiguity.
+- The HTTP clients (`webClient`, `nettyHttpClient`) are the only **(b)** 
plugins: they resubscribe
+  their own upstream `Mono` for retry/failover. This is the single most 
important executor constraint
+  (§4.4, contract §11).
+- `mock` and `redirect` are the clean **short-circuit** terminals: they commit 
a response without an
+  upstream, so they are natural exclusive branch leaves.
+- Plugins marked "Prefix/Suffix-only" are wrappers (class (c)). They are 
*supported*, but a whole-
+  pipeline wrapper placed inside one CHOICE branch would only measure that 
branch, silently changing
+  its semantics — hence the restriction.
+
+## 4. Representative deep-dives
+
+### 4.1 Global / context init — `GlobalPlugin` (mandatory prefix, exactly once)
+
+`GlobalPlugin#execute` builds the `ShenyuContext` and publishes it before 
delegating:
+
+```java
+ShenyuContext shenyuContext = builder.build(exchange);
+exchange.getAttributes().put(Constants.CONTEXT, shenyuContext);
+return chain.execute(exchange);
+```
+
+Every downstream plugin reads `exchange.getAttribute(Constants.CONTEXT)` and 
calls
+`Objects.requireNonNull(shenyuContext)` (e.g. `SentinelPlugin`, 
`ResponsePlugin`). Consequences for
+the graph:
+
+- `global` is a **hard mandatory dependency** (contract §12): a graph whose 
entry path does not run
+  `global` before any context-reading node is rejected at validation.
+- It must run **exactly once per request** and **before** the dispatcher hands 
off to any branch. The
+  GRAPH executor therefore pins context init as the first node of the common 
prefix; it is never a
+  CHOICE target and never duplicated on a path.
+- Continuation class (a): the executor may treat its output normally.
+
+### 4.2 Sentinel-style wrapper — `SentinelPlugin` (wrapper scope must be 
preserved)
+
+`SentinelPlugin#doExecute` wraps the *entire downstream* and attaches fallback 
+ metrics on the way
+out:
+
+```java
+return chain.execute(exchange)
+        .transform(new SentinelReactorTransformer<>(resourceName))
+        .onErrorResume(throwable -> fallbackHandler.fallback(exchange, ...))
+        .doFinally(monoV -> { /* report status to metrics consumer */ });
+```
+
+This is the canonical class (c) decorator. The wrapper's meaning depends on 
**what it encloses**:
+
+- If `sentinel` sits in the common prefix, it wraps the whole pipeline (the 
LEGACY-equivalent
+  semantics) — circuit breaking and fallback see the real downstream outcome.
+- If `sentinel` were placed inside one CHOICE branch, it would only guard that 
branch; the other
+  branches and the shared suffix would run un-guarded. That is a **silent 
semantic change**, so the
+  validator restricts whole-pipeline wrappers to prefix/suffix placement (§5, 
rule W1).
+- The same shape applies to `resilience4j`, `hystrix`, `rateLimiter`, 
`tracing`, `metrics`, the
+  `logging*` family, and `cache`: all decorate `chain.execute(...)` rather 
than merely calling it.
+
+`sentinel` also installs a `Constants.WATCHER_HTTP_STATUS` consumer and reads
+`Constants.METRICS_SENTINEL` in `doFinally` — request-scoped attributes that 
are safe under the
+per-subscription state model (contract §11) but would be corrupted by any 
cross-branch state sharing.
+
+### 4.3 Response owner / writer — `ResponsePlugin` + `MessageWriter` (single 
owner, suffix)
+
+`ResponsePlugin#execute` delegates by RPC type to a `MessageWriter` that owns 
the actual write:
+
+```java
+return writerMap.get(shenyuContext.getRpcType()).writeWith(exchange, chain);
+```
+
+`MessageWriter#writeWith(exchange, chain)` receives the chain and decorates 
the response write
+(`NettyClientMessageWriter`, `RPCMessageWriter`, `WebClientMessageWriter`). 
`CachePlugin` participates
+in the same ownership boundary (it caches/replays the written response — see
+[cache-request-eligibility.md](./cache-request-eligibility.md)). Consequences:
+
+- The response owner is a **common-suffix** concern: exactly one owner per 
root→END path. Two response
+  owners on one path (e.g. `response` and a second writer) would double-write 
the response. The
+  validator enforces "≤1 response-owner node per path" (§5, rule R1).
+- Because the writer wraps the terminal write, the owner is class (c): it 
cannot be split across a
+  branch boundary, and the proxy terminal it writes for must be on the same 
path (§4.4).
+- `modifyResponse` / `cryptorResponse` / `aiResponseTransformer` mutate the 
response and must sit
+  **before** the owner on the suffix; they are response-side transforms, not 
owners.
+
+### 4.4 Proxy terminal + retry — `divide` → HTTP client (exclusive, 
side-effecting, re-subscribable)
+
+The HTTP proxy path is `divide` (selects the upstream, runs the load balancer) 
followed by an HTTP
+client (`webClient` / `nettyHttpClient`) that performs the actual call. The 
client is the only class
+**(b)** plugin: on timeout/failure it **resubscribes its own upstream `Mono`** 
to retry or fail over.
+`DefaultRetryStrategy#resend` re-invokes `httpClientPlugin.doRequest(...)` 
and, for failover, re-runs
+the load balancer to pick a different upstream (this is the path fixed in 
#7974/#7975 for P2C inflight
+accounting). Consequences for the graph:
+
+- **No silent `cache()`.** If the GRAPH executor cached a proxy node's output 
`Mono` to "reuse" it, the
+  retry/failover resubscription would be short-circuited and the plugin would 
stop retrying. The
+  executor must keep proxy node output re-subscribable (contract §11).
+- **Exclusive, non-duplicated.** A proxy node performs a side-effecting 
upstream call. The validator
+  rejects any path that reaches the same proxy plugin twice, and rejects a 
proxy appearing in both the
+  common prefix and a branch (contract §16.3 "duplicate side-effecting plugin 
on a path").
+- **No cross-executor fallback.** Because a retry already resubscribes inside 
the node, a graph-level
+  "fall back to LEGACY on error" would duplicate the upstream call. This is 
exactly why the contract
+  forbids implicit fallback (§13) and mid-request executor switches (§10, rule 
8).
+- `websocket` / `tcp` / `mqtt` are long-lived streams: the executor pins the 
plan snapshot for the life
+  of the stream; a revision swap affects new connections only (contract §10, 
rule 8; §14).
+
+### 4.5 `fork()` internal dispatch — `AgentHttpToolExecutor` / `mcpServer`
+
+`agentGateway` (via `AgentHttpToolExecutor`) and `mcpServer` re-enter the 
chain for an internal
+sub-request:
+
+```java
+return 
chain.fork().execute(child).then(Mono.fromCallable(response::result)).timeout(remaining);
+```
+
+`ShenyuPluginChain#fork()` is documented as "an independent cursor over this 
request's plugin snapshot
+from the first plugin … custom chains must explicitly support internal 
dispatch rather than sharing a
+mutable cursor." Consequences:
+
+- **Executor identity is inherited** (contract §10, rule 9): a `fork()` from a 
GRAPH node starts an
+  independent GRAPH execution from the entry over the same pinned plan 
snapshot; it does **not**
+  consult the global dispatcher again and must not flip to LEGACY.
+- **Recursion is bounded.** Because a fork re-enters from the entry, an 
unbounded fork chain could
+  recurse indefinitely; the contract caps fork/internal-dispatch recursion 
depth (default 8, contract
+  §8). The validator/runtime reject graphs whose fork depth exceeds the cap.
+- The internal marker attributes (`HTTP_TOOL_CALL_ATTRIBUTE`, `MCP_TOOL_CALL`) 
are request-scoped and
+  safe under per-subscription state, but the forked child must not share 
mutable traversal state with
+  the parent — which is precisely the property `fork()`'s independent-cursor 
contract guarantees.
+
+## 5. Validation rules derived from the audit (input to #7995)
+
+The validator (#7995) must reject, with the offending `nodeId`/`pluginName` in 
the error:
+
+- **G1 — mandatory prefix.** The entry path runs `global` before any 
context-reading node; `global`
+  appears exactly once and is never a CHOICE target.
+- **A1 — auth fail closed.** Any node marked `required` (and any plugin in the 
Auth/security class)
+  that is missing/disabled at activation rejects the plan (contract §12); auth 
nodes live in the
+  common prefix and are never bypassed by a branch.
+- **W1 — wrapper scope.** A class (c) whole-pipeline wrapper (tracing, 
metrics, sentinel, resilience4j,
+  hystrix, rateLimiter, the `logging*` family, cache) is placed in the common 
prefix/suffix; it cannot
+  be a node inside a CHOICE branch unless the branch is the entire enclosed 
sub-path.
+- **R1 — single response owner.** At most one response-owner node (`response`, 
plus the cache writer
+  role) per root→END path; response-side transforms (`modifyResponse`, 
`cryptorResponse`,
+  `aiResponseTransformer`) precede the owner.
+- **P1 — proxy exclusivity.** A proxy/terminal plugin appears at most once per 
path and never in both
+  the common prefix and a branch.
+- **B1 — no `cache()` on (b) nodes.** The compiler tags HTTP-client nodes as 
re-subscribable so the
+  runtime does not memoize their output (contract §11).
+- **F1 — bounded fork.** Fork/internal-dispatch recursion depth ≤ configured 
cap (default 8).
+- **S1 — structural restrictions & limits.** The §7 shape (single entry, 
common prefix, simple-chain
+  CHOICE branches, single convergence, common suffix) and the §8 limits hold; 
every reachable path
+  terminates at an `END` or a response-committing terminal.
+- **X1 — unsupported plugin.** Referencing a plugin the audit marks "Rejected 
(Phase 1)" fails
+  validation (contract §15). No plugin in the current tree is fully rejected; 
the restriction set is
+  the "Restricted"/"Prefix/Suffix-only" verdicts above, which are placement 
constraints rather than
+  outright bans. This rule exists so future plugins that mutate the shared 
cursor can be rejected.
+
+## 6. Phase-1 supported graph shape (concrete)
+
+Combining §3–§5, the initial supported graphs are exactly:
+
+```text
+[common prefix]  global (mandatory) -> auth/security* -> whole-pipeline 
wrapper*
+[CHOICE region]  zero or more CHOICE nodes whose branches are simple chains of
+                 transform / proxy-terminal / short-circuit nodes (exclusive, 
non-duplicated)
+[convergence]    a single designated convergence node
+[common suffix]  response-side transform* -> response owner (single) -> END
+```
+
+This shape keeps path analysis polynomial (contract §7), preserves every 
wrapper's scope, guarantees a
+single response owner, and never duplicates a side-effecting proxy — while 
still delivering the core
+#7993 value: explicit ordering (no more `code` ties), conditional exclusive 
routing, and declarative
+mandatory dependencies.
+
+## 7. Residual risks and open questions
+
+1. **Cache + response owner interaction.** `cache` both wraps the response 
write and replays cached
+   responses; its exact node placement (prefix vs suffix, and relative to the 
owner) needs a dedicated
+   decision before #7996. Tracked as an open question here rather than guessed.
+2. **AI transformer ordering.** 
`aiRequestTransformer`/`aiResponseTransformer`/`aiPrompt`/
+   `sensitiveWord` straddle the request/response boundary; the audit 
classifies them by the side they
+   mutate, but a graph that mixes AI and non-AI proxies may need finer 
ordering rules.
+3. **Long-lived streams under revision swap.** `websocket`/`tcp`/`mqtt` pin 
the plan for the life of the
+   connection; the precise "new connections only" cutover semantics are a 
#7996/#7998 concern.
+4. **Plugin-store / migrated plugins.** Some `PluginEnum` names (e.g. the 
`logging*` compatibility
+   entries, `hystrix`) point at implementations that live in the plugin store. 
A graph node binding such
+   a name must resolve against the *installed* catalog at activation; the 
fail-closed rule (A1/X1)
+   covers the missing case, but the catalog-resolution contract is finalized 
in #7995/#7999.
diff --git a/docs/plugin-graph-orchestration.md 
b/docs/plugin-graph-orchestration.md
new file mode 100644
index 0000000000..2f5a5dd20e
--- /dev/null
+++ b/docs/plugin-graph-orchestration.md
@@ -0,0 +1,397 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# Plugin Graph Orchestration — Contract (Phase 1)
+
+Status: **design contract for community review**, produced for issue #7994 
under the parent
+proposal #7993 (*optional DAG-based plugin orchestration*). Nothing here is an 
accepted design;
+this document is the artifact intended to drive that review. It defines the 
versioned graph
+schema, node and condition semantics, structural restrictions, graph limits, 
plugin requirement
+and wrapper-scope classes, the dual-executor boundary, and explicit decisions 
for the hard cases.
+The companion compatibility audit lives in 
[plugin-graph-compatibility-audit.md](./plugin-graph-compatibility-audit.md).
+
+## 1. Scope and non-goals
+
+Phase 1 adds an **optional, serial** plugin execution graph as a *second 
execution mode* beside the
+existing sorted-list chain. It preserves the current 
`ShenyuPlugin.execute(exchange, chain)`
+contract verbatim.
+
+In scope: explicit dependency constraints, conditional **exclusive** branches, 
convergence of
+mutually exclusive paths, one serial path per request.
+
+Out of scope (future proposals): parallel execution, fan-out/fan-in join 
scheduling, duplicate
+same-plugin invocation on one path, arbitrary executable scripts, a universal 
workflow engine, and
+any HTTP/RPC **API aggregation** or backend method composition. Per-request 
executor routing is
+deferred; only deployment-level mode selection ships first.
+
+## 2. Grounding in the current runtime
+
+The contract is written against the code as it exists today:
+
+- `org.apache.shenyu.web.handler.ShenyuWebHandler#handle` builds a fresh
+  `DefaultShenyuPluginChain(plugins)` per subscription and runs it, optionally 
on a scheduler,
+  wrapped by `before`/`after` chain timing.
+- `DefaultShenyuPluginChain` is a mutable `index` cursor over an immutable 
sorted
+  `List<ShenyuPlugin>`; `execute` honours `plugin.skip`, then calls 
`plugin.before`,
+  `plugin.execute(exchange, this)`, and `plugin.after` in a `finally`.
+- `ShenyuPluginChain#fork()` returns an **independent cursor over the same 
snapshot** for internal
+  dispatch.
+- Plugin ordering comes from `PluginData.sort` (else 
`ShenyuPlugin#getOrder()`), re-sorted on
+  `PluginHandlerEvent` (ENABLED / DISABLED / DELETE / SORTED).
+- Selector/rule matching and condition evaluation already exist in 
`shenyu-plugin-base`
+  (`AbstractShenyuPlugin`, `condition/judge/PredicateJudge`, 
`condition/strategy/MatchStrategy`,
+  `condition/data/ParameterData`) over 
`org.apache.shenyu.common.dto.ConditionData`.
+
+**Invariant:** graph orchestration chooses *which plugins run and in what 
order*; it never replaces
+a plugin's own selector/rule matching. A graph edge is not a rule.
+
+## 3. Terminology
+
+| Term | Meaning |
+|------|---------|
+| Graph definition | Versioned, editable source-of-truth for a graph (draft → 
published revision). |
+| Revision | Immutable, monotonically numbered publication of a graph 
definition. |
+| Plan | Compiled, immutable, request-path-free form of a revision (validated 
+ bound successors). |
+| Executor | One of two runtimes: `LEGACY` (sorted cursor) or `GRAPH` (plan 
traversal). |
+| Dispatcher | Selects the executor **before any** `ShenyuPlugin` runs and 
pins it for the subscription. |
+| Node | A graph vertex: `PLUGIN`, `CHOICE`, or `END`. |
+| Wrapper scope | The sub-path a wrapping plugin must fully enclose to 
preserve its semantics. |
+
+## 4. Versioned graph schema
+
+A graph definition carries the following fields. Types are indicative; the 
DTOs are defined in
+`shenyu-common` by #7995.
+
+| Field | Type | Required | Notes |
+|-------|------|----------|-------|
+| `schemaVersion` | int | yes | Contract version of this document; validators 
reject unknown majors. |
+| `namespaceId` | string | yes | Must match the deployment's configured 
namespace. |
+| `deploymentBinding` | string | no | Optional explicit deployment/group id; 
else namespace-wide. |
+| `graphId` | string | yes | Stable identity across revisions. |
+| `revision` | long | yes | Monotonic per `graphId`; activation ordering key. |
+| `contentHash` | string | yes | Hash of the normalized node/edge content; 
change detection + idempotent sync. |
+| `entryNodeId` | string | yes | Must reference a node in `nodes`. |
+| `nodes` | list<Node> | yes | Non-empty; unique `nodeId`s. |
+
+`Node` fields:
+
+| Field | Type | Applies to | Notes |
+|-------|------|-----------|-------|
+| `nodeId` | string | all | Unique within the graph. |
+| `type` | enum `PLUGIN\|CHOICE\|END` | all | See §5. |
+| `pluginName` | string | PLUGIN | Binds an installed plugin **by name** 
(`ShenyuPlugin#named()` / `PluginData.name`). |
+| `next` | string (nodeId) | PLUGIN | Exactly one successor. |
+| `conditions` | list<Branch> | CHOICE | Ordered; first match wins. |
+| `defaultTarget` | string (nodeId) | CHOICE | Required fallback when no 
condition matches. |
+| `required` | bool | PLUGIN | If true, the plugin is a mandatory dependency 
(fail-closed, §12). |
+
+`Branch` = `{ List<ConditionData> conditions; MatchStrategy match; String 
target; }` — reusing the
+existing `ConditionData` model so operators author conditions exactly as they 
do for selectors/rules.
+
+## 5. Node model and semantics
+
+- **PLUGIN** — binds one installed plugin by name and has exactly one 
successor (`next`). At runtime
+  the executor invokes `plugin.skip`, `before`, `execute(exchange, 
continuation)`, `after`, where the
+  `continuation` executes **the successor node** rather than advancing a 
shared cursor. The plugin's
+  own selector/rule matching is untouched.
+- **CHOICE** — evaluates `conditions` **in declaration order** and selects the 
**first** branch whose
+  conditions match (under the branch's `MatchStrategy`); if none match it 
selects `defaultTarget`.
+  `defaultTarget` is mandatory, which makes selection total and deterministic. 
CHOICE has no plugin
+  side effects of its own.
+- **END** — terminal; completes the traversal. Every reachable path must 
terminate at an `END`
+  (or at a plugin that owns/commits the response, e.g. a proxy — see the 
audit).
+
+## 6. Condition semantics and determinism
+
+- Conditions reuse `ConditionData` 
(`paramType`/`operator`/`paramName`/`paramValue`) and are
+  evaluated by the existing `PredicateJudge`/`MatchStrategy` SPI in 
`shenyu-plugin-base` at request
+  time (in the GRAPH runtime, #7996). #7995 only validates and *compiles* 
conditions structurally.
+- **Determinism rules (validated by #7995):** ordered branches, first-match 
wins, mandatory
+  `defaultTarget`, all branch targets reference existing nodes, and no two 
branches are
+  byte-identical duplicates (ambiguous authoring).
+
+## 7. Phase-1 structural restrictions
+
+The audit (§9 and the companion doc) shows that arbitrary DAG re-convergence 
makes *path-wise*
+guarantees (no duplicate plugin on a path; well-defined wrapper scope) 
expensive and error-prone.
+Phase 1 therefore **restricts the graph shape** so that path analysis stays 
polynomial:
+
+1. Exactly one `entryNodeId`.
+2. A **common prefix** of zero or more PLUGIN nodes from the entry.
+3. Zero or more **CHOICE** nodes whose branches are **simple chains** (no 
nested re-convergence
+   inside a branch); a branch may itself contain a CHOICE, but branches must 
not merge back into a
+   shared node other than a single, well-defined **convergence point**.
+4. A **common suffix** (may be empty) leading to `END`.
+5. No node is reachable by two different CHOICE branches at the same 
convergence unless it is the
+   designated convergence point (exclusive-branch + single-convergence model).
+
+Consequence: the set of distinct root→END paths equals the product of branch 
fan-outs, but because
+branches are simple chains that converge once, **path-wise duplicate/scope 
checks reduce to checking
+each chain segment plus the shared prefix/suffix** — polynomial in graph size. 
Arbitrary DAGs are a
+future extension once the audit and runtime mature.
+
+## 8. Graph limits
+
+Validated by #7995; defaults are conservative and configurable. Rationale: 
bound compile cost,
+plan memory, retained-revision memory, and internal-dispatch recursion.
+
+| Limit | Default | Purpose |
+|-------|---------|---------|
+| max nodes per graph | 128 | bound validation/compile + plan size |
+| max out-degree (CHOICE branches) | 16 | bound branch fan-out |
+| max conditions per branch | 32 | bound condition compile |
+| max path length (prefix+branch+suffix) | 64 | bound traversal depth |
+| max fork/internal-dispatch recursion depth | 8 | bound `fork()` re-entry |
+| max retained revisions per graph | 8 | bound admin/gateway memory |
+| max graphs per namespace | 32 | bound sync + registry cardinality |
+
+## 9. Plugin requirements and wrapper-scope classes
+
+Plugins fall into wrapper-scope classes that determine whether they may live 
inside a branch or must
+sit in the common prefix/suffix. The full per-category matrix and 
representative deep-dives are in
+[plugin-graph-compatibility-audit.md](./plugin-graph-compatibility-audit.md); 
the classes are:
+
+| Class | Examples | Placement rule |
+|-------|----------|----------------|
+| Global / context init | `GlobalPlugin` (builds `ShenyuContext`) | 
**Mandatory common prefix**; cannot be branched or skipped. |
+| Auth / security | sign, jwt, oauth2, basic-auth, key-auth, waf | **Common 
prefix**; mandatory deps fail closed; never bypassed by graph config. |
+| Wrapper (observability/resilience) | tracing, metrics, fault-tolerance, 
logging | **Wrapper scope**: must fully enclose the sub-path it measures; if it 
wraps the whole pipeline it belongs in the common prefix/suffix, not inside one 
branch. |
+| Response owner / writer | response plugin, cache response writer | **Common 
suffix**; exactly one response owner per path. |
+| Transform | context-path, rewrite, param-mapping, request, modify-response, 
redirect, uri | Branch-safe if idempotent and not duplicated on a path; 
order-sensitive. |
+| Proxy / terminal | divide+httpclient, dubbo, grpc, spring-cloud, sofa, tars, 
websocket, mqtt | Exclusive per path; side-effecting (upstream calls) — must 
not be duplicated or re-executed on fallback. |
+| Short-circuit | mock | Branch-safe terminal alternative. |
+
+**Decision (initial supported graphs):** enforce a structured common prefix 
(global + auth + any
+whole-pipeline wrapper) and common suffix (response owner), with CHOICE 
branches limited to
+transform/proxy/short-circuit alternatives. This is the restriction that keeps 
§7 polynomial and
+preserves wrapper semantics.
+
+## 10. Dual-executor contract
+
+Orchestration is a **separate execution mode**, never a plugin inserted into 
the legacy chain and
+never an extension of its mutable cursor.
+
+```text
+Request / existing shared WebFilters
+  -> execution-mode dispatcher (runs BEFORE any ShenyuPlugin)
+       -> LEGACY : existing sorted List<ShenyuPlugin> + 
DefaultShenyuPluginChain cursor
+       -> GRAPH  : immutable compiled GraphPlan + independent GraphExecution 
state
+     (both executors reuse the SAME ShenyuPlugin instances)
+```
+
+Rules:
+
+1. **Dispatch before plugins.** The dispatcher chooses the executor before the 
first `ShenyuPlugin`
+   runs. No shared plugin prefix is executed and then re-entered by an 
executor.
+2. **Deployment-level mode.** Initial selection is configuration, e.g.
+   `shenyu.execution.mode=legacy|graph` and `shenyu.execution.graph-id`; 
`legacy` is the default.
+   Namespace graph binding must match the deployment's namespace. Per-request 
routing is deferred.
+3. **Pin per subscription.** The chosen executor is pinned for the whole 
subscription/request
+   execution and for anything it `fork()`s.
+4. **Separate lifecycle ownership.** Each executor owns its complete plugin 
lifecycle: exactly one
+   global/context init, one auth wrapper pass, one proxy, one response owner 
per request.
+5. **No graph state in the legacy cursor.** `DefaultShenyuPluginChain` and its 
`index` are unchanged;
+   graph traversal state lives only in `GraphExecution`. Legacy 
sorting/semantics are untouched
+   (any minimal entry refactoring to introduce the dispatcher must carry 
regression coverage).
+6. **Shared WebFilters.** Existing `WebFilter`s remain shared and run before 
the dispatcher.
+7. **Graph-not-ready rejection.** A GRAPH deployment without a ready required 
plan **rejects** the
+   request (and affects readiness); it never silently runs LEGACY.
+8. **No implicit fallback / no mid-request switch.** A failed graph execution 
never restarts on the
+   legacy chain (that could duplicate upstream calls and side effects). Mode 
changes affect **new
+   requests only**; in-flight requests, long-lived streams and forks keep 
their original
+   executor/plan.
+9. **fork() identity.** `fork()` inherits the executor kind and the structural 
plan snapshot and
+   starts independent execution from the entry without consulting the global 
dispatcher again.
+10. **Rollback.** Operational rollback to LEGACY is an explicit deployment 
configuration change (or
+    restart). Graph *revision* rollback (publish old content under a new 
revision) is a **different**
+    operation from executor-mode rollback. An optional live mode switch, if 
ever added, applies
+    atomically to new requests only.
+
+### 10.1 Sequence — LEGACY (unchanged)
+
+```mermaid
+sequenceDiagram
+    participant WF as Shared WebFilters
+    participant D as Mode Dispatcher
+    participant H as ShenyuWebHandler
+    participant C as DefaultShenyuPluginChain (cursor)
+    participant P as ShenyuPlugin[i]
+    WF->>D: request
+    D->>D: mode = LEGACY (deployment config)
+    D->>H: handle(exchange)
+    H->>C: new chain(plugins).execute(exchange)
+    loop index < plugins.size
+        C->>P: skip? before; execute(exchange, chain); after
+        P-->>C: Mono<Void> (may call chain.execute / chain.fork)
+    end
+    C-->>H: Mono.empty()
+    H-->>WF: response
+```
+
+### 10.2 Sequence — GRAPH (new)
+
+```mermaid
+sequenceDiagram
+    participant WF as Shared WebFilters
+    participant D as Mode Dispatcher
+    participant G as GraphExecution (per-request state)
+    participant PL as Compiled GraphPlan (immutable)
+    participant P as ShenyuPlugin (reused)
+    WF->>D: request
+    D->>D: mode = GRAPH; pin plan snapshot
+    alt no ready required plan
+        D-->>WF: reject (no legacy fallback)
+    else plan ready
+        D->>G: start(entryNodeId, exchange, plan)
+        loop traverse successor / CHOICE branch
+            G->>P: skip? before; execute(exchange, continuation->successor); 
after
+            P-->>G: Mono<Void> (continuation runs the bound successor)
+        end
+        G-->>WF: END -> response (single owner)
+    end
+```
+
+## 11. Repeated continuation and resubscription semantics
+
+**Decision:** the continuation passed to a plugin executes its bound 
successor. Whether a plugin may
+invoke the continuation more than once (or resubscribe the returned `Mono`) is 
**per the compatibility
+contract**, not blanket-cached:
+
+- Plugins that legitimately resubscribe (retry / failover in 
`shenyu-plugin-httpclient`) keep working:
+  the GRAPH executor must not insert a silent `cache()` that would change 
retry semantics.
+- The audit records, per plugin, whether it (a) calls the continuation exactly 
once, (b) may call it
+  multiple times (retry), or (c) wraps it (decorator). Phase 1 supports (a) 
and (b); a plugin that
+  would re-enter a side-effecting proxy successor more than once is flagged 
and restricted to a
+  single-execution position.
+- Repeated **subscription** of the request `Mono` re-runs traversal from the 
entry with fresh
+  per-subscription state (matching today's `Mono.defer` behaviour in 
`ShenyuWebHandler#handle`).
+
+## 12. Mandatory dependencies fail closed
+
+**Decision:** a PLUGIN node marked `required` (and any plugin the audit 
classifies as a mandatory
+security prerequisite) is a hard dependency. If it is missing, disabled, or 
unavailable at activation
+time, the GRAPH plan is **rejected** for new requests rather than executed 
without it. Preserving an
+old plan must not bypass an explicit disable. Graph configuration can never be 
used to skip mandatory
+authentication. (Runtime enforcement and lifecycle re-evaluation are #7999; 
this contract fixes the
+semantics.)
+
+## 13. Legacy fallback
+
+**Decision:** none. Graph mode never falls back to the legacy chain — not on 
missing plan, not on
+compile failure, not on in-flight execution error. Rationale: cross-executor 
fallback can duplicate
+upstream calls and side effects and hides misconfiguration. LEGACY deployments 
are unaffected by
+graph publication/validation state.
+
+## 14. Runtime configuration update
+
+- Graph revisions are published immutably and synced (WebSocket/HTTP first, 
#7998) with
+  full/incremental state, digests, deletion, reconnect and 
out-of-order/duplicate tolerance.
+- The gateway compiles candidates **before** an atomic plan swap; each request 
holds one topology
+  snapshot. A topology snapshot does **not** freeze selector/rule/plugin 
caches — those keep their
+  existing live-update behaviour (documented limitation).
+- Publication alone never changes executor mode; receiving/publishing a graph 
never switches a LEGACY
+  deployment to GRAPH.
+
+## 15. Unsupported-plugin behavior
+
+A plugin that the audit marks as **graph-incompatible** for Phase 1 (e.g. one 
that mutates the shared
+cursor, or cannot preserve wrapper scope under branching) is rejected at 
validation when referenced by
+a graph node, with an actionable error naming the plugin and node id. Such 
plugins remain fully usable
+in LEGACY mode. The supported-plugin list and constraints are published with 
the compatibility matrix.
+
+## 16. Schema examples
+
+### 16.1 Valid — dependency-only linear graph (compiles to a linear plan)
+
+```json
+{
+  "schemaVersion": 1,
+  "namespaceId": "ns-prod",
+  "graphId": "g-linear",
+  "revision": 3,
+  "contentHash": "a1b2c3",
+  "entryNodeId": "n-global",
+  "nodes": [
+    { "nodeId": "n-global", "type": "PLUGIN", "pluginName": "global", 
"required": true, "next": "n-sign" },
+    { "nodeId": "n-sign",   "type": "PLUGIN", "pluginName": "sign",   
"required": true, "next": "n-divide" },
+    { "nodeId": "n-divide", "type": "PLUGIN", "pluginName": "divide", "next": 
"n-end" },
+    { "nodeId": "n-end",    "type": "END" }
+  ]
+}
+```
+
+### 16.2 Valid — exclusive CHOICE branch with convergence
+
+```json
+{
+  "schemaVersion": 1,
+  "namespaceId": "ns-prod",
+  "graphId": "g-choice",
+  "revision": 1,
+  "contentHash": "d4e5f6",
+  "entryNodeId": "n-global",
+  "nodes": [
+    { "nodeId": "n-global", "type": "PLUGIN", "pluginName": "global", 
"required": true, "next": "n-choice" },
+    { "nodeId": "n-choice", "type": "CHOICE", "defaultTarget": "n-http",
+      "conditions": [
+        { "match": "all", "target": "n-grpc",
+          "conditions": [ { "paramType": "req_method", "operator": "match", 
"paramName": "", "paramValue": "grpc" } ] }
+      ] },
+    { "nodeId": "n-grpc", "type": "PLUGIN", "pluginName": "grpc",   "next": 
"n-resp" },
+    { "nodeId": "n-http", "type": "PLUGIN", "pluginName": "divide", "next": 
"n-resp" },
+    { "nodeId": "n-resp", "type": "PLUGIN", "pluginName": "response", "next": 
"n-end" },
+    { "nodeId": "n-end",  "type": "END" }
+  ]
+}
+```
+
+### 16.3 Invalid examples (each must be rejected with the node id)
+
+| Defect | Why rejected |
+|--------|--------------|
+| `entryNodeId` references a missing node | reference integrity |
+| PLUGIN `next` → unknown node | dangling reference |
+| CHOICE without `defaultTarget` | non-total / non-deterministic selection |
+| `n-a → n-b → n-a` | cycle |
+| node not reachable from entry | unreachable node |
+| same proxy plugin on prefix **and** a branch | duplicate side-effecting 
plugin on a path |
+| `nodes.size` > max | resource limit |
+| a `required` plugin absent from the catalog | mandatory dependency (fail 
closed) |
+| branch re-converges into two different shared nodes | violates §7 
single-convergence restriction |
+
+## 17. Acceptance mapping (#7994)
+
+| Acceptance item | Where addressed |
+|-----------------|-----------------|
+| reviewed contract + compatibility matrix | this doc + 
[audit](./plugin-graph-compatibility-audit.md) |
+| representative wrapper/response/retry cases | audit deep-dives 
(Sentinel-style wrapper, response owner, httpclient retry) |
+| explicit decisions: repeated continuations, mandatory deps, legacy fallback 
| §11, §12, §13 |
+| schema examples and invalid examples | §16 |
+| dual-executor contract, dispatch-before-plugin, separate lifecycle, no graph 
state in cursor | §10 |
+| namespace/deployment binding + bootstrap boundary | §4, §10.2 |
+| graph-not-ready rejection, fork() executor identity, deployment/restart 
rollback, optional live switch = new requests only | §10 (rules 3,7,8,9,10) |
+| sequence diagrams for both executors | §10.1, §10.2 |
+| ownership of global/auth/proxy/response plugins | §9 |
+| no automatic mode change on publication; explicit no-fallback / 
no-mid-request-switch | §10 (rules 8,10), §13, §14 |
+
+## 18. Open questions for review
+
+1. Are the §7 structural restrictions acceptable for Phase 1, or is 
arbitrary-DAG support required
+   before adoption (at the cost of exponential path analysis)?
+2. Should `deploymentBinding` be mandatory (safer canary story) or optional 
(namespace-wide default)?
+3. Is the default limit set (§8) reasonable, and which limits must be 
hot-reconfigurable?
+4. Do we want a machine-readable JSON Schema published alongside these 
examples in Phase 1?

Reply via email to