Skip to content

Latest commit

 

History

History
241 lines (181 loc) · 40.1 KB

runtime-semantics.md

File metadata and controls

241 lines (181 loc) · 40.1 KB

Runtime semantics

The runtime exists where C++ standard-library behavior is observably different from TypeScript. Wrappers are justified by semantics, not by a desire to reproduce JavaScript method names.

Initial invariants

  • Array<T> and Map<K, V> are reference values. Copying a wrapper preserves backing identity; clone() explicitly creates new storage.
  • Length construction keeps the runtime's array representation deliberately dense: Array<T>(length) materializes length value-initialized T elements rather than pretending that C++ can observe JavaScript holes. Floating lengths use JavaScript's array-length domain (finite integers from zero through 2^32 - 1) and reject other values; actual allocation remains subject to address-space and allocator limits. This choice means hole-sensitive operations are outside the dense profile, while length, indexing, iteration, and writes have one explicit representation.
  • Array<T>::at and Map<K, V>::get return absence rather than throwing for a missing index or key.
  • Array inclusion and map key identity use SameValueZero: NaN equals NaN and positive and negative zero are the same value.
  • Map iteration retains insertion order. Updating an existing key does not move it.
  • Undefined, Null, and Presence<T> keep the two TypeScript sentinels distinct from one another and from an ordinary value.
  • String stores UTF-16 code units, so length, indexing, slicing, splitting, and unpaired-surrogate behavior do not depend on the machine encoding. UTF-8 conversion is an explicit boundary.
  • Set<T> shares Map's SameValueZero and insertion-order rules. Typed-array copies and subarray share a fixed backing store, while slice copies; clamped bytes use saturating ties-to-even conversion. Compiler-facing indexed reads project every element to a number and use NaN for the number-only absent-index lane. Indexed writes return the uncoerced assignment value while applying the target element's modulo, clamping, or floating-point conversion, and an invalid integer-indexed property ignores the write. ArrayBufferLike retains ordinary, shared, or host-owned memory together with stable backing identity, lifetime, mutability, and concurrency policy. Typed arrays and DataView keep that backing without copying.
  • Blob copies its input parts into immutable shared byte storage. String parts use UTF-8, MIME types are printable ASCII normalized to lowercase, byte slices use JavaScript's signed-index rules, and text decoding follows the runtime's UTF-8 replacement behavior.
  • URI component encoding operates on UTF-16 scalar values and emits uppercase UTF-8 percent escapes; decoding rejects malformed escapes and UTF-8. Base64 uses the browser binary-string domain, strips the specified ASCII whitespace while decoding, and rejects input code units outside one byte while encoding.
  • Stream copies share identity and one lock state. Native sinks and sources return Task values for writes, close, abort, reads, and cancellation; released readers and writers reject further operations. The current contract does not yet model Web Streams backpressure, piping, transforms, or teeing.
  • Shared container and binary-view handles use logical constness: a compiler-emitted value capture cannot rebind its handle, but it can mutate the JavaScript object referenced by that handle. TextEncoder converts UTF-16 scalar values to UTF-8 and replaces each unpaired surrogate with U+FFFD.
  • WeakSet<K> and WeakMap<K, V> copy as aliases, compare keys by object identity, remove expired entries opportunistically, and never retain or enumerate keys. Flight references and Array<T> provide recoverable weak ownership; host handles require an explicit policy with the same lifetime and identity guarantees. A policy must supply a non-owning weak token, recovery, stable identity, hash, and equality for its exact key type; primitive and unapproved key types do not instantiate a weak collection. Values remain their represented C++ type, including references and structural rows. ErasedValue is a checked type-tagged compatibility carrier for the exact WeakMap<object, unknown> lane, not an unchecked pointer fallback.
  • Task<T> is copyable and shares one settlement. Observers run through a non-reentrant executor, completed tasks can be awaited repeatedly, and exception or non-exception rejection values retain their identity and type.
  • Date applies ECMAScript-style finite millisecond clipping and stores an epoch instant. Its integer proleptic-Gregorian projection covers the complete valid range from -8.64e15 through +8.64e15 milliseconds, including signed extended years; the projection is UTC-only, and local-zone getFullYear behavior is not yet claimed.
  • number_to_fixed follows Number.prototype.toFixed, rounding the double's exact decimal expansion rather than asking printf for the final precision: printf rounds a tie to even and the language rounds it away from zero, so (2.5).toFixed(0) is "3". Negative zero produces no sign while a negative value that rounds to zero keeps one, and a magnitude at or above 1e21 falls back to the number's own string. npm run number-to-fixed:oracle compares the helper directly with Node using identical double bit patterns across digit normalization and range errors, non-finite values, both neighbours of the 1e21 cutoff, representation-sensitive ties, negative zero, and large values below the cutoff.
  • round follows Math.round: nearest-integer ties move toward positive infinity, inputs from -0.5 through negative zero retain a negative-zero result, and NaN and infinities pass through unchanged.
  • RowRequired<Row> is the inverse of RowPartial<Row>, not its absence: it names every member of the row as present. The subject's storage is unchanged -- a member the subject declared optional stays an std::optional in the object -- but reading it through a required row yields the value rather than the optional, and a row that does not in fact hold one throws instead of handing back an empty optional to invoke. Because it is an inverse, it overrides an inner RowPartial rather than inheriting from it, so Required<Partial<T>> names every member present; readonly-ness passes through in both directions.
  • Ref<T> is shared_ptr<T> for a record and T for a value shape, and it answers the same way whether or not T's definition is in scope yet. Two records that name each other are representable: the completeness question is asked through overload resolution rather than through a class template specialization, because a specialization cannot be used while it is still being selected and the recursive query re-enters exactly that. A type that names itself through Ref, directly or through another type that names it back, must derive from ReferenceEnabled -- it is asked about while incomplete, when the only available answer is "record", and make_ref refuses a type where that answer and the marker disagree rather than letting one spelling mean two types.
  • Any is the erased dynamic value for the any and unknown type positions the SDK leaves unconstrained. It carries every ECMAScript language type the runtime has as its own alternative, so a value written unknown that is a number stays a number rather than becoming an object reference. typeof reports the language's own results, including object for null and function for a callable. === never coerces, SameValueZero matches NaN with NaN so an erased value can key a Map, and Object.is separates positive from negative zero. Object, callable, and host values compare by reference identity and come back only at the type they went in as.
  • A missing entry and an entry whose value is undefined are different things, and stay different. Any holds undefined as a present value; AnySlot — std::optional<Any> — reports "no such entry". Record::get and AttachedProperties::get already return the latter, so both facts survive a round trip through a container.
  • Error is an object, and its name is a dynamic property. catch hands back the value that was thrown and e instanceof Error is true for a RangeError, so a read of name through an Error-typed binding has to come from the object: name() is an instance accessor and virtual, and reading it off a RangeError held as an Error& answers "RangeError" rather than the declared class. The base and its two subclasses are ReferenceEnabled, so Ref<Error> is a shared reference and never a copy — a copy would slice the subclass, which is the one answer the accessor exists to prevent. Any::error_if() is the checked view from an erased value to that object, and flight::detail::any_to<Ref<Error>> reaches it through the ordinary checked read. It accepts subclasses — a RangeError boxed as Ref<RangeError> IS an Error — where the exact-type object_if cannot, and it hands back the very object that was boxed, with no copy and no second Error minted. Absence is the same answer as a value that is not an Error, including a DOMException, which derives from std::runtime_error and is not a source-language Error. The one upward conversion it performs is recorded where the concrete type is still known — a thunk set at boxing, beside the row-owner thunk — so nothing is asked at runtime that boxing did not already decide, and a base conversion is granted to Error alone: Ref<Widget> still comes back only at Widget.
  • AttachedProperties gives an object symbol-keyed properties that every projection of that object shares. One owner is resolved per object identity, keyed on the object's address rather than on its static type, so a typed row, a structural row, and an erased Ref<void> all read and write the same entries. Attachments last as long as the object, not as long as the view that wrote them, and an address reused by a later object never inherits the earlier one's entries.
  • Error carries stack and cause as a stated profile contract, and neither is invented. stack is not part of ECMAScript: it is an implementation-defined property engines add when they capture a call stack. This runtime captures none, so Error::stack() is an optional<String> that is always absent, and reading error.stack yields undefined on every Error. No placeholder string is ever produced, and a profile that later captures traces would change this contract deliberately. cause is ES2022: Error(message, ErrorOptions{cause}) keeps the given erased value exactly as given, so an object or Error cause is the same object, and Error::cause() returns it. An omitted cause and an explicit undefined cause are both absent, because that is what reading error.cause yields; only an in test could tell them apart, and the runtime offers none on an Error. The existing constructors are unchanged, and an Error built through them has no cause. A cause that is not an Error does not pass the error_if() projection.
  • structured_clone deep-copies the runtime's own value domain. Sharing is preserved — two members that referenced one object still reference one object afterwards — and a cyclic reference graph clones into an equally cyclic one, because each clone is recorded before its contents are copied. Symbols and callables are refused with DataCloneError, as they are in JavaScript.
  • TaskFulfillment<T> and TaskRejectionResult are the two arms of a settled result, separately. TaskSettlement<T> carries both a value and a rejection because it is the union of the arms; neither arm carries the other's member, and as_fulfilled/as_rejected narrow without inventing one.
  • CanvasRenderingContext2D owns the half of the Canvas 2D contract that is the same on every device: the drawing-state stack, the current transformation matrix, path construction, dash lists, gradient stops, and pattern parameters. Path segments retain the transform that was in force when each was added, so a transformed arc stays an exact arc instead of being flattened at a tolerance the runtime cannot know.

