Skip to main content

pdf_xfa/js_runtime/
rquickjs_backend.rs

1//! M3-B Phase B — rquickjs backend for [`super::XfaJsRuntime`].
2//!
3//! Compiled in only when the `xfa-js-sandboxed` Cargo feature is enabled.
4//!
5//! This backend is intentionally minimal:
6//!
7//! - No host bindings registered. Phase C adds the first useful ones per
8//!   `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md`.
9//! - `Date.now`, `Math.random`, `fetch`, `require`, `process` and friends
10//!   are absent because they are never registered (rquickjs default
11//!   contexts expose only spec-mandated ECMAScript built-ins).
12//! - `JS_SetMemoryLimit` enforces the per-document memory budget; any
13//!   allocation that pushes the runtime over the limit fails with
14//!   [`super::SandboxError::OutOfMemory`].
15//! - Per-script time budget is enforced with the rquickjs interrupt handler
16//!   that polls a wall-clock deadline; `eval_with_options` bails out with
17//!   [`super::SandboxError::Timeout`] when the deadline elapses.
18//! - All FFI is wrapped in `std::panic::catch_unwind` so a QuickJS panic
19//!   never crosses into the parent flatten path
20//!   (`benchmarks/runs/M3B_RUNTIME_SECURITY_MODEL.md` §1 S-17).
21
22// Copyright (c) 2026 Innovation Trigger B.V.
23//
24// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
25// the PDFluent Commercial Licence. See the LICENSE file in this repository --
26// that file travels with the copy you received, which a URL does not.
27
28use std::cell::RefCell;
29use std::panic::{catch_unwind, AssertUnwindSafe};
30use std::rc::Rc;
31use std::sync::{
32    atomic::{AtomicBool, AtomicU64, Ordering},
33    Arc, OnceLock,
34};
35use std::time::{Duration, Instant};
36
37use rquickjs::function::Opt;
38use rquickjs::{CatchResultExt, Coerced, Context, Function, Object, Persistent, Runtime};
39use xfa_layout_engine::form::{FormNodeId, FormTree};
40
41use super::regex_guard::{scan_script_for_redos, RegexScanVerdict};
42use super::{
43    activity_allowed_for_sandbox_with_gate, walltime_fallback_multiplier, HostBindings,
44    RuntimeMetadata, RuntimeOutcome, SandboxError, XfaJsRuntime, DEFAULT_MEMORY_BUDGET_BYTES,
45    DEFAULT_TIME_BUDGET_MS, MAX_SCRIPT_BODY_BYTES, MAX_VARIABLES_SCRIPT_BODY_BYTES,
46};
47
48/// QuickJS-backed runtime adapter. One instance is reusable across many
49/// documents; callers MUST invoke [`XfaJsRuntime::reset_for_new_document`]
50/// at the start of each flatten.
51pub struct QuickJsRuntime {
52    eval_script: Option<Persistent<Function<'static>>>,
53    /// Phase D-ι: hook that registers a `<variables>` `<script>` body as a
54    /// form-level global. Called once per named script at document load.
55    set_variables_script: Option<Persistent<Function<'static>>>,
56    /// Phase D-ι: clears all registered variables-script globals at
57    /// document boundary (`reset_per_document`).
58    clear_variables_scripts: Option<Persistent<Function<'static>>>,
59    /// W3-D RETRY: hook that registers a `<variables>` `<text>` data item
60    /// (XFA 3.3 §5.5.2) as a form-level mutable string container. Called
61    /// once per named data item at document load. Sibling to
62    /// [`Self::set_variables_script`] — the `clear_variables_scripts` hook
63    /// also drops the data-item namespace.
64    set_variables_data_item: Option<Persistent<Function<'static>>>,
65    context: Context,
66    runtime: Runtime,
67    metadata: RuntimeMetadata,
68    time_budget: Duration,
69    memory_budget_bytes: usize,
70    script_deadline: Arc<AtomicU64>,
71    script_started: Arc<AtomicBool>,
72    /// QF1-E / SEC-01 — start-time of the currently executing script in
73    /// nanoseconds since [`epoch`]. Set by [`Self::set_deadline`] before
74    /// QuickJS is invoked and read by the post-execution classification
75    /// branch to compute elapsed wall-clock and decide whether to surface
76    /// [`SandboxError::WallTimeExceeded`] instead of
77    /// [`SandboxError::Timeout`]. Cleared back to 0 by
78    /// [`Self::clear_deadline`].
79    script_start_nanos: Arc<AtomicU64>,
80    /// QF1-E / SEC-01 — multiplier on `time_budget` used by the wall-time
81    /// fallback. Snapshotted at construction time from
82    /// [`super::walltime_fallback_multiplier`] so tests can toggle the env
83    /// var per-instance without racing other tests.
84    walltime_fallback_multiplier: u32,
85    host: Rc<RefCell<HostBindings>>,
86    bindings_registered: bool,
87}
88
89impl std::fmt::Debug for QuickJsRuntime {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("QuickJsRuntime")
92            .field("metadata", &self.metadata)
93            .field("time_budget_ms", &self.time_budget.as_millis())
94            .field("memory_budget_bytes", &self.memory_budget_bytes)
95            .finish()
96    }
97}
98
99fn parse_node_id_csv(raw: &str) -> Vec<FormNodeId> {
100    raw.split(',')
101        .filter_map(|part| part.trim().parse::<usize>().ok())
102        .map(FormNodeId)
103        .collect()
104}
105
106impl QuickJsRuntime {
107    /// Construct a new sandboxed runtime with default budgets.
108    pub fn new() -> Result<Self, SandboxError> {
109        let runtime =
110            Runtime::new().map_err(|e| SandboxError::ScriptError(format!("rquickjs init: {e}")))?;
111        runtime.set_memory_limit(DEFAULT_MEMORY_BUDGET_BYTES);
112        let context = Context::full(&runtime)
113            .map_err(|e| SandboxError::ScriptError(format!("rquickjs context: {e}")))?;
114
115        let script_deadline = Arc::new(AtomicU64::new(0));
116        let script_started = Arc::new(AtomicBool::new(false));
117        // QF1-E: independent of the deadline atomic — start-time is needed
118        // for the post-execution elapsed comparison even when the deadline
119        // has been cleared.
120        let script_start_nanos = Arc::new(AtomicU64::new(0));
121
122        // Interrupt handler: poll the deadline. When the started flag is set
123        // and the wall-clock has crossed the deadline, return `true` to abort
124        // script execution. QuickJS turns this into a JS-level interrupt that
125        // surfaces as `eval` returning `Err`.
126        let deadline_for_handler = Arc::clone(&script_deadline);
127        let started_for_handler = Arc::clone(&script_started);
128        runtime.set_interrupt_handler(Some(Box::new(move || {
129            if !started_for_handler.load(Ordering::Acquire) {
130                return false;
131            }
132            let deadline_nanos = deadline_for_handler.load(Ordering::Acquire);
133            if deadline_nanos == 0 {
134                return false;
135            }
136            let now_nanos = Instant::now()
137                .checked_duration_since(epoch())
138                .map(|d| d.as_nanos() as u64)
139                .unwrap_or(0);
140            now_nanos >= deadline_nanos
141        })));
142
143        Ok(Self {
144            eval_script: None,
145            set_variables_script: None,
146            clear_variables_scripts: None,
147            set_variables_data_item: None,
148            context,
149            runtime,
150            metadata: RuntimeMetadata::default(),
151            time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
152            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
153            script_deadline,
154            script_started,
155            script_start_nanos,
156            walltime_fallback_multiplier: walltime_fallback_multiplier(),
157            host: Rc::new(RefCell::new(HostBindings::new())),
158            bindings_registered: false,
159        })
160    }
161
162    /// Override the per-script wall-clock budget. Must be called before any
163    /// `execute_script` invocation; takes effect on the next call.
164    pub fn with_time_budget(mut self, budget: Duration) -> Self {
165        self.time_budget = budget;
166        self
167    }
168
169    /// Override the per-document memory budget. Must be called before any
170    /// `execute_script` invocation; takes effect on the next document.
171    pub fn with_memory_budget(mut self, bytes: usize) -> Self {
172        self.memory_budget_bytes = bytes;
173        self.runtime.set_memory_limit(bytes);
174        self
175    }
176
177    /// QF1-E / SEC-01 — override the wall-time fallback multiplier. The
178    /// default is read from
179    /// [`super::ENV_WALLTIME_FALLBACK_MULTIPLIER`] / falls back to
180    /// [`super::WALLTIME_FALLBACK_MULTIPLIER_DEFAULT`]. Values below `2`
181    /// are clamped to `2` so a clean timeout cannot be re-labelled as
182    /// `WallTimeExceeded` by accident. Tests use this to deterministically
183    /// pin the multiplier without racing the process-global env var.
184    pub fn with_walltime_fallback_multiplier(mut self, multiplier: u32) -> Self {
185        self.walltime_fallback_multiplier = multiplier.max(2);
186        self
187    }
188
189    fn set_deadline(&self) {
190        let now = Instant::now()
191            .checked_duration_since(epoch())
192            .unwrap_or(Duration::ZERO);
193        let deadline = now + self.time_budget;
194        self.script_deadline
195            .store(deadline.as_nanos() as u64, Ordering::Release);
196        // QF1-E: capture start-time before flagging the script as started so
197        // the post-execution branch can compute elapsed wall-clock even if
198        // `clear_deadline` runs first.
199        self.script_start_nanos
200            .store(now.as_nanos() as u64, Ordering::Release);
201        self.script_started.store(true, Ordering::Release);
202    }
203
204    fn clear_deadline(&self) {
205        self.script_started.store(false, Ordering::Release);
206        self.script_deadline.store(0, Ordering::Release);
207        // Intentionally keep `script_start_nanos` populated until the next
208        // `set_deadline` so the caller's elapsed-snapshot is stable.
209    }
210
211    /// QF1-E / SEC-01 — classify a timeout-shaped error as either the
212    /// primary [`SandboxError::Timeout`] (interrupt fired within multiplier
213    /// × budget) or the defence-in-depth [`SandboxError::WallTimeExceeded`]
214    /// (elapsed ≥ multiplier × budget at error-classification time).
215    ///
216    /// This is intentionally a no-op in the common case: the rquickjs
217    /// interrupt callback fires within a few opcodes of the deadline for
218    /// any reasonable JS source, so the elapsed time at this point is
219    /// ≈ `time_budget`, well below `multiplier × time_budget`. The
220    /// fallback variant only surfaces when execution was trapped inside a
221    /// non-interruptible C-level call long enough that the elapsed
222    /// wall-clock crossed the multiplier threshold.
223    ///
224    /// `now_nanos` is the wall-clock at the moment the post-execution
225    /// branch captured the deadline; passed in so the caller can reuse a
226    /// single `Instant::now()` snapshot for both checks.
227    fn classify_timeout_or_walltime(&self, now_nanos: u64) -> SandboxError {
228        let start = self.script_start_nanos.load(Ordering::Acquire);
229        if start == 0 || now_nanos <= start {
230            // Defensive: if the start was never set or the snapshot went
231            // backwards (impossible barring monotonic clock issues), keep
232            // the primary classification.
233            return SandboxError::Timeout;
234        }
235        let elapsed_nanos = now_nanos - start;
236        let threshold_nanos = (self.time_budget.as_nanos() as u64)
237            .saturating_mul(self.walltime_fallback_multiplier as u64);
238        if elapsed_nanos >= threshold_nanos {
239            let elapsed_ms = elapsed_nanos / 1_000_000;
240            let budget_ms = self.time_budget.as_millis();
241            SandboxError::WallTimeExceeded(format!(
242                "{elapsed_ms} ms ≥ {mult}× budget ({budget_ms} ms)",
243                mult = self.walltime_fallback_multiplier
244            ))
245        } else {
246            SandboxError::Timeout
247        }
248    }
249
250    fn register_host_bindings(&mut self) -> Result<(), String> {
251        if self.bindings_registered {
252            return Ok(());
253        }
254
255        let host = Rc::clone(&self.host);
256        let eval_script = self.context.with(|ctx| {
257            let globals = ctx.globals();
258            let internal =
259                Object::new(ctx.clone()).map_err(|e| format!("host internal object: {e}"))?;
260
261            let resolve_host = Rc::clone(&host);
262            let resolve_node_id = Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| {
263                let Some(path) = path.0 else {
264                    let _ = resolve_host.borrow_mut().resolve_node("");
265                    return -1i32;
266                };
267                resolve_host
268                    .borrow_mut()
269                    .resolve_node(&path.0)
270                    .map(|node_id| node_id.0 as i32)
271                    .unwrap_or(-1)
272            })
273            .map_err(|e| format!("resolveNodeId: {e}"))?;
274            internal
275                .set("resolveNodeId", resolve_node_id)
276                .map_err(|e| format!("set resolveNodeId: {e}"))?;
277
278            let resolve_nodes_host = Rc::clone(&host);
279            let resolve_node_ids =
280                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
281                    let Some(path) = path.0 else {
282                        let _ = resolve_nodes_host.borrow_mut().resolve_nodes("");
283                        return Vec::new();
284                    };
285                    resolve_nodes_host
286                        .borrow_mut()
287                        .resolve_nodes(&path.0)
288                        .into_iter()
289                        .map(|node_id| node_id.0 as i32)
290                        .collect()
291                })
292                .map_err(|e| format!("resolveNodeIds: {e}"))?;
293            internal
294                .set("resolveNodeIds", resolve_node_ids)
295                .map_err(|e| format!("set resolveNodeIds: {e}"))?;
296
297            let generation_host = Rc::clone(&host);
298            let generation = Function::new(ctx.clone(), move || {
299                generation_host.borrow().generation() as i64
300            })
301            .map_err(|e| format!("generation: {e}"))?;
302            internal
303                .set("generation", generation)
304                .map_err(|e| format!("set generation: {e}"))?;
305
306            let current_host = Rc::clone(&host);
307            let current_node = Function::new(ctx.clone(), move || {
308                current_host
309                    .borrow()
310                    .current_node()
311                    .map(|node_id| node_id.0 as i32)
312                    .unwrap_or(-1)
313            })
314            .map_err(|e| format!("currentNodeId: {e}"))?;
315            internal
316                .set("currentNodeId", current_node)
317                .map_err(|e| format!("set currentNodeId: {e}"))?;
318
319            let implicit_host = Rc::clone(&host);
320            let resolve_implicit_node_id = Function::new(
321                ctx.clone(),
322                move |current_id: i32, name: Opt<Coerced<String>>| -> i32 {
323                    if current_id < 0 {
324                        return -1;
325                    }
326                    let Some(name) = name.0 else {
327                        return -1;
328                    };
329                    implicit_host
330                        .borrow_mut()
331                        .resolve_implicit(FormNodeId(current_id as usize), &name.0)
332                        .map(|node_id| node_id.0 as i32)
333                        .unwrap_or(-1)
334                },
335            )
336            .map_err(|e| format!("resolveImplicitNodeId: {e}"))?;
337            internal
338                .set("resolveImplicitNodeId", resolve_implicit_node_id)
339                .map_err(|e| format!("set resolveImplicitNodeId: {e}"))?;
340
341            let implicit_candidates_host = Rc::clone(&host);
342            let resolve_implicit_node_ids = Function::new(
343                ctx.clone(),
344                move |current_id: i32, name: Opt<Coerced<String>>| -> Vec<i32> {
345                    if current_id < 0 {
346                        return Vec::new();
347                    }
348                    let Some(name) = name.0 else {
349                        return Vec::new();
350                    };
351                    implicit_candidates_host
352                        .borrow_mut()
353                        .resolve_implicit_candidates(FormNodeId(current_id as usize), &name.0)
354                        .into_iter()
355                        .map(|node_id| node_id.0 as i32)
356                        .collect()
357                },
358            )
359            .map_err(|e| format!("resolveImplicitNodeIds: {e}"))?;
360            internal
361                .set("resolveImplicitNodeIds", resolve_implicit_node_ids)
362                .map_err(|e| format!("set resolveImplicitNodeIds: {e}"))?;
363
364            // XFA-DATA-M3C: quiet probe for implicit identifier resolution.
365            // Same scope walk as `resolveImplicitNodeIds`; a miss returns an
366            // empty array without bumping `resolve_failures`. The JS proxy
367            // uses this when probing schema-optional underscore-shorthand
368            // globals (`_<Name>`).
369            let implicit_quiet_host = Rc::clone(&host);
370            let resolve_implicit_node_ids_quiet = Function::new(
371                ctx.clone(),
372                move |current_id: i32, name: Opt<Coerced<String>>| -> Vec<i32> {
373                    if current_id < 0 {
374                        return Vec::new();
375                    }
376                    let Some(name) = name.0 else {
377                        return Vec::new();
378                    };
379                    implicit_quiet_host
380                        .borrow_mut()
381                        .resolve_implicit_candidates_quiet(FormNodeId(current_id as usize), &name.0)
382                        .into_iter()
383                        .map(|node_id| node_id.0 as i32)
384                        .collect()
385                },
386            )
387            .map_err(|e| format!("resolveImplicitNodeIdsQuiet: {e}"))?;
388            internal
389                .set(
390                    "resolveImplicitNodeIdsQuiet",
391                    resolve_implicit_node_ids_quiet,
392                )
393                .map_err(|e| format!("set resolveImplicitNodeIdsQuiet: {e}"))?;
394
395            let child_host = Rc::clone(&host);
396            let resolve_child_node_id = Function::new(
397                ctx.clone(),
398                move |parent_id: i32, name: Opt<Coerced<String>>| {
399                    if parent_id < 0 {
400                        return -1i32;
401                    }
402                    let Some(name) = name.0 else {
403                        return -1;
404                    };
405                    child_host
406                        .borrow_mut()
407                        .resolve_child(FormNodeId(parent_id as usize), &name.0)
408                        .map(|node_id| node_id.0 as i32)
409                        .unwrap_or(-1)
410                },
411            )
412            .map_err(|e| format!("resolveChildNodeId: {e}"))?;
413            internal
414                .set("resolveChildNodeId", resolve_child_node_id)
415                .map_err(|e| format!("set resolveChildNodeId: {e}"))?;
416
417            let child_candidates_host = Rc::clone(&host);
418            let resolve_child_node_ids = Function::new(
419                ctx.clone(),
420                move |parent_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
421                    let Some(parent_ids) = parent_ids.0 else {
422                        return Vec::new();
423                    };
424                    let Some(name) = name.0 else {
425                        return Vec::new();
426                    };
427                    child_candidates_host
428                        .borrow_mut()
429                        .resolve_child_candidates(&parse_node_id_csv(&parent_ids.0), &name.0)
430                        .into_iter()
431                        .map(|node_id| node_id.0 as i32)
432                        .collect()
433                },
434            )
435            .map_err(|e| format!("resolveChildNodeIds: {e}"))?;
436            internal
437                .set("resolveChildNodeIds", resolve_child_node_ids)
438                .map_err(|e| format!("set resolveChildNodeIds: {e}"))?;
439
440            // XFA-DATA-M3C: quiet variant of resolveChildNodeIds. Returns the
441            // same ids on success and an empty array on miss, but does not
442            // bump the `resolve_failures` metric — used by underscore-shorthand
443            // probes where a miss is the expected schema-optional path and
444            // would otherwise inflate the resolve-failure budget.
445            let child_quiet_host = Rc::clone(&host);
446            let resolve_child_node_ids_quiet = Function::new(
447                ctx.clone(),
448                move |parent_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
449                    let Some(parent_ids) = parent_ids.0 else {
450                        return Vec::new();
451                    };
452                    let Some(name) = name.0 else {
453                        return Vec::new();
454                    };
455                    child_quiet_host
456                        .borrow_mut()
457                        .resolve_child_candidates_quiet(&parse_node_id_csv(&parent_ids.0), &name.0)
458                        .into_iter()
459                        .map(|node_id| node_id.0 as i32)
460                        .collect()
461                },
462            )
463            .map_err(|e| format!("resolveChildNodeIdsQuiet: {e}"))?;
464            internal
465                .set("resolveChildNodeIdsQuiet", resolve_child_node_ids_quiet)
466                .map_err(|e| format!("set resolveChildNodeIdsQuiet: {e}"))?;
467
468            // Phase D-θ: lookahead-hinted variants of the implicit and child
469            // resolvers. The JS proxy carries the next-segment property name
470            // and asks the host to keep only candidates whose subtree
471            // contains a descendant matching that hint. Falls back to the
472            // un-hinted candidate list when the hint disambiguates nothing,
473            // so single-token reads remain byte-for-byte compatible.
474            let implicit_hinted_host = Rc::clone(&host);
475            let resolve_implicit_node_ids_hinted = Function::new(
476                ctx.clone(),
477                move |current_id: i32,
478                      name: Opt<Coerced<String>>,
479                      hint: Opt<Coerced<String>>|
480                      -> Vec<i32> {
481                    if current_id < 0 {
482                        return Vec::new();
483                    }
484                    let Some(name) = name.0 else {
485                        return Vec::new();
486                    };
487                    let hint = hint.0.map(|s| s.0).unwrap_or_default();
488                    implicit_hinted_host
489                        .borrow_mut()
490                        .resolve_implicit_candidates_hinted(
491                            FormNodeId(current_id as usize),
492                            &name.0,
493                            &hint,
494                        )
495                        .into_iter()
496                        .map(|node_id| node_id.0 as i32)
497                        .collect()
498                },
499            )
500            .map_err(|e| format!("resolveImplicitNodeIdsHinted: {e}"))?;
501            internal
502                .set(
503                    "resolveImplicitNodeIdsHinted",
504                    resolve_implicit_node_ids_hinted,
505                )
506                .map_err(|e| format!("set resolveImplicitNodeIdsHinted: {e}"))?;
507
508            let child_hinted_host = Rc::clone(&host);
509            let resolve_child_node_ids_hinted = Function::new(
510                ctx.clone(),
511                move |parent_ids: Opt<Coerced<String>>,
512                      name: Opt<Coerced<String>>,
513                      hint: Opt<Coerced<String>>|
514                      -> Vec<i32> {
515                    let Some(parent_ids) = parent_ids.0 else {
516                        return Vec::new();
517                    };
518                    let Some(name) = name.0 else {
519                        return Vec::new();
520                    };
521                    let hint = hint.0.map(|s| s.0).unwrap_or_default();
522                    child_hinted_host
523                        .borrow_mut()
524                        .resolve_child_candidates_hinted(
525                            &parse_node_id_csv(&parent_ids.0),
526                            &name.0,
527                            &hint,
528                        )
529                        .into_iter()
530                        .map(|node_id| node_id.0 as i32)
531                        .collect()
532                },
533            )
534            .map_err(|e| format!("resolveChildNodeIdsHinted: {e}"))?;
535            internal
536                .set("resolveChildNodeIdsHinted", resolve_child_node_ids_hinted)
537                .map_err(|e| format!("set resolveChildNodeIdsHinted: {e}"))?;
538
539            let scoped_candidates_host = Rc::clone(&host);
540            let resolve_scoped_node_ids = Function::new(
541                ctx.clone(),
542                move |scope_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
543                    let Some(scope_ids) = scope_ids.0 else {
544                        return Vec::new();
545                    };
546                    let Some(name) = name.0 else {
547                        return Vec::new();
548                    };
549                    scoped_candidates_host
550                        .borrow_mut()
551                        .resolve_scoped_candidates(&parse_node_id_csv(&scope_ids.0), &name.0)
552                        .into_iter()
553                        .map(|node_id| node_id.0 as i32)
554                        .collect()
555                },
556            )
557            .map_err(|e| format!("resolveScopedNodeIds: {e}"))?;
558            internal
559                .set("resolveScopedNodeIds", resolve_scoped_node_ids)
560                .map_err(|e| format!("set resolveScopedNodeIds: {e}"))?;
561
562            // Phase D-θ.2: full-chain SOM resolution with backtracking. The
563            // JS proxy accumulates property names (the SOM chain) until a
564            // terminal property is read, then asks the host to walk the
565            // entire chain in one shot. `chain_csv` is a comma-separated
566            // list of property names; commas are illegal in SOM names so the
567            // delimiter is unambiguous. Passing a `current_id` of -1 + an
568            // empty `parent_ids_csv` is invalid and yields an empty result;
569            // callers must supply either explicit parents OR a current id.
570            let full_chain_host = Rc::clone(&host);
571            let resolve_with_full_chain = Function::new(
572                ctx.clone(),
573                move |parent_ids_csv: Opt<Coerced<String>>,
574                      current_id: i32,
575                      chain_csv: Opt<Coerced<String>>|
576                      -> Vec<i32> {
577                    let parents: Vec<FormNodeId> = parent_ids_csv
578                        .0
579                        .map(|s| parse_node_id_csv(&s.0))
580                        .unwrap_or_default();
581                    let chain: Vec<String> = chain_csv
582                        .0
583                        .map(|s| s.0.split(',').map(|part| part.trim().to_string()).collect())
584                        .unwrap_or_default();
585                    let implicit_origin = if current_id >= 0 {
586                        Some(FormNodeId(current_id as usize))
587                    } else {
588                        None
589                    };
590                    full_chain_host
591                        .borrow_mut()
592                        .resolve_with_full_chain(&parents, &chain, implicit_origin)
593                        .into_iter()
594                        .map(|node_id| node_id.0 as i32)
595                        .collect()
596                },
597            )
598            .map_err(|e| format!("resolveWithFullChain: {e}"))?;
599            internal
600                .set("resolveWithFullChain", resolve_with_full_chain)
601                .map_err(|e| format!("set resolveWithFullChain: {e}"))?;
602
603            // Strict full-chain probe: returns empty when the chain cannot
604            // be completed at full depth. Used by the JS chain proxy during
605            // accumulation to surface `undefined` for unreachable paths
606            // (so `A.B === undefined` keeps the byte-for-byte D-θ.1
607            // behaviour for forms that test dead-end chains).
608            let full_chain_strict_host = Rc::clone(&host);
609            let resolve_with_full_chain_strict = Function::new(
610                ctx.clone(),
611                move |parent_ids_csv: Opt<Coerced<String>>,
612                      current_id: i32,
613                      chain_csv: Opt<Coerced<String>>|
614                      -> Vec<i32> {
615                    let parents: Vec<FormNodeId> = parent_ids_csv
616                        .0
617                        .map(|s| parse_node_id_csv(&s.0))
618                        .unwrap_or_default();
619                    let chain: Vec<String> = chain_csv
620                        .0
621                        .map(|s| s.0.split(',').map(|part| part.trim().to_string()).collect())
622                        .unwrap_or_default();
623                    let implicit_origin = if current_id >= 0 {
624                        Some(FormNodeId(current_id as usize))
625                    } else {
626                        None
627                    };
628                    full_chain_strict_host
629                        .borrow_mut()
630                        .resolve_with_full_chain_strict(&parents, &chain, implicit_origin)
631                        .into_iter()
632                        .map(|node_id| node_id.0 as i32)
633                        .collect()
634                },
635            )
636            .map_err(|e| format!("resolveWithFullChainStrict: {e}"))?;
637            internal
638                .set("resolveWithFullChainStrict", resolve_with_full_chain_strict)
639                .map_err(|e| format!("set resolveWithFullChainStrict: {e}"))?;
640
641            let get_raw_host = Rc::clone(&host);
642            let get_raw_value = Function::new(
643                ctx.clone(),
644                move |id: i32, generation: i64| -> Option<String> {
645                    if id < 0 || generation < 0 {
646                        return None;
647                    }
648                    get_raw_host
649                        .borrow_mut()
650                        .get_raw_value(FormNodeId(id as usize), generation as u64)
651                },
652            )
653            .map_err(|e| format!("getRawValue: {e}"))?;
654            internal
655                .set("getRawValue", get_raw_value)
656                .map_err(|e| format!("set getRawValue: {e}"))?;
657
658            let set_raw_host = Rc::clone(&host);
659            let set_raw_value = Function::new(
660                ctx.clone(),
661                move |id: i32, generation: i64, value: Coerced<String>| -> bool {
662                    if id < 0 || generation < 0 {
663                        return false;
664                    }
665                    set_raw_host.borrow_mut().set_raw_value(
666                        FormNodeId(id as usize),
667                        value.0,
668                        generation as u64,
669                    )
670                },
671            )
672            .map_err(|e| format!("setRawValue: {e}"))?;
673            internal
674                .set("setRawValue", set_raw_value)
675                .map_err(|e| format!("set setRawValue: {e}"))?;
676
677            let instance_count_host = Rc::clone(&host);
678            let instance_count =
679                Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
680                    if id < 0 || generation < 0 {
681                        return 0;
682                    }
683                    instance_count_host
684                        .borrow_mut()
685                        .instance_count_for_handle(FormNodeId(id as usize), generation as u64)
686                })
687                .map_err(|e| format!("instanceCount: {e}"))?;
688            internal
689                .set("instanceCount", instance_count)
690                .map_err(|e| format!("set instanceCount: {e}"))?;
691
692            let zero_instance_host = Rc::clone(&host);
693            let has_zero_instance_run = Function::new(
694                ctx.clone(),
695                move |id: i32, generation: i64, name: Opt<Coerced<String>>| -> bool {
696                    if id < 0 || generation < 0 {
697                        return false;
698                    }
699                    let Some(name) = name.0 else {
700                        return false;
701                    };
702                    zero_instance_host.borrow_mut().has_zero_instance_run(
703                        FormNodeId(id as usize),
704                        generation as u64,
705                        &name.0,
706                    )
707                },
708            )
709            .map_err(|e| format!("hasZeroInstanceRun: {e}"))?;
710            internal
711                .set("hasZeroInstanceRun", has_zero_instance_run)
712                .map_err(|e| format!("set hasZeroInstanceRun: {e}"))?;
713
714            let node_index_host = Rc::clone(&host);
715            let node_index = Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
716                if id < 0 || generation < 0 {
717                    return 0;
718                }
719                node_index_host
720                    .borrow_mut()
721                    .instance_index_for_handle(FormNodeId(id as usize), generation as u64)
722            })
723            .map_err(|e| format!("nodeIndex: {e}"))?;
724            internal
725                .set("nodeIndex", node_index)
726                .map_err(|e| format!("set nodeIndex: {e}"))?;
727
728            let node_name_host = Rc::clone(&host);
729            let node_name = Function::new(ctx.clone(), move |id: i32, generation: i64| -> String {
730                if id < 0 || generation < 0 {
731                    return String::new();
732                }
733                node_name_host
734                    .borrow()
735                    .node_name(FormNodeId(id as usize), generation as u64)
736                    .unwrap_or_default()
737            })
738            .map_err(|e| format!("nodeName: {e}"))?;
739            internal
740                .set("nodeName", node_name)
741                .map_err(|e| format!("set nodeName: {e}"))?;
742
743            // D5: occur handle resolution (liveness check + lookup counters).
744            let occur_resolve_host = Rc::clone(&host);
745            let occur_resolve =
746                Function::new(ctx.clone(), move |id: i32, generation: i64| -> bool {
747                    if id < 0 || generation < 0 {
748                        return false;
749                    }
750                    occur_resolve_host
751                        .borrow_mut()
752                        .occur_resolve(FormNodeId(id as usize), generation as u64)
753                })
754                .map_err(|e| format!("occurResolve: {e}"))?;
755            internal
756                .set("occurResolve", occur_resolve)
757                .map_err(|e| format!("set occurResolve: {e}"))?;
758
759            // D5: read an occur property (`min`/`max`/`initial`); -1 = unlimited/unknown.
760            let occur_read_host = Rc::clone(&host);
761            let occur_read = Function::new(
762                ctx.clone(),
763                move |id: i32, generation: i64, prop: String| -> i64 {
764                    if id < 0 || generation < 0 {
765                        return -1;
766                    }
767                    occur_read_host.borrow_mut().occur_read(
768                        FormNodeId(id as usize),
769                        generation as u64,
770                        &prop,
771                    )
772                },
773            )
774            .map_err(|e| format!("occurRead: {e}"))?;
775            internal
776                .set("occurRead", occur_read)
777                .map_err(|e| format!("set occurRead: {e}"))?;
778
779            // D5: capture an occur property write (`min`/`max`) — trace-only, no
780            // layout effect. Returns true so the JS assignment proceeds.
781            let occur_capture_host = Rc::clone(&host);
782            let occur_capture = Function::new(
783                ctx.clone(),
784                move |id: i32, generation: i64, prop: String, value: i64| -> bool {
785                    if id < 0 || generation < 0 {
786                        return false;
787                    }
788                    occur_capture_host.borrow_mut().occur_capture(
789                        FormNodeId(id as usize),
790                        generation as u64,
791                        &prop,
792                        value,
793                    )
794                },
795            )
796            .map_err(|e| format!("occurCapture: {e}"))?;
797            internal
798                .set("occurCapture", occur_capture)
799                .map_err(|e| format!("set occurCapture: {e}"))?;
800
801            // XFA-DATA-M3C: container test used by the JS proxy to decide
802            // whether `<handle>._<Name>` should fall back to an empty
803            // instance-manager sentinel (containers) or stay `undefined`
804            // (fields/draws). Mirrors XFA 3.3 §6.4.3.2.
805            let is_container_host = Rc::clone(&host);
806            let node_is_container =
807                Function::new(ctx.clone(), move |id: i32, generation: i64| -> bool {
808                    if id < 0 || generation < 0 {
809                        return false;
810                    }
811                    is_container_host
812                        .borrow_mut()
813                        .node_is_container(FormNodeId(id as usize), generation as u64)
814                })
815                .map_err(|e| format!("nodeIsContainer: {e}"))?;
816            internal
817                .set("nodeIsContainer", node_is_container)
818                .map_err(|e| format!("set nodeIsContainer: {e}"))?;
819
820            // XFA-DATA-M3C: form-tree parent lookup used by the JS proxy to
821            // materialise the bare global `parent` identifier and `<handle>.
822            // parent` chain access. Returns -1 when no parent exists, when
823            // the handle is stale, or when no form is installed.
824            let parent_node_host = Rc::clone(&host);
825            let parent_of_node =
826                Function::new(ctx.clone(), move |id: i32, generation: i64| -> i32 {
827                    if id < 0 || generation < 0 {
828                        return -1;
829                    }
830                    parent_node_host
831                        .borrow_mut()
832                        .parent_of_node(FormNodeId(id as usize), generation as u64)
833                        .map(|node_id| node_id.0 as i32)
834                        .unwrap_or(-1)
835                })
836                .map_err(|e| format!("parentOfNode: {e}"))?;
837            internal
838                .set("parentOfNode", parent_of_node)
839                .map_err(|e| format!("set parentOfNode: {e}"))?;
840
841            let instance_set_host = Rc::clone(&host);
842            let instance_set = Function::new(
843                ctx.clone(),
844                move |id: i32, generation: i64, n: Opt<i32>| -> i32 {
845                    if id < 0 || generation < 0 {
846                        return -1;
847                    }
848                    let n = n.0.unwrap_or(0).max(0) as u32;
849                    instance_set_host
850                        .borrow_mut()
851                        .instance_set_for_handle(FormNodeId(id as usize), generation as u64, n)
852                        .map(|count| count as i32)
853                        .unwrap_or(-1)
854                },
855            )
856            .map_err(|e| format!("instanceSet: {e}"))?;
857            internal
858                .set("instanceSet", instance_set)
859                .map_err(|e| format!("set instanceSet: {e}"))?;
860
861            let instance_add_host = Rc::clone(&host);
862            let instance_add = Function::new(ctx.clone(), move |id: i32, generation: i64| -> i32 {
863                if id < 0 || generation < 0 {
864                    return -1;
865                }
866                instance_add_host
867                    .borrow_mut()
868                    .instance_add_for_handle(FormNodeId(id as usize), generation as u64)
869                    .map(|node_id| node_id.0 as i32)
870                    .unwrap_or(-1)
871            })
872            .map_err(|e| format!("instanceAdd: {e}"))?;
873            internal
874                .set("instanceAdd", instance_add)
875                .map_err(|e| format!("set instanceAdd: {e}"))?;
876
877            let instance_remove_host = Rc::clone(&host);
878            let instance_remove = Function::new(
879                ctx.clone(),
880                move |id: i32, generation: i64, index: Opt<i32>| -> bool {
881                    if id < 0 || generation < 0 {
882                        return false;
883                    }
884                    let index = index.0.unwrap_or(0).max(0) as u32;
885                    instance_remove_host
886                        .borrow_mut()
887                        .instance_remove_for_handle(
888                            FormNodeId(id as usize),
889                            generation as u64,
890                            index,
891                        )
892                        .is_ok()
893                },
894            )
895            .map_err(|e| format!("instanceRemove: {e}"))?;
896            internal
897                .set("instanceRemove", instance_remove)
898                .map_err(|e| format!("set instanceRemove: {e}"))?;
899
900            let list_clear_host = Rc::clone(&host);
901            let list_clear = Function::new(ctx.clone(), move |id: i32, generation: i64| -> bool {
902                if id < 0 || generation < 0 {
903                    return false;
904                }
905                list_clear_host
906                    .borrow_mut()
907                    .list_clear_for_handle(FormNodeId(id as usize), generation as u64)
908                    .is_ok()
909            })
910            .map_err(|e| format!("listClear: {e}"))?;
911            internal
912                .set("listClear", list_clear)
913                .map_err(|e| format!("set listClear: {e}"))?;
914
915            let list_add_host = Rc::clone(&host);
916            let list_add = Function::new(
917                ctx.clone(),
918                move |id: i32,
919                      generation: i64,
920                      display: Coerced<String>,
921                      save: Opt<Coerced<String>>|
922                      -> bool {
923                    if id < 0 || generation < 0 {
924                        return false;
925                    }
926                    list_add_host
927                        .borrow_mut()
928                        .list_add_for_handle(
929                            FormNodeId(id as usize),
930                            generation as u64,
931                            display.0,
932                            save.0.map(|s| s.0),
933                        )
934                        .is_ok()
935                },
936            )
937            .map_err(|e| format!("listAdd: {e}"))?;
938            internal
939                .set("listAdd", list_add)
940                .map_err(|e| format!("set listAdd: {e}"))?;
941
942            let bound_item_host = Rc::clone(&host);
943            let bound_item = Function::new(
944                ctx.clone(),
945                move |id: i32, generation: i64, display: Coerced<String>| -> String {
946                    if id < 0 || generation < 0 {
947                        return display.0;
948                    }
949                    bound_item_host.borrow_mut().bound_item_for_handle(
950                        FormNodeId(id as usize),
951                        generation as u64,
952                        display.0,
953                    )
954                },
955            )
956            .map_err(|e| format!("boundItem: {e}"))?;
957            internal
958                .set("boundItem", bound_item)
959                .map_err(|e| format!("set boundItem: {e}"))?;
960
961            // BE-1: `#items` — XFA 3.3 §7.7 / §8.1 choiceList item labels.
962            // Returns a JS Array of display-label strings for the field, or
963            // an empty array when the handle is stale or the node is not a
964            // field with a populated item list.
965            let get_display_items_host = Rc::clone(&host);
966            let get_display_items = Function::new(
967                ctx.clone(),
968                move |id: i32, generation: i64| -> Vec<String> {
969                    if id < 0 || generation < 0 {
970                        return Vec::new();
971                    }
972                    get_display_items_host
973                        .borrow_mut()
974                        .get_display_items(FormNodeId(id as usize), generation as u64)
975                },
976            )
977            .map_err(|e| format!("getDisplayItems: {e}"))?;
978            internal
979                .set("getDisplayItems", get_display_items)
980                .map_err(|e| format!("set getDisplayItems: {e}"))?;
981
982            let num_pages_host = Rc::clone(&host);
983            let num_pages =
984                Function::new(ctx.clone(), move || num_pages_host.borrow_mut().num_pages())
985                    .map_err(|e| format!("numPages: {e}"))?;
986            internal
987                .set("numPages", num_pages)
988                .map_err(|e| format!("set numPages: {e}"))?;
989
990            let binding_error_host = Rc::clone(&host);
991            let binding_error = Function::new(ctx.clone(), move || {
992                binding_error_host.borrow_mut().metadata_binding_error();
993            })
994            .map_err(|e| format!("bindingError: {e}"))?;
995            internal
996                .set("bindingError", binding_error)
997                .map_err(|e| format!("set bindingError: {e}"))?;
998
999            let resolve_failure_host = Rc::clone(&host);
1000            let resolve_failure = Function::new(ctx.clone(), move || {
1001                resolve_failure_host.borrow_mut().metadata_resolve_failure();
1002            })
1003            .map_err(|e| format!("resolveFailure: {e}"))?;
1004            internal
1005                .set("resolveFailure", resolve_failure)
1006                .map_err(|e| format!("set resolveFailure: {e}"))?;
1007
1008            // BE-1 telemetry: `$data` bare-global resolve hit.
1009            let som_data_root_hit_host = Rc::clone(&host);
1010            let som_data_root_hit = Function::new(ctx.clone(), move || {
1011                som_data_root_hit_host
1012                    .borrow_mut()
1013                    .metadata_som_data_root_hit();
1014            })
1015            .map_err(|e| format!("somDataRootHit: {e}"))?;
1016            internal
1017                .set("somDataRootHit", som_data_root_hit)
1018                .map_err(|e| format!("set somDataRootHit: {e}"))?;
1019
1020            // BE-1 telemetry: `#items` property access resolved to non-empty list.
1021            let som_items_path_hit_host = Rc::clone(&host);
1022            let som_items_path_hit = Function::new(ctx.clone(), move || {
1023                som_items_path_hit_host
1024                    .borrow_mut()
1025                    .metadata_som_items_path_hit();
1026            })
1027            .map_err(|e| format!("somItemsPathHit: {e}"))?;
1028            internal
1029                .set("somItemsPathHit", som_items_path_hit)
1030                .map_err(|e| format!("set somItemsPathHit: {e}"))?;
1031
1032            // Phase E (XFA-JS-HOST-STUBS) — sandbox-safe accounting hook for
1033            // host capabilities that require viewer / user interaction. JS
1034            // callers pass an optional capability identifier purely for log
1035            // forensics; the host only increments the unsupported counter.
1036            // No filesystem / network / process access ever runs through
1037            // this path — see benchmarks/JS_SANDBOX_SECURITY_AUDIT.md.
1038            let unsupported_host_call_host = Rc::clone(&host);
1039            let unsupported_host_call =
1040                Function::new(ctx.clone(), move |_capability: Opt<Coerced<String>>| {
1041                    unsupported_host_call_host
1042                        .borrow_mut()
1043                        .metadata_unsupported_host_call();
1044                })
1045                .map_err(|e| format!("unsupportedHostCall: {e}"))?;
1046            internal
1047                .set("unsupportedHostCall", unsupported_host_call)
1048                .map_err(|e| format!("set unsupportedHostCall: {e}"))?;
1049
1050            // Phase D-θ.2 probe-skip telemetry: JS calls this when the strict
1051            // probe is elided because parentIds.length == 1 && chain.length == 1
1052            // (no same-name sibling ambiguity is possible in that case).
1053            let probe_skip_host = Rc::clone(&host);
1054            let probe_skip = Function::new(ctx.clone(), move || {
1055                probe_skip_host.borrow_mut().metadata_probe_skip();
1056            })
1057            .map_err(|e| format!("probeSkip: {e}"))?;
1058            internal
1059                .set("probeSkip", probe_skip)
1060                .map_err(|e| format!("set probeSkip: {e}"))?;
1061
1062            // Phase D-γ: DataDom host bindings --------------------------------
1063
1064            let dc_host = Rc::clone(&host);
1065            let data_children = Function::new(ctx.clone(), move |raw_id: i32| -> Vec<i32> {
1066                if raw_id < 0 {
1067                    return Vec::new();
1068                }
1069                dc_host
1070                    .borrow_mut()
1071                    .data_children(raw_id as usize)
1072                    .into_iter()
1073                    .map(|x| x as i32)
1074                    .collect()
1075            })
1076            .map_err(|e| format!("dataChildren: {e}"))?;
1077            internal
1078                .set("dataChildren", data_children)
1079                .map_err(|e| format!("set dataChildren: {e}"))?;
1080
1081            let dv_host = Rc::clone(&host);
1082            let data_value = Function::new(ctx.clone(), move |raw_id: i32| -> Option<String> {
1083                if raw_id < 0 {
1084                    return None;
1085                }
1086                dv_host.borrow_mut().data_value(raw_id as usize)
1087            })
1088            .map_err(|e| format!("dataValue: {e}"))?;
1089            internal
1090                .set("dataValue", data_value)
1091                .map_err(|e| format!("set dataValue: {e}"))?;
1092
1093            let dcbn_host = Rc::clone(&host);
1094            let data_child_by_name = Function::new(
1095                ctx.clone(),
1096                move |parent_raw: i32, name: Opt<Coerced<String>>| -> i32 {
1097                    if parent_raw < 0 {
1098                        return -1;
1099                    }
1100                    let Some(name) = name.0 else {
1101                        return -1;
1102                    };
1103                    dcbn_host
1104                        .borrow_mut()
1105                        .data_child_by_name(parent_raw as usize, &name.0)
1106                        .map(|x| x as i32)
1107                        .unwrap_or(-1)
1108                },
1109            )
1110            .map_err(|e| format!("dataChildByName: {e}"))?;
1111            internal
1112                .set("dataChildByName", data_child_by_name)
1113                .map_err(|e| format!("set dataChildByName: {e}"))?;
1114
1115            let dbr_host = Rc::clone(&host);
1116            let data_bound_record = Function::new(
1117                ctx.clone(),
1118                move |form_node_id: i32, generation: i64| -> i32 {
1119                    if form_node_id < 0 || generation < 0 {
1120                        return -1;
1121                    }
1122                    dbr_host
1123                        .borrow_mut()
1124                        .data_bound_record(FormNodeId(form_node_id as usize), generation as u64)
1125                        .map(|x| x as i32)
1126                        .unwrap_or(-1)
1127                },
1128            )
1129            .map_err(|e| format!("dataBoundRecord: {e}"))?;
1130            internal
1131                .set("dataBoundRecord", data_bound_record)
1132                .map_err(|e| format!("set dataBoundRecord: {e}"))?;
1133
1134            let drn_host = Rc::clone(&host);
1135            let data_resolve_node =
1136                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> i32 {
1137                    let Some(path) = path.0 else {
1138                        return -1;
1139                    };
1140                    drn_host
1141                        .borrow_mut()
1142                        .data_resolve_node(&path.0)
1143                        .map(|x| x as i32)
1144                        .unwrap_or(-1)
1145                })
1146                .map_err(|e| format!("dataResolveNode: {e}"))?;
1147            internal
1148                .set("dataResolveNode", data_resolve_node)
1149                .map_err(|e| format!("set dataResolveNode: {e}"))?;
1150
1151            // BE-1 tranche #1: predicate for the benign absent-declared-node
1152            // façade. The implicit-globals `lookup` calls this only after the
1153            // scope resolve returned 0 nodes; a `true` result means the name is
1154            // a template-declared container (subform/subformSet/exclGroup/area)
1155            // absent from the current scope, so `lookup` returns
1156            // `makeAbsentNodeHandle()` (isNull===true, chainable) instead of
1157            // `undefined`. Undeclared names → false → `undefined` (D-θ.1).
1158            let idan_host = Rc::clone(&host);
1159            let is_declared_absent_node =
1160                Function::new(ctx.clone(), move |name: Opt<Coerced<String>>| -> bool {
1161                    let Some(name) = name.0 else {
1162                        return false;
1163                    };
1164                    idan_host.borrow().is_declared_absent_node(&name.0)
1165                })
1166                .map_err(|e| format!("isDeclaredAbsentNode: {e}"))?;
1167            internal
1168                .set("isDeclaredAbsentNode", is_declared_absent_node)
1169                .map_err(|e| format!("set isDeclaredAbsentNode: {e}"))?;
1170
1171            let drns_host = Rc::clone(&host);
1172            let data_resolve_nodes =
1173                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
1174                    let Some(path) = path.0 else {
1175                        return Vec::new();
1176                    };
1177                    drns_host
1178                        .borrow_mut()
1179                        .data_resolve_nodes(&path.0)
1180                        .into_iter()
1181                        .map(|x| x as i32)
1182                        .collect()
1183                })
1184                .map_err(|e| format!("dataResolveNodes: {e}"))?;
1185            internal
1186                .set("dataResolveNodes", data_resolve_nodes)
1187                .map_err(|e| format!("set dataResolveNodes: {e}"))?;
1188
1189            // End Phase D-γ host bindings -------------------------------------
1190
1191            let factory: Function = ctx
1192                .eval(PHASE_C_BINDINGS_JS.as_bytes())
1193                .map_err(|e| format!("binding factory parse: {e}"))?;
1194            let bridge: Object = factory
1195                .call((internal,))
1196                .catch(&ctx)
1197                .map_err(|e| format!("binding factory call: {e}"))?;
1198            let xfa: Object = bridge.get("xfa").map_err(|e| format!("get xfa: {e}"))?;
1199            let app: Object = bridge.get("app").map_err(|e| format!("get app: {e}"))?;
1200            // JS2-01 (Sprint 2 Batch B): `form` is a top-level alias of
1201            // `xfa.form`. Adobe Reader exposes both spellings; templates
1202            // in the corpus reference bare `form.X`.
1203            let form: Object = bridge.get("form").map_err(|e| format!("get form: {e}"))?;
1204            let eval_script: Function = bridge
1205                .get("evalScript")
1206                .map_err(|e| format!("get evalScript: {e}"))?;
1207            let set_variables_script: Function = bridge
1208                .get("setVariablesScript")
1209                .map_err(|e| format!("get setVariablesScript: {e}"))?;
1210            let clear_variables_scripts: Function = bridge
1211                .get("clearVariablesScripts")
1212                .map_err(|e| format!("get clearVariablesScripts: {e}"))?;
1213            let set_variables_data_item: Function = bridge
1214                .get("setVariablesDataItem")
1215                .map_err(|e| format!("get setVariablesDataItem: {e}"))?;
1216            globals
1217                .set("xfa", xfa)
1218                .map_err(|e| format!("set xfa global: {e}"))?;
1219            globals
1220                .set("app", app)
1221                .map_err(|e| format!("set app global: {e}"))?;
1222            globals
1223                .set("form", form)
1224                .map_err(|e| format!("set form global: {e}"))?;
1225            Ok::<
1226                (
1227                    Persistent<Function<'static>>,
1228                    Persistent<Function<'static>>,
1229                    Persistent<Function<'static>>,
1230                    Persistent<Function<'static>>,
1231                ),
1232                String,
1233            >((
1234                Persistent::save(&ctx, eval_script),
1235                Persistent::save(&ctx, set_variables_script),
1236                Persistent::save(&ctx, clear_variables_scripts),
1237                Persistent::save(&ctx, set_variables_data_item),
1238            ))
1239        })?;
1240
1241        self.eval_script = Some(eval_script.0);
1242        self.set_variables_script = Some(eval_script.1);
1243        self.clear_variables_scripts = Some(eval_script.2);
1244        self.set_variables_data_item = Some(eval_script.3);
1245        self.bindings_registered = true;
1246        Ok(())
1247    }
1248
1249    /// Phase D-ι: extract top-level `var X` and `function X(` identifiers
1250    /// from a `<variables>` `<script>` body. Used to build the wrapper
1251    /// IIFE that returns the form-level global object. Best-effort regex
1252    /// scan — handles the common XFA authoring patterns; complex
1253    /// destructuring would be missed and the corresponding identifier
1254    /// would simply not appear in the namespace object.
1255    fn extract_top_level_idents(body: &str) -> Vec<String> {
1256        let mut out: Vec<String> = Vec::new();
1257        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1258        // Strip line and block comments to avoid extracting commented-out idents.
1259        let stripped = strip_js_comments(body);
1260        for token_kind in [
1261            ("var", false),
1262            ("let", false),
1263            ("const", false),
1264            ("function", true),
1265        ] {
1266            let kw = token_kind.0;
1267            let is_fn = token_kind.1;
1268            let mut search = stripped.as_str();
1269            while let Some(idx) = search.find(kw) {
1270                let (before, after) = search.split_at(idx);
1271                let head_ok = before
1272                    .chars()
1273                    .last()
1274                    .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$');
1275                let tail_after_kw = &after[kw.len()..];
1276                let tail_ok = tail_after_kw
1277                    .chars()
1278                    .next()
1279                    .is_some_and(|c| c.is_whitespace());
1280                if !(head_ok && tail_ok) {
1281                    search = &after[1..];
1282                    continue;
1283                }
1284                // Skip whitespace, then read an identifier.
1285                let after_ws = tail_after_kw.trim_start();
1286                let mut chars = after_ws.chars();
1287                let ident: String = std::iter::once(chars.next())
1288                    .chain(chars.map(Some))
1289                    .map_while(|c| c.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '$'))
1290                    .collect();
1291                if ident.is_empty() || ident.chars().next().is_some_and(|c| c.is_ascii_digit()) {
1292                    search = &after[kw.len()..];
1293                    continue;
1294                }
1295                if is_fn {
1296                    let after_ident = after_ws
1297                        .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '$');
1298                    if !after_ident.trim_start().starts_with('(') {
1299                        search = &after[kw.len()..];
1300                        continue;
1301                    }
1302                }
1303                if seen.insert(ident.clone()) {
1304                    out.push(ident);
1305                }
1306                search = &after[kw.len()..];
1307            }
1308        }
1309        out
1310    }
1311
1312    /// Phase D-ι: register a `<variables>` `<script name="name">` block
1313    /// as a form-level global. Called by the dispatch layer after
1314    /// `set_form_handle` so subsequent event/calculate scripts see the
1315    /// namespace.
1316    ///
1317    /// Variables-script bodies are evaluated through the same QuickJS
1318    /// context as event scripts, so they MUST run under the same
1319    /// per-script time budget (`set_deadline` / interrupt handler) and
1320    /// the same body-size cap (`MAX_SCRIPT_BODY_BYTES`). Without the
1321    /// budget, an untrusted/malformed XFA template that contains
1322    /// `while (true) {}` inside `<variables>` could hang flatten before
1323    /// any normal event script runs (Codex P1 review on PR #1499).
1324    /// Returns `Ok(true)` when the JS-side `setVariablesScript` bound the
1325    /// namespace, `Ok(false)` when it returned `false` (eval failure caught
1326    /// JS-side), or `Err` on a Rust-side skip (cap / REDOS / timeout / panic).
1327    /// D3: the boolean is now propagated so `set_form_handle` can count
1328    /// registration outcomes for trace observability (previously discarded).
1329    fn register_variables_script(
1330        &self,
1331        name: &str,
1332        body: &str,
1333        subform_scope: Option<&str>,
1334        expose_global: bool,
1335    ) -> Result<bool, SandboxError> {
1336        let Some(setter) = self.set_variables_script.clone() else {
1337            return Ok(false);
1338        };
1339        // W2-B: variables-scripts use a higher body-size cap than event
1340        // scripts because they are form-level helper libraries (XFA 3.3
1341        // §5.5). The time- and memory-budgets still apply via the
1342        // QuickJS interrupt handler + runtime memory limit; the larger
1343        // body cap simply lets real-world government XFA libraries (e.g.
1344        // `validateForm` ≈ 125 KB, `LOV` ≈ 507 KB, `CoreFunctions` ≈ 115
1345        // KB) register so dependent event scripts can resolve
1346        // `<scriptName>.<top_level_decl>(...)` rather than failing as
1347        // `js_resolve_failure` (W1-B `implicit_function` cluster).
1348        if body.len() > MAX_VARIABLES_SCRIPT_BODY_BYTES {
1349            return Err(SandboxError::BodyTooLarge);
1350        }
1351        // W3-A — REDOS-01 mitigation also gates variables-scripts.
1352        // A library-level helper that defines a global function whose
1353        // body contains `/(a+)+$/` would be evaluated by QuickJS at
1354        // registration time, and the interrupt cannot bound it.
1355        if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
1356            return Err(SandboxError::RegexRejected(reason));
1357        }
1358        let idents = Self::extract_top_level_idents(body);
1359        let scope = subform_scope.unwrap_or("").to_string();
1360        self.set_deadline();
1361        let result = catch_unwind(AssertUnwindSafe(|| {
1362            self.context.with(|ctx| -> Result<bool, rquickjs::Error> {
1363                let setter = setter.restore(&ctx)?;
1364                let ok: bool = setter.call((name, body, idents, scope, expose_global))?;
1365                Ok(ok)
1366            })
1367        }));
1368        // Detect timeouts the same way `execute_script` does: if the
1369        // deadline elapsed during evaluation, the interrupt handler aborts
1370        // QuickJS with an error.
1371        let deadline_now = self.script_deadline.load(Ordering::Acquire);
1372        let now_nanos = Instant::now()
1373            .checked_duration_since(epoch())
1374            .map(|d| d.as_nanos() as u64)
1375            .unwrap_or(0);
1376        let timed_out = deadline_now != 0 && now_nanos >= deadline_now;
1377        self.clear_deadline();
1378        match result {
1379            Ok(Ok(ok)) => Ok(ok),
1380            // QF1-E / SEC-01: defence-in-depth wall-time fallback also
1381            // covers `<variables>` `<script>` registration. The same
1382            // classifier upgrades a `Timeout` to `WallTimeExceeded` when
1383            // elapsed ≥ multiplier × budget.
1384            Ok(Err(_)) if timed_out => Err(self.classify_timeout_or_walltime(now_nanos)),
1385            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
1386                "variables-script `{name}` register: {e}"
1387            ))),
1388            Err(_) => Err(SandboxError::PanicCaptured(format!(
1389                "panic registering variables-script `{name}`"
1390            ))),
1391        }
1392    }
1393
1394    /// W3-D RETRY: register one `<variables>` `<text name="X">value</text>`
1395    /// data item (XFA 3.3 §5.5.2) as a form-level mutable string container.
1396    /// Sister of [`Self::register_variables_script`] but with no body cap,
1397    /// no REDOS scan, and no time budget — data items carry text payloads,
1398    /// not JavaScript code. Idempotent per `(subform_scope, name)`.
1399    fn register_variables_data_item(
1400        &self,
1401        name: &str,
1402        initial: &str,
1403        subform_scope: Option<&str>,
1404    ) -> Result<(), SandboxError> {
1405        let Some(setter) = self.set_variables_data_item.clone() else {
1406            return Ok(());
1407        };
1408        let initial_owned = initial.to_string();
1409        let scope = subform_scope.unwrap_or("").to_string();
1410        let name_owned = name.to_string();
1411        let result = catch_unwind(AssertUnwindSafe(|| {
1412            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
1413                let setter = setter.restore(&ctx)?;
1414                let _: bool = setter.call((name_owned, initial_owned, scope))?;
1415                Ok(())
1416            })
1417        }));
1418        match result {
1419            Ok(Ok(())) => Ok(()),
1420            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
1421                "variables data item `{name}` register: {e}"
1422            ))),
1423            Err(_) => Err(SandboxError::PanicCaptured(format!(
1424                "panic registering variables data item `{name}`"
1425            ))),
1426        }
1427    }
1428
1429    fn clear_variables_scripts_global(&self) -> Result<(), SandboxError> {
1430        let Some(clearer) = self.clear_variables_scripts.clone() else {
1431            return Ok(());
1432        };
1433        let result = catch_unwind(AssertUnwindSafe(|| {
1434            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
1435                let clearer = clearer.restore(&ctx)?;
1436                let _: () = clearer.call(())?;
1437                Ok(())
1438            })
1439        }));
1440        match result {
1441            Ok(Ok(())) => Ok(()),
1442            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
1443                "variables-script clear: {e}"
1444            ))),
1445            Err(_) => Err(SandboxError::PanicCaptured(
1446                "panic clearing variables-scripts".to_string(),
1447            )),
1448        }
1449    }
1450}
1451
1452/// Phase D-ι: strip `//` and `/* */` comments from a JS source body so
1453/// the regex scan for top-level `var` / `function` declarations does not
1454/// match commented-out tokens. Conservative — preserves strings (so a
1455/// `"//"` substring inside a string literal is left alone). XFA scripts
1456/// almost never have comments inside string literals, but the guard
1457/// matters for correctness.
1458fn strip_js_comments(src: &str) -> String {
1459    let mut out = String::with_capacity(src.len());
1460    let bytes = src.as_bytes();
1461    let mut i = 0;
1462    while i < bytes.len() {
1463        let b = bytes[i];
1464        if b == b'"' || b == b'\'' {
1465            // Copy string literal verbatim.
1466            let quote = b;
1467            out.push(b as char);
1468            i += 1;
1469            while i < bytes.len() {
1470                let c = bytes[i];
1471                out.push(c as char);
1472                if c == b'\\' && i + 1 < bytes.len() {
1473                    out.push(bytes[i + 1] as char);
1474                    i += 2;
1475                    continue;
1476                }
1477                i += 1;
1478                if c == quote {
1479                    break;
1480                }
1481            }
1482            continue;
1483        }
1484        if b == b'/' && i + 1 < bytes.len() {
1485            let n = bytes[i + 1];
1486            if n == b'/' {
1487                // Line comment.
1488                while i < bytes.len() && bytes[i] != b'\n' {
1489                    i += 1;
1490                }
1491                continue;
1492            }
1493            if n == b'*' {
1494                // Block comment.
1495                i += 2;
1496                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1497                    i += 1;
1498                }
1499                i = (i + 2).min(bytes.len());
1500                continue;
1501            }
1502        }
1503        out.push(b as char);
1504        i += 1;
1505    }
1506    out
1507}
1508
1509const PHASE_C_BINDINGS_JS: &str = r##"
1510(function(host) {
1511  function protoGuard() {
1512    return Object.freeze(Object.create(null));
1513  }
1514
1515  function nullProtoObject() {
1516    var obj = Object.create(null);
1517    Object.defineProperty(obj, "__proto__", {
1518      value: protoGuard(),
1519      enumerable: false,
1520      configurable: false,
1521      writable: false
1522    });
1523    return obj;
1524  }
1525
1526  function lookupObject() {
1527    return Object.create(null);
1528  }
1529
1530  var deferredGlobalNames = lookupObject();
1531  [
1532    "app", "arguments", "Array", "Boolean", "Bun", "console", "Date",
1533    "decodeURI", "decodeURIComponent", "Deno", "encodeURI",
1534    "encodeURIComponent", "Error", "eval", "EvalError", "fetch",
1535    "Function", "globalThis", "Infinity", "isFinite", "isNaN", "JSON",
1536    "Map", "Math", "NaN", "Number", "Object", "parseFloat", "parseInt",
1537    "process", "RangeError", "ReferenceError", "RegExp", "require",
1538    "Set", "String", "Symbol", "SyntaxError", "TypeError", "undefined",
1539    "URIError", "WeakMap", "WeakSet", "WebSocket", "XMLHttpRequest",
1540    // JS2-01 (Sprint 2 Batch B): `form` is a top-level alias of `xfa.form`.
1541    // It must defer to globalThis so the host SOM resolver does not first
1542    // try to resolve `form` as a form-tree node, find nothing, and then
1543    // surface a `js_resolve_failure` for what is really a viewer global.
1544    "xfa", "event", "form"
1545  ].forEach(function(name) {
1546    deferredGlobalNames[name] = true;
1547  });
1548
1549  var reservedHandleProperties = lookupObject();
1550  [
1551    "__defineGetter__", "__defineSetter__", "__lookupGetter__",
1552    "__lookupSetter__", "__proto__", "constructor", "hasOwnProperty",
1553    "isPrototypeOf", "propertyIsEnumerable", "then", "toJSON",
1554    "toLocaleString", "toString", "valueOf"
1555  ].forEach(function(name) {
1556    reservedHandleProperties[name] = true;
1557  });
1558
1559  function shouldDeferGlobalName(name, localNames) {
1560    return name.charAt(0) === "_" ||
1561      localNames[name] === true ||
1562      deferredGlobalNames[name] === true;
1563  }
1564
1565  // Properties that must NOT be deferred so their specific implementations run.
1566  var handlePropertyExclusions = lookupObject();
1567  ["rawValue", "somExpression", "isNull", "clearItems", "addItem", "boundItem",
1568   "$record", "nodes", "value", "length", "item", "choiceList", "#items"].forEach(function(name) {
1569    handlePropertyExclusions[name] = true;
1570  });
1571
1572  function shouldDeferHandleProperty(name) {
1573    if (handlePropertyExclusions[name] === true) {
1574      return false;
1575    }
1576    return name.charAt(0) === "_" || reservedHandleProperties[name] === true;
1577  }
1578
1579  function collectLocalNames(body) {
1580    var locals = lookupObject();
1581    var match;
1582    var decls = /\b(?:var|let|const)\s+([^;]+)/g;
1583    while ((match = decls.exec(body)) !== null) {
1584      match[1].split(",").forEach(function(part) {
1585        var ident = /^\s*([A-Za-z_$][0-9A-Za-z_$]*)/.exec(part);
1586        if (ident) {
1587          locals[ident[1]] = true;
1588        }
1589      });
1590    }
1591    var funcs = /\bfunction\s+([A-Za-z_$][0-9A-Za-z_$]*)/g;
1592    while ((match = funcs.exec(body)) !== null) {
1593      locals[match[1]] = true;
1594    }
1595    return locals;
1596  }
1597
1598  // XFA-DATA-M3C: `<handle>.ui` returns the ui-config sub-object that
1599  // Adobe templates expose for choiceList / textEdit / checkButton widget
1600  // tweaking. During static flatten the widget tree is not materialised, so
1601  // we hand back a minimal stub whose `choiceList` is an empty frozen
1602  // array (matching the bare `handle.choiceList` fallback). Property
1603  // writes are absorbed silently so scripts like
1604  // `field.ui.choiceList.commitOn = "exit"` complete without TypeError.
1605  function makeUiStub() {
1606    var stub = nullProtoObject();
1607    Object.defineProperty(stub, "choiceList", {
1608      enumerable: true,
1609      configurable: false,
1610      get: function() { return Object.freeze([]); }
1611    });
1612    Object.defineProperty(stub, "textEdit", {
1613      enumerable: true,
1614      configurable: false,
1615      get: function() { return Object.freeze({}); }
1616    });
1617    Object.defineProperty(stub, "checkButton", {
1618      enumerable: true,
1619      configurable: false,
1620      get: function() { return Object.freeze({}); }
1621    });
1622    return new Proxy(stub, {
1623      get: function(target, prop) {
1624        if (typeof prop !== "string") return undefined;
1625        if (prop in target) return target[prop];
1626        // Sub-property writes / reads are absorbed: scripts walking
1627        // unknown widget config (e.g. `ui.imageEdit.<x>`) get a chainable
1628        // viewer-stub rather than crashing on undefined.
1629        return makeViewerStub();
1630      },
1631      set: function(_t, _p, _v) { return true; },
1632      has: function(_t, _p) { return true; }
1633    });
1634  }
1635
1636  // D5: minimal `node.occur` handle. Reads `min`/`max`/`initial` from the
1637  // structural template (host.occurRead; -1 = unlimited/unknown). Writes to
1638  // `min`/`max` are CAPTURED (host.occurCapture) as mutation intent and then
1639  // discarded — no layout/pagination effect in D5. This stops
1640  // `subform.occur.min = N` from throwing `cannot set property 'min' of
1641  // undefined` so the script proceeds; the captured intent is for the next
1642  // milestone's bounded apply path.
1643  function makeOccurHandle(id, generation) {
1644    var occ = nullProtoObject();
1645    function defineOccurProp(name) {
1646      Object.defineProperty(occ, name, {
1647        enumerable: true,
1648        configurable: false,
1649        get: function() {
1650          return host.occurRead(id, generation, name);
1651        },
1652        set: function(value) {
1653          var n = (typeof value === "number") ? value : parseInt(value, 10);
1654          if (isNaN(n)) { n = 0; }
1655          host.occurCapture(id, generation, name, n);
1656        }
1657      });
1658    }
1659    defineOccurProp("min");
1660    defineOccurProp("max");
1661    defineOccurProp("initial");
1662    return occ;
1663  }
1664
1665  function makeInstanceManager(id, generation) {
1666    var manager = nullProtoObject();
1667    Object.defineProperty(manager, "count", {
1668      enumerable: true,
1669      configurable: false,
1670      get: function() {
1671        return host.instanceCount(id, generation);
1672      }
1673    });
1674    Object.defineProperty(manager, "setInstances", {
1675      enumerable: true,
1676      configurable: false,
1677      writable: false,
1678      value: function(n) {
1679        return host.instanceSet(id, generation, n);
1680      }
1681    });
1682    Object.defineProperty(manager, "addInstance", {
1683      enumerable: true,
1684      configurable: false,
1685      writable: false,
1686      value: function() {
1687        var newId = host.instanceAdd(id, generation);
1688        return newId < 0 ? null : makeHandle(newId, generation);
1689      }
1690    });
1691    Object.defineProperty(manager, "removeInstance", {
1692      enumerable: true,
1693      configurable: false,
1694      writable: false,
1695      value: function(idx) {
1696        return host.instanceRemove(id, generation, idx);
1697      }
1698    });
1699    return Object.freeze(manager);
1700  }
1701
1702  function makeEmptyInstanceManager() {
1703    var manager = nullProtoObject();
1704    Object.defineProperty(manager, "count", {
1705      enumerable: true,
1706      configurable: false,
1707      value: 0
1708    });
1709    Object.defineProperty(manager, "setInstances", {
1710      enumerable: true,
1711      configurable: false,
1712      writable: false,
1713      value: function() { return 0; }
1714    });
1715    Object.defineProperty(manager, "addInstance", {
1716      enumerable: true,
1717      configurable: false,
1718      writable: false,
1719      value: function() { return null; }
1720    });
1721    Object.defineProperty(manager, "removeInstance", {
1722      enumerable: true,
1723      configurable: false,
1724      writable: false,
1725      value: function() { return false; }
1726    });
1727    return Object.freeze(manager);
1728  }
1729
1730  function uniqueNodeIds(ids) {
1731    var out = [];
1732    if (!ids) return out;
1733    for (var i = 0; i < ids.length; i++) {
1734      var id = ids[i] | 0;
1735      if (id < 0) continue;
1736      var seen = false;
1737      for (var j = 0; j < out.length; j++) {
1738        if (out[j] === id) {
1739          seen = true;
1740          break;
1741        }
1742      }
1743      if (!seen) out.push(id);
1744    }
1745    return out;
1746  }
1747
1748  function nodeIdListArg(ids) {
1749    return uniqueNodeIds(ids).join(",");
1750  }
1751
1752  function resolveHandleChildIds(ids, prop) {
1753    var list = nodeIdListArg(ids);
1754    if (list.length === 0) return [];
1755    var childIds = uniqueNodeIds(host.resolveChildNodeIds(list, prop));
1756    if (childIds.length > 0) return childIds;
1757    return uniqueNodeIds(host.resolveScopedNodeIds(list, prop));
1758  }
1759
1760  function makeNodeHandleFromIds(ids, generation) {
1761    var unique = uniqueNodeIds(ids);
1762    if (unique.length === 0) return undefined;
1763    if (unique.length === 1) return makeHandle(unique[0], generation);
1764    return makeCandidateSet(unique, generation);
1765  }
1766
1767  // Phase D-θ: single-segment SOM lookahead. The proxy chain `F.P1.X.rawValue`
1768  // can resolve the leading `F.P1` correctly yet pick the wrong `P1` when
1769  // multiple same-named siblings exist — the resolver lacks the next-segment
1770  // context needed to disambiguate. These helpers return a one-step deferred
1771  // wrapper that, on its next property access, asks the host's hinted
1772  // resolver to drop candidates whose subtree does not contain the lookahead
1773  // name. Terminal properties (rawValue, instanceManager, …) bypass the hint
1774  // path entirely so single-token reads remain byte-for-byte identical.
1775  function isTerminalHandleProp(name) {
1776    if (handlePropertyExclusions[name] === true) return true;
1777    if (reservedHandleProperties[name] === true) return true;
1778    if (name.charAt(0) === "_") return true;
1779    switch (name) {
1780      case "rawValue":
1781      case "somExpression":
1782      case "instanceManager":
1783      case "index":
1784      case "setInstances":
1785      case "addInstance":
1786      case "removeInstance":
1787      case "isNull":
1788      case "clearItems":
1789      case "addItem":
1790      case "boundItem":
1791      case "#items":
1792      case "$record":
1793      case "variables":
1794      case "nodes":
1795      case "length":
1796      case "value":
1797      case "item":
1798        return true;
1799      default:
1800        return false;
1801    }
1802  }
1803
1804  function makeImplicitDeferred(currentId, name, eagerIds, generation) {
1805    function realize(hint) {
1806      if (!hint) return eagerIds;
1807      var refined = uniqueNodeIds(host.resolveImplicitNodeIdsHinted(currentId, name, hint));
1808      return refined.length > 0 ? refined : eagerIds;
1809    }
1810    var sentinel = nullProtoObject();
1811    return new Proxy(sentinel, {
1812      get: function(target, prop, receiver) {
1813        if (typeof prop !== "string") {
1814          return Reflect.get(target, prop, receiver);
1815        }
1816        // XFA-DATA2-02: short-circuit caption / resolveNode pair before any
1817        // host call. See captionSentinel + handleResolveNode definitions.
1818        if (prop === "caption") { return captionSentinel; }
1819        if (prop === "resolveNode") { return handleResolveNode; }
1820        if (prop === "resolveNodes") { return handleResolveNodes; }
1821        var ids = isTerminalHandleProp(prop) ? eagerIds : realize(prop);
1822        var handle = makeNodeHandleFromIds(ids, generation);
1823        if (handle === undefined) return undefined;
1824        return handle[prop];
1825      },
1826      set: function(_target, prop, value) {
1827        var handle = makeNodeHandleFromIds(eagerIds, generation);
1828        if (handle === undefined) return true;
1829        handle[prop] = value;
1830        return true;
1831      },
1832      has: function(_target, prop) {
1833        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
1834        return true;
1835      }
1836    });
1837  }
1838
1839  function makeChildDeferred(parentIds, childName, eagerChildIds, generation) {
1840    var parentList = nodeIdListArg(parentIds);
1841    function realize(hint) {
1842      if (!hint || parentList.length === 0) return eagerChildIds;
1843      var refined = uniqueNodeIds(host.resolveChildNodeIdsHinted(parentList, childName, hint));
1844      return refined.length > 0 ? refined : eagerChildIds;
1845    }
1846    var sentinel = nullProtoObject();
1847    return new Proxy(sentinel, {
1848      get: function(target, prop, receiver) {
1849        if (typeof prop !== "string") {
1850          return Reflect.get(target, prop, receiver);
1851        }
1852        // XFA-DATA2-02: short-circuit caption / resolveNode pair before any
1853        // host call. See captionSentinel + handleResolveNode definitions.
1854        if (prop === "caption") { return captionSentinel; }
1855        if (prop === "resolveNode") { return handleResolveNode; }
1856        if (prop === "resolveNodes") { return handleResolveNodes; }
1857        var ids = isTerminalHandleProp(prop) ? eagerChildIds : realize(prop);
1858        var handle = makeNodeHandleFromIds(ids, generation);
1859        if (handle === undefined) return undefined;
1860        return handle[prop];
1861      },
1862      set: function(_target, prop, value) {
1863        var handle = makeNodeHandleFromIds(eagerChildIds, generation);
1864        if (handle === undefined) return true;
1865        handle[prop] = value;
1866        return true;
1867      },
1868      has: function(_target, prop) {
1869        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
1870        return true;
1871      }
1872    });
1873  }
1874
1875  function makeChainHandle(parentIds, childName, generation) {
1876    var eagerChildIds = resolveHandleChildIds(parentIds, childName);
1877    if (eagerChildIds.length === 0) return undefined;
1878    if (eagerChildIds.length === 1) {
1879      // Singleton child with a single parent leaves the host resolver no
1880      // alternatives to filter even when a hint is provided — same-name
1881      // sibling disambiguation is impossible. Skip the wrapper so the
1882      // common path keeps its original Proxy identity and resolve budget.
1883      return makeHandle(eagerChildIds[0], generation);
1884    }
1885    // Phase D-θ.2: same-name siblings exist; defer through the full-chain
1886    // accumulator so a deeper segment (still unread) can disambiguate.
1887    return makeChainProxy(parentIds, [childName], eagerChildIds, generation, -1);
1888  }
1889
1890  // Phase D-θ.2: lazy chain proxy. Accumulates a SOM chain (string[]) as the
1891  // script walks property access; on the first terminal property read it
1892  // asks the host to resolve the FULL accumulated chain in one call,
1893  // letting the host backtrack and pick the same-name candidate at any
1894  // depth whose subtree actually completes the chain.
1895  //
1896  // Compared to D-θ.1 (single-segment hint), this lets `A.B.C.D.rawValue`
1897  // pick the correct same-name `A` not only when one is the parent of `B`
1898  // but specifically the one whose subtree contains the full B.C.D chain.
1899  //
1900  // Parameters:
1901  //   parentIds            — entry parent set; empty array means implicit walk.
1902  //   chain                — string[] of segments accumulated so far (>=1).
1903  //   eagerIds             — fallback ids returned if the host's full-chain
1904  //                          resolve produces no candidate at any depth
1905  //                          (preserves D-θ.1 byte-for-byte behaviour for
1906  //                          chains the host cannot improve on).
1907  //   generation           — handle generation to bind to.
1908  //   currentId            — implicit-walk anchor (used when parentIds is empty).
1909  //
1910  // Terminal property names trigger immediate resolution. Non-terminal
1911  // string property names return a NEW chain proxy with the property name
1912  // appended; no host call happens at that point.
1913  function makeChainProxy(parentIds, chain, eagerIds, generation, currentId) {
1914    var sentinel = nullProtoObject();
1915    function fullChain(extraSeg) {
1916      var parentCsv = nodeIdListArg(parentIds);
1917      var chainArr = chain;
1918      if (extraSeg !== undefined) {
1919        chainArr = chain.slice();
1920        chainArr.push(extraSeg);
1921      }
1922      var chainCsv = chainArr.join(",");
1923      var resolved = uniqueNodeIds(
1924        host.resolveWithFullChain(parentCsv, currentId | 0, chainCsv)
1925      );
1926      if (resolved.length === 0) return eagerIds;
1927      return resolved;
1928    }
1929    return new Proxy(sentinel, {
1930      get: function(target, prop, receiver) {
1931        if (typeof prop !== "string") {
1932          // Symbol.toPrimitive and similar non-string keys must force
1933          // terminal resolution so coercion (e.g. via Number(handle)) sees
1934          // the resolved candidate's primitive view rather than the proxy
1935          // sentinel itself.
1936          var primIds = fullChain();
1937          var primHandle = makeNodeHandleFromIds(primIds, generation);
1938          if (primHandle === undefined) {
1939            return Reflect.get(target, prop, receiver);
1940          }
1941          return primHandle[prop];
1942        }
1943        // XFA-DATA2-02: caption / resolveNode / resolveNodes short-circuit on
1944        // the chain proxy. Without this branch the chain accumulator would
1945        // append "caption" as a non-terminal segment, call
1946        // resolveWithFullChainStrict, miss, and return `undefined` —
1947        // `field.caption.value` then TypeErrors. Same null/empty-list
1948        // contract as the global pair.
1949        if (prop === "caption") {
1950          return captionSentinel;
1951        }
1952        if (prop === "resolveNode") {
1953          return handleResolveNode;
1954        }
1955        if (prop === "resolveNodes") {
1956          return handleResolveNodes;
1957        }
1958        if (isTerminalHandleProp(prop)) {
1959          var ids = fullChain();
1960          var handle = makeNodeHandleFromIds(ids, generation);
1961          if (handle === undefined) return undefined;
1962          return handle[prop];
1963        }
1964        // Non-terminal: probe the extended chain in STRICT mode. If the
1965        // host cannot complete `[chain..., prop]` at full depth anywhere
1966        // in the form, return `undefined` — mirroring the eager D-θ.1
1967        // behaviour where `A.B` resolved to `undefined` when no `A.B`
1968        // existed. When the probe succeeds, return a new chain proxy
1969        // with the segment appended; the lazy accumulation lets a
1970        // deeper segment still disambiguate which same-name `A` to keep.
1971        //
1972        // D-θ.2 probe-skip: when there is exactly one parent and the chain
1973        // so far has exactly one segment, no same-name sibling ambiguity is
1974        // possible — the parent uniquely identifies the node, and there are
1975        // no alternative subtrees for the host to choose between.  In that
1976        // case the strict probe would always return the same set as eagerIds,
1977        // so we skip the host round-trip and immediately build the next proxy
1978        // using eagerIds as the fallback.  The host-call budget is preserved
1979        // and disambiguation is unaffected because a single-parent / single-
1980        // segment chain cannot be disambiguated further by the host anyway.
1981        var nextChain = chain.slice();
1982        nextChain.push(prop);
1983        if (parentIds.length === 1 && chain.length === 1) {
1984          host.probeSkip();
1985          return makeChainProxy(parentIds, nextChain, eagerIds, generation, currentId);
1986        }
1987        var probe = host.resolveWithFullChainStrict(
1988          nodeIdListArg(parentIds),
1989          currentId | 0,
1990          nextChain.join(",")
1991        );
1992        if (!probe || probe.length === 0) {
1993          return undefined;
1994        }
1995        // The probe result becomes the new eager-fallback so future
1996        // probes that the host cannot improve on still degrade
1997        // gracefully. We must NOT collapse to a single makeHandle here:
1998        // the chain may continue to grow and disambiguate further.
1999        var nextEager = uniqueNodeIds(probe);
2000        return makeChainProxy(parentIds, nextChain, nextEager, generation, currentId);
2001      },
2002      set: function(_target, prop, value) {
2003        // Writes are always terminal: resolve the full chain and forward.
2004        var ids = fullChain();
2005        var handle = makeNodeHandleFromIds(ids, generation);
2006        if (handle === undefined) return true;
2007        handle[prop] = value;
2008        return true;
2009      },
2010      has: function(_target, prop) {
2011        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
2012        return true;
2013      }
2014    });
2015  }
2016
2017  function makeCandidateSet(ids, generation) {
2018    var candidates = uniqueNodeIds(ids);
2019    if (candidates.length === 0) return undefined;
2020    var firstId = candidates[0];
2021    var obj = nullProtoObject();
2022    return new Proxy(obj, {
2023      get: function(target, prop, receiver) {
2024        if (typeof prop !== "string") {
2025          return Reflect.get(target, prop, receiver);
2026        }
2027        if (prop === "rawValue") {
2028          var value = host.getRawValue(firstId, generation);
2029          return value === undefined ? null : value;
2030        }
2031        if (prop === "somExpression") {
2032          return "xfa[0].form[0].placeholder";
2033        }
2034        if (prop === "instanceManager") {
2035          return makeInstanceManager(firstId, generation);
2036        }
2037        if (prop === "occur") {
2038          host.occurResolve(firstId, generation);
2039          return makeOccurHandle(firstId, generation);
2040        }
2041        if (prop === "index") {
2042          return host.nodeIndex(firstId, generation);
2043        }
2044        // XFA-DATA-M3C: `<candidateSet>.parent` walks one form-tree level up
2045        // from the leading candidate. Same semantics as `<handle>.parent`;
2046        // see makeHandle for rationale.
2047        if (prop === "parent") {
2048          var parentId = host.parentOfNode(firstId, generation);
2049          if (parentId < 0) return undefined;
2050          return makeHandle(parentId, generation);
2051        }
2052        if (prop === "setInstances") {
2053          return function(n) {
2054            return host.instanceSet(firstId, generation, n);
2055          };
2056        }
2057        if (prop === "addInstance") {
2058          return function() {
2059            var newId = host.instanceAdd(firstId, generation);
2060            // WP-3 F3: return a chainable null-safe sentinel rather than null
2061            // so scripts that immediately access `.index` / `.value` on the
2062            // newly-added handle do not throw when the instance manager
2063            // refuses (min/max reached, unbound).
2064            return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
2065          };
2066        }
2067        if (prop === "removeInstance") {
2068          return function(idx) {
2069            return host.instanceRemove(firstId, generation, idx);
2070          };
2071        }
2072        if (prop === "isNull") {
2073          var raw = host.getRawValue(firstId, generation);
2074          return raw === undefined || raw === null || raw === "";
2075        }
2076        if (prop === "clearItems") {
2077          return function() {
2078            return host.listClear(firstId, generation);
2079          };
2080        }
2081        if (prop === "addItem") {
2082          return function(display, save) {
2083            if (save === undefined) {
2084              return host.listAdd(firstId, generation, String(display));
2085            }
2086            return host.listAdd(firstId, generation, String(display), String(save));
2087          };
2088        }
2089        if (prop === "boundItem") {
2090          return function(displayValue) {
2091            var coerced = displayValue === null || displayValue === undefined ?
2092              "" : String(displayValue);
2093            return host.boundItem(firstId, generation, coerced);
2094          };
2095        }
2096        if (prop === "$record") {
2097          var recRaw = host.dataBoundRecord(firstId, generation);
2098          if (recRaw < 0) return makeNullDataHandle();
2099          return makeDataHandle(recRaw);
2100        }
2101        if (prop === "variables") {
2102          var csNodeName = host.nodeName(firstId, generation);
2103          if (typeof csNodeName === "string" && csNodeName.length > 0 &&
2104              subformVariables[csNodeName] !== undefined) {
2105            return subformVariables[csNodeName];
2106          }
2107          return Object.create(null);
2108        }
2109        if (prop.charAt(0) === "_" && prop.length > 1) {
2110          var bareName = prop.substring(1);
2111          var imIds = uniqueNodeIds(
2112            host.resolveChildNodeIdsQuiet(nodeIdListArg(candidates), bareName)
2113          );
2114          if (imIds.length > 0) {
2115            return makeInstanceManager(imIds[0], generation);
2116          }
2117          for (var imIdx = 0; imIdx < candidates.length; imIdx++) {
2118            if (host.hasZeroInstanceRun(candidates[imIdx], generation, bareName)) {
2119              return makeEmptyInstanceManager();
2120            }
2121          }
2122          // XFA-DATA-M3C: same fallthrough as makeHandle underscore branch —
2123          // expose an empty instanceManager for schema-optional same-named
2124          // siblings that the merged tree omits, keeping schema-bound
2125          // scripts chainable. Restricted to container candidates so
2126          // field/draw candidate sets keep the original `undefined`
2127          // semantics for underscored property reads.
2128          if (host.nodeIsContainer(firstId, generation)) {
2129            return makeEmptyInstanceManager();
2130          }
2131        }
2132        // WP-3: choiceList is a listbox/combobox property not surfaced through
2133        // the FormTree. Return an empty frozen array.
2134        if (prop === "choiceList") {
2135          return Object.freeze([]);
2136        }
2137        // BE-1: `#items` on a candidate-set — mirrors the makeHandle path.
2138        // Uses the first candidate as the representative field id (same
2139        // convention as boundItem / clearItems / addItem above).
2140        if (prop === "#items") {
2141          var csDisplayItems = host.getDisplayItems(firstId, generation);
2142          if (csDisplayItems.length > 0) {
2143            host.somItemsPathHit();
2144          }
2145          return makeItemsSubstitute(csDisplayItems);
2146        }
2147        // XFA-DATA-M3C: `<candidateSet>.ui` returns the widget-config stub.
2148        // See makeUiStub on makeHandle for rationale.
2149        if (prop === "ui") {
2150          return makeUiStub();
2151        }
2152        if (prop === "formattedValue") {
2153          var fvc = host.getRawValue(firstId, generation);
2154          return fvc === undefined || fvc === null ? "" : String(fvc);
2155        }
2156        if (prop === "execEvent") {
2157          return function() { return undefined; };
2158        }
2159        // XFA-DATA2-02: caption / resolveNode / resolveNodes intercept on the
2160        // candidate-set path. Same rationale as makeHandle: skip the child
2161        // resolver host call and its resolve_failures bump, return a frozen
2162        // sentinel + the chainable method pair. Null contract preserved.
2163        if (prop === "caption") {
2164          return captionSentinel;
2165        }
2166        if (prop === "resolveNode") {
2167          return handleResolveNode;
2168        }
2169        if (prop === "resolveNodes") {
2170          return handleResolveNodes;
2171        }
2172        if (shouldDeferHandleProperty(prop)) {
2173          return undefined;
2174        }
2175        return makeChainHandle(candidates, prop, generation);
2176      },
2177      set: function(_target, prop, value) {
2178        // WP-3: `value` is an alias for rawValue on the set side.
2179        if (prop === "rawValue" || prop === "value") {
2180          host.setRawValue(firstId, generation, value);
2181        }
2182        return true;
2183      },
2184      has: function(target, prop) {
2185        if (typeof prop !== "string") {
2186          return Reflect.has(target, prop);
2187        }
2188        return prop === "rawValue" ||
2189          prop === "somExpression" ||
2190          prop === "instanceManager" ||
2191          prop === "index" ||
2192          prop === "parent" ||
2193          prop === "setInstances" ||
2194          prop === "addInstance" ||
2195          prop === "removeInstance" ||
2196          prop === "isNull" ||
2197          prop === "clearItems" ||
2198          prop === "addItem" ||
2199          prop === "boundItem" ||
2200          Reflect.has(target, prop);
2201      }
2202    });
2203  }
2204
2205  // XFA-DATA2-02: shared caption sentinel — singleton, frozen, null-safe.
2206  //
2207  // XFA templates commonly inspect `field.caption.value` or
2208  // `field.caption.text` to drive labels and conditional formatting. The
2209  // merged FormTree does not carry the optional `<caption>` sub-element, so
2210  // looking up `caption` as a normal child name would call
2211  // `resolve_child_candidates` → NoMatch → bump `resolve_failures`, return
2212  // `undefined`, and the very next `.value` access would throw TypeError
2213  // ("cannot read property 'value' of undefined") and abort the rest of the
2214  // script's initializers.
2215  //
2216  // We intercept `caption` BEFORE the child-resolver call and return a
2217  // chainable sentinel whose terminal reads are empty strings and whose
2218  // deeper reads keep chaining via `makeNullDataHandle`. This is purely
2219  // read-side: writes are silently absorbed (caption sub-element is a
2220  // viewer-only field, never a flatten mutation channel). The sentinel is
2221  // allocated once at factory build-time and frozen, so per-call access is
2222  // an `Object.freeze`d Proxy hit with no heap allocation.
2223  var captionSentinel = (function() {
2224    var base = nullProtoObject();
2225    Object.defineProperty(base, "value",   { enumerable: true, configurable: false,
2226      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
2227    Object.defineProperty(base, "text",    { enumerable: true, configurable: false,
2228      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
2229    Object.defineProperty(base, "name",    { enumerable: true, configurable: false,
2230      get: function() { return "caption"; } });
2231    Object.defineProperty(base, "rawValue",{ enumerable: true, configurable: false,
2232      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
2233    Object.defineProperty(base, "isNull",  { enumerable: true, configurable: false,
2234      get: function() { return true; } });
2235    return new Proxy(base, {
2236      get: function(target, prop) {
2237        if (prop in target || typeof prop !== "string") return target[prop];
2238        // Unknown reads stay chainable so `caption.font.typeface` etc. do
2239        // not throw. The sentinel is read-only and write-absorbing.
2240        return makeNullDataHandle();
2241      },
2242      set: function(_t, _p, _v) { return true; },
2243      has: function() { return true; }
2244    });
2245  })();
2246
2247  // XFA-DATA2-02: shared `resolveNode` / `resolveNodes` instance methods.
2248  //
2249  // Beyond the global `xfa.resolveNode(path)`, XFA scripts also call these
2250  // as methods on individual handles (`field.resolveNode("Subform.X")`,
2251  // `subform.resolveNodes("$.field[*]")`). Without an explicit shortcut these
2252  // property reads would fall through to `makeChainHandle`, miss in the
2253  // child resolver and inflate `js_resolve_failures` — the very pattern the
2254  // 60df78fe corpus replay flagged.
2255  //
2256  // Behaviour mirrors the global pair, including the data-path routing for
2257  // `data.`, `$data.`, and `xfa.datasets.data.` prefixes. The Cluster C
2258  // null-return contract is preserved: a miss in the host returns native
2259  // `null` for the singular form and a frozen empty array for the plural.
2260  // BE-1: XFA-compatible substitute for the <items> element returned by
2261  // resolveNode("FieldName.#items").  In a live viewer the result is an XFA
2262  // node whose .nodes collection contains one <text> child per option label;
2263  // each child exposes .value / .rawValue.  Scripts iterate:
2264  //   for (var i=0; i<items.nodes.length; i++) { items.nodes.item(i).value }
2265  // We materialise that shape from the host display-strings array so existing
2266  // scripts complete without TypeError and can read the label list.
2267  function makeItemsSubstitute(displayStrings) {
2268    var nodes = [];
2269    for (var k = 0; k < displayStrings.length; k++) {
2270      (function(label) {
2271        var textNode = nullProtoObject();
2272        Object.defineProperty(textNode, "value", {
2273          enumerable: true, configurable: false, writable: false, value: label
2274        });
2275        Object.defineProperty(textNode, "rawValue", {
2276          enumerable: false, configurable: false, writable: false, value: label
2277        });
2278        nodes.push(Object.freeze(textNode));
2279      })(displayStrings[k]);
2280    }
2281    var frozenNodes = Object.freeze(nodes);
2282    var nodesCollection = nullProtoObject();
2283    Object.defineProperty(nodesCollection, "length", {
2284      enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
2285    });
2286    Object.defineProperty(nodesCollection, "item", {
2287      enumerable: false, configurable: false, writable: false,
2288      value: function(idx) { return frozenNodes[idx] || null; }
2289    });
2290    var substitute = nullProtoObject();
2291    Object.defineProperty(substitute, "nodes", {
2292      enumerable: true, configurable: false, writable: false, value: Object.freeze(nodesCollection)
2293    });
2294    Object.defineProperty(substitute, "length", {
2295      enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
2296    });
2297    return Object.freeze(substitute);
2298  }
2299
2300  function handleResolveNode(path) {
2301    if (typeof path === "string" &&
2302        (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
2303         path.indexOf("xfa.datasets.data.") === 0)) {
2304      var rawId = host.dataResolveNode(path);
2305      if (rawId < 0) return null;
2306      return makeDataHandle(rawId);
2307    }
2308    // BE-1: intercept `FieldName.#items` (XFA 3.3 §7.7 / §8.1).
2309    // `#items` is the SOM class reference for a choiceList's <items> element.
2310    // The form-tree resolver has no node for it; we resolve the field part,
2311    // fetch its display labels, and return an XFA-compatible substitute.
2312    // Guard: path.length > 7 prevents false-positive when lastIndexOf returns
2313    // -1 and path.length-7 is also -1 (paths shorter than 8 chars).
2314    if (typeof path === "string" && path.length > 7 &&
2315        path.lastIndexOf(".#items") === path.length - 7) {
2316      var fieldPath = path.substring(0, path.length - 7);
2317      var fid = host.resolveNodeId(fieldPath);
2318      if (fid >= 0) {
2319        var displayItems = host.getDisplayItems(fid, host.generation());
2320        if (displayItems.length > 0) {
2321          host.somItemsPathHit();
2322        }
2323        return makeItemsSubstitute(displayItems);
2324      }
2325      return null;
2326    }
2327    var nid = host.resolveNodeId(path);
2328    if (nid < 0) return null;
2329    return makeHandle(nid, host.generation());
2330  }
2331  function handleResolveNodes(path) {
2332    if (typeof path === "string" &&
2333        (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
2334         path.indexOf("xfa.datasets.data.") === 0)) {
2335      var rawIds = host.dataResolveNodes(path);
2336      var out = [];
2337      for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
2338      return Object.freeze(out);
2339    }
2340    var generation = host.generation();
2341    var ids = host.resolveNodeIds(path);
2342    var out2 = [];
2343    for (var j = 0; j < ids.length; j++) {
2344      out2.push(makeHandle(ids[j], generation));
2345    }
2346    return Object.freeze(out2);
2347  }
2348
2349  function makeHandle(id, generation) {
2350    var obj = nullProtoObject();
2351    Object.defineProperty(obj, "rawValue", {
2352      enumerable: true,
2353      configurable: false,
2354      get: function() {
2355        var value = host.getRawValue(id, generation);
2356        return value === undefined ? null : value;
2357      },
2358      set: function(value) {
2359        host.setRawValue(id, generation, value);
2360      }
2361    });
2362    // Phase C-α: defensive stub. Real Adobe forms call
2363    // `this.somExpression` to obtain the SOM path string. We don't expose
2364    // the real SOM path (introspection capability), but returning a
2365    // placeholder lets viewer-tweak scripts (e.g. acroSOM substr(15))
2366    // proceed without ReferenceError. Mutations via this handle still
2367    // require the rawValue setter, which is the only side-effect channel.
2368    Object.defineProperty(obj, "somExpression", {
2369      enumerable: false,
2370      configurable: false,
2371      get: function() {
2372        return "xfa[0].form[0].placeholder";
2373      }
2374    });
2375    return new Proxy(obj, {
2376      get: function(target, prop, receiver) {
2377        if (typeof prop !== "string") {
2378          return Reflect.get(target, prop, receiver);
2379        }
2380        if (prop === "rawValue" || prop === "somExpression") {
2381          return Reflect.get(target, prop, receiver);
2382        }
2383        if (prop === "instanceManager") {
2384          return makeInstanceManager(id, generation);
2385        }
2386        if (prop === "occur") {
2387          host.occurResolve(id, generation);
2388          return makeOccurHandle(id, generation);
2389        }
2390        if (prop === "index") {
2391          return host.nodeIndex(id, generation);
2392        }
2393        // XFA-DATA-M3C: `<handle>.parent` returns the form-tree parent.
2394        // Adobe scripts commonly chain `parent.somExpression`,
2395        // `parent.index`, or `parent._Sibling.setInstances(...)` to walk
2396        // one level up from a field or subform without authoring an
2397        // explicit SOM path.
2398        if (prop === "parent") {
2399          var parentId = host.parentOfNode(id, generation);
2400          if (parentId < 0) return undefined;
2401          return makeHandle(parentId, generation);
2402        }
2403        if (prop === "setInstances") {
2404          return function(n) {
2405            return host.instanceSet(id, generation, n);
2406          };
2407        }
2408        if (prop === "addInstance") {
2409          return function() {
2410            var newId = host.instanceAdd(id, generation);
2411            // WP-3 F3: chainable null-safe sentinel on failure (see candidateSet).
2412            return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
2413          };
2414        }
2415        if (prop === "removeInstance") {
2416          return function(idx) {
2417            return host.instanceRemove(id, generation, idx);
2418          };
2419        }
2420        if (prop === "isNull") {
2421          var value = host.getRawValue(id, generation);
2422          return value === undefined || value === null || value === "";
2423        }
2424        if (prop === "clearItems") {
2425          return function() {
2426            return host.listClear(id, generation);
2427          };
2428        }
2429        if (prop === "addItem") {
2430          return function(display, save) {
2431            if (save === undefined) {
2432              return host.listAdd(id, generation, String(display));
2433            }
2434            return host.listAdd(id, generation, String(display), String(save));
2435          };
2436        }
2437        // XFA 3.3 §App A `boundItem` — listbox display→save lookup. Used as
2438        // `field.boundItem(xfa.event.newText)` to translate a user-visible
2439        // option label into its underlying save value. Falls back to the
2440        // input string when no match exists (Adobe behaviour).
2441        if (prop === "boundItem") {
2442          return function(displayValue) {
2443            var coerced;
2444            if (displayValue === null || displayValue === undefined) {
2445              coerced = "";
2446            } else {
2447              coerced = String(displayValue);
2448            }
2449            return host.boundItem(id, generation, coerced);
2450          };
2451        }
2452        if (prop === "$record") {
2453          var recRaw = host.dataBoundRecord(id, generation);
2454          if (recRaw < 0) return makeNullDataHandle();
2455          return makeDataHandle(recRaw);
2456        }
2457        // Phase D-ι.2: `subformHandle.variables` returns the namespace object
2458        // holding all `<variables><script>` entries registered for this
2459        // subform by name. Enables `Page2.variables.ValidationScript.fn()`.
2460        if (prop === "variables") {
2461          var nodeName = host.nodeName(id, generation);
2462          if (typeof nodeName === "string" && nodeName.length > 0 &&
2463              subformVariables[nodeName] !== undefined) {
2464            return subformVariables[nodeName];
2465          }
2466          return Object.create(null);
2467        }
2468        // XFA 3.3 §6.4.3.2 underscore shorthand: `_<name>` on a subform
2469        // refers to the instanceManager of the same-named child subform.
2470        // Used in the wild as `parent._child.setInstances(N)`. This MUST
2471        // run before `shouldDeferHandleProperty`, which otherwise returns
2472        // `undefined` for every underscore-prefixed property and makes the
2473        // shorthand unreachable for real bound subforms.
2474        if (prop.charAt(0) === "_" && prop.length > 1) {
2475          var bareName = prop.substring(1);
2476          var imChildIds = uniqueNodeIds(host.resolveChildNodeIdsQuiet(String(id), bareName));
2477          if (imChildIds.length > 0) {
2478            return makeInstanceManager(imChildIds[0], generation);
2479          }
2480          if (host.hasZeroInstanceRun(id, generation, bareName)) {
2481            return makeEmptyInstanceManager();
2482          }
2483          // XFA-DATA-M3C: when the same-named child subform is absent from
2484          // the merged form tree (occur.initial=0, optional bind, or
2485          // never-instantiated schema option), Adobe still hands the
2486          // script an empty instanceManager so `parent._Foo.setInstances(N)`
2487          // is a chainable no-op. This sentinel is ONLY safe on container
2488          // handles (root, subform, area, subformSet, exclGroup) — XFA
2489          // 3.3 §6.4.3.2 limits the underscore-shorthand to those node
2490          // classes. Fields and draws stay `undefined` so existing
2491          // narrow-handle tests (m3b_phaseC_bindings::field_handle_is_frozen_and_narrow)
2492          // keep their property-isolation contract.
2493          if (host.nodeIsContainer(id, generation)) {
2494            return makeEmptyInstanceManager();
2495          }
2496        }
2497        // WP-3: choiceList is a listbox/combobox property not surfaced through
2498        // the FormTree. Return an empty frozen array.
2499        if (prop === "choiceList") {
2500          return Object.freeze([]);
2501        }
2502        // XFA-DATA-M3C: `<handle>.ui` exposes the widget-config sub-object.
2503        // We materialise a chainable stub (see makeUiStub) so scripts like
2504        // `field.ui.choiceList.commitOn = "exit"` complete without throwing.
2505        if (prop === "ui") {
2506          return makeUiStub();
2507        }
2508        // XFA-DATA-M3C: `<handle>.formattedValue` (Adobe SDK §JS A) reads
2509        // the field value formatted by its picture clause. Static flatten
2510        // has no live picture-clause formatter; return the raw value so
2511        // scripts can compare-and-branch without TypeError.
2512        if (prop === "formattedValue") {
2513          var fv = host.getRawValue(id, generation);
2514          return fv === undefined || fv === null ? "" : String(fv);
2515        }
2516        // XFA-DATA-M3C: `<handle>.execEvent("activity")` (Adobe SDK) fires
2517        // an event handler. Static flatten cannot dispatch new events
2518        // mid-script; absorb the call as a no-op returning undefined.
2519        if (prop === "execEvent") {
2520          return function() { return undefined; };
2521        }
2522        // XFA-DATA2-02: caption sub-element + resolveNode/resolveNodes
2523        // instance methods. Intercepted BEFORE shouldDeferHandleProperty +
2524        // makeChainHandle so the child-resolver host call (and its
2525        // resolve_failures bump) never happens. See captionSentinel /
2526        // handleResolveNode/Nodes definitions above for rationale.
2527        if (prop === "caption") {
2528          return captionSentinel;
2529        }
2530        if (prop === "resolveNode") {
2531          return handleResolveNode;
2532        }
2533        if (prop === "resolveNodes") {
2534          return handleResolveNodes;
2535        }
2536        // BE-1: `#items` — XFA 3.3 §7.7 / §8.1 choiceList item labels.
2537        // Scripts access `fieldHandle.#items` (e.g. `Wojewodztwo.#items`) to
2538        // read the display-label collection.  Returns an XFA-compatible
2539        // substitute (makeItemsSubstitute) so scripts can use both direct
2540        // array indexing and the `.nodes` / `.nodes.item(i)` access pattern.
2541        // `#` bypasses `shouldDeferHandleProperty` via `handlePropertyExclusions`.
2542        if (prop === "#items") {
2543          var displayItems = host.getDisplayItems(id, generation);
2544          if (displayItems.length > 0) {
2545            host.somItemsPathHit();
2546          }
2547          return makeItemsSubstitute(displayItems);
2548        }
2549        if (shouldDeferHandleProperty(prop)) {
2550          return undefined;
2551        }
2552        return makeChainHandle([id], prop, generation);
2553      },
2554      set: function(_target, prop, value) {
2555        // WP-3: `value` is an alias for rawValue on the set side.
2556        if (prop === "rawValue" || prop === "value") {
2557          host.setRawValue(id, generation, value);
2558        }
2559        return true;
2560      },
2561      has: function(target, prop) {
2562        if (typeof prop !== "string") {
2563          return Reflect.has(target, prop);
2564        }
2565        return prop === "rawValue" ||
2566          prop === "somExpression" ||
2567          prop === "instanceManager" ||
2568          prop === "index" ||
2569          prop === "parent" ||
2570          prop === "ui" ||
2571          prop === "formattedValue" ||
2572          prop === "execEvent" ||
2573          prop === "setInstances" ||
2574          prop === "addInstance" ||
2575          prop === "removeInstance" ||
2576          prop === "isNull" ||
2577          prop === "clearItems" ||
2578          prop === "addItem" ||
2579          prop === "boundItem" ||
2580          prop === "caption" ||
2581          prop === "resolveNode" ||
2582          prop === "resolveNodes" ||
2583          prop === "#items" ||
2584          Reflect.has(target, prop);
2585      }
2586    });
2587  }
2588
2589  // Phase C-α: viewer-stub that absorbs property writes silently.
2590  // Used as the return value of `event.target.getField()` so
2591  // AcroForm widget-tweak scripts (`field.doNotScroll = true`,
2592  // `field.required = false`, etc.) complete without error and
2593  // without mutating any flatten-relevant state.
2594  function makeViewerStub() {
2595    return new Proxy({}, {
2596      get: function(_t, _prop) { return undefined; },
2597      set: function(_t, _prop, _val) {
2598        
2599        return true;
2600      },
2601      has: function() { return true; }
2602    });
2603  }
2604
2605  function toListIndex(value) {
2606    var n = Number(value);
2607    if (!isFinite(n) || n < 0) return -1;
2608    return Math.floor(n);
2609  }
2610
2611  // XFA §8.5: a `$record` reference when there is no bound data node must return
2612  // an empty object that chains safely (`.value` → null, `.nodes.length` → 0)
2613  // rather than null, which would throw TypeError on any property access.
2614  function makeNullDataHandle() {
2615    var emptyNodes = [];
2616    // XFA null-safe: item() on an empty sentinel list returns a chainable null handle,
2617    // not native null, so callers can do nodes.item(0).value without TypeError.
2618    emptyNodes.item = function() { return makeNullDataHandle(); };
2619    Object.freeze(emptyNodes);
2620    var sentinel = nullProtoObject();
2621    // WP-3: configurable:true so the Proxy set-trap can return true without
2622    // triggering the ECMAScript "non-configurable accessor without setter" invariant.
2623    Object.defineProperty(sentinel, "value",
2624      { get: function() { return null; }, enumerable: true, configurable: true });
2625    Object.defineProperty(sentinel, "rawValue",
2626      { get: function() { return null; }, enumerable: true, configurable: true });
2627    Object.defineProperty(sentinel, "length",
2628      { get: function() { return 0; }, enumerable: true, configurable: true });
2629    Object.defineProperty(sentinel, "nodes",
2630      { get: function() { return emptyNodes; }, enumerable: true, configurable: true });
2631    // WP-3: instance.index on an unbound node → 0
2632    Object.defineProperty(sentinel, "index",
2633      { get: function() { return 0; }, enumerable: true, configurable: true });
2634    sentinel.item = function() { return makeNullDataHandle(); };
2635    return new Proxy(sentinel, {
2636      get: function(target, prop) {
2637        if (prop in target) return target[prop];
2638        if (typeof prop !== "string") return undefined;
2639        return makeNullDataHandle();
2640      },
2641      // WP-3: absorb all writes — null data handles are read-only sentinels.
2642      set: function(_target, _prop, _value) {
2643        return true;
2644      }
2645    });
2646  }
2647
2648  // BE-1 tranche #1: benign "absent declared node" façade. Returned by the
2649  // implicit-globals `lookup` for a bare identifier that fails the scope
2650  // resolve but names a template-declared container (gated by the host
2651  // `isDeclaredAbsentNode`). Mirrors Adobe semantics: a reference to a
2652  // declared-but-absent node is an EMPTY node — `isNull === true`, `rawValue`/
2653  // `value` null, empty `nodes`, an empty `instanceManager`, and every further
2654  // SOM segment chains to another empty node. Writes are absorbed. This lets a
2655  // guarded script (`if (!Sub.Child.Field.isNull) {…} else {…}`) evaluate the
2656  // guard to `false` and run its else branch (e.g. `_X.setInstances(0)`,
2657  // `presence = "invisible"`) instead of throwing on `undefined`. Modelled on
2658  // `makeNullDataHandle`; the explicit boolean `isNull` is essential — without
2659  // it, `.isNull` would chain to a truthy façade object and invert the guard.
2660  function makeAbsentNodeHandle() {
2661    var sentinel = nullProtoObject();
2662    Object.defineProperty(sentinel, "isNull",
2663      { get: function() { return true; }, enumerable: true, configurable: true });
2664    Object.defineProperty(sentinel, "value",
2665      { get: function() { return null; }, enumerable: true, configurable: true });
2666    Object.defineProperty(sentinel, "rawValue",
2667      { get: function() { return null; }, enumerable: true, configurable: true });
2668    Object.defineProperty(sentinel, "length",
2669      { get: function() { return 0; }, enumerable: true, configurable: true });
2670    var emptyNodes = [];
2671    emptyNodes.item = function() { return makeAbsentNodeHandle(); };
2672    Object.freeze(emptyNodes);
2673    Object.defineProperty(sentinel, "nodes",
2674      { get: function() { return emptyNodes; }, enumerable: true, configurable: true });
2675    Object.defineProperty(sentinel, "instanceManager",
2676      { get: function() { return makeEmptyInstanceManager(); }, enumerable: true, configurable: true });
2677    sentinel.item = function() { return makeAbsentNodeHandle(); };
2678    return new Proxy(sentinel, {
2679      get: function(target, prop) {
2680        if (prop in target) return target[prop];
2681        if (typeof prop !== "string") return undefined;
2682        // `_<Name>` underscore-shorthand → an empty instanceManager, so chained
2683        // `Sub._Child.setInstances(n)` on an absent node is a safe no-op.
2684        if (prop.charAt(0) === "_" && prop.length > 1) return makeEmptyInstanceManager();
2685        // any further SOM segment chains to another empty node.
2686        return makeAbsentNodeHandle();
2687      },
2688      // absorb writes (presence, rawValue, mandatory, …) — empty nodes are inert.
2689      set: function(_target, _prop, _value) { return true; }
2690    });
2691  }
2692
2693  // Phase D-γ: Data DOM handle — wraps a raw DataDom node index and exposes
2694  // `.value`, `.nodes`, `.length`, `.item(i)`, and named child access via Proxy.
2695  function makeDataHandle(rawId) {
2696    // WP-3: return null-safe sentinel so callers can chain .value / .nodes safely.
2697    if (rawId === undefined || rawId < 0) return makeNullDataHandle();
2698    var handle = nullProtoObject();
2699    Object.defineProperty(handle, "value", {
2700      get: function() {
2701        var v = host.dataValue(rawId);
2702        return (v === undefined || v === null) ? null : v;
2703      },
2704      enumerable: true, configurable: false
2705    });
2706    // rawValue is an alias for value — scripts use both forms on data handles.
2707    Object.defineProperty(handle, "rawValue", {
2708      get: function() {
2709        var v = host.dataValue(rawId);
2710        return (v === undefined || v === null) ? null : v;
2711      },
2712      enumerable: true, configurable: false
2713    });
2714    Object.defineProperty(handle, "length", {
2715      get: function() { return host.dataChildren(rawId).length; },
2716      enumerable: true, configurable: false
2717    });
2718    Object.defineProperty(handle, "nodes", {
2719      get: function() {
2720        var ids = host.dataChildren(rawId);
2721        var arr = [];
2722        for (var i = 0; i < ids.length; i++) arr.push(makeDataHandle(ids[i]));
2723        // Phase D-γ fix: XFA scripts call `nodeList.item(i)` on the array
2724        // returned by `.nodes`. Plain JS arrays have no `.item()` method —
2725        // add one that mirrors the W3C NodeList API. Out-of-bounds indices
2726        // return a null-safe sentinel so `.value` access never throws.
2727        var NULLNODE = Object.freeze({ value: null, rawValue: null });
2728        arr.item = function(idx) {
2729          var index = toListIndex(idx);
2730          if (index < 0 || index >= arr.length) return NULLNODE;
2731          return arr[index];
2732        };
2733        return Object.freeze(arr);
2734      },
2735      enumerable: true, configurable: false
2736    });
2737    handle.item = function(i) {
2738      var ids = host.dataChildren(rawId);
2739      var index = toListIndex(i);
2740      if (index < 0 || index >= ids.length) return null;
2741      return makeDataHandle(ids[index]);
2742    };
2743    return new Proxy(handle, {
2744      get: function(target, prop) {
2745        if (prop in target || typeof prop !== "string") return target[prop];
2746        if (prop === "rawValue" || prop === "value") return target.value;
2747        var childId = host.dataChildByName(rawId, prop);
2748        if (childId < 0) return makeNullDataHandle();
2749        return makeDataHandle(childId);
2750      }
2751    });
2752  }
2753
2754  // Phase E (XFA-JS-HOST-STUBS): `xfa.host` is the most heavily used Adobe
2755  // Reader viewer namespace. The previous frozen stub only exposed
2756  // `numPages` + `messageBox`, so initializer scripts that touch
2757  // `xfa.host.title = "..."`, `xfa.host.openList(...)`, `xfa.host.beep()`,
2758  // etc. raised TypeError on the very first property access and inflated
2759  // `runtime_errors`. We expose a Proxy that:
2760  //   * resolves known read-only viewer properties to deterministic defaults
2761  //     (numPages, version, language, platform, name, title, validationsEnabled,
2762  //     calculationsEnabled, currentPage, pageCount);
2763  //   * absorbs every other write silently (viewer-only state has no flatten
2764  //     side-effects);
2765  //   * returns a null-safe sentinel for unknown reads so chains like
2766  //     `xfa.host.someVendorExt.message` don't TypeError;
2767  //   * exposes the interactive function family (messageBox, openList, beep,
2768  //     response, print, gotoURL, setFocus, exportData, importData,
2769  //     resetData) as safe-default thunks that ALSO bump the
2770  //     `unsupported_host_calls` counter so dispatch keeps observability
2771  //     without claiming a fake success (no "user clicked OK" lies).
2772  // No filesystem / network / process syscalls reach the host — every
2773  // interactive thunk is a pure JS no-op that delegates to host.unsupported
2774  // for accounting only. See benchmarks/JS_SANDBOX_SECURITY_AUDIT.md.
2775  var XFA_HOST_INTERACTIVE_CALLS = {
2776    "messageBox":   { kind: "ret", value: 0 },
2777    "openList":     { kind: "ret", value: -1 },
2778    "beep":         { kind: "ret", value: undefined },
2779    "response":     { kind: "ret", value: "" },
2780    "print":        { kind: "ret", value: undefined },
2781    "gotoURL":      { kind: "ret", value: undefined },
2782    "setFocus":     { kind: "ret", value: undefined },
2783    "exportData":   { kind: "ret", value: undefined },
2784    "importData":   { kind: "ret", value: undefined },
2785    "resetData":    { kind: "ret", value: undefined },
2786    "documentCountInBatch": { kind: "ret", value: 1 },
2787    "documentInBatch":      { kind: "ret", value: 0 },
2788    // JS2-01 (Sprint 2 Batch B): closeDoc is referenced by Canadian IMM
2789    // and Quebec dol4n templates as an interactive viewer entry point.
2790    // Static flatten cannot dismiss a document; safe-default undefined
2791    // with `unsupported_host_calls` accounting keeps observability without
2792    // a TypeError. Pure JS no-op — no filesystem / network reach the host.
2793    "closeDoc":     { kind: "ret", value: undefined }
2794  };
2795
2796  // Read-side defaults for viewer-readable host properties. These are pure
2797  // accessors — no host call leaves the sandbox. The values mirror Adobe
2798  // Reader behaviour during static, non-interactive rendering.
2799  function makeXfaHostBase() {
2800    var base = nullProtoObject();
2801    Object.defineProperty(base, "numPages", {
2802      enumerable: true, configurable: false,
2803      get: function() { return host.numPages(); }
2804    });
2805    Object.defineProperty(base, "currentPage", {
2806      enumerable: true, configurable: true,
2807      get: function() { return 0; },
2808      set: function(_v) { /* viewer-only */ }
2809    });
2810    Object.defineProperty(base, "pageCount", {
2811      enumerable: true, configurable: false,
2812      get: function() { return host.numPages(); }
2813    });
2814    // Static deterministic identity strings. We deliberately avoid claiming
2815    // Acrobat compatibility — scripts that branch on `xfa.host.name` for
2816    // Adobe-specific behaviour should fall through to a non-Acrobat path.
2817    var SCALAR_DEFAULTS = {
2818      "version":    "PDFluent-XFA",
2819      "language":   "ENU",
2820      "platform":   "PDFluent",
2821      "name":       "PDFluent",
2822      "title":      "",
2823      "appType":    "Reader",
2824      "variation":  "Reader",
2825      "calculationsEnabled": true,
2826      "validationsEnabled":  true,
2827      "runtimeHighlight":    false,
2828      "runtimeHighlightColor": "",
2829      "viewerType":          "PDFluent",
2830      "fullScreen":          false
2831    };
2832    Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
2833      var v = SCALAR_DEFAULTS[k];
2834      Object.defineProperty(base, k, {
2835        enumerable: true, configurable: true,
2836        get: function() { return v; },
2837        set: function(_v) { /* viewer-only, silently absorb */ }
2838      });
2839    });
2840    return base;
2841  }
2842
2843  // Install interactive function thunks. Each thunk increments the
2844  // unsupported-host-call counter and returns a deterministic safe default.
2845  function installXfaHostInteractive(base) {
2846    Object.keys(XFA_HOST_INTERACTIVE_CALLS).forEach(function(name) {
2847      var spec = XFA_HOST_INTERACTIVE_CALLS[name];
2848      Object.defineProperty(base, name, {
2849        enumerable: true, configurable: false, writable: false,
2850        value: function() {
2851          host.unsupportedHostCall(name);
2852          return spec.value;
2853        }
2854      });
2855    });
2856  }
2857
2858  var xfaHostBase = makeXfaHostBase();
2859  installXfaHostInteractive(xfaHostBase);
2860  // Wrap in a Proxy so unknown property reads return a null-safe sentinel
2861  // and unknown writes silently absorb. This is the "no TypeError" guarantee
2862  // for vendor-specific xfa.host extensions referenced by templated scripts.
2863  var xfaHost = new Proxy(xfaHostBase, {
2864    get: function(target, prop) {
2865      if (prop in target || typeof prop !== "string") return target[prop];
2866      // Unknown read — never throw, never claim a value. Sentinel only.
2867      return makeNullDataHandle();
2868    },
2869    set: function(_t, _p, _v) { return true; },
2870    has: function() { return true; }
2871  });
2872
2873  var xfaLayout = nullProtoObject();
2874  Object.defineProperty(xfaLayout, "pageCount", {
2875    enumerable: true,
2876    configurable: false,
2877    writable: false,
2878    value: function() {
2879      return host.numPages();
2880    }
2881  });
2882  // Phase D-γ: xfa.layout.page(node) — page number (1-based) of a form node.
2883  // During static flatten the layout is not yet run, so return a bounded
2884  // placeholder and mark the metadata as approximate.
2885  Object.defineProperty(xfaLayout, "page", {
2886    enumerable: true,
2887    configurable: false,
2888    writable: false,
2889    value: function() {
2890      host.resolveFailure();
2891      return 1;
2892    }
2893  });
2894  // Phase D-γ: xfa.layout.pageSpan(node) — number of pages a node spans.
2895  // Always 1 during static flatten; metadata records the approximation.
2896  Object.defineProperty(xfaLayout, "pageSpan", {
2897    enumerable: true,
2898    configurable: false,
2899    writable: false,
2900    value: function() {
2901      host.resolveFailure();
2902      return 1;
2903    }
2904  });
2905  Object.defineProperty(xfaLayout, "absPage", {
2906    enumerable: true,
2907    configurable: false,
2908    writable: false,
2909    value: function() {
2910      host.bindingError();
2911      return null;
2912    }
2913  });
2914
2915  var xfa = nullProtoObject();
2916  Object.defineProperty(xfa, "host", {
2917    enumerable: true,
2918    configurable: false,
2919    writable: false,
2920    // Note: deliberately NOT Object.freeze'd. `xfaHost` is already a Proxy
2921    // with sealed semantics (writes absorbed, unknown reads return sentinels).
2922    // Freezing would force every Set trap to throw under strict-mode scripts.
2923    value: xfaHost
2924  });
2925  Object.defineProperty(xfa, "layout", {
2926    enumerable: true,
2927    configurable: false,
2928    writable: false,
2929    value: Object.freeze(xfaLayout)
2930  });
2931
2932  // Phase E (XFA-JS-HOST-STUBS): viewer / interaction sub-namespaces. Each
2933  // is a Proxy that silently absorbs writes and returns chainable sentinels
2934  // on read so scripts can complete:
2935  //
2936  //   xfa.viewer        — Reader UI state (zoom, scrollbar, toolbar, ...)
2937  //   xfa.appState      — Reader-wide preference cache
2938  //   xfa.appearanceFilter — accessibility / high-contrast hints
2939  //   xfa.connection    — `<connection>` outbound data binding stubs
2940  //   xfa.signature     — interactive digital-signature panel
2941  //   xfa.aliasNode     — script-time DOM alias rebinding
2942  //   xfa.form          — top-level FormDOM root reference (we expose the
2943  //                       same resolveNode/resolveNodes pair so the script
2944  //                       feels uniform; legacy property reads sentinel)
2945  //
2946  // These never call out to the host other than for accounting; no
2947  // filesystem / network / process syscalls reach the host bindings. Reads
2948  // for known property names return spec-conforming defaults; everything
2949  // else falls back to the null-safe sentinel chain.
2950  function makeViewerStubNamespace(staticReads) {
2951    var base = nullProtoObject();
2952    if (staticReads) {
2953      Object.keys(staticReads).forEach(function(k) {
2954        var v = staticReads[k];
2955        Object.defineProperty(base, k, {
2956          enumerable: true, configurable: true,
2957          get: function() { return v; },
2958          set: function(_v) { /* viewer-only — silent absorb */ }
2959        });
2960      });
2961    }
2962    return new Proxy(base, {
2963      get: function(target, prop) {
2964        if (prop in target || typeof prop !== "string") return target[prop];
2965        return makeNullDataHandle();
2966      },
2967      set: function(_t, _p, _v) { return true; },
2968      has: function() { return true; }
2969    });
2970  }
2971
2972  function makeInteractiveStubNamespace(funcs) {
2973    // funcs: { "sign": defaultRet, "verify": defaultRet, ... }
2974    var base = nullProtoObject();
2975    Object.keys(funcs).forEach(function(name) {
2976      var def = funcs[name];
2977      Object.defineProperty(base, name, {
2978        enumerable: true, configurable: false, writable: false,
2979        value: function() {
2980          host.unsupportedHostCall(name);
2981          return def;
2982        }
2983      });
2984    });
2985    return new Proxy(base, {
2986      get: function(target, prop) {
2987        if (prop in target || typeof prop !== "string") return target[prop];
2988        return makeNullDataHandle();
2989      },
2990      set: function(_t, _p, _v) { return true; },
2991      has: function() { return true; }
2992    });
2993  }
2994
2995  Object.defineProperty(xfa, "viewer", {
2996    enumerable: true, configurable: false, writable: false,
2997    value: makeViewerStubNamespace({
2998      "zoomType":  "FitWidth",
2999      "zoom":      100,
3000      "scrollbar": "auto",
3001      "toolbar":   true,
3002      "menubar":   true,
3003      "statusbar": true
3004    })
3005  });
3006  Object.defineProperty(xfa, "appState", {
3007    enumerable: true, configurable: false, writable: false,
3008    value: makeViewerStubNamespace({
3009      "highlightRequiredFields": false,
3010      "fieldHighlightColor":     "",
3011      "ariaEnabled":             false
3012    })
3013  });
3014  Object.defineProperty(xfa, "appearanceFilter", {
3015    enumerable: true, configurable: false, writable: false,
3016    value: makeViewerStubNamespace(null)
3017  });
3018  Object.defineProperty(xfa, "aliasNode", {
3019    enumerable: true, configurable: false, writable: false,
3020    value: makeViewerStubNamespace(null)
3021  });
3022  Object.defineProperty(xfa, "connection", {
3023    enumerable: true, configurable: false, writable: false,
3024    // `<connection>` calls hit a real service endpoint in Adobe Reader.
3025    // Static flatten cannot honour that — every method is unsupported.
3026    value: makeInteractiveStubNamespace({
3027      "execute":   null,
3028      "open":      null,
3029      "close":     null,
3030      "send":      null
3031    })
3032  });
3033  Object.defineProperty(xfa, "signature", {
3034    enumerable: true, configurable: false, writable: false,
3035    // Digital signatures require an interactive certificate picker.
3036    value: makeInteractiveStubNamespace({
3037      "sign":     false,
3038      "verify":   "unknown",
3039      "enumerate": ""
3040    })
3041  });
3042  // WP-3 F3: `xfa.validate` is a viewer-only namespace controlling form-wide
3043  // validation behaviour (Reader UI prompts on field constraint violations).
3044  // Adobe initializers commonly do `xfa.validate.override = 0` /
3045  // `xfa.validate.max = N` to suppress modal dialogs that have no meaning
3046  // in a static flatten context. Expose a Proxy that silently absorbs every
3047  // property write (`override`, `max`, `messageMode`, etc.) and returns
3048  // empty defaults on read, so scripts complete without TypeError.
3049  Object.defineProperty(xfa, "validate", {
3050    enumerable: true,
3051    configurable: false,
3052    writable: false,
3053    value: new Proxy(nullProtoObject(), {
3054      get: function(_t, prop) {
3055        if (prop === "override" || prop === "max") return 0;
3056        if (prop === "messageMode") return "";
3057        if (typeof prop !== "string") return undefined;
3058        // Unknown reads return a chainable null-safe sentinel so deeper
3059        // access like `xfa.validate.scriptTest.message` does not throw.
3060        return makeNullDataHandle();
3061      },
3062      set: function(_t, _prop, _value) { return true; },
3063      has: function() { return true; }
3064    })
3065  });
3066  Object.defineProperty(xfa, "resolveNode", {
3067    enumerable: true,
3068    configurable: false,
3069    writable: false,
3070    value: function(path) {
3071      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
3072      if (typeof path === "string" &&
3073          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
3074           path.indexOf("xfa.datasets.data.") === 0)) {
3075        var rawId = host.dataResolveNode(path);
3076        if (rawId < 0) return null;
3077        return makeDataHandle(rawId);
3078      }
3079      // BE-1: intercept `FieldName.#items` — same logic as handleResolveNode.
3080      // Scripts call `xfa.resolveNode("Wojewodztwo.#items")` to get the items
3081      // collection for a choiceList; the form-tree SOM cannot resolve `#items`
3082      // as a node, so we resolve the field, fetch its display labels, and
3083      // return an XFA-compatible substitute (makeItemsSubstitute).
3084      // Guard: path.length > 7 prevents false-positive match when
3085      // lastIndexOf returns -1 and path.length-7 is also -1.
3086      if (typeof path === "string" && path.length > 7 &&
3087          path.lastIndexOf(".#items") === path.length - 7) {
3088        var fieldPath = path.substring(0, path.length - 7);
3089        var fid = host.resolveNodeId(fieldPath);
3090        if (fid >= 0) {
3091          var xrDisplayItems = host.getDisplayItems(fid, host.generation());
3092          if (xrDisplayItems.length > 0) {
3093            host.somItemsPathHit();
3094          }
3095          return makeItemsSubstitute(xrDisplayItems);
3096        }
3097        return null;
3098      }
3099      var id = host.resolveNodeId(path);
3100      if (id < 0) {
3101        return null;
3102      }
3103      return makeHandle(id, host.generation());
3104    }
3105  });
3106  // JS2-01 (Sprint 2 Batch B): `xfa.form` is the FormDOM root surface
3107  // Adobe Reader exposes (XFA 3.3 §6.1.4 — the result of merging template
3108  // + datasets). The previous build only commented that it should be
3109  // exposed (rquickjs_backend.rs:2477) but no actual binding existed —
3110  // scripts that did `xfa.form.resolveNode("Form1.Subform1")` or
3111  // `form.resolveNode(...)` ran into "cannot read property of undefined"
3112  // or "form is not defined". We expose a Proxy that:
3113  //   * forwards `resolveNode` / `resolveNodes` to the existing
3114  //     `xfa.resolveNode` / `xfa.resolveNodes` pair (cluster C contract
3115  //     preserved — missing paths still return `null`, not a sentinel);
3116  //   * absorbs unknown property writes (viewer-only flags) silently;
3117  //   * returns a chainable null-safe sentinel for unknown reads.
3118  // No new Rust closure is registered; the binding is pure JS routing.
3119  function makeFormNamespace() {
3120    var base = nullProtoObject();
3121    Object.defineProperty(base, "resolveNode", {
3122      enumerable: true, configurable: false, writable: false,
3123      value: function(path) { return xfa.resolveNode(path); }
3124    });
3125    Object.defineProperty(base, "resolveNodes", {
3126      enumerable: true, configurable: false, writable: false,
3127      value: function(path) { return xfa.resolveNodes(path); }
3128    });
3129    // Adobe's `form` exposes `recalculate(true/false)` as a no-op trigger
3130    // for the calculation cascade. Templates pass `1` (force) commonly.
3131    // Static flatten already ran calculate scripts — accept the call,
3132    // return undefined, no counter (it is not an interactive prompt).
3133    Object.defineProperty(base, "recalculate", {
3134      enumerable: true, configurable: false, writable: false,
3135      value: function() { return undefined; }
3136    });
3137    Object.defineProperty(base, "execValidate", {
3138      enumerable: true, configurable: false, writable: false,
3139      value: function() { return true; }
3140    });
3141    Object.defineProperty(base, "execInitialize", {
3142      enumerable: true, configurable: false, writable: false,
3143      value: function() { return undefined; }
3144    });
3145    Object.defineProperty(base, "execCalculate", {
3146      enumerable: true, configurable: false, writable: false,
3147      value: function() { return undefined; }
3148    });
3149    return new Proxy(base, {
3150      get: function(target, prop) {
3151        if (prop in target || typeof prop !== "string") return target[prop];
3152        return makeNullDataHandle();
3153      },
3154      set: function(_t, _p, _v) { return true; },
3155      has: function() { return true; }
3156    });
3157  }
3158  var formNamespace = makeFormNamespace();
3159  Object.defineProperty(xfa, "form", {
3160    enumerable: true,
3161    configurable: false,
3162    writable: false,
3163    value: formNamespace
3164  });
3165  Object.defineProperty(xfa, "resolveNodes", {
3166    enumerable: true,
3167    configurable: false,
3168    writable: false,
3169    value: function(path) {
3170      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
3171      if (typeof path === "string" &&
3172          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
3173           path.indexOf("xfa.datasets.data.") === 0)) {
3174        var rawIds = host.dataResolveNodes(path);
3175        var out = [];
3176        for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
3177        return Object.freeze(out);
3178      }
3179      var generation = host.generation();
3180      var ids = host.resolveNodeIds(path);
3181      var out = [];
3182      for (var i = 0; i < ids.length; i++) {
3183        out.push(makeHandle(ids[i], generation));
3184      }
3185      return Object.freeze(out);
3186    }
3187  });
3188  // Phase D-δ.2: expose `xfa.event` as an alias for the per-script event
3189  // global. Real Adobe Reader populates this with the firing event;
3190  // during static flatten there is no dispatched UI event, so the
3191  // accessor returns the same defensive event-stub that the per-script
3192  // `event` parameter receives — newText/prevText/change default to
3193  // empty strings, target resolves to the firing field handle.
3194  Object.defineProperty(xfa, "event", {
3195    enumerable: true,
3196    configurable: false,
3197    get: function() {
3198      return makeEvent();
3199    }
3200  });
3201
3202  // Phase E (XFA-JS-HOST-STUBS): Acrobat / Reader `app` global. Adobe
3203  // initializer scripts commonly do:
3204  //   app.calculate.override = true;       // viewer-only flag — silent absorb
3205  //   app.runtimeHighlight   = false;      // viewer-only flag — silent absorb
3206  //   app.alert("submitted"); app.launchURL("…")  // interactive — counted
3207  //
3208  // The previous frozen `app` only stubbed two functions; every other
3209  // property touch surfaced as TypeError. Wrap the whole namespace in a
3210  // Proxy with the same semantics as `xfa.host`:
3211  //   * known viewer properties resolve to deterministic defaults;
3212  //   * known interactive functions return a safe default + bump
3213  //     unsupported_host_calls (NOT runtime_errors);
3214  //   * unknown reads return a null-safe sentinel chain;
3215  //   * unknown writes are absorbed.
3216  //
3217  // `app.calculate` is itself an absorbing sub-Proxy — every assignment
3218  // (`app.calculate.override = true`, `app.calculate.suspend = false`, ...)
3219  // is viewer-only and has no flatten side-effect. We document this as an
3220  // explicit silent no-op (not UnsupportedHostCapability) because
3221  // suppressing the viewer's calculation cascade is the script author's
3222  // ASKING the viewer to stop, not a UI prompt to the user. See
3223  // benchmarks/JS_SANDBOX_SECURITY_AUDIT.md for the full classification.
3224  var APP_INTERACTIVE_CALLS = {
3225    "alert":       { kind: "ret", value: 0 },     // dialog OK pressed = 0
3226    "launchURL":   { kind: "ret", value: undefined },
3227    "execMenuItem":{ kind: "ret", value: undefined },
3228    "beep":        { kind: "ret", value: undefined },
3229    "openDoc":     { kind: "ret", value: null },
3230    "response":    { kind: "ret", value: "" },
3231    "mailMsg":     { kind: "ret", value: undefined },
3232    // JS2-01 (Sprint 2 Batch B): Adobe Acrobat SDK exposes `app.messageBox`
3233    // as a richer alias of `app.alert` (modal dialog, returns the pressed
3234    // button index — 0 = OK). Safe default mirrors `app.alert`. No new
3235    // capability surface; pure JS no-op with counter accounting.
3236    "messageBox":  { kind: "ret", value: 0 },
3237    // JS2-01: `app.closeDoc` is the Acrobat alias of `xfa.host.closeDoc`.
3238    // Templates targeting both Reader and Acrobat reference both spellings;
3239    // we counter-bump on each call and return undefined.
3240    "closeDoc":    { kind: "ret", value: undefined }
3241  };
3242  function makeAppBase() {
3243    var base = nullProtoObject();
3244    // Read-side static defaults — never call out, never lie about identity.
3245    var SCALAR_DEFAULTS = {
3246      "viewerType":     "PDFluent",
3247      "viewerVariation":"Reader",
3248      "viewerVersion":  0,
3249      "language":       "ENU",
3250      "platform":       "PDFluent",
3251      "fs":             null,  // file system gateway — explicitly null
3252      "media":          null,  // multimedia controller — explicitly null
3253      "fullscreen":     false,
3254      "runtimeHighlight": false,
3255      "runtimeHighlightColor": ""
3256    };
3257    Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
3258      var v = SCALAR_DEFAULTS[k];
3259      Object.defineProperty(base, k, {
3260        enumerable: true, configurable: true,
3261        get: function() { return v; },
3262        set: function(_v) { /* viewer-only, silently absorb */ }
3263      });
3264    });
3265    // app.calculate sub-namespace: writes absorb, reads return defaults.
3266    Object.defineProperty(base, "calculate", {
3267      enumerable: true, configurable: false, writable: false,
3268      value: makeViewerStubNamespace({
3269        "override": true,
3270        "suspend":  false
3271      })
3272    });
3273    // Install interactive function thunks (same pattern as xfa.host).
3274    Object.keys(APP_INTERACTIVE_CALLS).forEach(function(name) {
3275      var spec = APP_INTERACTIVE_CALLS[name];
3276      Object.defineProperty(base, name, {
3277        enumerable: true, configurable: false, writable: false,
3278        value: function() {
3279          host.unsupportedHostCall(name);
3280          return spec.value;
3281        }
3282      });
3283    });
3284    return base;
3285  }
3286  var app = new Proxy(makeAppBase(), {
3287    get: function(target, prop) {
3288      if (prop in target || typeof prop !== "string") return target[prop];
3289      return makeNullDataHandle();
3290    },
3291    set: function(_t, _p, _v) { return true; },
3292    has: function() { return true; }
3293  });
3294
3295  // Phase C-α: viewer-only `event` global. Real Adobe Reader populates
3296  // this with the firing event; during static flatten there is no
3297  // dispatched UI event, so the object is a defensive stub that:
3298  // - exposes `target` resolving to the firing field handle (≈ `this`),
3299  //   so scripts like `event.target.getField(somPath)` complete without
3300  //   ReferenceError;
3301  // - exposes `change` as an empty string (the spec default);
3302  // - returns viewer-stubs from `target.getField()` so AcroForm widget
3303  //   tweaks (`doNotScroll`, `required`, etc.) silently absorb.
3304  function makeEvent() {
3305    var id = host.currentNodeId();
3306    var fieldHandle = id < 0 ? null : makeHandle(id, host.generation());
3307    var target = nullProtoObject();
3308    Object.defineProperty(target, "getField", {
3309      enumerable: true, configurable: false, writable: false,
3310      value: function() {
3311        
3312        return makeViewerStub();
3313      }
3314    });
3315    Object.defineProperty(target, "name", {
3316      enumerable: true, configurable: false,
3317      get: function() { return ""; }
3318    });
3319    Object.defineProperty(target, "self", {
3320      enumerable: true, configurable: false,
3321      get: function() { return fieldHandle; }
3322    });
3323    var ev = nullProtoObject();
3324    Object.defineProperty(ev, "target", {
3325      enumerable: true, configurable: false,
3326      get: function() { return target; }
3327    });
3328    // Phase D-δ.2: stable empty-string defaults for the change-event property
3329    // family so scripts that read `xfa.event.newText` / `prevText` /
3330    // `change` on initialize/calculate (where no real change event occurred)
3331    // do not throw `cannot read property 'X' of undefined`. Adobe populates
3332    // these on actual `change`/`exit` events; we run those activities later
3333    // (or never) and surface deterministic empty defaults instead.
3334    Object.defineProperty(ev, "change", {
3335      enumerable: true, configurable: false,
3336      get: function() { return ""; }
3337    });
3338    Object.defineProperty(ev, "newText", {
3339      enumerable: true, configurable: false,
3340      get: function() { return ""; }
3341    });
3342    Object.defineProperty(ev, "prevText", {
3343      enumerable: true, configurable: false,
3344      get: function() { return ""; }
3345    });
3346    Object.defineProperty(ev, "fullText", {
3347      enumerable: true, configurable: false,
3348      get: function() { return ""; }
3349    });
3350    Object.defineProperty(ev, "selStart", {
3351      enumerable: true, configurable: false,
3352      get: function() { return 0; }
3353    });
3354    Object.defineProperty(ev, "selEnd", {
3355      enumerable: true, configurable: false,
3356      get: function() { return 0; }
3357    });
3358    // JS2-01 (Sprint 2 Batch B): the Phase E security audit §3.9 promised
3359    // that `event` exposes the full Adobe Acrobat SDK event surface
3360    // (`target`, `change`, `newText`, `prevText`, `fullText`, `selStart`,
3361    // `selEnd`, plus `name`, `type`, `shift`, `modifier`, `commitKey`,
3362    // `willCommit`, `rc`, `keyDown`, `value`, `reenter`). Only the first
3363    // seven were implemented; scripts that probe `event.name` for the
3364    // firing activity (`if (event.name === "calculate") {...}`) silently
3365    // saw `undefined`, and scripts that chained off `event.X` could trip
3366    // a "not a function" if `X` was expected to be callable.
3367    //
3368    // All values are deterministic spec defaults that match Adobe's
3369    // behaviour during a non-interactive flatten (no firing event):
3370    //   name       — current activity if known, else ""
3371    //   type       — event type label (Adobe ≈ "Field"), "" if unknown
3372    //   shift      — modifier-key state (false)
3373    //   modifier   — modifier-key state (false)
3374    //   commitKey  — 0 (no commit key pressed)
3375    //   willCommit — false (no commit pending)
3376    //   rc         — true (accept-by-default — same as Adobe's validate)
3377    //   keyDown    — false (no key pressed)
3378    //   value      — "" (current widget value, unknown during static flatten)
3379    //   reenter    — false (Acrobat re-entry flag)
3380    //
3381    // Read-only via getter, like the existing seven. The frozen ev object
3382    // means writes to these from scripts running in strict mode would throw
3383    // a TypeError — but no real script writes event scalars (the only
3384    // observed pattern is `event.value = ...` on a stale `event` reference
3385    // which is undefined, not the frozen object). If we ever observe
3386    // legitimate writes, swap getter for configurable getter+setter.
3387    Object.defineProperty(ev, "name", {
3388      enumerable: true, configurable: false,
3389      get: function() { return ""; }
3390    });
3391    Object.defineProperty(ev, "type", {
3392      enumerable: true, configurable: false,
3393      get: function() { return ""; }
3394    });
3395    Object.defineProperty(ev, "shift", {
3396      enumerable: true, configurable: false,
3397      get: function() { return false; }
3398    });
3399    Object.defineProperty(ev, "modifier", {
3400      enumerable: true, configurable: false,
3401      get: function() { return false; }
3402    });
3403    Object.defineProperty(ev, "commitKey", {
3404      enumerable: true, configurable: false,
3405      get: function() { return 0; }
3406    });
3407    Object.defineProperty(ev, "willCommit", {
3408      enumerable: true, configurable: false,
3409      get: function() { return false; }
3410    });
3411    Object.defineProperty(ev, "rc", {
3412      enumerable: true, configurable: false,
3413      get: function() { return true; }
3414    });
3415    Object.defineProperty(ev, "keyDown", {
3416      enumerable: true, configurable: false,
3417      get: function() { return false; }
3418    });
3419    Object.defineProperty(ev, "value", {
3420      enumerable: true, configurable: false,
3421      get: function() { return ""; }
3422    });
3423    Object.defineProperty(ev, "reenter", {
3424      enumerable: true, configurable: false,
3425      get: function() { return false; }
3426    });
3427    return Object.freeze(ev);
3428  }
3429
3430  // Phase D-γ: XFA global `util` (Acrobat SDK §Util).  Provides date/number
3431  // formatting helpers used by many XFA templates. Only the subset required
3432  // by real-corpus scripts is implemented; unknown methods return "".
3433  //
3434  // util.printd(sFormat, dDate)  — format a Date per sFormat using UTC fields.
3435  //   Supported tokens: yyyy (year), yy (two-digit year), mm (month 01-12),
3436  //   m (1-12), dd (day 01-31), d (1-31), HH (hour 00-23),
3437  //   MM (minute 00-59), SS (second 00-59).
3438  // util.printx(cPicture, cValue) — picture format; returns cValue as-is.
3439  // util.scand(sFormat, cDate)   — deterministic numeric parser for the same
3440  //   token subset; unsupported or invalid input returns Invalid Date.
3441  var xfaUtil = (function() {
3442    var DateCtor = Date;
3443    var dateUtc = Date.UTC;
3444    function pad2(n) { return (n < 10 ? "0" : "") + n; }
3445    function invalidDate() { return new DateCtor(NaN); }
3446    function escapeRegex(text) {
3447      return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3448    }
3449    function parseDate(fmt, input) {
3450      var fmtText, text;
3451      try {
3452        fmtText = String(fmt);
3453        text = String(input);
3454      } catch (_e) {
3455        return invalidDate();
3456      }
3457
3458      var groups = [];
3459      var pattern = "^";
3460      for (var i = 0; i < fmtText.length;) {
3461        var rest = fmtText.substring(i);
3462        if (rest.indexOf("yyyy") === 0) {
3463          pattern += "(\\d{4})";
3464          groups.push("yyyy");
3465          i += 4;
3466        } else if (rest.indexOf("yy") === 0) {
3467          pattern += "(\\d{2})";
3468          groups.push("yy");
3469          i += 2;
3470        } else if (rest.indexOf("mm") === 0) {
3471          pattern += "(\\d{2})";
3472          groups.push("mm");
3473          i += 2;
3474        } else if (rest.indexOf("dd") === 0) {
3475          pattern += "(\\d{2})";
3476          groups.push("dd");
3477          i += 2;
3478        } else if (rest.indexOf("HH") === 0) {
3479          pattern += "(\\d{2})";
3480          groups.push("HH");
3481          i += 2;
3482        } else if (rest.indexOf("MM") === 0) {
3483          pattern += "(\\d{2})";
3484          groups.push("MM");
3485          i += 2;
3486        } else if (rest.indexOf("SS") === 0) {
3487          pattern += "(\\d{2})";
3488          groups.push("SS");
3489          i += 2;
3490        } else if (rest.indexOf("m") === 0) {
3491          pattern += "(\\d{1,2})";
3492          groups.push("m");
3493          i += 1;
3494        } else if (rest.indexOf("d") === 0) {
3495          pattern += "(\\d{1,2})";
3496          groups.push("d");
3497          i += 1;
3498        } else {
3499          pattern += escapeRegex(fmtText.charAt(i));
3500          i += 1;
3501        }
3502      }
3503
3504      var match = new RegExp(pattern + "$").exec(text);
3505      if (!match) return invalidDate();
3506
3507      var year = NaN, month = 1, day = 1, hour = 0, minute = 0, second = 0;
3508      for (var g = 0; g < groups.length; g++) {
3509        var value = parseInt(match[g + 1], 10);
3510        if (!isFinite(value)) return invalidDate();
3511        if (groups[g] === "yyyy") year = value;
3512        else if (groups[g] === "yy") year = 2000 + value;
3513        else if (groups[g] === "mm" || groups[g] === "m") month = value;
3514        else if (groups[g] === "dd" || groups[g] === "d") day = value;
3515        else if (groups[g] === "HH") hour = value;
3516        else if (groups[g] === "MM") minute = value;
3517        else if (groups[g] === "SS") second = value;
3518      }
3519
3520      if (!isFinite(year) || month < 1 || month > 12 || day < 1 || day > 31 ||
3521          hour < 0 || hour > 23 || minute < 0 || minute > 59 ||
3522          second < 0 || second > 59) {
3523        return invalidDate();
3524      }
3525      var out = new DateCtor(dateUtc(year, month - 1, day, hour, minute, second));
3526      if (out.getUTCFullYear() !== year ||
3527          out.getUTCMonth() + 1 !== month ||
3528          out.getUTCDate() !== day ||
3529          out.getUTCHours() !== hour ||
3530          out.getUTCMinutes() !== minute ||
3531          out.getUTCSeconds() !== second) {
3532        return invalidDate();
3533      }
3534      return out;
3535    }
3536    var u = nullProtoObject();
3537    u.printd = function(fmt, date) {
3538      if (!(date instanceof DateCtor) || isNaN(date.getTime())) return "";
3539      var y = date.getUTCFullYear();
3540      var mo = date.getUTCMonth() + 1;
3541      var d  = date.getUTCDate();
3542      var h  = date.getUTCHours();
3543      var mi = date.getUTCMinutes();
3544      var s  = date.getUTCSeconds();
3545      var result = String(fmt);
3546      result = result.replace(/yyyy/g, y)
3547                     .replace(/yy/g,   String(y).slice(-2))
3548                     .replace(/mm/g,   pad2(mo))
3549                     .replace(/m/g,    mo)
3550                     .replace(/dd/g,   pad2(d))
3551                     .replace(/d/g,    d)
3552                     .replace(/HH/g,   pad2(h))
3553                     .replace(/MM/g,   pad2(mi))
3554                     .replace(/SS/g,   pad2(s));
3555      return result;
3556    };
3557    u.printx = function(_fmt, val) { return val === null || val === undefined ? "" : String(val); };
3558    u.scand  = function(fmt, str) { return parseDate(fmt, str); };
3559    return Object.freeze(u);
3560  }());
3561
3562  // Phase C-α: minimal `console` no-op. Many forms guard with
3563  // `if (typeof console !== "undefined") console.log(...)` and proceed
3564  // when the symbol exists. Stub returns undefined; never writes
3565  // anywhere observable to the script.
3566  var consoleStub = nullProtoObject();
3567  ["log","warn","error","info","debug","trace"].forEach(function(name) {
3568    Object.defineProperty(consoleStub, name, {
3569      enumerable: true, configurable: false, writable: false,
3570      value: function() {  return undefined; }
3571    });
3572  });
3573
3574  // Phase D-ι: form-level globals registered from `<variables>` `<script>`
3575  // blocks. Each entry is `name -> frozen object`. Populated by the host
3576  // once per document via `setVariablesScript`; cleared by
3577  // `clearVariablesScripts` at `reset_per_document`.
3578  var variablesScripts = lookupObject();
3579  // Phase D-ι.2: subform-scoped variables. Maps subform name -> namespace
3580  // object containing that subform's named scripts. Enables
3581  // `subformHandle.variables.ScriptName.method()` access paths.
3582  var subformVariables = lookupObject();
3583  // W3-D RETRY: form-level mutable string data items registered from
3584  // `<variables>` `<text name="X">value</text>` blocks (XFA 3.3 §5.5.2).
3585  // Each entry is `name -> { value: <string> }`. Adobe Reader exposes these
3586  // as global mutable string containers — the canonical IMM5709 pattern
3587  // is `<text name="globValidatePressed"/>` referenced from event scripts
3588  // as `globValidatePressed.value = "true";`. Populated by
3589  // `setVariablesDataItem`; cleared by `clearVariablesScripts` (shared
3590  // teardown — same per-document lifecycle).
3591  var variablesDataItems = lookupObject();
3592  // W3-D RETRY: subform-scoped variant of `variablesDataItems`, parallel to
3593  // `subformVariables` for scripts. Reserved for future subform-level data
3594  // items; currently populated only at root scope but the structure is
3595  // here so an upcoming pass can light it up without further refactor.
3596  var subformVariablesDataItems = lookupObject();
3597  // D4: flat first-wins exposure of subform-scoped named `<script>` objects to
3598  // bare-identifier lookup. The host only writes an entry here for names that
3599  // are unique across all subforms (ambiguous names are withheld — fail-closed
3600  // — and counted Rust-side). Consulted by the `with` proxy AFTER
3601  // `variablesScripts` / `variablesDataItems` (root scope wins) and BEFORE the
3602  // host SOM resolver, so a subform-scoped helper such as `countryScript`
3603  // resolves as a bare global instead of falling through to a SOM NoMatch.
3604  var subformScriptsByName = lookupObject();
3605
3606  function makeImplicitGlobals(body) {
3607    var currentId = host.currentNodeId();
3608    var generation = host.generation();
3609    var localNames = collectLocalNames(String(body));
3610    var cachedHandles = lookupObject();
3611    var dynamicLocals = lookupObject();
3612
3613    function lookup(name) {
3614      if (cachedHandles[name] !== undefined) {
3615        return cachedHandles[name];
3616      }
3617      var nodeIds = uniqueNodeIds(host.resolveImplicitNodeIds(currentId, name));
3618      if (nodeIds.length === 0) {
3619        // BE-1 tranche #1: a bare implicit identifier that fails the scope
3620        // resolve but names a template-declared container resolves to a benign
3621        // EMPTY node (Adobe semantics: isNull===true), not `undefined`. This
3622        // lets guarded scripts such as
3623        //   if (!Podmiot1.OsobaFizyczna.PESEL.isNull) {...} else {...}
3624        // take the empty (else) branch and run their setInstances(0)/presence
3625        // writes instead of throwing "cannot read property '…' of undefined"
3626        // and aborting. Undeclared names still return `undefined`, preserving
3627        // the D-θ.1 `A.B === undefined` byte-identity contract for genuine
3628        // misses. Gated host-side by `isDeclaredAbsentNode`; sandboxed-only.
3629        if (host.isDeclaredAbsentNode(name)) {
3630          var facade = makeAbsentNodeHandle();
3631          cachedHandles[name] = facade;
3632          return facade;
3633        }
3634        return undefined;
3635      }
3636      // Phase D-θ.2: wrap an implicit hit in the full-chain accumulator. The
3637      // resulting proxy accumulates SOM property names without contacting
3638      // the host until a terminal property is read; only then is the full
3639      // chain resolved with backtracking. This lets `A.B.C.D.rawValue`
3640      // disambiguate the same-name `A` whose subtree completes the chain,
3641      // rather than relying on a single-segment hint as in D-θ.1. Terminal
3642      // properties still resolve byte-for-byte identically to the
3643      // un-hinted walk so single-token reads keep their existing
3644      // semantics.
3645      var handle = makeChainProxy([], [name], nodeIds, generation, currentId);
3646      cachedHandles[name] = handle;
3647      return handle;
3648    }
3649
3650    return new Proxy(Object.create(null), {
3651      has: function(_target, prop) {
3652        if (typeof prop !== "string") {
3653          return false;
3654        }
3655        // XFA-DATA-M3C: `_<Name>` and `parent` are XFA-defined globals that
3656        // must be visible to `with()` lookup even when the local-names
3657        // collector treats them as locals (they shadow no real var/let).
3658        if (prop === "parent") {
3659          return true;
3660        }
3661        if (prop.charAt(0) === "_" && prop.length > 1 &&
3662            localNames[prop] !== true) {
3663          // Probe — only claim presence when the bare-name resolves to a
3664          // real subform/container; otherwise fall through to the standard
3665          // defer rules so unrelated underscored locals stay undefined.
3666          // Quiet probe: a miss here is the schema-optional path and must
3667          // not count as a script-level resolve failure.
3668          var bare = prop.substring(1);
3669          if (uniqueNodeIds(host.resolveImplicitNodeIdsQuiet(currentId, bare)).length > 0 ||
3670              host.hasZeroInstanceRun(currentId, generation, bare)) {
3671            return true;
3672          }
3673        }
3674        if (shouldDeferGlobalName(prop, localNames)) {
3675          return false;
3676        }
3677        return true;
3678      },
3679      get: function(_target, prop) {
3680        if (typeof prop !== "string") {
3681          return undefined;
3682        }
3683        // XFA-DATA-M3C: bare `parent` resolves to the form-tree parent of
3684        // the current script node. Common in calculate/initialize scripts
3685        // for `parent.index`, `parent.rawValue`, and chain navigation that
3686        // mirrors Adobe's implicit-scope walk one step upward.
3687        if (prop === "parent") {
3688          var parentId = host.parentOfNode(currentId, generation);
3689          if (parentId < 0) return undefined;
3690          return makeHandle(parentId, generation);
3691        }
3692        // XFA 3.3 §6.4.3.2 underscore shorthand at the global scope:
3693        // `_<Name>` referenced as a bare identifier denotes the
3694        // instanceManager of a same-named subform reachable from the
3695        // current implicit scope. Adobe Reader exposes this both as a
3696        // child property (`parent._Foo`) and as a bare global. Without
3697        // this branch the `shouldDeferGlobalName` rule below returns
3698        // `undefined` for every underscored bare ident and the calling
3699        // script throws `ReferenceError: _Foo is not defined`.
3700        //
3701        // Only triggers when the target subform actually exists; otherwise
3702        // fall through so unrelated underscored locals keep their
3703        // existing deferred-undefined semantics.
3704        if (prop.charAt(0) === "_" && prop.length > 1 &&
3705            localNames[prop] !== true) {
3706          var bareName = prop.substring(1);
3707          // Quiet probe: schema-optional misses are NOT a script-level
3708          // resolve failure. We surface either a live manager, an empty
3709          // manager (zero-instance run or never-instantiated optional
3710          // subform), or fall through to the defer rules.
3711          var imIds = uniqueNodeIds(
3712            host.resolveImplicitNodeIdsQuiet(currentId, bareName)
3713          );
3714          if (imIds.length > 0) {
3715            return makeInstanceManager(imIds[0], generation);
3716          }
3717          if (host.hasZeroInstanceRun(currentId, generation, bareName)) {
3718            return makeEmptyInstanceManager();
3719          }
3720        }
3721        if (shouldDeferGlobalName(prop, localNames)) {
3722          return undefined;
3723        }
3724        // Phase D-γ: $record as a script-level global refers to the data
3725        // record bound to the current field's enclosing subform context.
3726        // Scripts write `var addr = $record.SECTION.nodes;` — we intercept
3727        // this here instead of letting resolveImplicitNodeId fail (-1).
3728        if (prop === "$record") {
3729          var recRaw = host.dataBoundRecord(currentId, generation);
3730          if (recRaw < 0) return makeNullDataHandle();
3731          return makeDataHandle(recRaw);
3732        }
3733        // BE-1: `$data` as a bare global refers to the data-DOM root node
3734        // (XFA 3.3 §3.3.2: `$data` == `xfa.datasets.data`).  Scripts write
3735        // e.g. `var root = $data; root.child.value` — we intercept here
3736        // before the SOM resolver so the data-dom root is returned directly.
3737        // `data_resolve_node("$data")` resolves to the root of the DataDom
3738        // (SomRoot::Data + zero segments -> start node) via resolve_data_som.
3739        if (prop === "$data") {
3740          var dataRootRaw = host.dataResolveNode("$data");
3741          if (dataRootRaw < 0) return makeNullDataHandle();
3742          host.somDataRootHit();
3743          return makeDataHandle(dataRootRaw);
3744        }
3745        // Phase D-γ: `util` is an XFA global (Acrobat SDK §Util) that provides
3746        // date/number formatting functions.  `util.printd(fmt, date)` is widely
3747        // used by XFA templates to format Date objects.  We intercept it here so
3748        // scripts can complete without a TypeError instead of throwing and
3749        // aborting all later mutations in the same script body.
3750        if (prop === "util") {
3751          return xfaUtil;
3752        }
3753        if (dynamicLocals[prop] !== undefined) {
3754          return dynamicLocals[prop];
3755        }
3756        // Phase D-ι: form-level named-script globals from <variables>
3757        // outrank the form-tree implicit lookup. Adobe XFA spec §5.5
3758        // exposes `<scriptName>.<topLevelDecl>` to all event/calculate
3759        // scripts in the same document.
3760        if (variablesScripts[prop] !== undefined) {
3761          return variablesScripts[prop];
3762        }
3763        // W3-D RETRY: form-level data items (XFA 3.3 §5.5.2) outrank the
3764        // form-tree implicit lookup, same as scripts. This lets templates
3765        // like Canadian IMM5709 reference `globValidatePressed.value`
3766        // without the bare ident falling through to the SOM resolver as a
3767        // `js_resolve_failure`. Scripts take precedence over data items in
3768        // the unlikely case both share a name (spec-undefined; we follow
3769        // declaration order, which is `setVariablesScript` first in the
3770        // host loop).
3771        if (variablesDataItems[prop] !== undefined) {
3772          return variablesDataItems[prop];
3773        }
3774        // D4: subform-scoped named script objects (unique-name only) resolve as
3775        // bare globals here, after root-scope scripts/data items and before the
3776        // host SOM resolver. This is the minimal SOM resolution for the
3777        // `countryScript` / `partNoScript` cluster (XFA 3.3 §5.5 subform
3778        // variables). Ambiguous names were never written here (fail-closed).
3779        if (subformScriptsByName[prop] !== undefined) {
3780          return subformScriptsByName[prop];
3781        }
3782        return lookup(prop);
3783      },
3784      set: function(_target, prop, value) {
3785        if (typeof prop !== "string") {
3786          return true;
3787        }
3788        if (shouldDeferGlobalName(prop, localNames)) {
3789          return false;
3790        }
3791        if (cachedHandles[prop] !== undefined) {
3792          return true;
3793        }
3794        dynamicLocals[prop] = value;
3795        return true;
3796      }
3797    });
3798  }
3799
3800  return {
3801    // Phase E (XFA-JS-HOST-STUBS): `xfa` itself is still frozen because all
3802    // of its properties were installed with `configurable: false` and writes
3803    // go through inner-Proxy `set` traps that we control. `app` is a Proxy
3804    // whose underlying target is non-extensible after we install the
3805    // function thunks; freezing the Proxy itself would force the absorbing
3806    // `set` trap to throw a TypeError (proxy invariants: writes to
3807    // non-extensible targets must reject), defeating the whole point of the
3808    // silent-absorb design. We therefore expose `app` unfrozen — its safety
3809    // is enforced by the Proxy traps, not by Object.freeze.
3810    xfa: Object.freeze(xfa),
3811    app: app,
3812    // JS2-01 (Sprint 2 Batch B): top-level `form` global alias for
3813    // `xfa.form`. Adobe Reader exposes both spellings; templates such as
3814    // corpus forms reference bare `form.X`. The alias is identity-equal
3815    // to `xfa.form` (same Proxy), so cluster C contract preservation is
3816    // shared between the two surfaces. No new Rust closure introduced.
3817    form: formNamespace,
3818    consoleStub: Object.freeze(consoleStub),
3819    // Phase D-ι: register a `<variables>` `<script name="X">…` block as a
3820    // form-level global. Called by the host once per script body at
3821    // document load. `body` is the raw script source; `identNames` is a
3822    // pre-extracted array of top-level `var` / `function` identifiers
3823    // (Rust-side regex). The body is wrapped in an IIFE that returns a
3824    // frozen object whose properties are those identifiers. Variables
3825    // scripts share the same time/memory budget enforcement as event
3826    // scripts but emit no field mutations of their own. Errors during
3827    // evaluation are absorbed: the namespace remains undefined and
3828    // dependent event scripts will fail naturally at first use.
3829    // Phase D-ι / D-ι.2: register a named `<variables><script>` body.
3830    // `subformName` (4th param, optional) is non-empty for subform-scoped
3831    // scripts; omit or pass "" for root-level scripts.
3832    //
3833    // Root-level scripts (empty subformName) go into the flat
3834    // `variablesScripts` dict only — accessible as `ScriptName.X` from
3835    // any event script in the document.
3836    //
3837    // Subform-scoped scripts go into `subformVariables[subformName][name]`
3838    // ONLY — accessible as `subformHandle.variables.ScriptName.X`. They
3839    // are intentionally NOT written to the flat dict: two subforms may
3840    // define the same script name, and writing both to the flat map would
3841    // let the second registration silently shadow the first.
3842    setVariablesScript: function(name, body, identNames, subformName, exposeGlobal) {
3843      if (typeof name !== "string" || name.length === 0) return false;
3844      if (typeof body !== "string") return false;
3845      var idents = Array.isArray(identNames) ? identNames : [];
3846      var props = "";
3847      for (var i = 0; i < idents.length; i++) {
3848        var id = idents[i];
3849        if (typeof id !== "string" || id.length === 0) continue;
3850        if (i > 0) props += ",";
3851        props += JSON.stringify(id) + ": typeof " + id +
3852                 " !== \"undefined\" ? " + id + " : undefined";
3853      }
3854      // XFA-DATA2-02: inject `console` and `util` into the variables-script
3855      // closure so the form-level helper functions registered here can
3856      // reference them lexically and the dependent event scripts that call
3857      // those helpers do not blow up with `console is not defined` /
3858      // `util is not defined`. Both are the same sandbox-safe singletons
3859      // exposed to event scripts (silent no-op console, deterministic util).
3860      var wrapper = "(function(__console, __util){\n" +
3861                    "var console = __console; var util = __util;\n" +
3862                    body +
3863                    "\nreturn Object.freeze({" + props + "});\n})";
3864      try {
3865        var ns = (Function("return " + wrapper))()(consoleStub, xfaUtil);
3866        if (typeof subformName === "string" && subformName.length > 0) {
3867          if (subformVariables[subformName] === undefined) {
3868            subformVariables[subformName] = lookupObject();
3869          }
3870          subformVariables[subformName][name] = ns;
3871          // D4: also expose to bare-identifier lookup when the host marked this
3872          // name unique across subforms (ambiguous names are withheld).
3873          if (exposeGlobal === true && subformScriptsByName[name] === undefined) {
3874            subformScriptsByName[name] = ns;
3875          }
3876        } else {
3877          variablesScripts[name] = ns;
3878        }
3879        return true;
3880      } catch (_e) {
3881        return false;
3882      }
3883    },
3884    clearVariablesScripts: function() {
3885      var keys = Object.keys(variablesScripts);
3886      for (var i = 0; i < keys.length; i++) {
3887        delete variablesScripts[keys[i]];
3888      }
3889      var skeys = Object.keys(subformVariables);
3890      for (var j = 0; j < skeys.length; j++) {
3891        delete subformVariables[skeys[j]];
3892      }
3893      // D4: clear the flat subform-script exposure map on the same per-document
3894      // lifecycle as the scoped maps.
3895      var ssnkeys = Object.keys(subformScriptsByName);
3896      for (var n = 0; n < ssnkeys.length; n++) {
3897        delete subformScriptsByName[ssnkeys[n]];
3898      }
3899      // W3-D RETRY: data items share the same per-document lifecycle as
3900      // scripts. Dropping them here keeps a single bridge entry-point at
3901      // `reset_for_new_document` and prevents per-document drift.
3902      var dkeys = Object.keys(variablesDataItems);
3903      for (var k = 0; k < dkeys.length; k++) {
3904        delete variablesDataItems[dkeys[k]];
3905      }
3906      var sdkeys = Object.keys(subformVariablesDataItems);
3907      for (var m = 0; m < sdkeys.length; m++) {
3908        delete subformVariablesDataItems[sdkeys[m]];
3909      }
3910    },
3911    // W3-D RETRY: register a `<variables>` `<text name="X">value</text>`
3912    // data item (XFA 3.3 §5.5.2) as a form-level mutable string container.
3913    // Idempotent per (subformName?, name): a later registration overwrites
3914    // the earlier one, mirroring Adobe Reader's last-wins template merge.
3915    //
3916    // The returned shape is `{ value: <string> }` — minimal because real
3917    // templates only read/write `.value`. Writes coerce non-string inputs
3918    // to string (matching Adobe Reader's text-field semantics) so a
3919    // calculate script that does `globValidatePressed.value = true` does
3920    // not silently store a boolean.
3921    setVariablesDataItem: function(name, initial, subformName) {
3922      if (typeof name !== "string" || name.length === 0) return false;
3923      var initialStr = (typeof initial === "string") ? initial : "";
3924      // Use a closure-bound private slot so the `value` getter / setter
3925      // can coerce on write without exposing a plain-object property that
3926      // would let `delete item.value` succeed.
3927      var item = (function(initStr) {
3928        var current = initStr;
3929        var holder = Object.create(null);
3930        Object.defineProperty(holder, "value", {
3931          enumerable: true,
3932          configurable: false,
3933          get: function() { return current; },
3934          set: function(v) {
3935            current = (v === undefined || v === null) ? "" : String(v);
3936          }
3937        });
3938        // XFA 3.3 §5.5.2: data items also expose `.name`. Read-only.
3939        Object.defineProperty(holder, "name", {
3940          enumerable: true, configurable: false, writable: false,
3941          value: name
3942        });
3943        return holder;
3944      })(initialStr);
3945      if (typeof subformName === "string" && subformName.length > 0) {
3946        if (subformVariablesDataItems[subformName] === undefined) {
3947          subformVariablesDataItems[subformName] = lookupObject();
3948        }
3949        subformVariablesDataItems[subformName][name] = item;
3950      } else {
3951        variablesDataItems[name] = item;
3952      }
3953      return true;
3954    },
3955    evalScript: function(body) {
3956      var id = host.currentNodeId();
3957      var thisArg = id < 0 ? undefined : makeHandle(id, host.generation());
3958      // Phase C-α: install per-script `event` global in the function
3959      // closure so `event.target` resolves to the current field. Wrapping
3960      // body inside a function lets us pass `event` as a parameter
3961      // without leaking it to globalThis (where it would persist across
3962      // unrelated scripts).
3963      var ev = makeEvent();
3964      var consoleArg = consoleStub;
3965      var globals = makeImplicitGlobals(body);
3966      return (Function(
3967        "event",
3968        "console",
3969        "__globals",
3970        "with(__globals){\n" + String(body) + "\n}"
3971      )).call(thisArg, ev, consoleArg, globals);
3972    }
3973  };
3974})
3975"##;
3976
3977// Process-wide reference epoch; used together with `Instant::now() - EPOCH`
3978// to materialise a u64 nanosecond timestamp comparable across the interrupt
3979// handler closure and the dispatch path. We never expose this to scripts.
3980static EPOCH_CELL: OnceLock<Instant> = OnceLock::new();
3981fn epoch() -> Instant {
3982    *EPOCH_CELL.get_or_init(Instant::now)
3983}
3984
3985impl XfaJsRuntime for QuickJsRuntime {
3986    fn init(&mut self) -> Result<(), SandboxError> {
3987        // Defensive: ensure no host binding leaked into globalThis.
3988        // We call this in a `with` because rquickjs Contexts borrow a
3989        // !Send handle; the catch_unwind crosses the FFI boundary.
3990        let result = catch_unwind(AssertUnwindSafe(|| {
3991            self.context.with(|ctx| {
3992                let globals = ctx.globals();
3993                // Strip non-deterministic / capability-bearing globals if
3994                // any third-party crate ever registered them. Phase B
3995                // registers nothing, but defence in depth is cheap.
3996                for forbidden in [
3997                    "fetch",
3998                    "XMLHttpRequest",
3999                    "WebSocket",
4000                    "process",
4001                    "require",
4002                    "Deno",
4003                    "Bun",
4004                ] {
4005                    let _ = globals.set(forbidden, rquickjs::Undefined);
4006                }
4007                // Replace Date.now and Math.random with deterministic stubs.
4008                if let Ok(date_ctor) = globals.get::<_, rquickjs::Object>("Date") {
4009                    let zero_now = Function::new(ctx.clone(), || 0i64)
4010                        .map_err(|e| format!("date stub: {e}"))?;
4011                    let _ = date_ctor.set("now", zero_now);
4012                }
4013                if let Ok(math_ns) = globals.get::<_, rquickjs::Object>("Math") {
4014                    let _ = math_ns.set("random", rquickjs::Undefined);
4015                }
4016                Ok::<(), String>(())
4017            })?;
4018            self.register_host_bindings()?;
4019            Ok::<(), String>(())
4020        }));
4021        match result {
4022            Ok(Ok(())) => Ok(()),
4023            Ok(Err(e)) => Err(SandboxError::ScriptError(e)),
4024            Err(_) => Err(SandboxError::PanicCaptured(
4025                "panic while initialising sandbox globals".to_string(),
4026            )),
4027        }
4028    }
4029
4030    fn reset_for_new_document(&mut self) -> Result<(), SandboxError> {
4031        self.metadata = RuntimeMetadata::default();
4032        self.host.borrow_mut().reset_per_document();
4033        self.clear_deadline();
4034        // Memory limit is per-document; re-set to clear any prior accounting.
4035        self.runtime.set_memory_limit(self.memory_budget_bytes);
4036        // Phase D-ι: drop all `<variables>` namespace globals from the
4037        // previous document so they do not leak into the next. Failure
4038        // here is non-fatal — it just means a slightly polluted global
4039        // namespace, never a correctness issue, but log via metadata.
4040        if let Err(e) = self.clear_variables_scripts_global() {
4041            log::debug!("D-ι clear failed: {e:?}");
4042        }
4043        Ok(())
4044    }
4045
4046    // The `*mut FormTree` parameter is part of the existing
4047    // `XfaJsRuntime` trait — the caller in `flatten.rs` already enforces
4048    // that the pointer outlives this call. Clippy's
4049    // `not_unsafe_ptr_arg_deref` would require this signature to be
4050    // `unsafe fn`, which the trait does not allow.
4051    #[allow(clippy::not_unsafe_ptr_arg_deref)]
4052    fn set_form_handle(
4053        &mut self,
4054        form: *mut FormTree,
4055        root_id: FormNodeId,
4056    ) -> Result<(), SandboxError> {
4057        self.host.borrow_mut().set_form_handle(form, root_id);
4058        // Phase D-ι: register every `<variables>` `<script name="X">…` body
4059        // collected during merge as a form-level JS global. Done here
4060        // because by this point the form pointer is valid and the JS
4061        // runtime is initialised. Errors registering one script do not
4062        // block the others.
4063        if !form.is_null() {
4064            // SAFETY: caller guarantees `form` outlives this call.
4065            let scripts: Vec<(Option<String>, String, String)> =
4066                unsafe { (*form).variables_scripts.clone() };
4067            // D3 (trace-only): count collected vs registered vs failed and the
4068            // subform-scoped subset. Pure observability — no behaviour change.
4069            self.metadata.variables_scripts_collected = self
4070                .metadata
4071                .variables_scripts_collected
4072                .saturating_add(scripts.len());
4073            // D4: decide which subform-scoped script names are unique (and thus
4074            // eligible for bare-identifier exposure). A name declared by ≥2
4075            // subforms is ambiguous and withheld (fail-closed); count each such
4076            // ambiguous name once.
4077            let mut subform_name_counts: std::collections::HashMap<String, usize> =
4078                std::collections::HashMap::new();
4079            for (subform_scope, name, _) in &scripts {
4080                if subform_scope.is_some() {
4081                    *subform_name_counts.entry(name.clone()).or_insert(0) += 1;
4082                }
4083            }
4084            let ambiguous_subform_names = subform_name_counts.values().filter(|&&c| c > 1).count();
4085            self.metadata.som_lookup_ambiguous = self
4086                .metadata
4087                .som_lookup_ambiguous
4088                .saturating_add(ambiguous_subform_names);
4089            for (subform_scope, name, body) in scripts {
4090                if subform_scope.is_some() {
4091                    self.metadata.script_objects_subform_scoped = self
4092                        .metadata
4093                        .script_objects_subform_scoped
4094                        .saturating_add(1);
4095                }
4096                // D4: expose unique-name subform-scoped scripts as bare globals.
4097                let expose_global = subform_scope.is_some()
4098                    && subform_name_counts.get(&name).copied().unwrap_or(0) == 1;
4099                match self.register_variables_script(
4100                    &name,
4101                    &body,
4102                    subform_scope.as_deref(),
4103                    expose_global,
4104                ) {
4105                    Ok(true) => {
4106                        self.metadata.script_objects_registered =
4107                            self.metadata.script_objects_registered.saturating_add(1);
4108                        // Count only scripts that actually registered AND were
4109                        // exposed (JS bound the namespace into the flat map).
4110                        if expose_global {
4111                            self.metadata.som_subform_scripts_exposed =
4112                                self.metadata.som_subform_scripts_exposed.saturating_add(1);
4113                        }
4114                    }
4115                    Ok(false) => {
4116                        self.metadata.script_objects_register_failed = self
4117                            .metadata
4118                            .script_objects_register_failed
4119                            .saturating_add(1);
4120                        log::debug!("D-ι register `{name}` returned false (JS eval failed)");
4121                    }
4122                    Err(e) => {
4123                        self.metadata.script_objects_register_failed = self
4124                            .metadata
4125                            .script_objects_register_failed
4126                            .saturating_add(1);
4127                        log::debug!("D-ι register `{name}` failed: {e:?}");
4128                    }
4129                }
4130            }
4131            // W3-D RETRY: register `<variables><text name="X">…</text>`
4132            // data items after scripts so a same-named script (declaration
4133            // order) keeps precedence — matches the JS-side `get` trap
4134            // which consults `variablesScripts` before `variablesDataItems`.
4135            // SAFETY: same lifetime guarantee as above.
4136            let data_items: Vec<(Option<String>, String, String)> =
4137                unsafe { (*form).variables_data_items.clone() };
4138            // D3 (trace-only): count collected vs registered vs failed.
4139            self.metadata.variables_data_items_collected = self
4140                .metadata
4141                .variables_data_items_collected
4142                .saturating_add(data_items.len());
4143            for (subform_scope, name, initial) in data_items {
4144                match self.register_variables_data_item(&name, &initial, subform_scope.as_deref()) {
4145                    Ok(()) => {
4146                        self.metadata.script_objects_registered =
4147                            self.metadata.script_objects_registered.saturating_add(1);
4148                    }
4149                    Err(e) => {
4150                        self.metadata.script_objects_register_failed = self
4151                            .metadata
4152                            .script_objects_register_failed
4153                            .saturating_add(1);
4154                        log::debug!("W3-D register data item `{name}` failed: {e:?}");
4155                    }
4156                }
4157            }
4158        }
4159        Ok(())
4160    }
4161
4162    fn set_data_handle(&mut self, dom: *const xfa_dom_resolver::data_dom::DataDom) {
4163        self.host.borrow_mut().set_data_handle(dom);
4164    }
4165
4166    fn set_declared_subform_names(&mut self, names: std::collections::HashSet<String>) {
4167        self.host.borrow_mut().set_declared_subform_names(names);
4168    }
4169
4170    fn reset_per_script(
4171        &mut self,
4172        current_id: FormNodeId,
4173        activity: Option<&str>,
4174    ) -> Result<(), SandboxError> {
4175        self.host
4176            .borrow_mut()
4177            .reset_per_script(current_id, activity);
4178        Ok(())
4179    }
4180
4181    fn set_static_page_count(&mut self, page_count: u32) -> Result<(), SandboxError> {
4182        self.host.borrow_mut().set_static_page_count(page_count);
4183        Ok(())
4184    }
4185
4186    fn set_presave_gate(&mut self, enabled: bool) {
4187        // D1.B: mirror the dispatch decision into the host-binding layer so
4188        // mutating host calls see the same gate.
4189        self.host.borrow_mut().set_presave_gate(enabled);
4190    }
4191
4192    fn execute_script(
4193        &mut self,
4194        activity: Option<&str>,
4195        body: &str,
4196    ) -> Result<RuntimeOutcome, SandboxError> {
4197        // D1.B: when the per-flatten gate is ON, the QuickJS backend's
4198        // defence-in-depth check accepts `preSave` exactly like the dispatch
4199        // gate. Without the gate, behaviour is byte-identical to v4 (W3-B
4200        // closure). Hard-stop: only `preSave` is unlocked; every other
4201        // denylist activity stays denied. The gate value is owned by the
4202        // host bindings (set via `set_presave_gate` from dispatch).
4203        let presave_gate = self.host.borrow().presave_gate();
4204        if !activity_allowed_for_sandbox_with_gate(activity, presave_gate) {
4205            return Err(SandboxError::PhaseDenied(
4206                activity.unwrap_or("None").to_string(),
4207            ));
4208        }
4209        if body.len() > MAX_SCRIPT_BODY_BYTES {
4210            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
4211            return Err(SandboxError::BodyTooLarge);
4212        }
4213
4214        // W3-A — REDOS-01 mitigation. Reject script bodies containing
4215        // catastrophic-backtracking regex shapes BEFORE handing them to
4216        // QuickJS, because QuickJS's interrupt handler is polled at JS
4217        // opcode boundaries — not inside the regex C code — and cannot
4218        // bound a single pathological `test()` call.
4219        if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
4220            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
4221            return Err(SandboxError::RegexRejected(reason));
4222        }
4223
4224        self.set_deadline();
4225        let script_owned = body.to_string();
4226        // Phase D-γ: capture the actual JS exception message while still inside
4227        // the QuickJS context, so we get "TypeError: foo is not a function"
4228        // rather than the generic "Exception generated by QuickJS".
4229        let result = catch_unwind(AssertUnwindSafe(|| {
4230            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
4231                let Some(eval_script) = self.eval_script.clone() else {
4232                    return Err(rquickjs::Error::new_from_js_message(
4233                        "host bindings",
4234                        "Function",
4235                        "Phase C eval bridge not registered",
4236                    ));
4237                };
4238                let eval_script = eval_script.restore(&ctx)?;
4239                if let Err(e) = eval_script.call::<_, ()>((script_owned,)) {
4240                    // rquickjs stores the thrown value as a pending exception in
4241                    // the context. `ctx.catch()` pops it and lets us stringify it
4242                    // for much more useful diagnostic output.
4243                    let exc_msg = if matches!(e, rquickjs::Error::Exception) {
4244                        let val = ctx.catch();
4245                        // Try to get a string representation of the exception.
4246                        if let Some(exc) = val.as_exception() {
4247                            exc.message().unwrap_or_else(|| exc.to_string())
4248                        } else {
4249                            e.to_string()
4250                        }
4251                    } else {
4252                        e.to_string()
4253                    };
4254                    return Err(rquickjs::Error::new_from_js_message(
4255                        "script", "Error", exc_msg,
4256                    ));
4257                }
4258                Ok(())
4259            })
4260        }));
4261        // Capture deadline state BEFORE clearing so the error-classification
4262        // branch below can distinguish a timeout from a genuine ScriptError.
4263        let captured_deadline = self.script_deadline.load(Ordering::Acquire);
4264        let captured_now = Instant::now()
4265            .checked_duration_since(epoch())
4266            .map(|d| d.as_nanos() as u64)
4267            .unwrap_or(0);
4268        let timed_out = captured_deadline != 0 && captured_now >= captured_deadline;
4269        self.clear_deadline();
4270
4271        match result {
4272            Ok(Ok(())) => {
4273                self.metadata.executed = self.metadata.executed.saturating_add(1);
4274                let host_metadata = self.host.borrow_mut().take_metadata();
4275                self.metadata.accumulate(host_metadata);
4276                Ok(RuntimeOutcome {
4277                    executed: true,
4278                    mutated_field_count: host_metadata.mutations,
4279                })
4280            }
4281            Ok(Err(other)) => {
4282                let host_metadata = self.host.borrow_mut().take_metadata();
4283                self.metadata.accumulate(host_metadata);
4284                // rquickjs ≤ 0.8 collapses interrupts, OOM, and thrown
4285                // exceptions into a small set of Error variants. We
4286                // distinguish a Timeout via the deadline snapshot captured
4287                // before clear_deadline() above; OOM via a substring scan of
4288                // the error message; everything else is ScriptError.
4289                if timed_out {
4290                    // QF1-E / SEC-01: defence-in-depth wall-time fallback.
4291                    // The interrupt fired (so this *is* a timeout shape);
4292                    // re-label as `WallTimeExceeded` only if elapsed
4293                    // crossed `multiplier × budget`. Both classifications
4294                    // bump the same `timeouts` counter — observability of
4295                    // the typed variant is via error type, not metrics.
4296                    self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
4297                    Err(self.classify_timeout_or_walltime(captured_now))
4298                } else {
4299                    let msg = other.to_string();
4300                    if msg.to_ascii_lowercase().contains("memory") {
4301                        self.metadata.oom = self.metadata.oom.saturating_add(1);
4302                        Err(SandboxError::OutOfMemory)
4303                    } else {
4304                        self.metadata.runtime_errors =
4305                            self.metadata.runtime_errors.saturating_add(1);
4306                        Err(SandboxError::ScriptError(msg))
4307                    }
4308                }
4309            }
4310            Err(_) => {
4311                let host_metadata = self.host.borrow_mut().take_metadata();
4312                self.metadata.accumulate(host_metadata);
4313                self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
4314                Err(SandboxError::PanicCaptured(
4315                    "panic during sandboxed script execution".to_string(),
4316                ))
4317            }
4318        }
4319    }
4320
4321    fn take_metadata(&mut self) -> RuntimeMetadata {
4322        std::mem::take(&mut self.metadata)
4323    }
4324
4325    fn take_occur_mutations(&mut self) -> Vec<(usize, String, i64)> {
4326        self.host
4327            .borrow_mut()
4328            .take_occur_mutations()
4329            .into_iter()
4330            .map(|(id, prop, value)| (id.0, prop, value))
4331            .collect()
4332    }
4333
4334    fn take_diag_logs(&mut self) -> crate::js_runtime::RuntimeDiagLogs {
4335        self.host.borrow_mut().take_diag_logs()
4336    }
4337}
4338
4339#[cfg(test)]
4340mod tests {
4341    use super::*;
4342
4343    fn fresh_runtime() -> QuickJsRuntime {
4344        let mut rt = QuickJsRuntime::new().expect("rquickjs init");
4345        rt.init().expect("init");
4346        rt.reset_for_new_document().expect("reset");
4347        rt
4348    }
4349
4350    #[test]
4351    fn harmless_calculate_script_executes() {
4352        let mut rt = fresh_runtime();
4353        let outcome = rt
4354            .execute_script(Some("calculate"), "var x = 1 + 1; x")
4355            .expect("ok");
4356        assert!(outcome.executed);
4357        let md = rt.take_metadata();
4358        assert_eq!(md.executed, 1);
4359        assert!(md.is_clean());
4360    }
4361
4362    // BE-1 tranche #1: a bare implicit reference to a template-declared
4363    // container that has no live node resolves to a benign EMPTY node
4364    // (isNull===true, chainable, writes absorbed) — so the guarded
4365    // second-party pattern takes its else branch instead of throwing
4366    // "cannot read property '…' of undefined" and aborting the script.
4367    #[test]
4368    fn absent_declared_subform_resolves_to_empty_node() {
4369        let mut rt = fresh_runtime();
4370        let mut names = std::collections::HashSet::new();
4371        names.insert("Podmiot1".to_string());
4372        rt.set_declared_subform_names(names);
4373        // Mirrors the real NIP1/PESEL1 initialize script on 2ff85101.
4374        rt.execute_script(
4375            Some("calculate"),
4376            r#"
4377            if (Podmiot1.OsobaFizyczna.PESEL.isNull !== true)
4378                throw new Error('expected isNull === true on absent declared node');
4379            var tookElse = false;
4380            if (!Podmiot1.OsobaFizyczna.PESEL.isNull) { tookElse = false; }
4381            else { tookElse = true; }
4382            if (!tookElse) throw new Error('guard did not take the empty (else) branch');
4383            // Chained writes on the empty node must be inert (no throw).
4384            Podmiot1.OsobaFizyczna.presence = "invisible";
4385            Podmiot1.OsobaFizyczna.PESEL.rawValue = 2;
4386            if (Podmiot1.nodes.length !== 0) throw new Error('expected empty .nodes');
4387            "#,
4388        )
4389        .expect("absent declared subform must resolve to a benign empty node");
4390    }
4391
4392    // BE-1 tranche #1: the discriminator is necessary — an UNDECLARED bare name
4393    // must still surface as `undefined`, preserving the D-θ.1 byte-identity
4394    // contract (`A.B === undefined` for genuine misses). Without the declared
4395    // set installed, the same identifier stays undefined.
4396    #[test]
4397    fn undeclared_bare_name_stays_undefined() {
4398        let mut rt = fresh_runtime();
4399        // No declared names installed at all.
4400        rt.execute_script(
4401            Some("calculate"),
4402            "if (typeof NemoNonexistent7 !== 'undefined') \
4403             throw new Error('D-theta.1 violated: undeclared name became defined');",
4404        )
4405        .expect("undeclared bare name must remain undefined");
4406        // Even with an UNRELATED declared name present, an undeclared ref stays undefined.
4407        let mut names = std::collections::HashSet::new();
4408        names.insert("SomeOtherSubform".to_string());
4409        rt.set_declared_subform_names(names);
4410        rt.execute_script(
4411            Some("calculate"),
4412            "if (typeof NemoNonexistent7 !== 'undefined') \
4413             throw new Error('D-theta.1 violated: undeclared name became defined (declared set present)');",
4414        )
4415        .expect("undeclared bare name must remain undefined even when other names are declared");
4416    }
4417
4418    #[test]
4419    fn ui_activity_is_phase_denied() {
4420        let mut rt = fresh_runtime();
4421        let err = rt.execute_script(Some("click"), "1+1").unwrap_err();
4422        assert!(matches!(err, SandboxError::PhaseDenied(_)));
4423    }
4424
4425    #[test]
4426    fn oversized_body_rejected_before_parse() {
4427        let mut rt = fresh_runtime();
4428        let body = "1;\n".repeat(MAX_SCRIPT_BODY_BYTES);
4429        let err = rt.execute_script(Some("calculate"), &body).unwrap_err();
4430        assert_eq!(err, SandboxError::BodyTooLarge);
4431    }
4432
4433    #[test]
4434    fn fetch_is_undefined() {
4435        let mut rt = fresh_runtime();
4436        // Reading `typeof fetch` from a fresh context should return
4437        // "undefined" because we never register it. Surface as a thrown
4438        // error if it isn't, by using `if (typeof fetch !== 'undefined') throw 0`.
4439        rt.execute_script(
4440            Some("calculate"),
4441            "if (typeof fetch !== 'undefined') throw new Error('fetch leaked');",
4442        )
4443        .expect("must run cleanly with fetch undefined");
4444    }
4445
4446    #[test]
4447    fn require_is_undefined() {
4448        let mut rt = fresh_runtime();
4449        rt.execute_script(
4450            Some("calculate"),
4451            "if (typeof require !== 'undefined') throw new Error('require leaked');",
4452        )
4453        .expect("must run cleanly with require undefined");
4454    }
4455
4456    #[test]
4457    fn process_is_undefined() {
4458        let mut rt = fresh_runtime();
4459        rt.execute_script(
4460            Some("calculate"),
4461            "if (typeof process !== 'undefined') throw new Error('process leaked');",
4462        )
4463        .expect("must run cleanly with process undefined");
4464    }
4465
4466    #[test]
4467    fn date_now_is_zero() {
4468        let mut rt = fresh_runtime();
4469        rt.execute_script(
4470            Some("calculate"),
4471            "if (Date.now() !== 0) throw new Error('Date.now not stubbed');",
4472        )
4473        .expect("Date.now must return 0");
4474    }
4475
4476    #[test]
4477    fn math_random_is_undefined() {
4478        let mut rt = fresh_runtime();
4479        rt.execute_script(
4480            Some("calculate"),
4481            "if (typeof Math.random !== 'undefined') throw new Error('Math.random leaked');",
4482        )
4483        .expect("Math.random must be undefined");
4484    }
4485
4486    #[test]
4487    fn infinite_loop_times_out() {
4488        let mut rt = QuickJsRuntime::new()
4489            .expect("init")
4490            .with_time_budget(Duration::from_millis(50));
4491        rt.init().unwrap();
4492        rt.reset_for_new_document().unwrap();
4493        let err = rt
4494            .execute_script(Some("calculate"), "while(true){}")
4495            .unwrap_err();
4496        assert_eq!(err, SandboxError::Timeout);
4497        let md = rt.take_metadata();
4498        assert_eq!(md.timeouts, 1);
4499        assert_eq!(md.executed, 0);
4500    }
4501
4502    #[test]
4503    fn syntax_error_is_recoverable() {
4504        let mut rt = fresh_runtime();
4505        let err = rt
4506            .execute_script(Some("calculate"), "this is not javascript {{")
4507            .unwrap_err();
4508        assert!(matches!(err, SandboxError::ScriptError(_)));
4509        // Subsequent script should still run.
4510        rt.execute_script(Some("calculate"), "var ok = 1;")
4511            .expect("recovered");
4512    }
4513
4514    #[test]
4515    fn null_receiver_silent_setter() {
4516        let mut rt = fresh_runtime();
4517        rt.execute_script(
4518            Some("calculate"),
4519            r#"
4520$record.NONEXISTENT_FIELD.rawValue = "hello";
4521$record.NONEXISTENT_FIELD.value = "world";
4522"#,
4523        )
4524        .expect("WP-3 null_receiver_silent_setter: set on null DataHandle must not throw");
4525    }
4526
4527    #[test]
4528    fn null_record_returns_empty_nodelist() {
4529        let mut rt = fresh_runtime();
4530        rt.execute_script(
4531            Some("calculate"),
4532            r#"
4533var nodes = $record.FIELD.nodes;
4534if (nodes.length !== 0) {
4535  throw new Error("expected nodes.length == 0, got " + nodes.length);
4536}
4537// item() on an empty null-sentinel NodeList returns a chainable null handle (not
4538// native null) so callers can safely do .item(0).value without TypeError.
4539var itemVal = nodes.item(0).value;
4540if (itemVal !== null) {
4541  throw new Error("expected nodes.item(0).value == null, got " + itemVal);
4542}
4543"#,
4544        )
4545        .expect("WP-3 null_record_returns_empty_nodelist: $record.FIELD.nodes must be empty and chainable");
4546    }
4547
4548    // WP-3 F3: xfa.validate is a no-op viewer stub absorbing writes silently.
4549    // Adobe-generated initialize scripts call `xfa.validate.override = 0`,
4550    // `xfa.validate.max = N`, `xfa.validate.scriptTest.message = "…"`; without
4551    // the stub these throw "Cannot set property X of undefined" because
4552    // `xfa.validate` was previously not defined on the host xfa object.
4553    #[test]
4554    fn xfa_validate_stub_absorbs_writes_silently() {
4555        let mut rt = fresh_runtime();
4556        rt.execute_script(
4557            Some("initialize"),
4558            r#"
4559xfa.validate.override = 0;
4560xfa.validate.max = 5;
4561xfa.validate.messageMode = "warning";
4562xfa.validate.scriptTest.message = "ignored";
4563xfa.validate.scriptTest.nested.deeper = true;
4564// Reads must return harmless defaults, never throw.
4565if (xfa.validate.override !== 0) throw new Error("override default");
4566if (xfa.validate.max !== 0) throw new Error("max default");
4567if (xfa.validate.messageMode !== "") throw new Error("messageMode default");
4568"#,
4569        )
4570        .expect("WP-3 F3 xfa_validate_stub: writes must absorb and reads must not throw");
4571    }
4572
4573    // WP-3 F3: addInstance failure returns chainable sentinel so common
4574    // pattern `parent._Child.addInstance().rawValue = X` does not throw when
4575    // the instance manager refuses (occur/max bounds reached or unbound).
4576    // Note: this test exercises only the JS-side null-safety contract; full
4577    // end-to-end instance addition lives in m3b_phaseD_instance_manager.rs.
4578    #[test]
4579    fn add_instance_failure_returns_chainable_null_handle() {
4580        let mut rt = fresh_runtime();
4581        // No FormTree is installed, so any addInstance call must fail at the
4582        // host shim and surface a sentinel rather than native null.
4583        rt.execute_script(
4584            Some("initialize"),
4585            r#"
4586// Resolve a non-existent subform, addInstance() on an unbound instance
4587// manager must yield a chainable handle whose `.index` is a number and
4588// whose `.rawValue` setter is silent.
4589var im = xfa.resolveNode("NoSuchSubform");
4590if (im !== null) {
4591  // If a real handle was returned (shouldn't be), addInstance still must
4592  // either return a real handle or a null-safe sentinel — never throw.
4593  var added = im.addInstance();
4594  if (typeof added.index !== "number") {
4595    throw new Error("added.index must be a number, got " + typeof added.index);
4596  }
4597  added.rawValue = "chained";  // silent on sentinel
4598  added.value = "chained";     // silent on sentinel
4599}
4600"#,
4601        )
4602        .expect("WP-3 F3 add_instance_failure: chainable sentinel on failure path");
4603    }
4604}