@formbar/arbiter 0.8.0 → 0.9.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.
@@ -0,0 +1,303 @@
1
+ import { ArbiterError, ArbiterErrorCode, createSession } from "@arbitre/core";
2
+ import type { ProductionRule, RuleSession } from "@arbitre/core";
3
+ import { createForm } from "@formbar/core";
4
+ import { describe, expect, test } from "vitest";
5
+ import { createArbiterPlugin } from "../arbiter-plugin.js";
6
+ import { readFieldPolicyOutput } from "../field-policy-output.js";
7
+
8
+ const output = (path: string, policy: Record<string, unknown>) => ({ path, ...policy });
9
+
10
+ function policyRule(name: string, when: ProductionRule["when"], entries: Record<string, unknown>): ProductionRule {
11
+ return { name, when, then: [{ $set: entries }] };
12
+ }
13
+
14
+ function paths(form: ReturnType<typeof createForm>): readonly (readonly (string | number)[])[] {
15
+ return form.getState().fieldPolicy.map((item) => item.path.segments);
16
+ }
17
+
18
+ function tick(form: ReturnType<typeof createForm>, value: number) {
19
+ return form.setValue("tick", value);
20
+ }
21
+
22
+ describe("Arbiter field policy output decoding", () => {
23
+ test("retains every property, false, empty label, producer, and lexical output order", () => {
24
+ const rules = [
25
+ policyRule(
26
+ "policy",
27
+ { enabled: true },
28
+ {
29
+ "$formbar.fieldPolicy.z-last": output("/z", { required: true }),
30
+ "$formbar.fieldPolicy.a-first": output("/profile/name", {
31
+ visible: false,
32
+ disabled: false,
33
+ readOnly: false,
34
+ required: false,
35
+ label: "",
36
+ }),
37
+ },
38
+ ),
39
+ ];
40
+ const form = createForm({ initialData: { enabled: false, tick: 0 }, plugins: [createArbiterPlugin({ rules })] });
41
+
42
+ form.setValue("enabled", true);
43
+ expect(form.getState().fieldPolicy).toEqual([
44
+ {
45
+ path: { namespace: "data", segments: ["profile", "name"] },
46
+ producerId: "arbiter",
47
+ visible: false,
48
+ disabled: false,
49
+ readOnly: false,
50
+ required: false,
51
+ label: "",
52
+ },
53
+ { path: { namespace: "data", segments: ["z"] }, producerId: "arbiter", required: true },
54
+ ]);
55
+ const previous = form.getState().fieldPolicy;
56
+ tick(form, 1);
57
+ expect(form.getState().fieldPolicy).toBe(previous);
58
+ form.dispose();
59
+ });
60
+
61
+ test("normalizes nested, literal dotted, concrete array, leading-zero, and literal $ui data targets", () => {
62
+ const rules = [
63
+ policyRule(
64
+ "paths",
65
+ { "profile.country": "US" },
66
+ {
67
+ "$formbar.fieldPolicy.array": output("/items/0/code", { disabled: true }),
68
+ "$formbar.fieldPolicy.dot": output("/profile.name", { readOnly: true }),
69
+ "$formbar.fieldPolicy.leading": output("/items/01/code", { required: true }),
70
+ "$formbar.fieldPolicy.nested": output("/profile/contact/email", { visible: false }),
71
+ "$formbar.fieldPolicy.ui-data": output("/$ui/name", { label: "Data $ui" }),
72
+ },
73
+ ),
74
+ ];
75
+ const form = createForm({
76
+ initialData: { profile: { country: "", contact: { email: "" } }, items: [{ enabled: false }], tick: 0 },
77
+ plugins: [createArbiterPlugin({ rules })],
78
+ });
79
+ form.setValue("profile.country", "US");
80
+
81
+ expect(paths(form)).toEqual([
82
+ ["items", 0, "code"],
83
+ ["profile.name"],
84
+ ["items", "01", "code"],
85
+ ["profile", "contact", "email"],
86
+ ["$ui", "name"],
87
+ ]);
88
+ form.setValue("profile.country", "CA");
89
+ expect(form.getState().fieldPolicy).toEqual([]);
90
+ form.dispose();
91
+ });
92
+
93
+ test.each([
94
+ ["wildcard", { path: "/items/*/name", visible: true }],
95
+ ["scope", { path: "$row.name", visible: true }],
96
+ ["ui namespace", { path: "$ui.name", visible: true }],
97
+ ["bad escape", { path: "/bad~2path", visible: true }],
98
+ ["unsafe segment", { path: "/__proto__/name", visible: true }],
99
+ ])("rejects unsupported %s target with Arbiter path diagnostics", (_name, record) => {
100
+ expect(() => readFieldPolicyOutput({ getPath: () => ({ target: record }) })).toThrowError(
101
+ expect.objectContaining<Partial<ArbiterError>>({ code: ArbiterErrorCode.INVALID_PATH }),
102
+ );
103
+ });
104
+
105
+ test("includes output ID and path in target diagnostics", () => {
106
+ try {
107
+ readFieldPolicyOutput({ getPath: () => ({ repeated: output("/items/*/name", { visible: true }) }) });
108
+ throw new Error("expected path rejection");
109
+ } catch (error) {
110
+ expect(error).toBeInstanceOf(ArbiterError);
111
+ expect((error as ArbiterError).details).toEqual({
112
+ root: "$formbar.fieldPolicy",
113
+ outputId: "repeated",
114
+ path: "/items/*/name",
115
+ });
116
+ }
117
+ });
118
+
119
+ test.each([
120
+ ["root", []],
121
+ ["ID", { "bad.id": output("/name", { visible: true }) }],
122
+ ["record", { bad: null }],
123
+ ["unknown key", { bad: output("/name", { hidden: true }) }],
124
+ ["missing policy", { bad: { path: "/name" } }],
125
+ ["boolean type", { bad: output("/name", { visible: 1 }) }],
126
+ ["label type", { bad: output("/name", { label: false }) }],
127
+ ])("rejects malformed %s with Arbiter compilation diagnostics", (_name, root) => {
128
+ expect(() => readFieldPolicyOutput({ getPath: () => root })).toThrowError(
129
+ expect.objectContaining<Partial<ArbiterError>>({ code: ArbiterErrorCode.RULE_COMPILATION_FAILED }),
130
+ );
131
+ });
132
+
133
+ test("rejects duplicate normalized targets atomically", () => {
134
+ const session = createSession();
135
+ const form = createForm({ initialData: { tick: 0 }, plugins: [createArbiterPlugin({ session })] });
136
+ session.assert("$formbar.fieldPolicy.good", output("/items/0/code", { visible: false }));
137
+ expect(tick(form, 1).ok).toBe(true);
138
+ session.assert("$formbar.fieldPolicy.duplicate", output("/items/00/code", { required: true }));
139
+ expect(tick(form, 2)).toMatchObject({ ok: true });
140
+ const previous = form.getState().fieldPolicy;
141
+ session.assert("$formbar.fieldPolicy.duplicate", output("/items/0/code", { required: true }));
142
+ const failed = tick(form, 3);
143
+ expect(failed.ok).toBe(false);
144
+ expect(failed.error).toContain("target the same path");
145
+ expect(form.getState().fieldPolicy).toBe(previous);
146
+ expect((form.getState().data as { tick: number }).tick).toBe(2);
147
+ form.dispose();
148
+ });
149
+
150
+ test("rejects a malformed replacement without changing the committed snapshot", () => {
151
+ const session = createSession();
152
+ const form = createForm({ initialData: { tick: 0 }, plugins: [createArbiterPlugin({ session })] });
153
+ session.assert("$formbar.fieldPolicy.target", output("/name", { visible: false }));
154
+ tick(form, 1);
155
+ const previous = form.getState().fieldPolicy;
156
+ session.assert("$formbar.fieldPolicy.target", output("/name", { visible: "no" }));
157
+ expect(tick(form, 2)).toMatchObject({ ok: false });
158
+ expect(form.getState().fieldPolicy).toBe(previous);
159
+ form.dispose();
160
+ });
161
+ });
162
+
163
+ describe("Arbiter policy snapshot synchronization", () => {
164
+ test("replaces moved output and clears deactivated and explicitly unset output", () => {
165
+ const rules: readonly ProductionRule[] = [
166
+ {
167
+ name: "move",
168
+ when: { active: true },
169
+ then: [{ $set: { "$formbar.fieldPolicy.target": output("/old", { visible: false }) } }],
170
+ },
171
+ {
172
+ name: "clear",
173
+ when: { clear: true },
174
+ then: [{ $unset: { "$formbar.fieldPolicy.target": true } }],
175
+ },
176
+ ];
177
+ const session = createSession({ rules });
178
+ const form = createForm({
179
+ initialData: { active: false, clear: false, tick: 0 },
180
+ plugins: [createArbiterPlugin({ session })],
181
+ });
182
+ form.setValue("active", true);
183
+ expect(paths(form)).toEqual([["old"]]);
184
+ session.assert("$formbar.fieldPolicy.target", output("/new", { visible: false }));
185
+ tick(form, 1);
186
+ expect(paths(form)).toEqual([["new"]]);
187
+ form.setValue("clear", true);
188
+ expect(form.getState().fieldPolicy).toEqual([]);
189
+ form.dispose();
190
+ });
191
+
192
+ test("observes borrowed rule removal and output cessation on the next relevant tick", () => {
193
+ const session = createSession({
194
+ rules: [
195
+ policyRule(
196
+ "borrowed",
197
+ { active: true },
198
+ { "$formbar.fieldPolicy.borrowed": output("/name", { required: true }) },
199
+ ),
200
+ ],
201
+ });
202
+ const form = createForm({ initialData: { active: false, tick: 0 }, plugins: [createArbiterPlugin({ session })] });
203
+ form.setValue("active", true);
204
+ expect(form.getState().fieldPolicy).toHaveLength(1);
205
+ session.removeRule("borrowed");
206
+ tick(form, 1);
207
+ expect(form.getState().fieldPolicy).toEqual([]);
208
+ session.assert("$formbar.fieldPolicy.host", output("/host", { disabled: true }));
209
+ tick(form, 2);
210
+ expect(paths(form)).toEqual([["host"]]);
211
+ session.retract("$formbar.fieldPolicy.host");
212
+ tick(form, 3);
213
+ expect(form.getState().fieldPolicy).toEqual([]);
214
+ form.dispose();
215
+ expect(() => session.assert("hostOwned", true)).not.toThrow();
216
+ session.dispose();
217
+ });
218
+
219
+ test("retracts synchronized roots that disappear and resynchronizes after reset", () => {
220
+ const session = createSession({
221
+ rules: [
222
+ policyRule(
223
+ "dynamic",
224
+ { dynamic: true },
225
+ { "$formbar.fieldPolicy.dynamic": output("/dynamic", { visible: false }) },
226
+ ),
227
+ ],
228
+ });
229
+ const form = createForm({
230
+ initialData: { tick: 0 },
231
+ initialUiState: {},
232
+ plugins: [createArbiterPlugin({ session })],
233
+ });
234
+ form.setValue("dynamic", true);
235
+ form.setValue("$ui.transient", true);
236
+ expect(form.getState().fieldPolicy).toHaveLength(1);
237
+ expect(session.getPath("$ui.transient")).toBe(true);
238
+ form.reset();
239
+ expect(form.getState().fieldPolicy).toEqual([]);
240
+ tick(form, 1);
241
+ expect(session.getPath("dynamic")).toBeUndefined();
242
+ expect(session.getPath("$ui.transient")).toBeUndefined();
243
+ expect(form.getState().fieldPolicy).toEqual([]);
244
+ form.dispose();
245
+ });
246
+
247
+ test("keeps policy on skipped evaluation and disposes only owned sessions", () => {
248
+ const ownedPlugin = createArbiterPlugin({
249
+ rules: [
250
+ policyRule("owned", { active: true }, { "$formbar.fieldPolicy.owned": output("/name", { visible: false }) }),
251
+ ],
252
+ });
253
+ const form = createForm({ initialData: { active: false }, plugins: [ownedPlugin] });
254
+ form.setValue("active", true);
255
+ const previous = form.getState().fieldPolicy;
256
+ const context = {
257
+ action: { type: "init" },
258
+ data: form.getState().data,
259
+ uiState: form.getState().uiState,
260
+ prevData: form.getState().data,
261
+ prevUiState: form.getState().uiState,
262
+ change: { type: "init", path: undefined, dataChanged: false, uiChanged: false },
263
+ issues: [],
264
+ origin: "init",
265
+ getValueAtPath: () => undefined,
266
+ } as const;
267
+ const skipped = ownedPlugin.evaluate?.(context);
268
+ expect(skipped).toBeUndefined();
269
+ expect(form.getState().fieldPolicy).toBe(previous);
270
+ form.dispose();
271
+ expect(() =>
272
+ ownedPlugin.evaluate?.({
273
+ ...context,
274
+ change: { ...context.change, dataChanged: true },
275
+ }),
276
+ ).toThrowError(expect.objectContaining<Partial<ArbiterError>>({ code: ArbiterErrorCode.SESSION_DISPOSED }));
277
+ });
278
+
279
+ test("models demo-21-style section fields with concrete policy targets", () => {
280
+ const rules = [
281
+ policyRule(
282
+ "employment",
283
+ { "profile.employed": true },
284
+ {
285
+ "$formbar.fieldPolicy.company": output("/profile/employment/company", { visible: true, required: true }),
286
+ "$formbar.fieldPolicy.role": output("/profile/employment/role", { visible: true }),
287
+ },
288
+ ),
289
+ ];
290
+ const form = createForm({
291
+ initialData: { profile: { employed: false, employment: { company: "", role: "" } } },
292
+ plugins: [createArbiterPlugin({ rules })],
293
+ });
294
+ form.setValue("profile.employed", true);
295
+ expect(paths(form)).toEqual([
296
+ ["profile", "employment", "company"],
297
+ ["profile", "employment", "role"],
298
+ ]);
299
+ form.setValue("profile.employed", false);
300
+ expect(form.getState().fieldPolicy).toEqual([]);
301
+ form.dispose();
302
+ });
303
+ });
@@ -1,8 +1,11 @@
1
- import { createSession } from "@arbitre/core";
1
+ import { ArbiterError, ArbiterErrorCode, createSession } from "@arbitre/core";
2
2
  import type { FiringResult, ProductionRule, RuleSession } from "@arbitre/core";