Deliberate gaps

The task runtime implements flight-runtime-task-capability-abi/1: synchronous executor invocation, first-call-wins settlement, task assimilation, queued then and rejection recovery, awaited finally cleanup, ordered all/join_all, ordered non-rejecting all_settled, coroutine suspension and resumption, exact rejection, and void. TaskSettlement<T> preserves status plus the exact fulfilled value or rejection reason, and all_settled_tasks bridges semantic arrays to that carrier. QueueExecutor provides the deterministic default and ExecutorScope installs a host executor while tasks are created or resumed. An executor must enqueue rather than invoke post reentrantly; host integrations own the event-loop wakeup and thread-affinity policy. Cancellation and structured concurrency remain separate future capabilities rather than being implied by Promise compatibility.

Array<T> currently requires copyable elements for operations that return copied values. Map<K, V> uses an ordered linear store to make identity and order correct before optimization; a later index must preserve those results exactly. Object keys will need an explicit reference-identity trait rather than accidental value equality.

String case conversion is exact for ASCII and delegates non-ASCII input to the active UnicodeService. The core never consults the process locale. Production hosts must provide a Unicode implementation with a pinned Unicode-data version and conformance tests for expansion, supplementary characters, Turkish casing, normalization non-equivalence, and unpaired surrogates.

