agent-control-plane-core 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,10 +28,11 @@ removes.
28
28
  (a tool about to run, a tool that just ran, a prompt, a session start).
29
29
  - **`Verdict`** — one normalized way to say what your guardrail decided:
30
30
  `allow` / `deny` / `ask`, optionally with a rewritten input or extra context.
31
- - **Adapters** — a thin `{ parse, render }` translator per agent. `parse` turns
32
- that agent's raw hook payload into a `ToolCallEvent`; `render` turns your
33
- `Verdict` back into the **real** native signal that agent enforces (the right
34
- JSON body, exit code, or thrown error — not just a print).
31
+ - **Adapters** — a thin `{ parse, render }` translator per agent, plus the
32
+ declaration of what that agent's transport cannot carry. `parse` turns that
33
+ agent's raw hook payload into a `ToolCallEvent`; `render` turns your `Verdict`
34
+ back into the **real** native signal that agent enforces (the right JSON body,
35
+ exit code, or thrown error — not just a print).
35
36
 
36
37
  You write your logic **once** against the normalized types. Adding support for a
37
38
  new agent is a new adapter (~one file + fixtures), not a rewrite of your tool.
@@ -90,13 +91,35 @@ pnpm add agent-control-plane-core
90
91
  }
91
92
  ```
92
93
 
93
- An **Adapter** is a pair `{ AGENT, parse, render }`:
94
+ An **Adapter** is `{ AGENT, COVERAGE, UNRENDERED_FIELDS, parse, render }`:
94
95
 
95
96
  ```js
96
97
  parse(nativeEvent) -> ToolCallEvent // never throws on unknown input
97
98
  render(verdict, event) -> nativeResponse // the agent's native shape