3
3
  import type { FormPlugin, PluginEvaluateContext, PluginEvaluateResult, PluginWrite } from "@formbar/core";
4
+ import { readFieldPolicyOutput } from "./field-policy-output.js";
4
5
  import { isArbiterInternalPath } from "./internal-paths.js";
5
6
 
7
+ const RESERVED_DATA_ROOT = "$formbar";
8
+
6
9
  export interface ArbiterPluginOptions {
7
10
  /** Provide raw rules — a session will be created internally. */
8
11
  readonly rules?: readonly ProductionRule[];
@@ -16,11 +19,48 @@ function resolveSession(options: ArbiterPluginOptions): { session: RuleSession;
16
19
  throw new Error("createArbiterPlugin requires either `rules` or `session`");
17
20
  }
18
21
 
19
- function syncSession(session: RuleSession, ctx: PluginEvaluateContext): void {
20
- const data = ctx.data as Record<string, unknown>;
21
- for (const key of Object.keys(data)) session.assert(key, data[key]);
22
- const uiState = ctx.uiState as Record<string, unknown>;
23
- for (const key of Object.keys(uiState)) session.assert(`$ui.${key}`, uiState[key]);
22
+ interface SynchronizedRoots {
23
+ data: Set<string>;
24
+ ui: Set<string>;
25
+ }
26
+
27
+ function syncRoots(
28
+ session: RuleSession,
29
+ values: Record<string, unknown>,
30
+ previous: Set<string>,
31
+ prefix: string,
32
+ include: (key: string) => boolean = () => true,
33
+ ): Set<string> {
34
+ const current = new Set(Object.keys(values).filter(include));
35
+ for (const key of previous) {
36
+ if (!current.has(key)) session.retract(`${prefix}${key}`);
37
+ }
38
+ for (const key of current) session.assert(`${prefix}${key}`, values[key]);
39
+ return current;
40
+ }
41
+
42
+ function isFormDataRoot(key: string): boolean {
43
+ return key !== RESERVED_DATA_ROOT && !key.startsWith(`${RESERVED_DATA_ROOT}.`);
44
+ }
45
+
46
+ function syncSession(session: RuleSession, ctx: PluginEvaluateContext, roots: SynchronizedRoots): void {
47
+ roots.data = syncRoots(session, ctx.data as Record<string, unknown>, roots.data, "", isFormDataRoot);
48
+ roots.ui = syncRoots(session, ctx.uiState as Record<string, unknown>, roots.ui, "$ui.");
49
+ }
50
+
51
+ function fireSession(session: RuleSession): FiringResult {
52
+ try {
53
+ return session.fire();
54
+ } catch (error) {
55
+ if (error instanceof ArbiterError) throw error;
56
+ throw new ArbiterError(
57
+ ArbiterErrorCode.RULE_COMPILATION_FAILED,
58
+ "Arbiter session state could not be evaluated safely",
59
+ error instanceof Error
60
+ ? { details: { root: "$formbar.fieldPolicy" }, cause: error }
61
+ : { details: { root: "$formbar.fieldPolicy" } },
62
+ );
63
+ }
24
64
  }
25
65
 
26
66
  function toWrites(result: FiringResult): readonly PluginWrite[] {
@@ -29,12 +69,16 @@ function toWrites(result: FiringResult): readonly PluginWrite[] {
29
69
  .map((change) => ({ path: change.path, value: change.newValue, mode: "set" as const }));
30
70
  }
31
71
 
32
- function evaluateSession(session: RuleSession, ctx: PluginEvaluateContext): PluginEvaluateResult | undefined {
72
+ function evaluateSession(
73
+ session: RuleSession,
74
+ ctx: PluginEvaluateContext,
75
+ roots: SynchronizedRoots,
76
+ ): PluginEvaluateResult | undefined {
33
77
  if (ctx.origin.startsWith("plugin:arbiter")) return;
34
78
  if (!ctx.change.dataChanged && !ctx.change.uiChanged) return;
35
- syncSession(session, ctx);
36
- const writes = toWrites(session.fire());
37
- return { writes: writes.length > 0 ? writes : undefined };
79
+ syncSession(session, ctx, roots);
80
+ const writes = toWrites(fireSession(session));
81
+ return { writes: writes.length > 0 ? writes : undefined, fieldPolicy: readFieldPolicyOutput(session) };
38
82
  }
39
83
 
40
84
  /**
@@ -44,9 +88,10 @@ function evaluateSession(session: RuleSession, ctx: PluginEvaluateContext): Plug
44
88
  */
45
89
  export function createArbiterPlugin(options: ArbiterPluginOptions): FormPlugin {
46
90
  const { session, owned } = resolveSession(options);
91
+ const roots: SynchronizedRoots = { data: new Set(), ui: new Set() };
47
92
  return {
48
93
  id: "arbiter",
49
- evaluate: (ctx) => evaluateSession(session, ctx),
94
+ evaluate: (ctx) => evaluateSession(session, ctx, roots),
50
95
  onDispose() {
51
96
  if (owned) session.dispose();
52
97
  },
@@ -0,0 +1,139 @@
1
+ import { ArbiterError, ArbiterErrorCode } from "@arbitre/core";
2
+ import { type FieldPolicyInput, parsePath } from "@formbar/core";
3
+
4
+ const OUTPUT_ROOT = "$formbar.fieldPolicy";
5
+ const OUTPUT_ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
6
+ const POLICY_KEYS = new Set<PropertyKey>(["path", "visible", "disabled", "readOnly", "required", "label"]);
7
+ const BOOLEAN_KEYS = ["visible", "disabled", "readOnly", "required"] as const;
8
+ const UNSAFE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
9
+ const INTEGER_SEGMENT = /^(?:0|[1-9]\d*)$/;
10
+
11
+ interface CapturedRecord {
12
+ readonly keys: readonly string[];
13
+ readonly values: ReadonlyMap<string, unknown>;
14
+ }
15
+
16
+ function outputError(message: string, outputId?: string): never {
17
+ throw new ArbiterError(ArbiterErrorCode.RULE_COMPILATION_FAILED, message, {
18
+ details: outputId === undefined ? { root: OUTPUT_ROOT } : { root: OUTPUT_ROOT, outputId },
19
+ });
20
+ }
21
+
22
+ function pathError(message: string, outputId: string, path: unknown): never {
23
+ throw new ArbiterError(ArbiterErrorCode.INVALID_PATH, message, {
24
+ details: { root: OUTPUT_ROOT, outputId, path },
25
+ });
26
+ }
27
+
28
+ function captureRecord(value: unknown, outputId?: string): CapturedRecord {
29
+ if (value === null || typeof value !== "object") {
30
+ return outputError("Arbiter field policy output must be a plain data record", outputId);
31
+ }
32
+ let array: boolean;
33
+ let prototype: object | null;
34
+ let keys: readonly PropertyKey[];
35
+ const descriptors = new Map<PropertyKey, PropertyDescriptor | undefined>();
36
+ try {
37
+ array = Array.isArray(value);
38
+ prototype = Reflect.getPrototypeOf(value);
39
+ keys = Reflect.ownKeys(value);
40
+ for (const key of keys) descriptors.set(key, Reflect.getOwnPropertyDescriptor(value, key));
41
+ } catch {
42
+ return outputError("Arbiter field policy output must be inspectable", outputId);
43
+ }
44
+ if (array) return outputError("Arbiter field policy output must be a plain data record", outputId);
45
+ if (prototype !== Object.prototype && prototype !== null) {
46
+ return outputError("Arbiter field policy output must use a plain prototype", outputId);
47
+ }
48
+ const values = new Map<string, unknown>();
49
+ for (const key of keys) {
50
+ const descriptor = descriptors.get(key);
51
+ if (typeof key !== "string" || !descriptor?.enumerable || !("value" in descriptor)) {
52
+ return outputError("Arbiter field policy output requires enumerable data properties", outputId);
53
+ }
54
+ values.set(key, descriptor.value);
55
+ }
56
+ return { keys: [...values.keys()], values };
57
+ }
58
+
59
+ function normalizedPathKey(path: unknown, outputId: string): string {
60
+ if (typeof path !== "string" || !path.startsWith("/") || path === "/") {
61
+ return pathError(`Field policy output "${outputId}" path must be a non-empty RFC-6901 pointer`, outputId, path);
62
+ }
63
+ let segments: readonly (string | number)[];
64
+ try {
65
+ segments = parsePath(path).segments;
66
+ } catch {
67
+ return pathError(`Field policy output "${outputId}" has an invalid RFC-6901 pointer`, outputId, path);
68
+ }
69
+ if (segments.some((segment) => segment === "" || segment === "*" || UNSAFE_SEGMENTS.has(String(segment)))) {
70
+ return pathError(`Field policy output "${outputId}" has an unsupported target path`, outputId, path);
71
+ }
72
+ const normalized = segments.map((segment) => (INTEGER_SEGMENT.test(String(segment)) ? Number(segment) : segment));
73
+ if (normalized.some((segment) => typeof segment === "number" && !Number.isSafeInteger(segment))) {
74
+ return pathError(`Field policy output "${outputId}" has an unsafe array index`, outputId, path);
75
+ }
76
+ return JSON.stringify(normalized);
77
+ }
78
+
79
+ function decodeRecord(
80
+ value: unknown,
81
+ outputId: string,
82
+ ): { readonly input: FieldPolicyInput; readonly pathKey: string } {
83
+ const { keys, values } = captureRecord(value, outputId);
84
+ for (const key of keys) {
85
+ if (!POLICY_KEYS.has(key)) outputError(`Field policy output "${outputId}" has unknown property "${key}"`, outputId);
86
+ }
87
+ if (!keys.includes("path")) outputError(`Field policy output "${outputId}" requires "path"`, outputId);
88
+ if (!keys.some((key) => key !== "path")) {
89
+ outputError(`Field policy output "${outputId}" requires at least one policy property`, outputId);
90
+ }
91
+ for (const key of BOOLEAN_KEYS) {
92
+ if (values.has(key) && typeof values.get(key) !== "boolean") {
93
+ outputError(`Field policy output "${outputId}" property "${key}" must be boolean`, outputId);
94
+ }
95
+ }
96
+ if (values.has("label") && typeof values.get("label") !== "string") {
97
+ outputError(`Field policy output "${outputId}" property "label" must be a string`, outputId);
98
+ }
99
+ const path = values.get("path");
100
+ const pathKey = normalizedPathKey(path, outputId);
101
+ const input: FieldPolicyInput = Object.freeze({
102
+ path: path as string,
103
+ ...(values.has("visible") ? { visible: values.get("visible") as boolean } : {}),
104
+ ...(values.has("disabled") ? { disabled: values.get("disabled") as boolean } : {}),
105
+ ...(values.has("readOnly") ? { readOnly: values.get("readOnly") as boolean } : {}),
106
+ ...(values.has("required") ? { required: values.get("required") as boolean } : {}),
107
+ ...(values.has("label") ? { label: values.get("label") as string } : {}),
108
+ });
109
+ return { input, pathKey };
110
+ }
111
+
112
+ export function readFieldPolicyOutput(session: { getPath(path: string): unknown }): readonly FieldPolicyInput[] {
113
+ let root: unknown;
114
+ try {
115
+ root = session.getPath(OUTPUT_ROOT);
116
+ } catch (error) {
117
+ if (error instanceof ArbiterError) throw error;
118
+ return outputError("Arbiter field policy output root could not be read");
119
+ }
120
+ if (root === undefined) return Object.freeze([]);
121
+ const captured = captureRecord(root);
122
+ const outputIds = [...captured.keys].sort();
123
+ const seen = new Map<string, string>();
124
+ const inputs = outputIds.map((outputId) => {
125
+ if (!OUTPUT_ID.test(outputId)) outputError(`Invalid field policy output ID "${outputId}"`, outputId);
126
+ const decoded = decodeRecord(captured.values.get(outputId), outputId);
127
+ const duplicate = seen.get(decoded.pathKey);
128
+ if (duplicate) {
129
+ pathError(
130
+ `Field policy outputs "${duplicate}" and "${outputId}" target the same path`,
131
+ outputId,
132
+ decoded.input.path,
133
+ );
134
+ }
135
+ seen.set(decoded.pathKey, outputId);
136
+ return decoded.input;
137
+ });
138
+ return Object.freeze(inputs);
139
+ }