External ArrayBufferLike storage must remain valid through its shared owner and suitably aligned for each typed view. Read-only backing supports DataView reads and rejects writes; mutable typed arrays reject read-only backing at construction. Shared-memory policy is observable metadata, while callers remain responsible for synchronization. Atomic operations, detachment, resizable buffers, and JavaScript's full shared-memory access model remain outside the current runtime contract.

Any has no bigint alternative, because the runtime has no arbitrary-precision integer; a host that needs one carries it as an external value. Loose equality (==) and ordering of non-primitives are deliberately absent rather than approximated: ECMAScript defines both through ToPrimitive, this runtime has no prototype chain to consult, and a fabricated "[object Object]" would be an invented answer rather than a missing one. relational_compare therefore throws TypeError for an object, callable, host, or symbol operand, and implements the abstract relational comparison exactly over the primitive domain.

structured_clone has no definition for an arbitrary aggregate, and that is a refusal rather than an omission: C++20 cannot enumerate an aggregate's members, so a default that copied the aggregate would produce a shallow copy wearing a deep copy's name. Such a type fails to compile with a message naming structured_clone_traits, which is the customization point a generated or host type specializes. An erased Any holding an object or a host value is refused at runtime for the same reason — the handle carries no clone definition.

CanvasRenderingContext2D is a contract, not a renderer. flight-cpp ships no rasterizer and does not pretend to: a context is created with a Canvas2DRasterizer a host supplies, and a context without one throws Canvas2DUnattachedError from every operation that would have produced or read pixels while continuing to maintain the state and transform the runtime owns. Gradients and patterns retain their parameters and their identity; interpolating them is the rasterizer's. drawImage and createPattern accept any host source type through Canvas2DImageSource, which carries the host's own value with its type intact and never interprets it. The context's public attribute members mirror the Web IDL attributes and are republished into the shared drawing state before every operation that consumes them, because the compiler passes a bound context by value; getLineDash, save, and restore therefore behave correctly within a call, and a context handed to a callee shares its rasterizer, its state stack, and its identity.