99
+
100
+ // Which Verdict content fields this host has NO native channel for, per event
101
+ // kind. Every EventKind needs a row: an omission would read as "every field
102
+ // reaches a channel here", which is the reverse of the truth on a transport
103
+ // that carries none.
104
+ UNRENDERED_FIELDS = Object.freeze({
105
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
106
+ [EventKind.POST_TOOL]: readonlySet(["mutated_output"]),
107
+ [EventKind.PROMPT_SUBMIT]: UNRENDERED_ON_UNKNOWN,
108
+ [EventKind.SESSION_START]: UNRENDERED_ON_UNKNOWN,
109
+ [EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
110
+ })
98
111
  ```
99
112
 
113
+ `readonlySet([])` is the row for a kind that carries all three;
114
+ `UNRENDERED_ON_UNKNOWN` is the row for one that carries none. Both come from
115
+ `agent-control-plane-core/contract`. Build every row with `readonlySet` — a
116
+ plain `Set` exposes `clear()`, so a consumer could empty a declaration after
117
+ conformance certified it, and `Object.freeze` does not stop that. Freeze the map
118
+ itself too: an immutable row still leaves a consumer free to swap a whole row
119
+ for one claiming another channel. A render that ships a field the row
120
+ declares dropped fails conformance, and so does a row that declares a channel
121
+ the render does not carry.
122
+
100
123
  ## Usage
101
124
 
102
125
  ```js
@@ -167,7 +190,13 @@ The harness pins both directions and rejects a vacuous suite:
167
190
  3. The fixture set collectively renders an `allow`, a `deny`, an `ask`, **and** a
168
191
  `mutated_input` — so a suite can't pass while skipping a decision the contract
169
192
  requires every adapter to express.
170
- 4. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
193
+ 4. Every `Verdict` content field either reaches this host's wire or is declared
194
+ in `UNRENDERED_FIELDS` for that event kind, checked both ways on a
195
+ synthesized verdict — so an adapter with no channel for a field must say so,
196
+ and a declared drop that still ships the value fails. Set the optional
197
+ `NATIVE_EVENT_FOR` to have a kind your fixtures skip probed on its real
198
+ native event rather than on your unrecognized-event branch.
199
+ 5. A deny the adapter cannot enforce (`this_call_vetoable: false`) must render as
171
200
  something OTHER than that host's allow signal — an ask, or an advisory body.
172
201
  The harness probes this itself on a non-vetoable variant of every pre-tool
173
202
  fixture event, so an adapter that collapses the objection onto "run it" fails
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Codex CLI EXTERNAL_HOOK entry. Reads a native PreToolUse/PermissionRequest
4
- * payload on stdin, applies the demo judge, and emits Codex's native response.
3
+ * Codex CLI EXTERNAL_HOOK entry. Reads a native
4
+ * PreToolUse/PermissionRequest/PostToolUse payload on stdin, applies the demo
5
+ * judge, and emits Codex's native response.
5
6
  *
6
7
  * FAILURE POSTURE — fail OPEN (exit 0), like Claude Code: Codex treats a
7
8
  * non-conforming hook as non-blocking. On a pre-v0.135 Codex the render is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * This adapter is what proves the transport split earns its keep: the normalized
10
10
  * ToolCallEvent/Verdict are identical to Claude's, but `render` carries the
11
- * decision in `exit_code` with no `stdout` at all.
11
+ * decision in `exit_code` with no `stdout` at all. An enforced deny's `reason`
12
+ * goes to the helper's STDERR, which Amp surfaces.
12
13
  */
13
14
 
14
15
  import {
@@ -25,15 +26,21 @@ import {
25
26
  makeEvent,
26
27
  normalizeVerdict,
27
28
  nativeResponse,
28
- collectPassthrough,
29
+ readonlySet,
30
+ VERDICT_CONTENT_FIELDS,
31
+ baseMeta,
29
32
  asObject,
30
33
  asStringOrNull,
31
34
  } from "../control-plane.mjs";
32
35
 
33
36
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
34
37
  /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
35
- /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
36
38
  /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
39
+ // Re-exported on this subpath even though no signature below names it:
40
+ // tsc turns each @typedef into an `export type` in the generated .d.mts,
41
+ // so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
42
+ // for every TypeScript consumer.
43
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
37
44
 
38
45
  export const AGENT = "amp";
39
46
  export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
@@ -45,6 +52,23 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
45
52
  * gates calls inside subagents — so builtin, MCP, and subagent are all COVERED.
46
53
  * Resumed-thread firing is a strong-but-uncited structural argument, so it is
47
54
  * held at UNKNOWN until an item-⑤ probe confirms it.
55
+ *
56
+ * That UNKNOWN is this adapter's own fail-closed reading, NOT a transcription of
57
+ * the matrix cell, which reads `✅ struct. [A4]` — COVERED, on evidence the
58
+ * `struct.` suffix marks as uncited. A cell is a CEILING: an adapter may never
59
+ * claim more than it, but declaring LESS is always safe, since every step down
60
+ * degrades a deny toward notify. This row takes that conservative option until a
61
+ * probe cites it; Claude's takes the cell at face value on the same class of
62
+ * argument. The difference is free today, for the reason below.
63
+ *
64
+ * The row is DECLARATIVE ONLY either way: no host marks a lone tool event as
65
+ * belonging to a resumed session, so {@link classifyCallClass} never returns
66
+ * RESUMED and `parse` never reads the entry — a call in a resumed thread is
67
+ * classified by its TOOL, BUILTIN or MCP, and both are COVERED, so it parses
68
+ * vetoable. (The SUBAGENT row is reachable — the classifier answers it for a
69
+ * payload carrying `agent_type` — but Amp's subagent coverage is COVERED too,
70
+ * so it changes nothing here either.) The entry is there for a consumer reading
71
+ * COVERAGE directly, and becomes load-bearing only once a signal exists.
48
72
  */
49
73
  /** @type {import("../control-plane.mjs").CoverageMap} */
50
74
  export const COVERAGE = Object.freeze({
@@ -62,11 +86,44 @@ export const COVERAGE = Object.freeze({
62
86
  * after `assertGatedKinds` has already run.
63
87
  */
64
88
  const GATED_EVENTS = Object.freeze(new Set([EventKind.PRE_TOOL]));
89
+
90
+ /**
91
+ * The native event a conformance probe should carry for each kind — this
92
+ * adapter's own answer, so an every-kind probe exercises the branch that kind
93
+ * really takes. Amp's observer names every event `delegate` and reaches no
94
+ * other kind.
95
+ * @type {Record<string, string|undefined>}
96
+ */
97
+ export const NATIVE_EVENT_FOR = Object.freeze({
98
+ [EventKind.PRE_TOOL]: "delegate",
99
+ });
65
100
  assertGatedKinds(GATED_EVENTS, AGENT);
66
101
 
102
+ /**
103
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel, so `render`
104
+ * drops them. Amp's transport is the exit code and nothing else — there is no
105
+ * stdout body to carry a replacement input, a replacement output or extra
106
+ * context, so ALL THREE are dropped on every kind. A guardrail that needs any of
107
+ * them cannot use Amp as its only integration. `reason` is not one of them and
108
+ * is NOT dropped: an enforced deny writes it to the helper's stderr, which Amp
109
+ * surfaces (see {@link render}). Built over every
110
+ * {@link EventKind} rather than the one `parse` emits: a row this adapter cannot
111
+ * reach is still the honest answer for a caller that asks.
112
+ * @type {Record<string, ReadonlySet<string>|undefined>}
113
+ */
114
+ export const UNRENDERED_FIELDS = Object.freeze(
115
+ Object.fromEntries(
116
+ Object.values(EventKind).map((kind) => [
117
+ kind,
118
+ readonlySet(VERDICT_CONTENT_FIELDS),
119
+ ]),
120
+ ),
121
+ );
122
+
67
123
  // Amp invokes the delegate for a tool call; the payload carries the tool name +
68
- // input and the session context. Pinned by fixtures/amp.json.
69
- const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
124
+ // input. The session context is the STANDARD_META_FIELDS set `baseMeta`
125
+ // consumes, so it is not listed here. Pinned by fixtures/amp.json.
126
+ const CONSUMED = new Set(["tool", "input"]);
70
127
 
71
128
  /**
72
129
  * @param {any} native
@@ -74,16 +131,14 @@ const CONSUMED = new Set(["tool", "input", "session_id", "cwd"]);
74
131
  */
75
132
  export function parse(native) {
76
133
  const raw = asObject(native);
77
- /** @type {EventMeta} */
78
- const meta = {
134
+ const meta = baseMeta({
79
135
  agent: AGENT,
80
136
  native_event: "delegate",
81
137
  integration_mode: INTEGRATION_MODE,
82
138
  primary_gate_present: true,
83
- passthrough: collectPassthrough(raw, CONSUMED),
84
- };
85
- if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
86
- if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
139
+ native: raw,
140
+ consumed: CONSUMED,
141
+ });
87
142
  const nativeTool = asStringOrNull(raw.tool);
88
143
  if (nativeTool !== null) meta.native_tool = nativeTool;
89
144
  return makeEvent({
@@ -146,10 +201,14 @@ for (const decision of Object.values(Decision)) {
146
201
 
147
202
  /**
148
203
  * Render into Amp's pure exit-code transport: the decision is the exit code,
149
- * with no stdout body. `reason` has no native channel here, so it is dropped
150
- * (Amp surfaces the helper's own stderr). No `soleGate` option — an allow
151
- * already renders as exit 0 either way, so there's no distinct "real approve"
152
- * signal for this transport to opt into.
204
+ * with no stdout body. An ENFORCED deny also carries its `reason` on
205
+ * `NativeResponse.stderr`, which `emit` writes to fd 2: the delegate is a PATH
206
+ * helper whose stderr Amp surfaces, and this render is what that helper writes,
207
+ * so a block that reached the user with no rationale was the adapter throwing
208
+ * the reason away. Only the enforced path — a deny this call cannot veto and an
209
+ * ask have blocked nothing, and `stderr` is the contract's block-reason channel.
210
+ * No `soleGate` option — an allow already renders as exit 0 either way, so
211
+ * there's no distinct "real approve" signal for this transport to opt into.
153
212
  * @param {Verdict} verdict
154
213
  * @param {ToolCallEvent} event
155
214
  * @returns {NativeResponse}
@@ -178,8 +237,21 @@ export function render(verdict, event) {
178
237
  throw new Error(
179
238
  `amp adapter: exit-code table has no entry for decision ${JSON.stringify(vd.decision)} / this_call_vetoable ${JSON.stringify(vetoable)}`,
180
239
  );
181
- return nativeResponse({ transport: INTEGRATION_MODE, exit_code, enforced });
240
+ return nativeResponse({
241
+ transport: INTEGRATION_MODE,
242
+ exit_code,
243
+ enforced,
244
+ ...(enforced && vd.reason !== undefined ? { stderr: vd.reason } : {}),
245
+ });
182
246
  }
183
247
 
184
248
  /** @type {import("../control-plane.mjs").Adapter} */
185
- export const ampAdapter = { AGENT, INTEGRATION_MODE, COVERAGE, parse, render };
249
+ export const ampAdapter = {
250
+ AGENT,
251
+ INTEGRATION_MODE,
252
+ COVERAGE,
253
+ UNRENDERED_FIELDS,
254
+ NATIVE_EVENT_FOR,
255
+ parse,
256
+ render,
257
+ };
@@ -26,7 +26,9 @@ import {
26
26
  makeEvent,
27
27
  normalizeVerdict,
28
28
  nativeResponse,
29
- collectPassthrough,
29
+ UNRENDERED_ON_UNKNOWN,
30
+ readonlySet,
31
+ baseMeta,
30
32
  asObject,
31
33
  asString,
32
34
  asStringOrNull,
@@ -34,8 +36,12 @@ import {
34
36
 
35
37
  /** @typedef {import("../control-plane.mjs").ToolCallEvent} ToolCallEvent */
36
38
  /** @typedef {import("../control-plane.mjs").Verdict} Verdict */
37
- /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
38
39
  /** @typedef {import("../control-plane.mjs").NativeResponse} NativeResponse */
40
+ // Re-exported on this subpath even though no signature below names it:
41
+ // tsc turns each @typedef into an `export type` in the generated .d.mts,
42
+ // so dropping this line breaks `import type { EventMeta } from "…/<agent>"`
43
+ // for every TypeScript consumer.
44
+ /** @typedef {import("../control-plane.mjs").EventMeta} EventMeta */
39
45
 
40
46
  /** Producing-agent id stamped onto every event this adapter parses. */
41
47
  export const AGENT = "claude";
@@ -48,7 +54,13 @@ export const INTEGRATION_MODE = IntegrationMode.EXTERNAL_HOOK;
48
54
  * for builtins, for MCP tools (surfaced as `mcp__<server>__<tool>` through the
49
55
  * same hook), and for every tool a subagent uses (subagents do not inherit the
50
56
  * parent's permissions, so the hook is often their only gate). Resumed sessions
51
- * re-read hooks from settings and fire per new call — structural, uncontested.
57
+ * re-read hooks from settings and fire per new call — `✅ struct. [C4]`, where
58
+ * the `struct.` suffix marks the status as uncited: no source addresses resumed
59
+ * sessions directly, and COVERED is an inference from the reload mechanism. This
60
+ * row takes the cell at face value. Amp holds the equivalent row at UNKNOWN,
61
+ * which the matrix permits — a cell is a ceiling, and declaring less coverage
62
+ * than it is always the safe direction. Both are free today, since
63
+ * `classifyCallClass` never returns RESUMED and no call is judged by this entry.
52
64
  */
53
65
  /** @type {import("../control-plane.mjs").CoverageMap} */
54
66
  export const COVERAGE = Object.freeze({
@@ -72,6 +84,36 @@ const GATED_EVENTS = Object.freeze(
72
84
  );
73
85
  assertGatedKinds(GATED_EVENTS, AGENT);
74
86
 
87
+ // The kinds whose only content channel is context: the tool has already run
88
+ // (or there is no tool), so neither an input nor an output replacement has
89
+ // anywhere to go.
90
+ const CONTEXT_ONLY = readonlySet(["mutated_input", "mutated_output"]);
91
+
92
+ /**
93
+ * Which {@link VERDICT_CONTENT_FIELDS} have no native channel on each event
94
+ * kind, so `render` drops them. Claude Code splits its content channels by
95
+ * event: `updatedInput` exists only on PreToolUse (the call has not run yet),
96
+ * and `updatedToolOutput` only on PostToolUse (there is no tool output to
97
+ * replace anywhere else). `additionalContext` is the one channel every kind
98
+ * carries. The conformance harness holds this to what `render` actually emits,
99
+ * in both directions.
100
+ *
101
+ * `additionalContext` on PreToolUse is inherited behaviour, not a checked claim:
102
+ * Claude Code documents the field for UserPromptSubmit, SessionStart and
103
+ * PostToolUse, and this adapter has emitted it on PreToolUse since before the
104
+ * declaration existed. Rule ⑩ can see that the value reaches the wire, never
105
+ * that the host reads the key — confirm it against the hook reference before
106
+ * relying on it.
107
+ * @type {Record<string, ReadonlySet<string>|undefined>}
108
+ */
109
+ export const UNRENDERED_FIELDS = Object.freeze({
110
+ [EventKind.PRE_TOOL]: readonlySet(["mutated_output"]),
111
+ [EventKind.POST_TOOL]: readonlySet(["mutated_input"]),
112
+ [EventKind.PROMPT_SUBMIT]: CONTEXT_ONLY,
113
+ [EventKind.SESSION_START]: CONTEXT_ONLY,
114
+ [EventKind.UNKNOWN]: UNRENDERED_ON_UNKNOWN,
115
+ });
116
+
75
117
  /** Claude Code native hook event names (the `hook_event_name` field). */
76
118
  export const HookEvent = Object.freeze({
77
119
  PRE_TOOL_USE: "PreToolUse",
@@ -94,12 +136,17 @@ const KIND_TO_NATIVE = Object.freeze({
94
136
  [EventKind.SESSION_START]: HookEvent.SESSION_START,
95
137
  });
96
138
 
139
+ /**
140
+ * The native event a conformance probe should carry for each kind — this
141
+ * adapter's own answer, so an every-kind probe exercises the branch that kind
142
+ * really takes. `unknown` has no native name by definition and is absent.
143
+ * @type {Record<string, string|undefined>}
144
+ */
145
+ export const NATIVE_EVENT_FOR = KIND_TO_NATIVE;
146
+
147
+ // The STANDARD_META_FIELDS are consumed by `baseMeta`, not listed here.
97
148
  const CONSUMED = new Set([
98
149
  "hook_event_name",
99
- "session_id",
100
- "cwd",
101
- "permission_mode",
102
- "transcript_path",
103
150
  "tool_name",
104
151
  "tool_input",
105
152
  "tool_response",
@@ -129,29 +176,6 @@ function claudeTool(kind, raw) {
129
176
  return asStringOrNull(raw.tool_name);
130
177
  }
131
178
 
132
- /**
133
- * @param {string} nativeEvent
134
- * @param {Record<string, unknown>} raw
135
- * @returns {EventMeta}
136
- */
137
- function claudeMeta(nativeEvent, raw) {
138
- /** @type {EventMeta} */
139
- const meta = {
140
- agent: AGENT,
141
- native_event: nativeEvent,
142
- integration_mode: INTEGRATION_MODE,
143
- primary_gate_present: true,
144
- passthrough: collectPassthrough(raw, CONSUMED),
145
- };
146
- if (typeof raw.session_id === "string") meta.session_id = raw.session_id;
147
- if (typeof raw.cwd === "string") meta.cwd = raw.cwd;
148
- if (typeof raw.permission_mode === "string")
149
- meta.permission_mode = raw.permission_mode;
150
- if (typeof raw.transcript_path === "string")
151
- meta.transcript_path = raw.transcript_path;
152
- return meta;
153
- }
154
-
155
179
  /**
156
180
  * Parse a raw Claude Code hook payload into a normalized {@link ToolCallEvent}.
157
181
  * Never throws on an unmodelled event type or tool-input field.
@@ -168,7 +192,14 @@ export function parse(native) {
168
192
  ) ?? EventKind.UNKNOWN;
169
193
  const response = kind === EventKind.POST_TOOL ? raw.tool_response : undefined;
170
194
  const nativeTool = claudeTool(kind, raw);
171
- const meta = claudeMeta(nativeEvent, raw);
195
+ const meta = baseMeta({
196
+ agent: AGENT,
197
+ native_event: nativeEvent,
198
+ integration_mode: INTEGRATION_MODE,
199
+ primary_gate_present: true,
200
+ native: raw,
201
+ consumed: CONSUMED,
202
+ });
172
203
  if (nativeTool !== null) meta.native_tool = nativeTool;
173
204
  return makeEvent({
174
205
  event: kind,
@@ -215,7 +246,7 @@ export function render(verdict, event, { soleGate = false } = {}) {
215
246
  const stdout =
216
247
  kind === EventKind.PRE_TOOL
217
248
  ? gatingBody(hookEventName, vd, soleGate)
218
- : nonGatingBody(hookEventName, vd);
249
+ : nonGatingBody(hookEventName, kind, vd);
219
250
 
220
251
  return nativeResponse({
221
252
  transport: INTEGRATION_MODE,
@@ -253,18 +284,24 @@ function gatingBody(hookEventName, vd, soleGate) {
253
284
 
254
285
  /**
255
286
  * @param {string} hookEventName
287
+ * @param {string} kind the normalized {@link EventKind} being rendered
256
288
  * @param {Verdict} vd
257
289
  * @returns {Record<string, unknown>}
258
290
  */
259
- function nonGatingBody(hookEventName, vd) {
291
+ function nonGatingBody(hookEventName, kind, vd) {
260
292
  /** @type {Record<string, unknown>} */
261
293
  const hookSpecificOutput = { hookEventName };
262
294
  // A PostToolUse content transform: `updatedToolOutput` replaces what the model
263
295
  // sees (the tool already ran, so this governs only the model's view). The
264
- // native channel for the whole redaction/sanitize pipeline.
265
- if (vd.mutated_output !== undefined)
296
+ // native channel for the whole redaction/sanitize pipeline. PostToolUse ONLY:
297
+ // a UserPromptSubmit or SessionStart carries no tool output, so the field is
298
+ // dropped there (declared in {@link UNRENDERED_FIELDS}) rather than emitted
299
+ // into a key the host ignores, which reads to a caller as a redaction applied.
300
+ if (kind === EventKind.POST_TOOL && vd.mutated_output !== undefined)
266
301
  hookSpecificOutput.updatedToolOutput = vd.mutated_output;
267
- if (vd.additional_context !== undefined)
302
+ // Not on an UNKNOWN kind: `hookEventName` there is the unrecognized native
303
+ // event name, so the adapter has established no channel to write into.
304
+ if (kind !== EventKind.UNKNOWN && vd.additional_context !== undefined)
268
305
  hookSpecificOutput.additionalContext = vd.additional_context;
269
306
  /** @type {Record<string, unknown>} */
270
307
  const out = { hookSpecificOutput };
@@ -280,6 +317,8 @@ export const claudeAdapter = {
280
317
  AGENT,
281
318
  INTEGRATION_MODE,
282
319
  COVERAGE,
320
+ UNRENDERED_FIELDS,
321
+ NATIVE_EVENT_FOR,
283
322
  parse,
284
323
  render,
285
324
  };