From 7f633c633f112a0f37a9ed603ec2efc0cca9b8fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:58:33 +0000 Subject: [PATCH 1/2] fix(runtime): instance reads skip a parent class object's own properties (#10890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declared class that extends a class OBJECT returned by a factory (`class InitError extends ErrorClass("…") {}`, Effect's `Schema.ErrorClass`) records that parent class object in `CLASS_PROTOTYPE_OBJECTS` (#1788), so static reads on the subclass inherit the parent's statics. The instance-side walks read the same entry as if it were a prototype. An instance then saw the parent's statics, and `name` read the class binding's own name (`out`) instead of the `name` written to `out.prototype`. Instance walks now skip that entry. They read the parent evaluation's already-materialized prototype, then continue up the template chain: - `resolve_proto_chain_field_inner` (property reads with a receiver) - the vtable walk in `dispatch_handle` (instance method calls) - the symbol chain walk, split by whether the receiver is a class object The entry for a synthetic id (`Object.create(C)`) is a real prototype and is unchanged. --- .../src/object/class_registry.rs | 4 +- .../class_registry/prototype_objects.rs | 92 +++++++++- .../parent_class_object_tests.rs | 135 ++++++++++++++ .../perry-runtime/src/object/field_get_set.rs | 3 +- .../field_get_set/class_object_props.rs | 23 +++ .../native_call_method/handle_methods.rs | 3 +- crates/perry-runtime/src/symbol/get.rs | 14 +- ...t_issue_10890_parent_class_object_reads.ts | 166 ++++++++++++++++++ 8 files changed, 425 insertions(+), 15 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_registry/prototype_objects/parent_class_object_tests.rs create mode 100644 test-files/test_issue_10890_parent_class_object_reads.ts diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index bfdbbe2ca0..ded1f978f9 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -108,8 +108,8 @@ pub use state::{ // ── prototype_objects.rs ──────────────────────────────────────────────────── pub(crate) use prototype_objects::{ class_prototype_object, ensure_function_prototype_object, function_class_id, - function_value_for_class_id, proto_chain_symbol_slot, resolve_proto_chain_field, - resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, + function_value_for_class_id, instance_class_prototype_object, object_proto_chain_symbol_slot, + resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, synthetic_class_prototype_object, SYNTHETIC_CLASS_ID_BASE, }; pub use prototype_objects::{ diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 9a8f4e168e..3aae2cbc3c 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -223,6 +223,10 @@ pub(crate) fn test_alloc_synthetic_class_id() -> u32 { alloc_synthetic_class_id() } +#[cfg(test)] +#[path = "prototype_objects/parent_class_object_tests.rs"] +mod parent_class_object_tests; + /// The `[[Prototype]]` object recorded for a SYNTHETIC class id — one of the /// ids `Object.create(proto)` (#809) and `F.prototype = obj` (#711) allocate /// from [`NEXT_SYNTHETIC_CLASS_ID`]. That link is the authoritative prototype @@ -240,6 +244,34 @@ pub(crate) fn synthetic_class_prototype_object(class_id: u32) -> *mut ObjectHead class_prototype_object(class_id) } +/// `proto_obj` (the [`class_prototype_object`] entry for `class_id`) when it +/// is the parent CLASS OBJECT of a class-expression subclass (#1788/#6552) +/// rather than a prototype. A synthetic id's entry is a real prototype even +/// when that prototype is itself a class object (`Object.create(C)`). +fn declared_parent_class_object( + class_id: u32, + proto_obj: *mut ObjectHeader, +) -> Option<*mut ObjectHeader> { + if proto_obj.is_null() + || (SYNTHETIC_CLASS_ID_BASE..SYNTHETIC_CLASS_ID_END).contains(&class_id) + || !is_class_object_ptr(proto_obj as *const u8) + { + return None; + } + Some(proto_obj) +} + +/// [`class_prototype_object`] for a walk that serves an INSTANCE. Null where +/// that entry is a declared class's parent class object: its statics are not +/// on the instance's prototype chain (#10890). +pub(crate) fn instance_class_prototype_object(class_id: u32) -> *mut ObjectHeader { + let proto_obj = class_prototype_object(class_id); + if declared_parent_class_object(class_id, proto_obj).is_some() { + return std::ptr::null_mut(); + } + proto_obj +} + /// Perform ordinary `.prototype` assignment, then synchronize the synthetic /// class metadata used when a class extends a function (#711, #9365). #[no_mangle] @@ -767,7 +799,26 @@ unsafe fn resolve_proto_chain_field_inner( } } } - let proto_obj = class_prototype_object(cid); + let mut proto_obj = class_prototype_object(cid); + if let Some(receiver) = receiver { + if let Some(parent_class) = declared_parent_class_object(cid, proto_obj) { + // #10890: for a DECLARED class id this entry is the parent + // CLASS OBJECT (#1788), which is on the constructor's static + // chain and never on an instance's. Reading it for an instance + // returned the parent's statics, and `name` returned the + // class binding's own name (Effect's `out` / `Base`) instead + // of the `name` written to that class's `prototype`. An + // instance read goes to that evaluation's prototype object. + if let Some(evaluated) = + super::super::field_get_set::class_object_materialized_prototype(parent_class) + { + if let Some(value) = evaluated_parent_instance_field(evaluated, key, receiver) { + return Some(value); + } + } + proto_obj = std::ptr::null_mut(); + } + } if !proto_obj.is_null() { if let Some(receiver) = receiver { if let Some(value) = inherited_proto_accessor_value(proto_obj, key, receiver) { @@ -844,6 +895,29 @@ pub(crate) unsafe fn resolve_proto_chain_symbol( pub(crate) unsafe fn proto_chain_symbol_slot( class_id: u32, sym_f64: f64, +) -> Option { + proto_chain_symbol_slot_inner(class_id, sym_f64, false) +} + +/// [`proto_chain_symbol_slot`] for a read on `obj` itself. A class object +/// inherits its parent class object's statics. Any other object (an instance +/// or a prototype) reads that parent's evaluated prototype instead (#10890). +pub(crate) unsafe fn object_proto_chain_symbol_slot( + obj: *const ObjectHeader, + sym_f64: f64, +) -> Option { + let class_id = crate::object::js_object_get_class_id(obj); + if class_id == 0 { + return None; + } + let instance = !is_class_object_ptr(obj as *const u8); + proto_chain_symbol_slot_inner(class_id, sym_f64, instance) +} + +unsafe fn proto_chain_symbol_slot_inner( + class_id: u32, + sym_f64: f64, + instance: bool, ) -> Option { let mut cid = class_id; let mut depth = 0usize; @@ -853,8 +927,22 @@ pub(crate) unsafe fn proto_chain_symbol_slot( break; } visited[depth] = cid; - let proto_obj = class_prototype_object(cid); + let mut proto_obj = class_prototype_object(cid); let mut next_cid: u32 = 0; + if instance { + if let Some(parent_class) = declared_parent_class_object(cid, proto_obj) { + if let Some(evaluated) = + super::super::field_get_set::class_object_materialized_prototype(parent_class) + { + let evaluated = f64::from_bits(JSValue::pointer(evaluated as *const u8).bits()); + if let Some(slot) = crate::symbol::own_symbol_slot(evaluated, sym_f64) { + return Some(slot); + } + } + // The `extends` axis below reaches the parent's template. + proto_obj = std::ptr::null_mut(); + } + } if !proto_obj.is_null() { let proto_f64 = f64::from_bits(JSValue::pointer(proto_obj as *const u8).bits()); // OWN lookup only — this fn IS the chain walk, so recursing into diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects/parent_class_object_tests.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects/parent_class_object_tests.rs new file mode 100644 index 0000000000..c41ee9d92b --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects/parent_class_object_tests.rs @@ -0,0 +1,135 @@ +//! #10890: an instance of a declared class whose heritage is a per-evaluation +//! class OBJECT (`class InitError extends makeClass(...) {}`) reads that +//! evaluation's prototype, never the parent constructor's own properties. +//! +//! Built through the entry points compiled code calls: a class object marked +//! with `js_object_mark_class`, its prototype materialized by a `.prototype` +//! read, and the heritage edge recorded by `js_register_class_parent_dynamic`. + +use super::super::{ + class_prototype_object, instance_class_prototype_object, is_class_object_ptr, + js_object_mark_class, js_register_class_id, js_register_class_name, + js_register_class_parent_dynamic, resolve_proto_chain_field, test_alloc_synthetic_class_id, +}; +use crate::object::{ + class_prototype_object_root_store, js_object_alloc, js_object_get_field_by_name, + js_object_set_field_by_name, ObjectHeader, +}; +use crate::JSValue; + +unsafe fn key(name: &str) -> *mut crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +unsafe fn string_value(text: &str) -> f64 { + f64::from_bits(crate::js_nanbox_string(key(text) as i64).to_bits()) +} + +unsafe fn read(obj: *const ObjectHeader, name: &str) -> JSValue { + js_object_get_field_by_name(obj, key(name)) +} + +unsafe fn read_string(obj: *const ObjectHeader, name: &str) -> Option { + let value = read(obj, name); + if !value.is_string() { + return None; + } + let s = value.as_string_ptr(); + let bytes = std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize); + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +/// A heap class object for `template`, named `out` and carrying one static. +unsafe fn class_object(template: u32) -> *mut ObjectHeader { + js_register_class_id(template); + js_register_class_name(template, b"out".as_ptr(), 3); + let class = js_object_alloc(template, 1); + js_object_mark_class(class as i64); + js_object_set_field_by_name(class, key("identifier"), string_value("static")); + class +} + +#[test] +fn an_instance_reads_the_parent_evaluations_prototype_not_the_parent_constructor() { + let _lock = crate::gc::global_side_table_test_lock(); + const TEMPLATE: u32 = 0x1089_0001; + const CHILD: u32 = 0x1089_0002; + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let class = scope.root_raw_mut_ptr(class_object(TEMPLATE)); + let class_value = class.with_const_ptr::(|class| { + f64::from_bits(crate::value::js_nanbox_pointer(class as i64).to_bits()) + }); + // `Object.assign(out.prototype, { name: identifier })`. + let proto = class.with_const_ptr::(|class| read(class, "prototype")); + assert!( + proto.is_pointer(), + "reading `.prototype` must materialize it" + ); + let proto = scope.root_raw_mut_ptr(proto.as_pointer::() as *mut ObjectHeader); + proto.with_mut_ptr::(|proto| { + js_object_set_field_by_name(proto, key("name"), string_value("ProviderInitError")) + }); + + js_register_class_id(CHILD); + js_register_class_parent_dynamic(CHILD, class_value); + class.with_const_ptr::(|class| { + assert_eq!( + class_prototype_object(CHILD) as usize, + class as usize, + "fixture must record the parent CLASS OBJECT for the declared child, \ + or every verdict below is vacuous" + ); + assert!(is_class_object_ptr(class as *const u8)); + }); + assert!( + instance_class_prototype_object(CHILD).is_null(), + "a parent class object is not on an instance's prototype chain" + ); + + let instance = scope.root_raw_mut_ptr(js_object_alloc(CHILD, 0)); + let name = instance.with_const_ptr::(|inst| read_string(inst, "name")); + assert_eq!( + name.as_deref(), + Some("ProviderInitError"), + "`name` comes from the parent's prototype, not the parent's own `name` (`out`)" + ); + let leaked = instance.with_const_ptr::(|inst| read(inst, "identifier")); + assert!( + leaked.is_undefined(), + "a parent static must not surface on an instance" + ); + + // The constructor side still inherits the parent's statics. + let inherited = resolve_proto_chain_field(CHILD, key("identifier")); + assert!( + inherited.is_some_and(|value| value.is_string()), + "the subclass constructor still reads the parent class object's static" + ); + } +} + +/// `Object.create(C)` where `C` is a class object: the synthetic id's entry +/// IS the instance's prototype, so its own properties stay readable. +#[test] +fn a_synthetic_prototype_that_is_a_class_object_stays_on_the_instance_chain() { + let _lock = crate::gc::global_side_table_test_lock(); + const TEMPLATE: u32 = 0x1089_0003; + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let class = scope.root_raw_mut_ptr(class_object(TEMPLATE)); + let synthetic = test_alloc_synthetic_class_id(); + assert_ne!(synthetic, 0, "the synthetic id range is exhausted"); + class.with_mut_ptr::(|class| { + class_prototype_object_root_store(synthetic, class); + assert_eq!( + instance_class_prototype_object(synthetic) as usize, + class as usize + ); + }); + let instance = scope.root_raw_mut_ptr(js_object_alloc(synthetic, 0)); + let value = + instance.with_const_ptr::(|inst| read_string(inst, "identifier")); + assert_eq!(value.as_deref(), Some("static")); + } +} diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 90cc1a2e14..35cc8e8b38 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -270,7 +270,8 @@ pub(crate) use accessors::{ primitive_tagged_prototype_property, string_index_value, }; pub(crate) use class_object_props::{ - class_evaluation_prototype_class_id, class_object_prototype_value, + class_evaluation_prototype_class_id, class_object_materialized_prototype, + class_object_prototype_value, }; pub(crate) use crypto_key::{ crypto_key_property_value, CLASS_ID_BOXED_BIGINT, CLASS_ID_BOXED_BOOLEAN, diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 549d21311a..0635647e7e 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -199,6 +199,29 @@ pub(crate) unsafe fn class_object_prototype_value(obj: *const ObjectHeader) -> J JSValue::from_bits(class_evaluation_prototype_value(obj).to_bits()) } +/// #10890: the prototype object [`class_evaluation_prototype_value`] already +/// materialized for this evaluation of a heap class object, or `None` when +/// nothing has read `C.prototype` yet. +/// +/// Never allocates, so an instance-read walk that holds raw pointers can call +/// it. An unmaterialized prototype cannot hold a user write (writing one +/// needs the `C.prototype` read that materializes it). Its declared methods +/// are served by the template walk that the caller continues with. +pub(crate) fn class_object_materialized_prototype( + obj: *const ObjectHeader, +) -> Option<*mut ObjectHeader> { + let value = super::super::class_registry::class_object_own_field_bytes( + obj, + CLASS_EVALUATION_PROTOTYPE_KEY, + )?; + let value = JSValue::from_bits(value.to_bits()); + if !value.is_pointer() { + return None; + } + let proto = value.as_pointer::() as *mut ObjectHeader; + (!proto.is_null()).then_some(proto) +} + /// Resolve `.name` for an `OBJECT_TYPE_CLASS` heap object. An explicit /// `static name` member (an own field on the class object) wins; a deleted /// key still reads `undefined` (returns `None`). diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index faa60870c5..3848f52788 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -1124,7 +1124,8 @@ pub(super) unsafe fn dispatch_handle( let proto_obj = if deleted { std::ptr::null_mut() } else { - class_prototype_object(cur_cid) + // #10890: never a parent class object's statics. + instance_class_prototype_object(cur_cid) }; if !proto_obj.is_null() { let method_key = crate::string::js_string_from_bytes( diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 6cbb126aa9..89ba0f012e 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -499,11 +499,8 @@ unsafe fn explicit_prototype_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option Option { if class_id == 0 { return None; } - if let Some(slot) = crate::object::proto_chain_symbol_slot(class_id, sym) { + if let Some(slot) = crate::object::object_proto_chain_symbol_slot(ptr as *const _, sym) { return Some(slot); } own_symbol_slot(declared_prototype_symbol_holder(obj, sym, class_id)?, sym) diff --git a/test-files/test_issue_10890_parent_class_object_reads.ts b/test-files/test_issue_10890_parent_class_object_reads.ts new file mode 100644 index 0000000000..1c5b139f6d --- /dev/null +++ b/test-files/test_issue_10890_parent_class_object_reads.ts @@ -0,0 +1,166 @@ +// #10890: a class declaration that extends a class OBJECT returned by a +// factory. Instance reads must walk that evaluation's prototype, never the +// parent constructor itself. Perry answered with the parent's statics, and +// `name` came back as the class binding's own name (`out`) instead of the +// inherited prototype property. + +// Effect 4's `Schema.ErrorClass`: `makeClass` builds a class expression bound +// to `out`, then writes the tag onto `out.prototype`. +const YieldableError = (function () { + class YieldableError extends globalThis.Error {} + Object.assign(YieldableError.prototype, { op: "YieldableError" }); + return YieldableError; +})(); +const CoreError = (function () { + return class Base extends YieldableError { + constructor(args: any) { + super(args?.message, args?.cause ? { cause: args.cause } : undefined); + if (args) Object.assign(this, args); + } + }; +})(); +function makeClass(Inherited: any, identifier: string, proto?: (id: string) => any) { + const out = class extends Inherited { + constructor(...[input, options]: any[]) { + super(input, { ...options }); + } + static identifier = identifier; + static make(input: any) { + return new this(input); + } + static get ast() { + return "ast:" + identifier; + } + }; + if (proto !== undefined) { + Object.assign(out.prototype, proto(identifier)); + } + return out; +} +const ErrorClass = (identifier: string) => + makeClass(CoreError, identifier, (id) => ({ name: id })); +class InitError extends ErrorClass("ProviderInitError") {} +class NoProvidersError extends ErrorClass("ProviderNoProvidersError") {} +const init: any = new InitError({ providerID: "anthropic" }); +const none: any = new NoProvidersError({}); +console.log("effect-name", init.name, none.name); +console.log("effect-string", String(init), String(none)); +console.log("effect-field", init.providerID, init.op); +console.log("effect-instanceof", init instanceof InitError, init instanceof NoProvidersError); +console.log("effect-own-name", Object.prototype.hasOwnProperty.call(init, "name")); +console.log("effect-statics", (InitError as any).identifier, (InitError as any).ast); +console.log("effect-make", (InitError as any).make({}).name); +console.log("effect-instance-statics", init.identifier, typeof init.make, init.ast); + +// The same edge without Error. +function makeLabelled(label: string) { + const out = class { + static label = label; + static describe() { + return "static:" + label; + } + own() { + return "own:" + label; + } + }; + Object.assign(out.prototype, { + tag: label, + greet() { + return "greet:" + label; + }, + }); + return out; +} +const First = makeLabelled("first"); +class Labelled extends First {} +class Other extends makeLabelled("second") {} +const labelled: any = new Labelled(); +const other: any = new Other(); +console.log("plain-proto", labelled.tag, other.tag); +console.log("plain-name", labelled.name, typeof labelled.describe, labelled.label); +console.log("plain-static", (Labelled as any).label, (Other as any).describe()); +console.log("call-proto", labelled.greet(), other.greet()); +console.log("call-own", labelled.own(), other.own()); +try { + labelled.describe(); + console.log("call-static", "no throw"); +} catch (e) { + console.log("call-static", e instanceof TypeError); +} +console.log( + "in", + "tag" in labelled, + "label" in labelled, + "describe" in labelled, + "greet" in labelled, +); +const keys: string[] = []; +for (const k in labelled) keys.push(k); +console.log("for-in", keys.join(",")); +console.log("chain", Object.getPrototypeOf(Labelled.prototype) === First.prototype); + +// Symbol-keyed statics follow the same rule. Effect's `Schema.isSchema(u)` is +// `TypeId in u`, so a leaked static brands every error instance as a schema. +const TypeId = Symbol.for("test/10890/TypeId"); +const ProtoId = Symbol.for("test/10890/ProtoId"); +function makeBranded(label: string) { + const out = class { + static [TypeId] = TypeId; + static label = label; + }; + Object.assign(out.prototype, { [ProtoId]: label }); + return out; +} +class Branded extends makeBranded("branded") {} +const branded: any = new Branded(); +console.log("sym-instance", String(branded[TypeId]), TypeId in branded); +console.log("sym-static", String((Branded as any)[TypeId]), TypeId in Branded); +console.log("sym-proto", branded[ProtoId], ProtoId in branded); + +// A subclass's own members still shadow the parent evaluation's prototype. +function makeBase(label: string) { + const out = class { + static label = label; + m() { + return "parent-m:" + label; + } + get g() { + return "parent-g:" + label; + } + }; + Object.assign(out.prototype, { + a() { + return "parent-a:" + label; + }, + d: "parent-d", + }); + return out; +} +class Child extends makeBase("x") { + m() { + return "child-m"; + } + a() { + return "child-a"; + } + get g() { + return "child-g"; + } + get d() { + return "child-d"; + } +} +const child: any = new Child(); +console.log("override", child.m(), child.a(), child.g, child.d, child.a === Child.prototype.a); +const Base = makeBase("z"); +class Plain extends Base {} +const plain: any = new Plain(); +console.log( + "inherit", + plain.m(), + plain.a(), + plain.g, + plain.d, + plain.m === Base.prototype.m, + plain.a === Base.prototype.a, +); From 20dbb298595d7ac90c68ee8a790a53391746a6fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:10:46 +0000 Subject: [PATCH 2/2] docs: add changelog for PR 11332 --- ...instance-reads-skip-parent-class-object.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 changelog.d/11332-instance-reads-skip-parent-class-object.md diff --git a/changelog.d/11332-instance-reads-skip-parent-class-object.md b/changelog.d/11332-instance-reads-skip-parent-class-object.md new file mode 100644 index 0000000000..f66dd445fe --- /dev/null +++ b/changelog.d/11332-instance-reads-skip-parent-class-object.md @@ -0,0 +1,38 @@ +Fixed instance reads on a class that extends a factory-returned class object +(#10890). + +A class declaration that extends a class OBJECT (Effect's +`class InitError extends Schema.ErrorClass(...)(...) {}`, whose `makeClass` +returns `const out = class extends Inherited { … }`) records that parent +class object in `CLASS_PROTOTYPE_OBJECTS` (#1788). Static reads on the +subclass use it to inherit the parent's statics. The instance-side walks read +the same entry as if it were a prototype, so an instance: + +- read the parent's statics (`init.identifier`, `typeof init.make`, + `init.ast`), where Node answers `undefined`; +- read `name` as the class binding's own name (`out`) instead of the `name` + written to `out.prototype`; +- missed `Object.assign(out.prototype, { tag, greet() {} })` entirely (`tag` + read `undefined` and `greet()` threw "not a function"); +- read symbol-keyed statics (`init[TypeId]`, `TypeId in init`). Effect's + `Schema.isSchema(u)` is `TypeId in u`, so every such instance passed it. + +The instance walks now skip that entry and read the parent evaluation's +materialized prototype object (non-allocating: an unmaterialized prototype +cannot hold a user write). This covers the property walk +(`resolve_proto_chain_field_inner`), the vtable walk for instance method +calls, and the symbol chain walk (split by whether the receiver is a class +object). A synthetic id's entry (`Object.create(C)`) is a real prototype and +is unchanged. + +Coverage: `test-files/test_issue_10890_parent_class_object_reads.ts` +reproduces Effect 4's `ErrorClass` shape plus plain, symbol-keyed and +override variants; it fails on the previous runtime and matches Node here. +`prototype_objects::parent_class_object_tests` asserts the instance and +constructor sides of the same edge, plus the `Object.create(C)` case, and +fails when the new predicate is disabled. + +The published `effect` 4.0.0 betas that still export `Schema.TaggedErrorClass` +currently fail to link (undefined `__perry_wrap_perry_fn_…_Array_js__Array_`), +which is a separate bug. The fixture reproduces the package's class shapes +instead.