The dynamic named-property view is read-only and ordered by declaration. flight::NamedProperties is what Object.keys, Object.entries and a computed value[name] read need over a generated object or a structural row — explainHost enumerates a host and then each capability group it finds, knowing neither type. Keys come back in source declaration order, recovered from member addresses: [class.mem] ties address order to declaration order for members sharing access control, and every generated struct declares all of its members public. A key written into the row after construction has no member to order against and follows the declared ones, which is where JavaScript puts a property added later.

Named string properties and symbol attachments stay separate spaces. A symbol-keyed property is not an own enumerable string key and Object.keys does not report one, so this view reports only names and flight::AttachedProperties reports only symbols; a name and a symbol of the same spelling are two properties and neither view sees the other. Reading an absent key yields undefined, as JavaScript does, and has is the question that separates absence from a stored undefined. A property whose C++ type has no erased reading — flight::Array, a structural row, a variant — raises UnrepresentedProperty naming the key and the type rather than inventing an Any, and is_represented asks that in advance.

flight::Ref<void> cannot carry a checked binding, and flight::ErasedRef is what does. The entity binding slot holds an arbitrary object that getEntityBindingAs<Type> later reads at one type. A std::shared_ptr<void> has already forgotten what it pointed at by the time it is stored, so the only conversion out of it is std::static_pointer_cast, which always "succeeds" and hands a binding read at the wrong type a pointer to an object of another type. ErasedRef captures the type where it is still known — at the call that erases it — and as<Type>() answers only for that type, returning null otherwise. The type travels inside the value: there is no registry keyed on object identity, nothing keeping entries alive, and nothing to go stale.

ErasedRef is also the key of a Map<object, ...>, so an object that is already erased has to reach one without a cast. Any::erased_ref() hands over the object an Any holds, at the exact type it was boxed at, with the identity it already has; it is empty for a primitive, null, undefined, and the arrays and functions an Any does not hold as references. Record::erased_ref() is a record's own identity, shared by its copies as they share their entries. Neither pairs a pointer with a type the runtime did not record, so as<Type>() stays checked.

flight::Array compares by identity. An array is an object, so a === b asks whether two names denote the same array and never whether they hold equal elements; copies of a flight::Array share one storage and are therefore one array. Deep comparison is deliberately absent, because JavaScript has none for arrays and a container-style operator== would silently give every emitted !== the wrong answer.

The emitted free flight::is_array(const Any&) guard dispatches to the erased array tag, including when reached through an engaged optional; it does not use the generic false fallback. Erased arrays expose checked, live reads through Any::array_size() and Any::array_get(double). Both consult the existing exact Array<T> handle retained by AnyArray; neither snapshots length nor converts the storage to Array<Any>. Later pushes, replacements, splices and clears through aliases are visible. Indexed reads use Array<T>::get bounds rules: a negative, fractional, nonfinite or past-end index is absent, while negative zero is index zero. The result is optional<Any>: a missing index is empty, and present undefined and null remain engaged values. Elements use the existing checked erased-value boxing, retaining object and nested-array references; a present cell with no represented erased reading throws BadAnyAccess rather than disappearing. Non-array receivers also throw. Exact external_if<Array<T>>() recovery and array identity remain unchanged; there is no implicit numeric cast, named length property or general subscript on Any.

