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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 4822e9d1d4de1fa15bf19b6006761b300d920077
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 09:54:13 2026 +0800

    api-client: port wire types and rest clients
---
 packages/api-client/src/dsl-debugging.ts | 712 +++++++++++++++++++++++++++++++
 packages/api-client/src/index.ts         | 102 ++++-
 packages/api-client/src/inspect.ts       | 370 ++++++++++++++++
 packages/api-client/src/oal.ts           | 182 ++++++++
 packages/api-client/src/runtime-rule.ts  | 244 +++++++++++
 packages/api-client/src/status.ts        |  90 ++++
 packages/api-client/src/types.ts         | 224 ++++++++++
 7 files changed, 1922 insertions(+), 2 deletions(-)

diff --git a/packages/api-client/src/dsl-debugging.ts 
b/packages/api-client/src/dsl-debugging.ts
new file mode 100644
index 0000000..1ff4f9c
--- /dev/null
+++ b/packages/api-client/src/dsl-debugging.ts
@@ -0,0 +1,712 @@
+/*
+ * 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.
+ */
+
+/**
+ * DSL live-debugger client — `/dsl-debugging/*` endpoints, typed against
+ * the **as-built** wire emitted by `DSLDebuggingRestHandler.java` on the
+ * `swip-13-dsl-debugger` branch. The wire shape was rewritten upstream
+ * (commits 4275f61df5 / 0e4058614c / 6f2db069a0): records now carry an
+ * envelope (`startedAtMs`, `dsl`, `rule`) plus a per-execution
+ * `samples[]` array, and the per-DSL stage vocabulary collapsed to five
+ * unified sample types (`input | filter | function | aggregation |
+ * output`).
+ *
+ *   POST   /dsl-debugging/session?catalog=&name=&ruleName=&clientId=
+ *                                 [&granularity=]
+ *                                 body (optional JSON):
+ *                                 { recordCap?, retentionMillis?,
+ *                                   granularity? }
+ *   GET    /dsl-debugging/session/{id}
+ *   POST   /dsl-debugging/session/{id}/stop
+ *   GET    /dsl-debugging/sessions             — JSON object, not NDJSON
+ *   GET    /dsl-debugging/status               — 5-field health snapshot
+ *
+ * Notable wire facts:
+ * - Inputs are query params; the optional JSON body carries
+ *   `recordCap` / `retentionMillis` / `granularity` overrides.
+ *   `granularity` query param wins over body.
+ * - The session response carries top-level `ruleKey` + `capturedAt`,
+ *   per-record `startedAtMs` + `dsl` + `rule` envelope, and per-record
+ *   `samples[]` whose entries discriminate via `type`.
+ * - Per-record `contentHash` was removed; the verbatim `dsl` string
+ *   carries hot-update awareness instead.
+ * - `priorCleanup` is now a structured object `{local, peers[]}`, not a
+ *   flat array.
+ * - Peer install ack values are UPPERCASE: `INSTALLED | NOT_LOCAL |
+ *   FAILED`.
+ * - Session-start can fail with `cluster_view_split` (HTTP 421) when
+ *   the cluster's view of the rule disagrees across nodes.
+ */
+
+import { RuntimeRuleApiError, type ApplyResult } from './types.js';
+import type { FetchLike } from './runtime-rule.js';
+
+// ── Catalogs ───────────────────────────────────────────────────────
+
+/** Debug-session catalog — every wire name accepted by the handler.
+ *  Matches `Catalog.java` enum's `wireName` values. */
+export type DebugCatalog = 'otel-rules' | 'log-mal-rules' | 'telegraf-rules' | 
'lal' | 'oal';
+
+export const DEBUG_CATALOGS: readonly DebugCatalog[] = [
+  'otel-rules',
+  'log-mal-rules',
+  'telegraf-rules',
+  'lal',
+  'oal',
+] as const;
+
+export function isDebugCatalog(value: unknown): value is DebugCatalog {
+  return typeof value === 'string' && (DEBUG_CATALOGS as readonly 
string[]).includes(value);
+}
+
+// ── Session start ──────────────────────────────────────────────────
+
+/** Query-param inputs (mandatory) for `POST /dsl-debugging/session`. */
+export interface StartSessionQuery {
+  /** Stable per-debug-context UUID. UI mints one per browser tab /
+   *  debugger widget into sessionStorage and reuses it across polls. */
+  clientId: string;
+  catalog: DebugCatalog;
+  /** For LAL: rule file name — the upstream allows either with or
+   *  without an extension; runtime-rule-applied LAL uses the rule
+   *  name directly.
+   *  For MAL: the rule's `name` from `/runtime/rule/list`.
+   *  For OAL: source class name (e.g. `Endpoint`, `ServiceRelation`). */
+  name: string;
+  /** For LAL: the rule within the file (or the rule name for
+   *  runtime-rule LAL).
+   *  For MAL: the metric's full name (matches the holder lookup key).
+   *  For OAL: the source class name (same value as `name`). */
+  ruleName: string;
+  /** Per-DSL capture granularity. Currently only LAL distinguishes
+   *  block vs statement; MAL/OAL ignore the flag server-side.
+   *  Defaults to `block`. */
+  granularity?: Granularity;
+}
+
+/** Mirror of OAP's `SessionLimits.MAX_RECORD_CAP` — both default and
+ *  hard ceiling for `recordCap` on `POST /dsl-debugging/session`.
+ *  Studio's BFF rejects requests above this with `400
+ *  invalid_recordCap` before the OAP round-trip; UI inputs bound to
+ *  the same value. Keep in sync with `SessionLimits.java` upstream. */
+export const MAX_RECORD_CAP = 100;
+
+/** Mirror of OAP's `SessionLimits.MAX_RETENTION_MILLIS` — 1 hour
+ *  hard cap on per-session retention windows. */
+export const MAX_RETENTION_MILLIS = 60 * 60 * 1000;
+
+/** Optional JSON body for `POST /dsl-debugging/session`. */
+export interface StartSessionBody {
+  /** Default + hard cap 100 (`SessionLimits.MAX_RECORD_CAP`). Session
+   *  moves to `captured` once this many records have been appended.
+   *  OAP rejects `recordCap > 100` with `400 invalid_limits`; the BFF
+   *  short-circuits the same way before the OAP round-trip. */
+  recordCap?: number;
+  /** Default 5 min, max 1 h (3 600 000 ms). Wall-clock retention
+   *  before the session is reaped. */
+  retentionMillis?: number;
+  /** Body fallback for granularity — query param wins when set. */
+  granularity?: Granularity;
+}
+
+/** LAL-only knob — does the recorder emit per-statement records or
+ *  just block-level ones. Server query param wins over body. */
+export type Granularity = 'block' | 'statement';
+
+export const GRANULARITIES: readonly Granularity[] = ['block', 'statement'] as 
const;
+
+export function isGranularity(v: unknown): v is Granularity {
+  return v === 'block' || v === 'statement';
+}
+
+export type StartSessionArgs = StartSessionQuery & StartSessionBody;
+
+/** Wire-side ack from the `InstallDebugSession` fan-out. The first
+ *  five come from the proto enum (`InstallState`); `FAILED` is
+ *  appended by the receiving node when the peer call itself failed
+ *  (timeout, RPC error). */
+export type PeerInstallAckState =
+  | 'INSTALLED'
+  | 'NOT_LOCAL'
+  | 'ALREADY_INSTALLED'
+  | 'REJECTED'
+  | 'TOO_MANY_SESSIONS'
+  | 'FAILED';
+
+/** Per-peer install ack. Values are UPPERCASE in the wire. */
+export interface PeerInstallAck {
+  peer: string;
+  /** Set when the peer responded. */
+  nodeId?: string;
+  ack: PeerInstallAckState;
+  detail?: string;
+}
+
+/** At-a-glance "session live on N of M OAPs" summary attached to the
+ *  start-session response. `total` counts the receiving node + every
+ *  reachable peer; `created` counts those that returned `INSTALLED`
+ *  or `ALREADY_INSTALLED`. Per-peer detail lives in `peers[]`. */
+export interface InstallSummary {
+  created: number;
+  total: number;
+}
+
+/** Local node's prior-cleanup outcome — the broadcast also runs locally
+ *  and the receiving node reports the count of prior sessions it
+ *  terminated for the same `clientId`. */
+export interface LocalPriorCleanup {
+  nodeId: string;
+  stoppedCount: number;
+  stoppedSessionIds: string[];
+}
+
+/** Per-peer prior-cleanup outcome from the `StopByClientId` fan-out. */
+export interface PeerPriorCleanup {
+  peer: string;
+  /** Set on success. */
+  nodeId?: string;
+  stoppedCount?: number;
+  stoppedSessionIds?: string[];
+  /** Set instead of the success fields when the peer call failed. */
+  ack?: 'failed';
+  detail?: string;
+}
+
+/** Cluster-wide prior-cleanup report. The local slice is always present;
+ *  `peers[]` is one entry per known peer (may be empty in single-node). */
+export interface PriorCleanup {
+  local: LocalPriorCleanup;
+  peers: PeerPriorCleanup[];
+}
+
+export interface RuleKey {
+  catalog: DebugCatalog;
+  name: string;
+  ruleName: string;
+}
+
+export interface StartSessionResponse {
+  sessionId: string;
+  clientId: string;
+  ruleKey: RuleKey;
+  /** Unix-ms. */
+  createdAt: number;
+  /** Unix-ms. The session is reaped at this wall-clock. */
+  retentionDeadline: number;
+  /** Echoed back from the request (or the server default `block`). */
+  granularity: Granularity;
+  /** True when the receiving node had the rule loaded and bound a
+   *  recorder locally. False on a router-only node where the install
+   *  was a pure fan-out. */
+  localInstalled: boolean;
+  /** "session live on N of M OAPs" rollup — `created` counts nodes
+   *  whose ack was `INSTALLED` / `ALREADY_INSTALLED`; `total` is
+   *  receiving node + every reachable peer (1 + peers.length). */
+  installed: InstallSummary;
+  peers: PeerInstallAck[];
+  priorCleanup: PriorCleanup;
+}
+
+// ── Sample-level shape (the new per-execution capture) ─────────────
+
+/** The five unified sample types. The rule's static stage vocabulary
+ *  (per-DSL block names like `meterEmit`, `extractor`, `aggregation`)
+ *  collapsed into these on the wire — each DSL only emits a subset:
+ *
+ *  - MAL: `input | filter | function | output`
+ *  - LAL: `input | filter | function | output`
+ *  - OAL: `input | filter | function | aggregation | output` */
+export type SampleType = 'input' | 'filter' | 'function' | 'aggregation' | 
'output';
+
+export const SAMPLE_TYPES: readonly SampleType[] = [
+  'input',
+  'filter',
+  'function',
+  'aggregation',
+  'output',
+] as const;
+
+// ─── MAL payload shapes ────────────────────────────────────────────
+
+/** A single MAL `Sample` row inside a SampleFamily — what the upstream
+ *  ELSampleFamilyDebugDump.toJson surfaces per row. */
+export interface MalSampleRow {
+  name: string;
+  labels: Record<string, string>;
+  /** Numeric value as JSON number (Long / Double / counter). */
+  value: number;
+  /** Unix-ms. */
+  timestamp: number;
+}
+
+/** MAL non-output payload — the captured `SampleFamily.toJson()`.
+ *  `families` only appears on the file-level filter probe, which
+ *  captures the full multi-family input map. The flat case (`samples` +
+ *  `items`) is the per-stage shape every chain method emits. */
+export interface MalSamplesPayload {
+  /** Set on the file-level filter probe only — count of source
+   *  families that fed this filter. */
+  families?: number;
+  /** Sample-row count. `0` together with `empty: true` means the
+   *  family was empty (probe still captured for visibility). */
+  samples?: number;
+  empty?: boolean;
+  /** Either flat sample rows (chain-method probes) or nested
+   *  per-family arrays (file-level filter probe). The recorder picks
+   *  the shape based on which probe fired; clients must check. */
+  items?: MalSampleRow[] | MalSamplesPayload[];
+}
+
+/** MAL `output`-type payload (the `appendMeterEmit` probe) — the
+ *  materialised metric ready for L1 push. Per OAP's
+ *  `MALDebugRecorderImpl.meterEmitPayload`: always `metric`, `entity`,
+ *  `valueType`, `timeBucket`; `value` only when the holder type is
+ *  recognised (LongValueHolder / IntValueHolder / DoubleValueHolder /
+ *  LabeledValueHolder). Studio renders whatever's present and never
+ *  infers from upstream stages. */
+export interface MalOutputPayload {
+  metric: string;
+  /** `MeterEntity#toString()` — operator-readable form of the entity
+   *  the metric is bound to (scope, service / instance / endpoint
+   *  names, layer, attrs). */
+  entity: string;
+  /** Resolved meter function name (e.g. `sum`, `avg`, `histogram`,
+   *  `avgLabeled`) — surfaced via the `@MeterFunction` annotation
+   *  walk so the operator doesn't see the generated subclass name. */
+  valueType: string;
+  /** Time bucket of the emit (yyyyMMddHHmm). */
+  timeBucket: number;
+  /** Materialised reading. Three shapes per the recorder's holder
+   *  switch:
+   *  - `number` — scalar holders (Sum / Avg / Max / Min / Latest /
+   *    SumPerMin / CPM …; finite doubles).
+   *  - `string` — non-finite doubles serialised as `"NaN"`,
+   *    `"Infinity"`, `"-Infinity"` so the wire stays valid JSON.
+   *  - `Record<string, number>` — labeled metrics (`*Labeled`) and
+   *    histogram-percentile functions emit a `DataTable`; keys are
+   *    label combos for `*Labeled`, `p=<rank>` entries for percentile
+   *    functions. */
+  value?: number | string | Record<string, number>;
+}
+
+// ─── LAL payload shapes ────────────────────────────────────────────
+
+/** LAL `LogData.toJson()` — what the input probe sees on the way in. */
+export interface LalLogDataInput {
+  type: 'LogData';
+  timestamp?: number;
+  service?: string;
+  serviceInstance?: string;
+  endpoint?: string;
+  layer?: string;
+  tags?: { key: string; value: string }[];
+  body?: {
+    contentType?: string;
+    format?: 'TEXT' | 'YAML' | 'JSON';
+    text?: string;
+  };
+  /** Trace identifiers — only when the agent attached them. */
+  traceId?: string;
+  segmentId?: string;
+  spanId?: number;
+  /** Open shape — Envoy access-log path materialises as `Message` /
+   *  alternative inputs that the framework's typed dispatcher
+   *  serialises. */
+  [key: string]: unknown;
+}
+
+/** LAL `Message`-class input (gRPC envelope). The framework's typed
+ *  dispatcher captures whatever the rule's input type is — we model
+ *  the open-payload case here. */
+export interface LalMessageInput {
+  type: 'Message';
+  /** Class name + serialisable proto fields, captured opaquely. */
+  [key: string]: unknown;
+}
+
+/** Tagged-union LAL input. `[type=LogData]` is the common case. */
+export type LalInput = LalLogDataInput | LalMessageInput | { type: string; [k: 
string]: unknown };
+
+/** LAL `LogBuilder.outputToJson()` — the DB-bound row the rule has
+ *  built so far. Stable shape across stages because the builder is
+ *  cached on `bindInput`. */
+export interface LalLogBuilderOutput {
+  type: 'LogBuilder';
+  name?: string;
+  service?: string;
+  serviceInstance?: string;
+  endpoint?: string;
+  layer?: string;
+  traceId?: string;
+  segmentId?: string;
+  spanId?: number;
+  timestamp?: number;
+  contentType?: string;
+  content?: string;
+  /** Merged-tag view: `original` from input, `lal-added` from the
+   *  rule, `lal-override` when the rule's key collides with an input
+   *  tag (runtime concatenates rather than replaces). */
+  tags?: LalLogBuilderTag[];
+  /** Open shape — EnvoyAccessLogBuilder etc. add custom fields. */
+  [key: string]: unknown;
+}
+
+export interface LalLogBuilderTag {
+  key: string;
+  value: string;
+  status?: 'original' | 'lal-added' | 'lal-override';
+}
+
+/** LAL `Message`-typed builder (e.g. EnvoyAccessLogBuilder). */
+export interface LalMessageBuilderOutput {
+  type: string;
+  [key: string]: unknown;
+}
+
+export type LalOutput = LalLogBuilderOutput | LalMessageBuilderOutput;
+
+/** LAL sample payload — every stage carries the same envelope. The
+ *  input probe populates `input`; bindInput-onwards probes populate
+ *  `output`. Either may be present (rare double-bind cases). */
+export interface LalSamplePayload {
+  /** True when the rule body called `abort()`. */
+  aborted?: boolean;
+  /** True when `parsed` slots have been populated by parser probes. */
+  hasParsed?: boolean;
+  /** Convenience list of keys the parser ran (extractor reads). */
+  parsedKeys?: string[];
+  input?: LalInput;
+  output?: LalOutput;
+}
+
+// ─── OAL payload shapes ────────────────────────────────────────────
+
+/** OAL input / filter payload — the source object's columns. The
+ *  upstream recorder calls `source.toJson()`; the column set is per
+ *  source class. */
+export interface OalSourcePayload {
+  type: string;
+  /** Column-bag — source-class-specific. The first row is `scope:
+   *  number` (the OAL scope ordinal); other rows are operator-readable
+   *  fields like `entityId`, `timeBucket`, `sourceServiceName`, etc. */
+  fields: { scope?: number; entityId?: string; timeBucket?: number; [key: 
string]: unknown };
+}
+
+/** OAL function / aggregation / output payload — the materialised
+ *  metric class's columns at this probe. The shape is `Metrics#toJson`
+ *  so the field set is per-metric (CPM has `count/total/value`,
+ *  histogram-style metrics have buckets, etc.). */
+export interface OalMetricsPayload {
+  type: string;
+  timeBucket?: number;
+  lastUpdateTimestamp?: number;
+  id?: string;
+  total?: number;
+  value?: number;
+  /** Open shape — per-metric extra columns (count, summation,
+   *  histogram dataset, …). */
+  [key: string]: unknown;
+}
+
+/** Union of every per-DSL payload a sample's `payload` can carry. */
+export type SamplePayload =
+  | MalSamplesPayload
+  | MalOutputPayload
+  | LalSamplePayload
+  | OalSourcePayload
+  | OalMetricsPayload
+  | Record<string, unknown>;
+
+/** A single captured probe sample — one execution step of the DSL
+ *  pipeline. Multiple samples sit inside one `SessionRecord` and
+ *  represent the in-order trace of one rule execution.
+ *
+ *  - `type`: the unified five-state lifecycle position.
+ *  - `sourceText`: verbatim DSL fragment from ANTLR (or empty for
+ *     LAL probes that don't correspond to a single text slice).
+ *  - `continueOn`: did the pipeline continue past this probe? `false`
+ *     on rejected filter branches, on `abort()` calls, on builder
+ *     failures.
+ *  - `payload`: per-DSL shape — see the per-DSL types above.
+ *  - `sourceLine`: 1-based line number in the rule body. Omitted when
+ *     0 / not applicable (block-level LAL probes, MAL chain stages on
+ *     a one-liner rule). */
+export interface SessionSample {
+  type: SampleType;
+  sourceText: string;
+  continueOn: boolean;
+  payload: SamplePayload;
+  sourceLine?: number;
+}
+
+// ── Per-record envelope ────────────────────────────────────────────
+
+/** Catalog-specific structured rule metadata. The recorder fills in
+ *  whatever it has — common fields:
+ *  - all DSLs:  `ruleName`
+ *  - OAL:       `sourceLine` (the OAL source statement's line in the
+ *               .oal file)
+ *  - MAL:       `metricPrefix`, `name`, `filter`, `exp`, `expSuffix`
+ *  - LAL:       `ruleName` only
+ *  Open string-keyed bag — Studio reads selectively. */
+export interface SessionRecordRule {
+  ruleName: string;
+  sourceLine?: string;
+  /** Open: MAL emits multi-field rule metadata (filter, exp, …). */
+  [key: string]: string | undefined;
+}
+
+/** One captured execution of the rule. The verbatim `dsl` is the
+ *  rule source as it stood at capture time — Studio renders this
+ *  directly in the per-record card and the foldable LAL source pane,
+ *  so hot-update edits show up record-by-record without an extra
+ *  fetch (each record carries its own snapshot). */
+export interface SessionRecord {
+  /** Unix-ms when the execution started on the receiving node. */
+  startedAtMs: number;
+  /** Verbatim rule source — multi-line, exactly as the holder owned
+   *  it when this execution fired. */
+  dsl: string;
+  rule: SessionRecordRule;
+  samples: SessionSample[];
+}
+
+// ── Per-node slice + session response ──────────────────────────────
+
+export type NodeStatus = 'ok' | 'captured' | 'not_local' | 'unreachable';
+
+export interface NodeSlice {
+  /** Set on the local slice and on successful peer slices. */
+  nodeId?: string;
+  /** Set on peer slices (the gRPC peer address); always present for
+   *  unreachable peers, may also appear on healthy ones. */
+  peer?: string;
+  status: NodeStatus;
+  captured?: boolean;
+  totalBytes?: number;
+  records: SessionRecord[];
+  /** Set when `status === 'unreachable'`. */
+  detail?: string;
+}
+
+export interface SessionResponse {
+  sessionId: string;
+  /** Unix-ms when the receiving node snapshotted the slice. */
+  capturedAt: number;
+  /** Echo of the install-time `RuleKey` — omitted when the local
+   *  slice is null (post-stop polls / unknown session). */
+  ruleKey?: RuleKey;
+  nodes: NodeSlice[];
+}
+
+// ── Stop / list / status ───────────────────────────────────────────
+
+export interface StopPeerOutcome {
+  peer: string;
+  nodeId?: string;
+  stopped?: boolean;
+  ack?: 'failed';
+  detail?: string;
+}
+
+export interface StopSessionResponse {
+  sessionId: string;
+  localStopped: boolean;
+  peers: StopPeerOutcome[];
+}
+
+export interface ActiveSessionRow {
+  sessionId: string;
+  clientId: string;
+  ruleKey: RuleKey;
+  createdAt: number;
+  retentionDeadline: number;
+  captured: boolean;
+  totalBytes: number;
+}
+
+export interface ActiveSessionsResponse {
+  sessions: ActiveSessionRow[];
+  count: number;
+}
+
+export interface DslDebuggingStatus {
+  module: string;
+  phase: string;
+  nodeId: string;
+  injectionEnabled: boolean;
+  activeSessions: number;
+}
+
+// ── Error envelope ─────────────────────────────────────────────────
+
+/** Known `code` values the handler emits. Open string for forward
+ *  compatibility — the server may extend this enum. */
+export type DebugErrorCode =
+  | 'injection_disabled'
+  | 'invalid_catalog'
+  | 'invalid_limits'
+  | 'invalid_granularity'
+  | 'missing_param'
+  | 'rule_not_found'
+  | 'session_not_found'
+  | 'registry_misconfigured'
+  | 'source_not_found'
+  | 'missing_source'
+  | 'too_many_sessions'
+  | 'cluster_view_split'
+  | (string & {});
+
+export interface DebugErrorBody {
+  status: 'error';
+  code: DebugErrorCode;
+  message: string;
+}
+
+// ── Client ─────────────────────────────────────────────────────────
+
+export interface DslDebuggingClientOptions {
+  adminUrl: string;
+  fetch?: FetchLike;
+  headers?: Record<string, string>;
+  timeoutMs?: number;
+}
+
+export class DslDebuggingClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: DslDebuggingClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.adminUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `POST /dsl-debugging/session?catalog=&name=&ruleName=&clientId=
+   *  [&granularity=]`, optional JSON body with `recordCap` /
+   *  `retentionMillis` / `granularity`. The query param wins over the
+   *  body for granularity (matches upstream resolution order). */
+  async startSession(args: StartSessionArgs): Promise<StartSessionResponse> {
+    const params = new URLSearchParams({
+      catalog: args.catalog,
+      name: args.name,
+      ruleName: args.ruleName,
+      clientId: args.clientId,
+    });
+    if (args.granularity !== undefined) params.set('granularity', 
args.granularity);
+    const url = `${this.base}/dsl-debugging/session?${params.toString()}`;
+    const body: StartSessionBody = {};
+    if (args.recordCap !== undefined) body.recordCap = args.recordCap;
+    if (args.retentionMillis !== undefined) body.retentionMillis = 
args.retentionMillis;
+    const hasBody = Object.keys(body).length > 0;
+    const init: RequestInit = {
+      method: 'POST',
+      ...(hasBody
+        ? {
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(body),
+          }
+        : {}),
+    };
+    const res = await this.send(url, init);
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as StartSessionResponse;
+  }
+
+  /** `GET /dsl-debugging/session/{id}`. Returns `null` on `404
+   *  session_not_found`; live polls return a normal envelope where
+   *  the local slice may be `status: "not_local"` after a stop. */
+  async getSession(id: string): Promise<SessionResponse | null> {
+    const url = `${this.base}/dsl-debugging/session/${encodeURIComponent(id)}`;
+    const res = await this.send(url, { method: 'GET' });
+    if (res.status === 404) return null;
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as SessionResponse;
+  }
+
+  /** `POST /dsl-debugging/session/{id}/stop` — idempotent. */
+  async stopSession(id: string): Promise<StopSessionResponse> {
+    const url = 
`${this.base}/dsl-debugging/session/${encodeURIComponent(id)}/stop`;
+    const res = await this.send(url, { method: 'POST' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as StopSessionResponse;
+  }
+
+  /** `GET /dsl-debugging/sessions` — JSON object, not NDJSON. */
+  async listSessions(): Promise<ActiveSessionsResponse> {
+    const url = `${this.base}/dsl-debugging/sessions`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as ActiveSessionsResponse;
+  }
+
+  /** `GET /dsl-debugging/status` — per-node 5-field health snapshot. */
+  async getStatus(): Promise<DslDebuggingStatus> {
+    const url = `${this.base}/dsl-debugging/status`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as DslDebuggingStatus;
+  }
+
+  // ── private helpers ─────────────────────────────────────────────
+
+  private async send(url: string, init: RequestInit): Promise<Response> {
+    const headers: Record<string, string> = {
+      Accept: 'application/json',
+      ...this.defaultHeaders,
+      ...((init.headers as Record<string, string>) ?? {}),
+    };
+    const finalInit: RequestInit = { ...init, headers };
+    if (this.timeoutMs > 0) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+      try {
+        return await this.fetchImpl(url, { ...finalInit, signal: 
controller.signal });
+      } finally {
+        clearTimeout(timer);
+      }
+    }
+    return this.fetchImpl(url, finalInit);
+  }
+
+  private async toError(res: Response, url: string): 
Promise<RuntimeRuleApiError> {
+    const text = await res.text();
+    let parsed: ApplyResult | string = text;
+    try {
+      const json = JSON.parse(text) as Record<string, unknown>;
+      // Accept either the legacy `{ applyStatus, message }` envelope
+      // (runtime-rule pipeline) or the `{ status, code, message }`
+      // envelope (dsl-debugging / runtime-oal). Downstream `outcomeOf`
+      // helpers switch on `code` or `applyStatus`.
+      if (typeof json.applyStatus === 'string' && typeof json.message === 
'string') {
+        parsed = json as unknown as ApplyResult;
+      } else if (
+        json.status === 'error' &&
+        typeof json.code === 'string' &&
+        typeof json.message === 'string'
+      ) {
+        parsed = json as unknown as ApplyResult;
+      }
+    } catch {
+      // not JSON; keep the raw text.
+    }
+    return new RuntimeRuleApiError(res.status, parsed, url);
+  }
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index f577a05..4913ff5 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -15,5 +15,103 @@
  * limitations under the License.
  */
 
-// Stage 1.3 will populate this with REST clients ported from vantage-studio.
-export {};
+export * from './types.js';
+export {
+  RuntimeRuleClient,
+  type RuntimeRuleClientOptions,
+  type AddOrUpdateArgs,
+  type GetRuleArgs,
+  type FetchLike,
+} from './runtime-rule.js';
+export { StatusClient, type StatusClientOptions, type NormalisedClusterNode } 
from './status.js';
+export {
+  OalClient,
+  type OalClientOptions,
+  type OalFilesResponse,
+  type OalRulesResponse,
+  type OalSourceListing,
+  type OalSourceDetail,
+  type OalSourceMetricDetail,
+} from './oal.js';
+export {
+  InspectClient,
+  InspectApiError,
+  INSPECT_STEPS,
+  INSPECT_ENTITY_LIMIT_MAX,
+  formatInspectDate,
+  isInspectDate,
+  type InspectClientOptions,
+  type InspectCatalog,
+  type InspectMetricType,
+  type InspectScope,
+  type InspectStep,
+  type ListMetricsArgs,
+  type ListEntitiesArgs,
+  type MetricRow,
+  type MetricsResponse,
+  type EntityRow,
+  type EntitiesResponse,
+  type MqeEntity,
+  type DecodedEntity,
+  type InspectErrorBody,
+  type ExpressionResultType,
+  type ExpressionResult,
+  type MqeValues,
+  type MqeValue,
+  type MqeMetadata,
+  type MqeKeyValue,
+  type MqeOwner,
+  type InspectExecRequest,
+} from './inspect.js';
+export {
+  DslDebuggingClient,
+  DEBUG_CATALOGS,
+  GRANULARITIES,
+  SAMPLE_TYPES,
+  MAX_RECORD_CAP,
+  MAX_RETENTION_MILLIS,
+  isDebugCatalog,
+  isGranularity,
+  type DslDebuggingClientOptions,
+  type DebugCatalog,
+  type Granularity,
+  type StartSessionArgs,
+  type StartSessionQuery,
+  type StartSessionBody,
+  type StartSessionResponse,
+  type PeerInstallAck,
+  type PeerInstallAckState,
+  type InstallSummary,
+  type LocalPriorCleanup,
+  type PeerPriorCleanup,
+  type PriorCleanup,
+  type RuleKey,
+  type SampleType,
+  type SessionSample,
+  type SessionRecord,
+  type SessionRecordRule,
+  type SamplePayload,
+  type MalSampleRow,
+  type MalSamplesPayload,
+  type MalOutputPayload,
+  type LalSamplePayload,
+  type LalInput,
+  type LalLogDataInput,
+  type LalMessageInput,
+  type LalOutput,
+  type LalLogBuilderOutput,
+  type LalLogBuilderTag,
+  type LalMessageBuilderOutput,
+  type OalSourcePayload,
+  type OalMetricsPayload,
+  type SessionResponse,
+  type NodeSlice,
+  type NodeStatus,
+  type StopSessionResponse,
+  type StopPeerOutcome,
+  type ActiveSessionRow,
+  type ActiveSessionsResponse,
+  type DslDebuggingStatus,
+  type DebugErrorBody,
+  type DebugErrorCode,
+} from './dsl-debugging.js';
diff --git a/packages/api-client/src/inspect.ts 
b/packages/api-client/src/inspect.ts
new file mode 100644
index 0000000..18f08e4
--- /dev/null
+++ b/packages/api-client/src/inspect.ts
@@ -0,0 +1,370 @@
+/*
+ * 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.
+ */
+
+/**
+ * SWIP-14 Inspect API — read-only catalog + entity enumeration on
+ * admin-server's port 17128. The endpoints are:
+ *
+ *   GET /inspect/metrics
+ *   GET /inspect/entities?metric=…&start=…&end=…&step=…&limit=…
+ *
+ * Both are admin-only and are off by default — the operator must set
+ * `SW_INSPECT=default` on OAP. Wire shapes match the implementation on
+ * `swip-14-inspect-api` (final, post-review-pass-2). See
+ * `oap-server/server-admin/inspect/src/main/java/.../response/`.
+ *
+ * The MQE values themselves live behind OAP's regular GraphQL
+ * `execExpression` surface; the Inspect API only returns the catalog
+ * and MQE-ready Entity shapes you can paste into that mutation.
+ */
+
+import type { FetchLike } from './runtime-rule.js';
+
+// ── Catalog / metrics ──────────────────────────────────────────────
+
+/** MetricsType emitted by OAP's `MetricsType.java`; values match
+ *  `Column.ValueDataType` after the metadata mapping. */
+export type InspectMetricType = 'REGULAR_VALUE' | 'LABELED_VALUE' | 'HEATMAP' 
| 'SAMPLED_RECORD';
+
+/** Catalog string from `DefaultScopeDefine` — uppercase, underscore-
+ *  separated. Used for the `/inspect/metrics?catalog=` filter. */
+export type InspectCatalog =
+  | 'SERVICE'
+  | 'SERVICE_INSTANCE'
+  | 'ENDPOINT'
+  | 'SERVICE_RELATION'
+  | 'SERVICE_INSTANCE_RELATION'
+  | 'ENDPOINT_RELATION'
+  | (string & {});
+
+/** Scope name (e.g. `Service`, `ServiceInstance`, `Endpoint`,
+ *  `ServiceRelation`, `ServiceInstanceRelation`, `EndpointRelation`).
+ *  Comes straight from `Scope.Finder.valueOf(scopeId).name()`. */
+export type InspectScope =
+  | 'Service'
+  | 'ServiceInstance'
+  | 'Endpoint'
+  | 'ServiceRelation'
+  | 'ServiceInstanceRelation'
+  | 'EndpointRelation'
+  | (string & {});
+
+export type InspectStep = 'MINUTE' | 'HOUR' | 'DAY';
+export const INSPECT_STEPS: readonly InspectStep[] = ['MINUTE', 'HOUR', 'DAY'] 
as const;
+
+/** Server-side hard cap on `/inspect/entities?limit=`. */
+export const INSPECT_ENTITY_LIMIT_MAX = 300;
+
+export interface MetricRow {
+  name: string;
+  type: InspectMetricType;
+  catalog: InspectCatalog;
+  scopeId: number;
+  scope: InspectScope;
+  valueColumnName: string;
+  downsamplings: InspectStep[];
+}
+
+export interface MetricsResponse {
+  metrics: MetricRow[];
+}
+
+// ── Entities ───────────────────────────────────────────────────────
+
+/** MQE Entity input shape — field names match SkyWalking's GraphQL
+ *  `Entity` input verbatim so the operator can paste this block
+ *  straight into a `mutation execExpression(…, entity: …)`. The OAP
+ *  side serialises with `@JsonInclude(NON_NULL)` so any field that
+ *  doesn't apply to the scope is omitted from the JSON. */
+export interface MqeEntity {
+  scope: InspectScope;
+  serviceName?: string;
+  normal?: boolean;
+  serviceInstanceName?: string;
+  endpointName?: string;
+  destServiceName?: string;
+  destNormal?: boolean;
+  destServiceInstanceName?: string;
+  destEndpointName?: string;
+}
+
+/** Decoded entity-id payload — scope-dependent shape. For single
+ *  scopes (`Service`, `ServiceInstance`, `Endpoint`) it carries
+ *  service/instance/endpoint fields at the top level; for *Relation
+ *  scopes it nests under `source` / `destination`. Modelled here as
+ *  an open record because the JSON shape is fixed per scope but the
+ *  union is wide. */
+export type DecodedEntity = Record<string, unknown>;
+
+export interface EntityRow {
+  entityId: string;
+  decoded: DecodedEntity;
+  /** Set for service-bearing rows; one row per registered Layer.
+   *  Omitted (Java `null` → field absent thanks to `NON_NULL`) when
+   *  the service is missing from the metadata cache. */
+  layer?: string;
+  mqeEntity: MqeEntity;
+}
+
+export interface EntitiesResponse {
+  metric: string;
+  scope: InspectScope;
+  step: InspectStep;
+  /** Echo of the `start` query param in the step-specific format. */
+  start: string;
+  /** Echo of the `end` query param in the step-specific format. */
+  end: string;
+  rows: EntityRow[];
+}
+
+// ── Request args ───────────────────────────────────────────────────
+
+export interface ListMetricsArgs {
+  /** Java regex.Pattern over metric name. No filter when omitted. */
+  regex?: string;
+  /** Repeatable; matches `MetricsType` enum names. */
+  type?: InspectMetricType[];
+  /** Repeatable; matches `DefaultScopeDefine` catalog names. */
+  catalog?: InspectCatalog[];
+  /** If true, narrows to MQE-queryable types (`REGULAR_VALUE` +
+   *  `LABELED_VALUE`). */
+  mqeQueryable?: boolean;
+}
+
+export interface ListEntitiesArgs {
+  metric: string;
+  /** Date string per step format. Use `formatInspectDate(date, step)`
+   *  to build it. */
+  start: string;
+  end: string;
+  step: InspectStep;
+  /** 1–300, default 300 server-side. Studio defaults to a smaller
+   *  number per widget. */
+  limit?: number;
+}
+
+// ── MQE result shape ───────────────────────────────────────────────
+//
+// What `mutation execExpression(...)` returns on the public GraphQL
+// surface — `data.execExpression`. Studio's BFF unwraps the GraphQL
+// envelope and forwards this shape verbatim to the SPA.
+
+export type ExpressionResultType =
+  | 'UNKNOWN'
+  | 'SINGLE_VALUE'
+  | 'TIME_SERIES_VALUES'
+  | 'SORTED_LIST'
+  | 'RECORD_LIST';
+
+export interface MqeOwner {
+  scope?: string | null;
+  serviceID?: string | null;
+  serviceName?: string | null;
+  normal?: boolean | null;
+  serviceInstanceID?: string | null;
+  serviceInstanceName?: string | null;
+  endpointID?: string | null;
+  endpointName?: string | null;
+}
+
+export interface MqeKeyValue {
+  key: string;
+  value: string;
+}
+
+export interface MqeMetadata {
+  labels: MqeKeyValue[];
+}
+
+export interface MqeValue {
+  id?: string | null;
+  owner?: MqeOwner | null;
+  /** Stringified number or `null` when absent. */
+  value: string | null;
+  traceID?: string | null;
+}
+
+export interface MqeValues {
+  metric: MqeMetadata;
+  values: MqeValue[];
+}
+
+export interface ExpressionResult {
+  type: ExpressionResultType;
+  results: MqeValues[];
+  error?: string | null;
+}
+
+/** Wire shape for Studio's `POST /api/inspect/exec`. The BFF will
+ *  translate this into a GraphQL `mutation execExpression(...)` call
+ *  against the resolved MQE base. */
+export interface InspectExecRequest {
+  expression: string;
+  entity: MqeEntity;
+  duration: {
+    start: string;
+    end: string;
+    step: InspectStep;
+    /** Cold-stage flag, BanyanDB-only. Default false. */
+    coldStage?: boolean;
+  };
+  /** Forwarded to GraphQL as `debug: Boolean`. Off by default. */
+  debug?: boolean;
+}
+
+// ── Date format ────────────────────────────────────────────────────
+
+/** Format a `Date` into the date string OAP expects for the given
+ *  step. Mirrors `Duration.getStartTimeBucket` / `getEndTimeBucket`'s
+ *  accepted shapes:
+ *
+ *    DAY    → `yyyy-MM-dd`
+ *    HOUR   → `yyyy-MM-dd HH`
+ *    MINUTE → `yyyy-MM-dd HHmm`
+ *
+ *  All values are zero-padded; the date is interpreted in the OAP
+ *  server's local timezone, so prefer feeding through `UTC` if your
+ *  OAP is configured for UTC (the default for containerised deploys).
+ */
+export function formatInspectDate(d: Date, step: InspectStep): string {
+  const y = d.getUTCFullYear();
+  const m = String(d.getUTCMonth() + 1).padStart(2, '0');
+  const day = String(d.getUTCDate()).padStart(2, '0');
+  const date = `${y}-${m}-${day}`;
+  if (step === 'DAY') return date;
+  const h = String(d.getUTCHours()).padStart(2, '0');
+  if (step === 'HOUR') return `${date} ${h}`;
+  const min = String(d.getUTCMinutes()).padStart(2, '0');
+  return `${date} ${h}${min}`;
+}
+
+/** True iff `s` parses as a valid date string for the given step. */
+export function isInspectDate(s: string, step: InspectStep): boolean {
+  if (step === 'DAY') return /^\d{4}-\d{2}-\d{2}$/.test(s);
+  if (step === 'HOUR') return /^\d{4}-\d{2}-\d{2} \d{2}$/.test(s);
+  return /^\d{4}-\d{2}-\d{2} \d{4}$/.test(s);
+}
+
+// ── Errors ─────────────────────────────────────────────────────────
+
+/** OAP's inspect error envelope: `{ "error": "string" }`. */
+export interface InspectErrorBody {
+  error: string;
+}
+
+export class InspectApiError extends Error {
+  constructor(
+    public readonly status: number,
+    public readonly body: InspectErrorBody | string,
+    public readonly url: string,
+  ) {
+    const detail = typeof body === 'string' ? body : body.error;
+    super(`${status} on ${url} — ${detail}`);
+    this.name = 'InspectApiError';
+  }
+}
+
+// ── Client ─────────────────────────────────────────────────────────
+
+export interface InspectClientOptions {
+  /** OAP admin port URL, e.g. `http://oap:17128`. No trailing slash. */
+  adminUrl: string;
+  fetch?: FetchLike;
+  headers?: Record<string, string>;
+  /** Default per-call timeout in ms. `0` disables. */
+  timeoutMs?: number;
+}
+
+export class InspectClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: InspectClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.adminUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `GET /inspect/metrics` with optional `regex` / `type` / `catalog`
+   *  / `mqeQueryable` filters. Returns the full catalog when no
+   *  filters are passed. */
+  async listMetrics(args: ListMetricsArgs = {}): Promise<MetricsResponse> {
+    const params = new URLSearchParams();
+    if (args.regex !== undefined) params.set('regex', args.regex);
+    if (args.mqeQueryable === true) params.set('mqeQueryable', 'true');
+    for (const t of args.type ?? []) params.append('type', t);
+    for (const c of args.catalog ?? []) params.append('catalog', c);
+    const qs = params.toString();
+    const url = `${this.base}/inspect/metrics${qs ? `?${qs}` : ''}`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as MetricsResponse;
+  }
+
+  /** `GET /inspect/entities` — `metric` + time range + step + limit. */
+  async listEntities(args: ListEntitiesArgs): Promise<EntitiesResponse> {
+    const params = new URLSearchParams({
+      metric: args.metric,
+      start: args.start,
+      end: args.end,
+      step: args.step,
+    });
+    if (args.limit !== undefined) params.set('limit', String(args.limit));
+    const url = `${this.base}/inspect/entities?${params.toString()}`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as EntitiesResponse;
+  }
+
+  // ── private helpers ─────────────────────────────────────────────
+
+  private async send(url: string, init: RequestInit): Promise<Response> {
+    const headers: Record<string, string> = {
+      Accept: 'application/json',
+      ...this.defaultHeaders,
+      ...((init.headers as Record<string, string>) ?? {}),
+    };
+    const finalInit: RequestInit = { ...init, headers };
+    if (this.timeoutMs > 0) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+      try {
+        return await this.fetchImpl(url, { ...finalInit, signal: 
controller.signal });
+      } finally {
+        clearTimeout(timer);
+      }
+    }
+    return this.fetchImpl(url, finalInit);
+  }
+
+  private async toError(res: Response, url: string): Promise<InspectApiError> {
+    const text = await res.text();
+    let parsed: InspectErrorBody | string = text;
+    try {
+      const json = JSON.parse(text) as Record<string, unknown>;
+      if (typeof json.error === 'string') {
+        parsed = json as unknown as InspectErrorBody;
+      }
+    } catch {
+      // not JSON; keep the raw text.
+    }
+    return new InspectApiError(res.status, parsed, url);
+  }
+}
diff --git a/packages/api-client/src/oal.ts b/packages/api-client/src/oal.ts
new file mode 100644
index 0000000..3b6609f
--- /dev/null
+++ b/packages/api-client/src/oal.ts
@@ -0,0 +1,182 @@
+/*
+ * 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.
+ */
+
+/**
+ * Read-only OAL listing — `GET /runtime/oal/*` introduced by SWIP-13.
+ *
+ * The actual implementation on the `swip-13-dsl-debugger` branch ships a
+ * **per-source-dispatcher** listing, not a per-rule one — the OAL debugger
+ * targets one source (e.g. `Endpoint`) and every metric routed off that
+ * source captures together. So the listing is shaped to match: one row per
+ * dispatcher, with the dispatcher's full metric set inline.
+ *
+ * File metadata is also intentionally minimal — `/files` returns just file
+ * names; `/files/{name}` returns the raw `.oal` text as `text/plain`.
+ *
+ * Read-only by design. OAL hot-update is upstream-deferred; the path
+ * prefix `/runtime/oal/*` is reserved for future write endpoints.
+ */
+
+import { RuntimeRuleApiError, type ApplyResult } from './types.js';
+import type { FetchLike } from './runtime-rule.js';
+
+export interface OalFilesResponse {
+  /** OAL file names (with extension), e.g. `core.oal`. */
+  files: string[];
+  count: number;
+}
+
+/** One row per dispatcher in the running OAP. The OAL debugger targets a
+ *  source — every metric on that source captures together. The listing
+ *  endpoint emits metric names only; per-metric `status` requires the
+ *  per-source detail call (`/runtime/oal/rules/{source}`). */
+export interface OalSourceListing {
+  /** OAL source class name without `Dispatcher` suffix, e.g. `Endpoint`. */
+  source: string;
+  /** Fully-qualified dispatcher class name. */
+  dispatcher: string;
+  /** Metric names routed off this source. */
+  metrics: string[];
+}
+
+export interface OalRulesResponse {
+  sources: OalSourceListing[];
+  count: number;
+}
+
+/** Per-metric gate state from the per-source detail endpoint.
+ *  `status: "live"` means the dispatcher's `DebugHolderProvider` has a
+ *  holder ready and a session install on this metric will succeed.
+ *  `no_holder` means the codegen knew the metric but no holder was
+ *  bound (test mocks or dispatchers compiled before SWIP-13). */
+export interface OalSourceMetricDetail {
+  name: string;
+  status: 'live' | 'no_holder';
+}
+
+/** Per-source detail — same `source` + `dispatcher` as the listing,
+ *  but `metrics` carries per-metric `{name, status}` rather than bare
+ *  strings. The wire has no source-level `status`; the rollup is
+ *  derived per metric. */
+export interface OalSourceDetail {
+  source: string;
+  dispatcher: string;
+  metrics: OalSourceMetricDetail[];
+}
+
+export interface OalClientOptions {
+  /** OAP admin-server URL, e.g. `http://oap:17128`. No trailing slash. */
+  adminUrl: string;
+  fetch?: FetchLike;
+  headers?: Record<string, string>;
+  timeoutMs?: number;
+}
+
+/**
+ * Typed wrapper for the four read-only OAL endpoints.
+ */
+export class OalClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: OalClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.adminUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `GET /runtime/oal/files` — bare file-name listing. */
+  async listFiles(): Promise<OalFilesResponse> {
+    const url = `${this.base}/runtime/oal/files`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as OalFilesResponse;
+  }
+
+  /** `GET /runtime/oal/files/{name}` — returns raw `.oal` text
+   *  (`text/plain`). Returns `null` when the file isn't loaded. */
+  async getFileContent(name: string): Promise<string | null> {
+    const url = `${this.base}/runtime/oal/files/${encodeURIComponent(name)}`;
+    const res = await this.send(url, { method: 'GET' });
+    if (res.status === 404) return null;
+    if (!res.ok) throw await this.toError(res, url);
+    return await res.text();
+  }
+
+  /** `GET /runtime/oal/rules` — per-dispatcher listing for the picker. */
+  async listSources(): Promise<OalRulesResponse> {
+    const url = `${this.base}/runtime/oal/rules`;
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as OalRulesResponse;
+  }
+
+  /** `GET /runtime/oal/rules/{source}` — single-source detail with
+   *  holder-bound status. Path param is the source class name. Returns
+   *  `null` when no dispatcher owns that source. */
+  async getSource(source: string): Promise<OalSourceDetail | null> {
+    const url = `${this.base}/runtime/oal/rules/${encodeURIComponent(source)}`;
+    const res = await this.send(url, { method: 'GET' });
+    if (res.status === 404) return null;
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as OalSourceDetail;
+  }
+
+  // ── private helpers ─────────────────────────────────────────────
+
+  private async send(url: string, init: RequestInit): Promise<Response> {
+    const headers: Record<string, string> = {
+      Accept: 'application/json, text/plain',
+      ...this.defaultHeaders,
+      ...((init.headers as Record<string, string>) ?? {}),
+    };
+    const finalInit: RequestInit = { ...init, headers };
+    if (this.timeoutMs > 0) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+      try {
+        return await this.fetchImpl(url, { ...finalInit, signal: 
controller.signal });
+      } finally {
+        clearTimeout(timer);
+      }
+    }
+    return this.fetchImpl(url, finalInit);
+  }
+
+  private async toError(res: Response, url: string): 
Promise<RuntimeRuleApiError> {
+    const text = await res.text();
+    let parsed: ApplyResult | string = text;
+    try {
+      const json = JSON.parse(text) as Record<string, unknown>;
+      if (typeof json.applyStatus === 'string' && typeof json.message === 
'string') {
+        parsed = json as unknown as ApplyResult;
+      } else if (
+        json.status === 'error' &&
+        typeof json.code === 'string' &&
+        typeof json.message === 'string'
+      ) {
+        parsed = json as unknown as ApplyResult;
+      }
+    } catch {
+      // not JSON; keep the raw text.
+    }
+    return new RuntimeRuleApiError(res.status, parsed, url);
+  }
+}
diff --git a/packages/api-client/src/runtime-rule.ts 
b/packages/api-client/src/runtime-rule.ts
new file mode 100644
index 0000000..e8e3914
--- /dev/null
+++ b/packages/api-client/src/runtime-rule.ts
@@ -0,0 +1,244 @@
+/*
+ * 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.
+ */
+
+import type {
+  ApplyResult,
+  BundledEntry,
+  Catalog,
+  DeleteMode,
+  ListEnvelope,
+  NotModified,
+  RuleResponse,
+  RuleSource,
+  RuleStatus,
+} from './types.js';
+import { RuntimeRuleApiError } from './types.js';
+
+export type FetchLike = (input: string | URL, init?: RequestInit) => 
Promise<Response>;
+
+export interface RuntimeRuleClientOptions {
+  /** OAP admin port URL, e.g. `http://oap:17128`. No trailing slash. */
+  adminUrl: string;
+  /** Optional fetch implementation. Defaults to the global `fetch`. */
+  fetch?: FetchLike;
+  /** Optional default headers (e.g. for a forward-RPC token). */
+  headers?: Record<string, string>;
+  /** Default per-call timeout in ms. `0` disables. */
+  timeoutMs?: number;
+}
+
+export interface AddOrUpdateArgs {
+  catalog: Catalog;
+  name: string;
+  /** Raw YAML. */
+  body: string;
+  /** Default false. Required when the edit moves storage identity. */
+  allowStorageChange?: boolean;
+  /** Default false. Re-runs apply on byte-identical content; subsumes
+   *  the prior `/fix` route. */
+  force?: boolean;
+}
+
+export interface GetRuleArgs {
+  catalog: Catalog;
+  name: string;
+  /** Default `runtime` — DAO row first, static fallback. `bundled`
+   *  bypasses the DAO and returns only the static twin. */
+  source?: RuleSource;
+  /** Conditional fetch — if the server's ETag matches, the client
+   *  returns a {@link NotModified} sentinel and saves the body. */
+  ifNoneMatch?: string;
+}
+
+/**
+ * Typed wrapper for the eight runtime-rule REST endpoints v1 binds to.
+ *
+ * Each method speaks the canonical route — the `/runtime/mal/otel/...`,
+ * `/runtime/mal/log/...`, `/runtime/lal/...` shortcut variants are
+ * sugar for scripted ops and aren't surfaced here.
+ */
+export class RuntimeRuleClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: RuntimeRuleClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.adminUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `GET /runtime/rule/list[?catalog=]` */
+  async list(catalog?: Catalog): Promise<ListEnvelope> {
+    const url = this.url('/runtime/rule/list', catalog ? { catalog } : 
undefined);
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as ListEnvelope;
+  }
+
+  /** `GET /runtime/rule/bundled?catalog=[&withContent=]` */
+  async listBundled(catalog: Catalog, withContent = true): 
Promise<BundledEntry[]> {
+    const url = this.url('/runtime/rule/bundled', {
+      catalog,
+      withContent: String(withContent),
+    });
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return (await res.json()) as BundledEntry[];
+  }
+
+  /** `GET /runtime/rule?catalog=&name=[&source=]`. Returns {@link
+   *  NotModified} when the server replies 304 to a conditional fetch. */
+  async get(args: GetRuleArgs): Promise<RuleResponse | NotModified> {
+    const params: Record<string, string> = {
+      catalog: args.catalog,
+      name: args.name,
+    };
+    if (args.source) params.source = args.source;
+    const url = this.url('/runtime/rule', params);
+
+    const headers: Record<string, string> = { Accept: 'application/x-yaml' };
+    if (args.ifNoneMatch) headers['If-None-Match'] = args.ifNoneMatch;
+
+    const res = await this.send(url, { method: 'GET', headers });
+
+    if (res.status === 304) {
+      return {
+        notModified: true,
+        etag: res.headers.get('ETag') ?? '',
+        contentHash: res.headers.get('X-Sw-Content-Hash') ?? '',
+        status: (res.headers.get('X-Sw-Status') as RuleStatus) ?? 'n/a',
+      };
+    }
+    if (!res.ok) throw await this.toError(res, url);
+
+    const body = await res.text();
+    return {
+      status: (res.headers.get('X-Sw-Status') as RuleResponse['status']) ?? 
'n/a',
+      source: (res.headers.get('X-Sw-Source') as RuleResponse['source']) ?? 
'runtime',
+      contentHash: res.headers.get('X-Sw-Content-Hash') ?? '',
+      updateTime: Number(res.headers.get('X-Sw-Update-Time') ?? '0'),
+      etag: res.headers.get('ETag') ?? '',
+      content: body,
+    };
+  }
+
+  /** `POST 
/runtime/rule/addOrUpdate?catalog=&name=[&allowStorageChange=][&force=]` */
+  async addOrUpdate(args: AddOrUpdateArgs): Promise<ApplyResult> {
+    const params: Record<string, string> = {
+      catalog: args.catalog,
+      name: args.name,
+    };
+    if (args.allowStorageChange) params.allowStorageChange = 'true';
+    if (args.force) params.force = 'true';
+    const url = this.url('/runtime/rule/addOrUpdate', params);
+
+    const res = await this.send(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'text/plain' },
+      body: args.body,
+    });
+
+    return this.expectApplyResult(res, url);
+  }
+
+  /** `POST /runtime/rule/inactivate?catalog=&name=` (Design A — see
+   *  `reference_runtime_rule_api.md`). */
+  async inactivate(catalog: Catalog, name: string): Promise<ApplyResult> {
+    const url = this.url('/runtime/rule/inactivate', { catalog, name });
+    const res = await this.send(url, { method: 'POST' });
+    return this.expectApplyResult(res, url);
+  }
+
+  /** `POST /runtime/rule/delete?catalog=&name=[&mode=revertToBundled]`.
+   *  The two-step gate is server-side: a 409 `requires_inactivate_first`
+   *  is surfaced via {@link RuntimeRuleApiError}. */
+  async delete(catalog: Catalog, name: string, mode: DeleteMode = ''): 
Promise<ApplyResult> {
+    const params: Record<string, string> = { catalog, name };
+    if (mode) params.mode = mode;
+    const url = this.url('/runtime/rule/delete', params);
+    const res = await this.send(url, { method: 'POST' });
+    return this.expectApplyResult(res, url);
+  }
+
+  /** `GET /runtime/rule/dump` or `/dump/{catalog}` — streams `tar.gz`. */
+  async dump(catalog?: Catalog): Promise<Response> {
+    const path = catalog
+      ? `/runtime/rule/dump/${encodeURIComponent(catalog)}`
+      : '/runtime/rule/dump';
+    const url = this.url(path);
+    const res = await this.send(url, { method: 'GET' });
+    if (!res.ok) throw await this.toError(res, url);
+    return res;
+  }
+
+  // ─── private helpers ─────────────────────────────────────────────
+
+  private url(path: string, params?: Record<string, string>): string {
+    const u = new URL(this.base + path);
+    if (params) {
+      for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
+    }
+    return u.toString();
+  }
+
+  private async send(url: string, init: RequestInit): Promise<Response> {
+    const headers: Record<string, string> = {
+      ...this.defaultHeaders,
+      ...((init.headers as Record<string, string>) ?? {}),
+    };
+    const finalInit: RequestInit = { ...init, headers };
+    if (this.timeoutMs > 0) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+      try {
+        return await this.fetchImpl(url, { ...finalInit, signal: 
controller.signal });
+      } finally {
+        clearTimeout(timer);
+      }
+    }
+    return this.fetchImpl(url, finalInit);
+  }
+
+  /** Handle the apply-result-or-error response shape that addOrUpdate /
+   *  inactivate / delete share. 2xx returns the parsed JSON; everything
+   *  else throws a {@link RuntimeRuleApiError} with the parsed body so
+   *  callers can switch on `applyStatus` (e.g. the 409
+   *  `storage_change_requires_explicit_approval` path). */
+  private async expectApplyResult(res: Response, url: string): 
Promise<ApplyResult> {
+    if (res.status >= 200 && res.status < 300) {
+      return (await res.json()) as ApplyResult;
+    }
+    throw await this.toError(res, url);
+  }
+
+  private async toError(res: Response, url: string): 
Promise<RuntimeRuleApiError> {
+    const text = await res.text();
+    let parsed: ApplyResult | string = text;
+    try {
+      const json = JSON.parse(text) as Partial<ApplyResult>;
+      if (typeof json.applyStatus === 'string' && typeof json.message === 
'string') {
+        parsed = json as ApplyResult;
+      }
+    } catch {
+      // not JSON; keep the raw text.
+    }
+    return new RuntimeRuleApiError(res.status, parsed, url);
+  }
+}
diff --git a/packages/api-client/src/status.ts 
b/packages/api-client/src/status.ts
new file mode 100644
index 0000000..ae99aba
--- /dev/null
+++ b/packages/api-client/src/status.ts
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+
+import type { ClusterNode, ClusterNodesResponse } from './types.js';
+import type { FetchLike } from './runtime-rule.js';
+
+export interface StatusClientOptions {
+  /** OAP query/status URL, default port 12800. No trailing slash. */
+  statusUrl: string;
+  fetch?: FetchLike;
+  headers?: Record<string, string>;
+  /** Per-call timeout in ms. 0 disables. Default 0. */
+  timeoutMs?: number;
+}
+
+/** Cluster node with both wire spellings of the self-flag normalised
+ *  into a single `self: boolean` field. */
+export interface NormalisedClusterNode {
+  host: string;
+  port: number;
+  self: boolean;
+}
+
+/**
+ * Read-only client for the upstream `/status/*` plugin endpoints v1
+ * needs. Today this is a single endpoint — cluster member discovery
+ * for the BFF's per-rule × per-node fan-out.
+ */
+export class StatusClient {
+  private readonly fetchImpl: FetchLike;
+  private readonly base: string;
+  private readonly defaultHeaders: Record<string, string>;
+  private readonly timeoutMs: number;
+
+  constructor(options: StatusClientOptions) {
+    this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+    this.base = options.statusUrl.replace(/\/$/, '');
+    this.defaultHeaders = options.headers ?? {};
+    this.timeoutMs = options.timeoutMs ?? 0;
+  }
+
+  /** `GET /status/cluster/nodes` — returns the OAP cluster member list. */
+  async clusterNodes(): Promise<NormalisedClusterNode[]> {
+    const url = `${this.base}/status/cluster/nodes`;
+    const init: RequestInit = {
+      method: 'GET',
+      headers: { Accept: 'application/json', ...this.defaultHeaders },
+    };
+    let res: Response;
+    if (this.timeoutMs > 0) {
+      const controller = new AbortController();
+      const timer = setTimeout(() => controller.abort(), this.timeoutMs);
+      try {
+        res = await this.fetchImpl(url, { ...init, signal: controller.signal 
});
+      } finally {
+        clearTimeout(timer);
+      }
+    } else {
+      res = await this.fetchImpl(url, init);
+    }
+    if (!res.ok) {
+      const body = await res.text();
+      throw new Error(`StatusClient.clusterNodes: ${res.status} on ${url} — 
${body}`);
+    }
+    const json = (await res.json()) as ClusterNodesResponse;
+    return (json.nodes ?? []).map(normaliseNode);
+  }
+}
+
+function normaliseNode(n: ClusterNode): NormalisedClusterNode {
+  return {
+    host: n.host,
+    port: n.port,
+    self: n.self ?? n.isSelf ?? false,
+  };
+}
diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts
new file mode 100644
index 0000000..b0dbb6e
--- /dev/null
+++ b/packages/api-client/src/types.ts
@@ -0,0 +1,224 @@
+/*
+ * 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.
+ */
+
+/**
+ * Wire types for the runtime-rule REST surface and the cluster-status
+ * query. Mirrors `reference_runtime_rule_api.md` in Claude memory; the
+ * authoritative source is the upstream
+ * `RuntimeRuleRestHandler.java` + `RuntimeRuleService.java` on
+ * `feature/runtime-rule-hot-update` (squashed tip in
+ * `~/github/skywalking`).
+ *
+ * v1 binds to MAL + LAL only. OAL is permanently excluded from
+ * hot-update.
+ */
+
+// ── Catalogs ───────────────────────────────────────────────────────
+
+export const CATALOGS = ['otel-rules', 'log-mal-rules', 'telegraf-rules', 
'lal'] as const;
+export type Catalog = (typeof CATALOGS)[number];
+
+export function isCatalog(value: unknown): value is Catalog {
+  return typeof value === 'string' && (CATALOGS as readonly 
string[]).includes(value);
+}
+
+// ── Status / loader enums ──────────────────────────────────────────
+
+export type RuleStatus = 'ACTIVE' | 'INACTIVE' | 'BUNDLED' | 'n/a';
+export type LocalState = 'RUNNING' | 'SUSPENDED' | 'NOT_LOADED';
+export type SuspendOrigin = 'NONE' | 'LOCAL_APPLY' | 'PEER_BROADCAST';
+export type LoaderGc = 'LIVE' | 'PENDING' | 'COLLECTED';
+export type LoaderKind = 'RUNTIME' | 'BUNDLED' | 'NONE';
+
+// ── /runtime/rule/list ─────────────────────────────────────────────
+
+export interface LoaderStats {
+  active: number;
+  pending: number;
+}
+
+interface ListRowBase {
+  catalog: Catalog;
+  name: string;
+  localState: LocalState;
+  loaderGc: LoaderGc;
+  loaderKind: LoaderKind;
+  /** Format: `<kind>:<catalog>/<rule>@<MMdd-HHmmss>`, e.g.
+   *  `runtime:otel-rules/vm@0429-101900`. Empty string when no per-file
+   *  loader exists (typical for bundled-only rules served from the
+   *  default loader). */
+  loaderName: string;
+  /** SHA-256 hex of the currently-served content. */
+  contentHash: string;
+  /** True iff a static twin exists on disk for this `(catalog, name)`. */
+  bundled: boolean;
+  /** SHA-256 hex of the static twin; absent when `bundled === false`. */
+  bundledContentHash?: string;
+}
+
+/** Operator-pushed runtime row. */
+export interface OperatorRow extends ListRowBase {
+  status: 'ACTIVE' | 'INACTIVE';
+  suspendOrigin: SuspendOrigin;
+  updateTime: number;
+  lastApplyError: string;
+  pendingUnregister: false;
+}
+
+/** Bundled-only row — shipped on disk, no operator override. */
+export interface BundledRow extends ListRowBase {
+  status: 'BUNDLED';
+  pendingUnregister: false;
+}
+
+/** Orphan row — DAO row was deleted, dslManager hasn't swept yet.
+ *  Transient — gone within one tick. */
+export interface OrphanRow extends ListRowBase {
+  status: 'n/a';
+  pendingUnregister: true;
+}
+
+export type ListRow = OperatorRow | BundledRow | OrphanRow;
+
+export interface ListEnvelope {
+  generatedAt: number;
+  loaderStats: LoaderStats;
+  rules: ListRow[];
+}
+
+// ── /runtime/rule/bundled ──────────────────────────────────────────
+
+export interface BundledEntry {
+  name: string;
+  /** mal | lal — derived from catalog when relevant. */
+  kind: string;
+  contentHash: string;
+  /** Present iff `withContent=true` was requested (the default). */
+  content?: string;
+  /** True if a runtime override row exists for this name. */
+  overridden: boolean;
+}
+
+// ── /runtime/rule (single-rule fetch) ──────────────────────────────
+
+export type RuleSource = 'runtime' | 'bundled';
+
+/** The JSON envelope returned when the client passes
+ *  `Accept: application/json`. Default mode returns raw YAML; the
+ *  client wrapper normalises both into a {@link RuleResponse}. */
+export interface RuleJsonEnvelope {
+  catalog: string;
+  name: string;
+  /** Note: legacy `STATIC` may appear on older OAP builds; new ones
+   *  return `BUNDLED` consistently with `/list`. */
+  status: RuleStatus | 'STATIC';
+  source: 'runtime' | 'static';
+  contentHash: string;
+  updateTime: number;
+  content: string;
+}
+
+export interface RuleResponse {
+  status: RuleStatus | 'STATIC';
+  source: 'runtime' | 'static';
+  contentHash: string;
+  updateTime: number;
+  /** Echo of `ETag` response header, with surrounding quotes. */
+  etag: string;
+  /** Raw YAML body. */
+  content: string;
+}
+
+/** Returned by `RuntimeRuleClient.get()` when the server replies `304
+ *  Not Modified` to a conditional request — the client did not waste
+ *  bandwidth on an unchanged body. */
+export interface NotModified {
+  notModified: true;
+  etag: string;
+  contentHash: string;
+  status: RuleStatus | 'STATIC';
+}
+
+// ── /runtime/rule/addOrUpdate ──────────────────────────────────────
+
+/**
+ * Open-ended for forward compatibility: OAP may add new applyStatus
+ * codes. The values here are the ones documented today.
+ */
+export type ApplyStatus =
+  | 'no_change'
+  | 'filter_only_applied'
+  | 'structural_applied'
+  | 'persisted_apply_pending'
+  | 'compile_failed'
+  | 'empty_body'
+  | 'invalid_catalog'
+  | 'invalid_name'
+  | 'invalid_delete_mode'
+  | 'no_bundled_twin'
+  | 'storage_change_requires_explicit_approval'
+  | 'requires_inactivate_first'
+  | 'ddl_verify_failed'
+  | 'apply_failed'
+  | 'persist_failed';
+
+export interface ApplyResult {
+  /** One of {@link ApplyStatus}; typed as `string` so a server-side
+   *  addition doesn't break the client deserialiser. */
+  applyStatus: ApplyStatus | (string & {});
+  catalog: string;
+  name: string;
+  message: string;
+}
+
+// ── /runtime/rule/delete ───────────────────────────────────────────
+
+export const DELETE_MODES = ['', 'revertToBundled'] as const;
+export type DeleteMode = (typeof DELETE_MODES)[number];
+
+// ── /status/cluster/nodes (port 12800) ─────────────────────────────
+
+export interface ClusterNode {
+  host: string;
+  port: number;
+  /** One of these two carries the boolean — Java field is `isSelf` and
+   *  Gson serialisation may emit either. The wrapper normalises to the
+   *  `self` field on its public type. */
+  self?: boolean;
+  isSelf?: boolean;
+}
+
+export interface ClusterNodesResponse {
+  nodes: ClusterNode[];
+}
+
+// ── Errors ─────────────────────────────────────────────────────────
+
+/** Thrown by the client for any HTTP response outside the expected
+ *  set. Exposes the parsed body so callers can switch on
+ *  `applyStatus`. */
+export class RuntimeRuleApiError extends Error {
+  constructor(
+    public readonly status: number,
+    public readonly body: ApplyResult | string,
+    public readonly url: string,
+  ) {
+    const detail = typeof body === 'string' ? body : `${body.applyStatus}: 
${body.message}`;
+    super(`${status} on ${url} — ${detail}`);
+    this.name = 'RuntimeRuleApiError';
+  }
+}

Reply via email to