A callback parameter that is a structural row accepts a reference to that very object. callable_signature_v1::accepts is otherwise exact — an int still does not satisfy a double parameter — but a row parameter over T admits a Ref<T>, because that is the runtime's own projection of one subject into its row: the same object, no value converted, no second identity, and no widening, since the argument's type must be exactly the row's object type. The compiler emits both halves of that seam (a signal whose listener takes Readonly<T>, an emitter holding the T), and refusing it would be the runtime declining a projection it defines.

FontFace is a portable value handle, not a font system. Flight constructs one in portable code — new FontFace(family, source) in packages/font/src/_fontFaceLoad.ts — so it cannot be an opaque handle only a host can mint, and it is not an Entity: a face carries no runtime key and no identity-keyed side table, so a HostFontLoadingCapability that receives one through addFontFace receives that object and nothing else. The gap is that this dependency-free runtime has no font parser and therefore cannot decide whether a source is a usable face. load() settles through the loader a profile installs with set_font_face_loader; with no loader installed it rejects, naming the missing capability, and the face's status becomes "error". It does not resolve, because the next thing the caller does is hand the face to the host as though it had loaded. A rejected load carries the host's own reason unchanged rather than one the runtime invented.

Row widening is proven, not assumed. TypeScript's interface GlTextureRenderTarget extends GlRenderTarget flattens to two unrelated C++ structs, so a derived subject satisfying a base row cannot be read off a base class. The proof is published as a trait: the runtime declares detail::GeneratedRowWidening with a conservative false primary, before StructuralRef uses it and before the generated table is included, and the generated table adds a single constrained partial specialization that answers true only for pairs it can prove. That split is what keeps the two halves separable — the name the conversion needs always exists, so a table that is absent, current, or older than the contract contributes fewer yes cases rather than leaving a consumer with an undeclared name. An exact-owner conversion never reaches the trait at all: same-subject projections, a readonly view of the owner's own type included, are answered by the same-subject rule first, so they work on the default-false path.

StructuralRef's converting constructor is constrained by detail::row_convertible_to, which allows three cases and rejects everything else: the same subject (every projection the compiler already emits — writable to readonly, whole to partial, a row to a merge), a row with no subject on either side, and a widening the generated member table proves — the source's subject declares every row key the target's subject declares, at the same type, with at least one key matching so that a type declaring none cannot "prove" against anything. Only keys the emitted SDK uses as RowKeys are checked, because only those can be asked for. With no generated table present nothing is provable and every cross-subject conversion is rejected, which is the safe direction.

One more case is admitted, and it is a weakening rather than a widening: a readonly partial target. Partial<T> asks nothing of its subject — every member reads as an optional, and a key the subject does not declare reads as an empty one rather than throwing — so operation as Partial<ColorMatrixAdjustment>, the duck-typed probe the SDK writes to ask whether an adjustment happens to carry a colour matrix, is honoured as written. It is restricted to readonly targets: a writable partial row over an unrelated subject would accept writes for members that subject does not have, and those would land in cell storage the object never reads.

A widened row keeps the source object and the source's single row owner, so row_get reads the real derived object's members — a later write to the derived object is visible through the widened base row, because they are one subject and not two that agree. shared_object() on a widened base row is null by design: there is no base object to hand back, and inventing one would be a copy. Direction is preserved in both dimensions — a readonly row never becomes writable, and widening runs derived-to-base only.

A readonly nullable named member reads as a value projection. Its schema's optional<T> may be backed by the original owner's exact optional<T> cell or, under the existing widening proof, by its exact present T cell. row_get returns a fresh optional<T> on each read, wrapping only one layer. It preserves an empty optional, an engaged null reference and an engaged reference as distinct states, and retains the same referenced object. Later owner writes are visible on the next read. The returned optional is not a reference to a cached wrapper or a mutable cell; resetting it cannot put null into a concrete present member. Missing sparse cells read empty, while a present cell of an unrelated type still throws std::bad_cast. Exact erased-cell lookup, writable reads, required-member reads and non-optional readonly references keep their existing contracts. This adds no conversion proof or write authority. The structural-row test covers projection, live updates, owner lifetime, nested optional lifting and rejection; the native present test covers the actual framebuffer handle through this boundary.

A C++-derived carrier is the other case, and there the base object does exist. The compiler sometimes emits an anonymous carrier that really inherits from the declared subject: createShapedRun allocates struct advance_width_..._69a845f27c0212e6 : public flight::types::ShapedRun. A row built from a typed pointer to such an object — StructuralRef<RowReadonly<RowOf<Ref<ShapedRun>>>> from a Ref<ShapedRun> or from the carrier's own pointer — holds a pointer the compiler itself proved is a ShapedRun, because flatten_ref only returns the same type or a static_pointer_cast it accepted through derived_from. shared_object() returns that pointer even though the shared row owner was first created for the carrier and records the carrier's type; identity stays the owner's, and the row keeps the object alive like the pointer it came from. The same proof lets such a row answer shared_exact_object<ShapedRun>() — asking for its own subject type is not a downcast — so structural_ref_cast<Ref<ShapedRun>>(row) recovers the very object instead of throwing bad_cast. Every other row — widened from another schema, or rebuilt from an owner with from_owner — has no such proof and keeps the exact-owner check, and so does a request for any type other than the row's subject, so no cross-owner cast is made. tests/structural_row_test.cpp covers both directions.

A merged row (A & B, RowMerge) has no subject of its own, so it has no object_type. Most intersections the SDK builds are one object seen twice, though: Host & Capabilities with Capabilities a Host, or a capability narrowed by Required<Pick<Capability, 'center'>>. detail::merge_subject_t names that one object when there is one. A constituent's subject S is the merge's subject when every constituent is a row over S itself, or over a subject the generated table proves S answers for (the widening rule above, including computed-cell agreement). It is void when no constituent qualifies (unrelated subjects), when two distinct subjects qualify (ambiguous), or when a constituent has no subject.

A subject gives a merge construction, and nothing else:

  • a merged row can be built from a pointer to its subject, and holds that object and its one owner like a single-subject row;
  • {} as EntityConstruction<A & B> becomes that subject on the cast, by the same empty-bag rule materialize_row already applies to a single subject. So the finished entity is a native object that structural_ref_cast<Ref<A>> recovers, rather than an anonymous bag that throws bad_cast.

Without a subject there is no pointer constructor and no minting, and the merge stays schema-only, as before.

Two rules apply to every merge, with or without a subject:

  • A row of a row. RowOf<StructuralRef<S>> is S. The emitter wraps an intersection in a StructuralRef before EntityConstruction wraps it again, and the outer row resolved no members.
  • Narrowed members. A member the merge declares present, but which a constituent stores as std::optional, is read and written with the required-row rule for that one member: the bare value or the engaged optional, and std::out_of_range for an empty one. It was read as the bare type, and the owner threw bad_cast.

tests/merged_row_test.cpp covers these, including the unrelated and ambiguous cases.

Before this constraint any row converted to any other. Two unrelated rows compiled and then threw std::out_of_range at the first read, and a readonly row could be converted to a writable one and grant the mutation its source had refused. What remains compiler-owned is the union arm: passing Readonly<GlTextureRenderTarget> where Readonly<GlRenderTarget> | null is expected still refuses with "contextual union value type … is not a represented runtime domain", because the compiler does not yet treat a provable widening as reaching the union's domain. The same call without the union already emits, and now converts under proof.

A plain host capability needs no new runtime primitive, and this is the finding rather than a plan. A method-bearing plain interface such as HostGlCapability emits as an ordinary struct whose members are std::function, reached as StructuralRef<RowReadonly<RowOf<Ref<Capability>>>> and invoked through row_get<RowKey<"acquire">>(row)(args...). An optional object capability — readonly context?: HostGlCapability — emits as std::optional<Ref<HostGlCapability>>, so absence and a present reference are two states of one optional and nothing compares a reference against flight::undefined. No Entity runtime key and no identity-keyed side table stand behind either: the row reaches the provider's own object. npm run host-capability:oracle compiles and runs the pinned compiler's own output for that shape — present and absent, two methods invoked, a returned callable round-tripped — so the contract is held by a test rather than by assertion.

Capability status in flight/contract.hpp is machine-readable but intentionally coarse during incubation. initial means an implementation and local semantic tests exist; it does not mean the backend as a whole is production-ready.

Profile-specific external type policy

Family Classification Runtime decision
WebGL scalar aliases and object handles Concrete SDL implementation Numeric WebGL aliases, including GLsizei, use the source number representation. SDL WebGL objects retain their native owner and expose explicit weak-key policies; the map never owns the object.
Web Audio Profile-specific unavailable capability SDL's explicit audio-device provider is not the browser AudioContext decode/node graph. AudioContext remains unbound until a host supplies that capability rather than receiving an inert placeholder.
Intl Concrete locale-neutral subset plus type-only segmentation seam Collator, date/time, number, plural, relative-time, and list operations retain the documented locale-neutral baseline. Locale and option records preserve exactly the fields Flight currently transports, but the baseline formatters do not interpret them. Intl.Segmenter, Intl.SegmentData, and Intl.Segments have type-space carriers so a host can supply a real segment operation. The Intl.Segmenter value remains deliberately unbound: this dependency-free runtime ships no Unicode segmentation tables and does not claim ECMA-402 behavior.
DOM and media Opaque capability handles plus intentional refusal SDL image/video/bitmap/offscreen/frame types are the existing ImageSource capability handle and carry stable weak identity. DOMException is a concrete portable value. Browser constructors and a general Navigator remain unbound: constructing or exposing those surfaces would claim browser services the SDL profile does not provide.
WebGPU Concrete SDL handle/value implementation Reference-counted GPU objects retain typed native handles with explicit weak policies. Closed WebGPU string domains such as GPUCanvasAlphaMode remain flight::String; represented descriptor records retain their typed fields.

The compiler's built-in flight-cpp symbols own static members of Array, typed arrays, ArrayBuffer, Object, and Promise. External profile manifests are appended after those symbols and therefore cannot augment their members. The runtime implements array_from, typed-array from and bytes_per_element, is_array_buffer_view, object_values, and all_settled_tasks; publishing those exact ambient members remains a compiler binding change rather than a permissive downstream duplicate.

The dynamic named view writes, but only exactly

flight::NamedProperties answers keys(), has(), get() and now set(String, Any) — the dynamic keyed write the SDK spells (target as Record<string, unknown>)[key] = value.

The write goes through the one owner every view of that object shares, so it is visible to a typed projection immediately, and the owner's write hook runs first so a structural set-trap sees it exactly as it sees a statically-keyed write. It returns false rather than throwing, for two refusals a caller handles the same way:

  • the key is not one the subject declares. A native object's members are fixed. Parking the value in owner-side storage would make it visible through this view and invisible to every typed reader of the same object — a half-write that looks like a success.
  • the value is not the member's type. Coercion is refused, and JavaScript's own coercions are precisely the ones that would hide the mistake: 1 into a String member, "1" into a number. flight::detail::any_to performs the check, because the cell is the only thing that still knows its type once the value has been erased.

mergeTextFormat is the shape this exists for, and it passes both checks by construction — it walks keyof TextFormat copying each value from one TextFormat into another, so key and type always agree.

Recovering an owning reference from this

this->add(this) passes the receiver on as an owning reference, and a raw this is not one. A type states that it needs this by deriving from flight::SelfReferencing; flight::make_ref then remembers the owner it created, and flight::ref_from_this(self) gives it back.

It returns an EMPTY reference when there is no owner to recover — an object built on the stack, or built by some path other than make_ref. Fabricating one would hand out a second owner for an object that already has one, or none, and the double free would land nowhere near the call.

Why the marker is empty and opt-in

The obvious implementation is to derive ReferenceEnabled from std::enable_shared_from_this. That does not work here. Emitted objects are aggregates, initialized as AudioChannelRuntime{.backend = b, .device = d}, and enable_shared_from_this has a protected default constructor, so every such initializer in the corpus stops compiling:

error: 'enable_shared_from_this()' is protected within this context
error: missing initializer for member '...::enable_shared_from_this'

Registering every object in a side table instead was measured at 13x on construction — 200,000 objects, 2.8 ms to 37.1 ms — and retains an entry for each one whether or not anything ever asks.

An empty opt-in marker avoids both. The aggregate stays an aggregate, sizeof is unchanged because the base is empty, and make_ref registers only types that carry the marker, so a type that never asks pays nothing. The registry holds objects weakly and sweeps expired entries, so remembering an owner never becomes the reason an object cannot die.

The exact allocation record

Every flight::make_ref allocation records the exact type it was allocated as. That is the runtime's only answer to "what is this object really?". The source asks it whenever a by-name read or an in test on a base-typed reference must answer from the members the object actually has, and whenever (target as Derived).member must read the derived field of the SAME object.

The record is a STATIC descriptor, one per allocated type, never one per object: the type, the cast chain along declared flight_native_base edges, and the type's row-owner factories. make_ref allocates every record with detail::AllocationRecordingDeleter, which deletes the exact type and carries that descriptor and the complete object. std::get_deleter finds it from any reference sharing the allocation's control block.

  • Set only by make_ref. An object allocated any other way (std::make_shared, the stack, a foreign owner) has no record, and every query answers "no provenance". A consumer that needs an answer refuses instead of guessing from the static type.
  • The allocation's, not the value's. The record lives in the control block, so a copy is a different allocation with no record, and assignment cannot move one.
  • Every reference to the allocation sees it. A base conversion, an erased shared_ptr<void> and an aliasing reference to a member all report the same exact type and complete object.
  • Recovery is the same reference. flight::recover_owner<T>(ref) returns an aliasing reference sharing the original control block, or empty when the allocation is not a T. It is a query, the thing a lowering branches on. flight::require_owner<T>(ref) is the native escape: the same owner, or the shared out-of-profile error (flight::NativeScalarEscapeError). It never returns a copy, an empty reference or a default.
  • No table, no lock, no layout change. flight::ReferenceEnabled stays empty, so records keep their size and stay aggregates. A lookup holds no reference, so reference counts and lifetime are unchanged. tests/allocation_descriptor_test.cpp asserts all of this directly.

Choosing the mechanism (user decision, Manager d28f9be6)

Two mechanisms were implemented and measured with the same harness (-O2, 1,000,000 objects of a two-double derived record; medians of 7 rounds; per-object heap counted through operator new):

A: intrusive descriptor in ReferenceEnabled A′: recording deleter (chosen)
transient create/destroy 17.2–17.7 ns 12.7–12.8 ns
live create (1M held) 15.3–17.2 ns 26.2–27.6 ns
recovery lookup (recover_owner from a base Ref) 6.7–6.9 ns 10.5–10.6 ns
per-object heap 48 B in 1 block (sizeof +8 B) 64 B in 2 blocks (sizeof unchanged)

For reference, the global-map record this replaces cost 112 ns per transient allocation and covered only types with a heritage edge.

The rule, fixed before measuring, was to choose A′ unless its transient create/destroy cost exceeds A's by more than 25%. A′ measured BELOW A (12.8 ns against 17.5 ns, a limit of about 22 ns), so A′ is the mechanism. It costs more for objects that stay live (a separate control block) and on recovery lookup, and it saves the 8-byte layout change. It is also the only one of the two that keeps provenance through an aliasing reference to a member: A reads the descriptor through the pointer's own type, which a member reference does not have.

The materializing cast (allocateEntity/finishEntity) allocates the finished object with make_ref, so an entity built that way is recorded as its entity type. Whether the construction itself should allocate the entity type is a separate compiler request (docs/compiler-requests/entity-construction-allocation-type.md).