@stream44.studio/encapsulate 0.4.0-rc.38 → 0.4.0-rc.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,18 @@ const userService = await encapsulate({
65
65
  value: '1.0.0'
66
66
  },
67
67
 
68
+ // ConstantGetterFunction: eagerly evaluated during property mapping.
69
+ // Receives { constants } (not this) — constants contains all Literal/String/Constant
70
+ // values from the same capsule, resolved Mapping constants, and capsule metadata.
71
+ // The computed result is stored on self like a Constant and included in extractConstants.
72
+ derivedPath: {
73
+ type: CapsulePropertyTypes.ConstantGetterFunction,
74
+ value: function ({ constants }: { constants: any }): string {
75
+ const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
76
+ return `/workbench/${meta.capsuleName}`
77
+ }
78
+ },
79
+
68
80
  // --- Function Properties ---
69
81
 
70
82
  // Function: bound to a self proxy. Receives arguments. Callable on the API.
@@ -253,6 +265,7 @@ Reference
253
265
  | `Literal` | read/write | read/write | yes | General-purpose value. Supports any JS type including `Map`, `Set`, etc. |
254
266
  | `String` | read/write | read/write | yes | Alias for `Literal`. Semantic hint for string values. |
255
267
  | `Constant` | read-only | read | no | Immutable value. Membrane contract throws on assignment. |
268
+ | `ConstantGetterFunction` | read-only | read | no | Eagerly evaluated computed constant. See below. |
256
269
 
257
270
  All value types accept a `value` in their definition. `undefined` means "no default — must be supplied via overrides/options".
258
271
 
@@ -263,6 +276,7 @@ All value types accept a `value` in their definition. `undefined` means "no defa
263
276
  | `Function` | `api.name(...args)` | `function(this, ...args)` | Callable method. Bound to self proxy. |
264
277
  | `GetterFunction` | `api.name` (no parens) | `function(this)` | Lazy getter. Evaluated on each access (unless memoized). |
265
278
  | `SetterFunction` | `api.name = value` | `function(this, value)` | Triggered on assignment. Enables validation/transformation logic. |
279
+ | `ProxyFunction` | `api.name(...args)` | `{ target(this), invoke(this, ...args) }` | Wraps a target function with argument transformation. See below. |
266
280
 
267
281
  All function types are bound to a **self proxy** where:
268
282
  - `this.<prop>` resolves through: self → encapsulatedApi → extendedCapsuleApi
@@ -290,6 +304,60 @@ Applies to `Function` and `GetterFunction`. Added as a sibling to `type` and `va
290
304
 
291
305
  Memoize caches are scoped per spine contract capsule instance and cleared automatically when `run()` completes.
292
306
 
307
+ #### ProxyFunction
308
+
309
+ `ProxyFunction` wraps calling a target function with argument transformation. Its `value` is an object with two methods:
310
+
311
+ - **`target(this)`** — resolves the function to call (bound to self proxy)
312
+ - **`invoke(this, ...args)`** — transforms the caller's arguments before passing to target (bound to self proxy, may be async)
313
+
314
+ ```ts
315
+ {
316
+ type: CapsulePropertyTypes.ProxyFunction,
317
+ value: {
318
+ target() { return this.service.fetchData }, // resolve target fn
319
+ async invoke(pathname: string) { // transform args
320
+ const origin = this.origin
321
+ return { url: `http://${origin}${pathname}`, headers: { Host: origin } }
322
+ }
323
+ }
324
+ }
325
+ ```
326
+
327
+ When `api.name(...args)` is called:
328
+ 1. `invoke(...args)` runs with self proxy as `this`, returning transformed args
329
+ 2. `target()` runs with self proxy as `this`, returning the function to call
330
+ 3. The target function is called with the transformed args (awaited if `invoke` returns a promise)
331
+
332
+ #### ConstantGetterFunction
333
+
334
+ `ConstantGetterFunction` is a computed constant — eagerly evaluated during property mapping, with its result stored on `self` like a `Constant`. Unlike `GetterFunction` (bound to `this`, evaluated lazily on each access), `ConstantGetterFunction` receives an explicit `{ constants }` parameter and is evaluated once.
335
+
336
+ ```ts
337
+ workbenchDir: {
338
+ type: CapsulePropertyTypes.ConstantGetterFunction,
339
+ value: function ({ constants }: { constants: any }): string {
340
+ const meta = constants['#@stream44.studio/encapsulate/structs/Capsule']
341
+ const capsuleDir = constants.lib.path.dirname(meta.rootCapsule.moduleFilepath)
342
+ const testName = constants.lib.path.basename(meta.rootCapsule.moduleFilepath).replace(/\.[^.]+$/, '')
343
+ return constants.lib.path.join(capsuleDir, '.~o/workbenches', testName)
344
+ }
345
+ }
346
+ ```
347
+
348
+ **What `constants` contains:**
349
+
350
+ | Context | Contents |
351
+ |---|---|
352
+ | During `extractConstants` (parent Mapping) | `Literal`/`String`/`Constant` values from the capsule definition, resolved `Mapping` constants (nested), and capsule metadata including `rootCapsule` |
353
+ | During `mapProperty` (instance creation) | `self` — all previously-processed properties, capsule metadata, resolved Mappings |
354
+
355
+ **Key properties:**
356
+ - **No `this` binding** — receives `{ constants }` as a plain function argument, making dependencies explicit
357
+ - **Available in parent's `constants`** — when a parent maps this capsule, the computed value is included in the `constants` parameter passed to the parent's `options` callback
358
+ - **Mapping constants are nested** — `constants.lib.path` accesses the `path` Constant from a mapped `lib` capsule
359
+ - **Capsule metadata** — `constants['#@stream44.studio/encapsulate/structs/Capsule']` includes `capsuleName`, `moduleFilepath`, and `rootCapsule` (the top-level capsule in the spine)
360
+
293
361
  ### Mapping
294
362
 
295
363
  `Mapping` composes another capsule as a sub-component.
@@ -307,7 +375,7 @@ prop: {
307
375
  - **`value`** — a capsule reference (from `encapsulate()`) or a string URI resolved relative to the current module.
308
376
  - **`options`** — forwarded to the mapped capsule. Keys starting with `'#'` target the mapped capsule's own property contracts. Keys without `'#'` are matched against capsule names deeper in the mapping tree (nested capsule-name-targeted options).
309
377
  - **`options({ self, constants })`** — when `options` is a function, it receives `{ self, constants }`.
310
- - `constants` — all `Literal`/`String` values from the mapped capsule's definition.
378
+ - `constants` — all `Literal`/`String`/`Constant` values from the mapped capsule's definition, computed `ConstantGetterFunction` results, resolved `Mapping` constants (nested by property name), and capsule metadata (`constants['#@stream44.studio/encapsulate/structs/Capsule']` with `rootCapsule`).
311
379
  - `self` — always contains the Capsule metadata struct (`self['#@stream44.studio/encapsulate/structs/Capsule']` with `moduleFilepath`, `capsuleName`, etc.). When `depends` is specified, `self` also contains the full parent capsule's resolved sibling mappings.
312
380
  - **`depends`** — array of sibling property names that must be resolved before this mapping's `options` function runs. Enables `options({ self })` to access already-resolved siblings (e.g. `self.$auth.realm`). Can be declared explicitly or auto-injected by the static analyzer when it detects `self.<name>` references in the options function body.
313
381
  - **Instance reuse** — named capsules are registered in an instance registry. If a capsule with the same name is mapped multiple times without options, the existing instance is reused via a deferred proxy.
@@ -382,13 +450,13 @@ A capsule can inherit properties from a parent capsule:
382
450
  - `this.self` in a parent function returns the parent's own values (`ownSelf`), not the merged context.
383
451
  - Multiple capsules can extend the same parent — each gets a separate parent instance with its own `self`.
384
452
 
385
- ### Spine Contracts: Static.v0 vs Membrane.v0
453
+ ### Spine Contracts: Static vs Membrane
386
454
 
387
455
  Both implement the same property mapping logic. The difference is observability.
388
456
 
389
- **Static.v0** — direct property assignment. No interception. Minimal overhead.
457
+ **Static** — direct property assignment. No interception. Minimal overhead.
390
458
 
391
- **Membrane.v0** — wraps the API in proxies that emit events for every property access:
459
+ **Membrane** — wraps the API in proxies that emit events for every property access:
392
460
 
393
461
  | Event | Emitted When | Payload |
394
462
  |---|---|---|
@@ -1,4 +1,5 @@
1
1
  import { CapsulePropertyTypes, join } from "../../encapsulate"
2
+ import type { TimingObserverInterface } from "../../spine-factories/TimingObserver"
2
3
 
3
4
  // Type for capsule instance registry - scoped per spine contract instance
4
5
  export type CapsuleInstanceRegistry = Map<string, any>
@@ -19,6 +20,7 @@ export class ContractCapsuleInstanceFactory {
19
20
  public childEncapsulatedApis?: Record<string, any>[]
20
21
  protected runtimeSpineContracts?: Record<string, any>
21
22
  protected capsuleInstance?: any
23
+ protected timing?: TimingObserverInterface
22
24
  public structInitFunctions: Array<() => any> = []
23
25
  public structDisposeFunctions: Array<() => any> = []
24
26
  public initFunctions: Array<() => any> = []
@@ -28,7 +30,7 @@ export class ContractCapsuleInstanceFactory {
28
30
  protected memoizeCache: Map<string, any> = new Map()
29
31
  protected memoizeTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map()
30
32
 
31
- constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any }) {
33
+ constructor({ spineContractUri, capsule, self, ownSelf, encapsulatedApi, resolve, importCapsule, spineFilesystemRoot, freezeCapsule, instanceRegistry, extendedCapsuleInstance, runtimeSpineContracts, capsuleInstance, timing }: { spineContractUri: string, capsule: any, self: any, ownSelf?: any, encapsulatedApi: Record<string, any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, freezeCapsule?: (capsule: any) => Promise<any>, instanceRegistry?: CapsuleInstanceRegistry, extendedCapsuleInstance?: any, runtimeSpineContracts?: Record<string, any>, capsuleInstance?: any, timing?: TimingObserverInterface }) {
32
34
  this.spineContractUri = spineContractUri
33
35
  this.capsule = capsule
34
36
  this.self = self
@@ -42,6 +44,7 @@ export class ContractCapsuleInstanceFactory {
42
44
  this.extendedCapsuleInstance = extendedCapsuleInstance
43
45
  this.runtimeSpineContracts = runtimeSpineContracts
44
46
  this.capsuleInstance = capsuleInstance
47
+ this.timing = timing
45
48
 
46
49
  // Inject importCapsule onto ownSelf so capsule functions can call this.self.importCapsule()
47
50
  if (ownSelf && !ownSelf.importCapsule) {
@@ -82,9 +85,9 @@ export class ContractCapsuleInstanceFactory {
82
85
  }
83
86
  }
84
87
 
85
- async mapProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
88
+ async mapProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
86
89
  if (property.definition.type === CapsulePropertyTypes.Mapping) {
87
- await this.mapMappingProperty({ overrides, options, property })
90
+ await this.mapMappingProperty({ overrides, options, transitiveOverrides, property })
88
91
  } else if (
89
92
  property.definition.type === CapsulePropertyTypes.String ||
90
93
  property.definition.type === CapsulePropertyTypes.Literal ||
@@ -93,6 +96,8 @@ export class ContractCapsuleInstanceFactory {
93
96
  this.mapLiteralProperty({ property })
94
97
  } else if (property.definition.type === CapsulePropertyTypes.Function) {
95
98
  this.mapFunctionProperty({ property })
99
+ } else if (property.definition.type === CapsulePropertyTypes.ConstantGetterFunction) {
100
+ this.mapConstantGetterFunctionProperty({ property })
96
101
  } else if (property.definition.type === CapsulePropertyTypes.GetterFunction) {
97
102
  this.mapGetterFunctionProperty({ property })
98
103
  } else if (property.definition.type === CapsulePropertyTypes.SetterFunction) {
@@ -107,6 +112,8 @@ export class ContractCapsuleInstanceFactory {
107
112
  this.mapDisposeProperty({ property })
108
113
  } else if (property.definition.type === CapsulePropertyTypes.OnFreeze) {
109
114
  this.mapOnFreezeProperty({ property })
115
+ } else if (property.definition.type === CapsulePropertyTypes.ProxyFunction) {
116
+ this.mapProxyFunctionProperty({ property })
110
117
  }
111
118
  }
112
119
 
@@ -134,11 +141,77 @@ export class ContractCapsuleInstanceFactory {
134
141
 
135
142
  // Use cst.source.moduleFilepath (always filesystem-relative) for path resolution.
136
143
  // encapsulateOptions.moduleFilepath may be an npm URI when loaded from projected files.
137
- const moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
144
+ // However, cst.source.moduleFilepath may be relative to a different package root than
145
+ // the current spineFilesystemRoot (e.g. cross-package capsule references). In that case,
146
+ // fall back to encapsulateOptions.moduleFilepath which is always relative to current spine root.
147
+ let moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
138
148
  if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
139
149
 
140
- const parentPath = join(this.spineFilesystemRoot, moduleFilepath)
141
- const filepath = await this.resolve(property.definition.value, parentPath)
150
+ // moduleFilepath may be a relative filesystem path (e.g. "../../../caps/Foo.ts")
151
+ // or an npm-style URI (e.g. "@stream44.studio/t44-docker.com/caps/Project")
152
+ // after freeze/hoist cycles. join(spineRoot, npmUri) produces a bogus path when
153
+ // the URI is npm-style and spineRoot is narrow. Detect this by checking if the
154
+ // moduleFilepath looks like an npm URI (starts with @, or doesn't start with / or .),
155
+ // and resolve it to get the actual filesystem path.
156
+ let parentPath = join(this.spineFilesystemRoot, moduleFilepath)
157
+ if (!moduleFilepath.startsWith('/') && !moduleFilepath.startsWith('.')) {
158
+ // Looks like an npm-style URI or filesystem-convention path — resolve it
159
+ try {
160
+ // Try with @ prefix first (npm URI convention)
161
+ if (moduleFilepath.startsWith('@')) {
162
+ parentPath = await this.resolve(moduleFilepath, this.spineFilesystemRoot)
163
+ } else {
164
+ // May be a filesystem-convention path like "scope/packages/pkg/path"
165
+ // Try resolving as @scope/pkg/path by extracting scope and package name
166
+ const parts = moduleFilepath.split('/')
167
+ if (parts.length >= 3 && parts[1] === 'packages') {
168
+ const scope = parts[0]
169
+ const pkg = parts[2]
170
+ const subpath = parts.slice(3).join('/')
171
+ const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
172
+ parentPath = await this.resolve(npmUri, this.spineFilesystemRoot)
173
+ }
174
+ }
175
+ } catch {
176
+ // Fall back to the joined path if resolution fails
177
+ }
178
+ }
179
+ let filepath: string
180
+ try {
181
+ filepath = await this.resolve(property.definition.value, parentPath)
182
+ } catch (resolveError) {
183
+ // cst.source.moduleFilepath may be relative to a different package root than spineFilesystemRoot
184
+ // (e.g. cross-package capsule references). Fall back to encapsulateOptions.moduleFilepath
185
+ // which is always relative to the current spine root.
186
+ const fallbackModuleFilepath = this.capsule.encapsulateOptions?.moduleFilepath
187
+ if (fallbackModuleFilepath && fallbackModuleFilepath !== moduleFilepath) {
188
+ // The fallback moduleFilepath may be a relative path or an npm URI.
189
+ // Apply the same resolution logic as the primary path.
190
+ let fallbackParentPath = join(this.spineFilesystemRoot, fallbackModuleFilepath)
191
+ if (!fallbackModuleFilepath.startsWith('/') && !fallbackModuleFilepath.startsWith('.')) {
192
+ try {
193
+ if (fallbackModuleFilepath.startsWith('@')) {
194
+ fallbackParentPath = await this.resolve!(fallbackModuleFilepath, this.spineFilesystemRoot)
195
+ } else {
196
+ const parts = fallbackModuleFilepath.split('/')
197
+ if (parts.length >= 3 && parts[1] === 'packages') {
198
+ const scope = parts[0]
199
+ const pkg = parts[2]
200
+ const subpath = parts.slice(3).join('/')
201
+ const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
202
+ fallbackParentPath = await this.resolve!(npmUri, this.spineFilesystemRoot)
203
+ }
204
+ }
205
+ } catch {
206
+ // Fall back to the joined path
207
+ }
208
+ }
209
+ filepath = await this.resolve(property.definition.value, fallbackParentPath)
210
+ } else {
211
+ throw resolveError
212
+ }
213
+ }
214
+ this.timing?.record(`resolveMappedCapsule: importCapsule(${filepath.replace(/^.*\/genesis\//, '')})`)
142
215
  mappedCapsule = await this.importCapsule(filepath)
143
216
  } else if (
144
217
  typeof property.definition.value === 'object' &&
@@ -152,8 +225,10 @@ export class ContractCapsuleInstanceFactory {
152
225
  return mappedCapsule
153
226
  }
154
227
 
155
- protected async extractConstants({ mappedCapsule }: { mappedCapsule: any }) {
228
+ protected async extractConstants({ mappedCapsule, _visited }: { mappedCapsule: any, _visited?: Set<string> }) {
156
229
  const constants: Record<string, any> = {}
230
+ const constantGetterFunctions: Array<{ key: string; fn: Function }> = []
231
+ const mappingProperties: Array<{ key: string; def: any }> = []
157
232
 
158
233
  const spineContractDef = mappedCapsule.definition[this.spineContractUri]
159
234
 
@@ -172,9 +247,14 @@ export class ContractCapsuleInstanceFactory {
172
247
 
173
248
  if (
174
249
  type === CapsulePropertyTypes.String ||
175
- type === CapsulePropertyTypes.Literal
250
+ type === CapsulePropertyTypes.Literal ||
251
+ type === CapsulePropertyTypes.Constant
176
252
  ) {
177
253
  constants[prop] = propValue
254
+ } else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
255
+ constantGetterFunctions.push({ key: prop, fn: propValue })
256
+ } else if (type === CapsulePropertyTypes.Mapping) {
257
+ mappingProperties.push({ key: prop, def: propDef })
178
258
  }
179
259
  }
180
260
  } else {
@@ -185,17 +265,67 @@ export class ContractCapsuleInstanceFactory {
185
265
 
186
266
  if (
187
267
  type === CapsulePropertyTypes.String ||
188
- type === CapsulePropertyTypes.Literal
268
+ type === CapsulePropertyTypes.Literal ||
269
+ type === CapsulePropertyTypes.Constant
189
270
  ) {
190
271
  constants[key] = propValue
272
+ } else if (type === CapsulePropertyTypes.ConstantGetterFunction) {
273
+ constantGetterFunctions.push({ key, fn: propValue })
274
+ } else if (type === CapsulePropertyTypes.Mapping) {
275
+ mappingProperties.push({ key, def: value })
191
276
  }
192
277
  }
193
278
  }
194
279
 
280
+ // Resolve Mapping properties and extract their constants recursively.
281
+ // Track visited capsules to prevent infinite recursion on circular refs.
282
+ const visited = _visited || new Set<string>()
283
+ const thisCapsuleName = mappedCapsule.encapsulateOptions?.capsuleName
284
+ if (thisCapsuleName) visited.add(thisCapsuleName)
285
+ for (const { key, def } of mappingProperties) {
286
+ try {
287
+ const nestedCapsule = await this.resolveMappedCapsule({
288
+ property: { definition: def, name: key }
289
+ })
290
+ const nestedName = nestedCapsule.encapsulateOptions?.capsuleName
291
+ if (nestedName && visited.has(nestedName)) continue
292
+ constants[key] = await this.extractConstants({ mappedCapsule: nestedCapsule, _visited: visited })
293
+ } catch {
294
+ // Skip mappings that cannot be resolved (e.g. missing dependencies)
295
+ }
296
+ }
297
+
298
+ // Build capsule metadata so ConstantGetterFunctions can access it.
299
+ // rootCapsule is inherited from the parent (same as what makeInstance would receive).
300
+ const capsuleStructKey = '#@stream44.studio/encapsulate/structs/Capsule'
301
+ const relModuleFilepath = mappedCapsule.cst?.source?.moduleFilepath
302
+ || mappedCapsule.encapsulateOptions?.moduleFilepath
303
+ const moduleFilepath = relModuleFilepath
304
+ ? (relModuleFilepath.startsWith('/')
305
+ ? relModuleFilepath
306
+ : join(this.spineFilesystemRoot || '', relModuleFilepath))
307
+ : undefined
308
+ constants[capsuleStructKey] = {
309
+ capsuleName: mappedCapsule.encapsulateOptions?.capsuleName,
310
+ moduleFilepath,
311
+ rootCapsule: this.capsuleInstance?.rootCapsule || {
312
+ capsuleName: this.capsule?.encapsulateOptions?.capsuleName,
313
+ moduleFilepath: this.self?.[capsuleStructKey]?.moduleFilepath,
314
+ },
315
+ }
316
+
317
+ // Evaluate ConstantGetterFunction values with the accumulated constants
318
+ // (including resolved Mapping constants). These receive { constants }
319
+ // (not this) — only static values and nested Mapping constants are available.
320
+ for (const { key, fn } of constantGetterFunctions) {
321
+ constants[key] = fn({ constants })
322
+ }
323
+
195
324
  return constants
196
325
  }
197
326
 
198
- protected async mapMappingProperty({ overrides, options, property }: { overrides: any, options: any, property: any }) {
327
+ protected async mapMappingProperty({ overrides, options, transitiveOverrides, property }: { overrides: any, options: any, transitiveOverrides?: any, property: any }) {
328
+ this.timing?.record(`mapMappingProperty: ${property.name} → ${typeof property.definition.value === 'string' ? property.definition.value : (property.definition.value?.encapsulateOptions?.capsuleName || 'obj')}`)
199
329
  const mappedCapsule = await this.resolveMappedCapsule({ property })
200
330
  const constants = await this.extractConstants({ mappedCapsule })
201
331
 
@@ -208,9 +338,25 @@ export class ContractCapsuleInstanceFactory {
208
338
  const minimalSelf = this.self[capsuleStructKey]
209
339
  ? { [capsuleStructKey]: this.self[capsuleStructKey] }
210
340
  : {}
211
- const mappingOptions = property.definition.delegateOptions
212
- || (typeof optionsFn === 'function'
213
- ? await optionsFn({ self: property.definition.depends ? this.self : minimalSelf, constants })
341
+ // During freeze phase, skip calling function-based options callbacks — runtime
342
+ // overrides haven't been applied so self.* references may be incomplete.
343
+ // Use a truthy placeholder so the instance registry still creates a fresh instance
344
+ // (preserving the instance tree for OnFreeze traversal and .sit.json generation).
345
+ // Object-based options and delegateOptions are always applied (they are static).
346
+ const isFreezePhase = !!this.capsuleInstance?.freezePhase
347
+ const hasFunctionOptions = typeof optionsFn === 'function'
348
+ const selfArg = { self: property.definition.depends ? this.self : minimalSelf, constants }
349
+ const delegateOpts = property.definition.delegateOptions
350
+ const resolvedDelegateOptions = delegateOpts
351
+ ? (typeof delegateOpts === 'function'
352
+ ? (isFreezePhase ? {} : await delegateOpts(selfArg))
353
+ : delegateOpts)
354
+ : undefined
355
+ const mappingOptions = resolvedDelegateOptions
356
+ || (hasFunctionOptions
357
+ ? (isFreezePhase
358
+ ? {} // truthy placeholder — signals "has options" without calling the function
359
+ : await optionsFn(selfArg))
214
360
  : optionsFn)
215
361
 
216
362
  // Check for existing instance in registry - reuse if available when no options
@@ -228,6 +374,7 @@ export class ContractCapsuleInstanceFactory {
228
374
 
229
375
  // Only reuse if current mapping has no options
230
376
  if (!mappingOptions) {
377
+ this.timing?.record(`mapMappingProperty: REGISTRY REUSE ${capsuleName} (deferred proxy)`)
231
378
  // Use deferred proxy that resolves from registry when accessed
232
379
  // Works for both null (pre-registered) and actual instances
233
380
  const apiTarget = this.getApiTarget({ property })
@@ -310,13 +457,16 @@ export class ContractCapsuleInstanceFactory {
310
457
  }
311
458
  }
312
459
 
313
- // Merge nested capsule-name-targeted options into overrides
314
- // These will be picked up when child capsules with matching names are instantiated
460
+ // Build transitive overrides for the child: merge any inherited transitive
461
+ // overrides with this mapping's nested capsule-name-targeted options.
462
+ // These flow as transitiveOverrides (not overrides) so they take precedence
463
+ // over the child's own Mapping options but runtime overrides remain less specific.
464
+ let mappedTransitiveOverrides: Record<string, any> | undefined = transitiveOverrides
315
465
  if (nestedCapsuleOptions) {
316
- mappedOverrides = { ...mappedOverrides }
466
+ mappedTransitiveOverrides = { ...(mappedTransitiveOverrides || {}) }
317
467
  for (const [capsuleNameKey, capsuleOptions] of Object.entries(nestedCapsuleOptions)) {
318
- mappedOverrides[capsuleNameKey] = {
319
- ...(mappedOverrides[capsuleNameKey] || {}),
468
+ mappedTransitiveOverrides[capsuleNameKey] = {
469
+ ...(mappedTransitiveOverrides[capsuleNameKey] || {}),
320
470
  ...capsuleOptions
321
471
  }
322
472
  }
@@ -326,11 +476,13 @@ export class ContractCapsuleInstanceFactory {
326
476
  const mappedInstance = await mappedCapsule.makeInstance({
327
477
  overrides: mappedOverrides,
328
478
  options: ownMappingOptions,
479
+ transitiveOverrides: mappedTransitiveOverrides,
329
480
  runtimeSpineContracts: this.runtimeSpineContracts,
330
481
  rootCapsule: this.capsuleInstance?.rootCapsule,
331
482
  parentCapsuleSourceUriLineRefInstanceId: this.capsuleInstance?.capsuleSourceUriLineRefInstanceId,
332
483
  sit: this.capsuleInstance?.sit,
333
- skipCache: isCapsuleStruct
484
+ skipCache: isCapsuleStruct,
485
+ freezePhase: this.capsuleInstance?.freezePhase || undefined
334
486
  })
335
487
 
336
488
  // Register the instance (replaces null pre-registration marker)
@@ -390,6 +542,20 @@ export class ContractCapsuleInstanceFactory {
390
542
  }
391
543
  }
392
544
 
545
+ protected mapConstantGetterFunctionProperty({ property }: { property: any }) {
546
+ const apiTarget = this.getApiTarget({ property })
547
+ // Pass self as constants — at instance creation time self contains capsule
548
+ // metadata, resolved Mappings, and all previously-processed properties.
549
+ const value = property.definition.value({ constants: this.self })
550
+
551
+ // Store eagerly like a Constant — available on self for subsequent properties
552
+ apiTarget[property.name] = value
553
+ this.self[property.name] = value
554
+ if (this.ownSelf) {
555
+ this.ownSelf[property.name] = value
556
+ }
557
+ }
558
+
393
559
  protected createSelfProxy() {
394
560
  const extendedApi = this.extendedCapsuleInstance?.api
395
561
  const ownSelf = this.ownSelf
@@ -405,21 +571,29 @@ export class ContractCapsuleInstanceFactory {
405
571
 
406
572
  // First check if the property exists in target (this.self)
407
573
  if (prop in target) {
574
+ // Virtual dispatch: if child has an override for this property,
575
+ // prefer the child's version over self (which may hold the parent's bound fn)
576
+ if (factory.childEncapsulatedApis) {
577
+ for (const childApi of factory.childEncapsulatedApis) {
578
+ if (prop in childApi) return childApi[prop]
579
+ }
580
+ }
408
581
  return target[prop]
409
582
  }
410
583
 
411
- // Fall back to encapsulatedApi
412
- if (prop in factory.encapsulatedApi) {
413
- return factory.encapsulatedApi[prop]
414
- }
415
-
416
- // Fall back to child capsule APIs (for parent→child function delegation)
584
+ // Check child capsule APIs (virtual dispatch —
585
+ // child overrides take precedence over parent's own API)
417
586
  if (factory.childEncapsulatedApis) {
418
587
  for (const childApi of factory.childEncapsulatedApis) {
419
588
  if (prop in childApi) return childApi[prop]
420
589
  }
421
590
  }
422
591
 
592
+ // Fall back to own encapsulatedApi
593
+ if (prop in factory.encapsulatedApi) {
594
+ return factory.encapsulatedApi[prop]
595
+ }
596
+
423
597
  // Fall back to extended capsule's API
424
598
  if (extendedApi && prop in extendedApi) {
425
599
  return extendedApi[prop]
@@ -447,12 +621,12 @@ export class ContractCapsuleInstanceFactory {
447
621
  getOwnPropertyDescriptor: (target: any, prop: string | symbol) => {
448
622
  if (typeof prop === 'symbol') return Object.getOwnPropertyDescriptor(target, prop)
449
623
  if (prop in target) return Object.getOwnPropertyDescriptor(target, prop)
450
- if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
451
624
  if (factory.childEncapsulatedApis) {
452
625
  for (const childApi of factory.childEncapsulatedApis) {
453
626
  if (prop in childApi) return { configurable: true, enumerable: true, writable: true, value: childApi[prop as string] }
454
627
  }
455
628
  }
629
+ if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
456
630
  if (extendedApi && prop in extendedApi) return { configurable: true, enumerable: true, writable: true, value: extendedApi[prop as string] }
457
631
  return undefined
458
632
  }
@@ -565,6 +739,38 @@ export class ContractCapsuleInstanceFactory {
565
739
  }
566
740
  }
567
741
 
742
+ protected mapProxyFunctionProperty({ property }: { property: any }) {
743
+ const apiTarget = this.getApiTarget({ property })
744
+ const selfProxy = this.createSelfProxy()
745
+
746
+ const childTargetFn = property.definition.value.target
747
+ const childInvokeFn = property.definition.value.invoke
748
+
749
+ // Inherit missing parts from parent's ProxyFunction (if extending)
750
+ const parentParts = this.self[`__proxyFn_${property.name}`]
751
+ const targetFn = childTargetFn ?? parentParts?.target
752
+ const invokeFn = childInvokeFn ?? parentParts?.invoke
753
+
754
+ if (!targetFn) throw new Error(`ProxyFunction '${property.name}': target() is required`)
755
+ if (!invokeFn) throw new Error(`ProxyFunction '${property.name}': invoke() is required`)
756
+
757
+ // Store parts for potential child override
758
+ this.self[`__proxyFn_${property.name}`] = { target: targetFn, invoke: invokeFn }
759
+
760
+ apiTarget[property.name] = (...args: any[]) => {
761
+ // 1. Call invoke() bound to selfProxy to transform args
762
+ const transformedArgs = invokeFn.call(selfProxy, ...args)
763
+ // 2. Call target() bound to selfProxy to get the function to call
764
+ const target = targetFn.call(selfProxy)
765
+ // 3. If invoke returned a promise, await it then call target
766
+ if (transformedArgs && typeof transformedArgs.then === 'function') {
767
+ return transformedArgs.then((resolved: any) => target(resolved))
768
+ }
769
+ // 4. Call target with the transformed args
770
+ return target(transformedArgs)
771
+ }
772
+ }
773
+
568
774
  protected mapSetterFunctionProperty({ property }: { property: any }) {
569
775
  const apiTarget = this.getApiTarget({ property })
570
776
  const setterFn = property.definition.value
@@ -637,7 +843,7 @@ export class ContractCapsuleInstanceFactory {
637
843
 
638
844
 
639
845
 
640
- export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string } = {}) {
846
+ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, spineFilesystemRoot, timing }: { freezeCapsule?: (capsule: any) => Promise<any>, resolve?: (uri: string, parentFilepath: string) => Promise<string>, importCapsule?: (filepath: string) => Promise<any>, spineFilesystemRoot?: string, timing?: TimingObserverInterface } = {}) {
641
847
 
642
848
  const instanceRegistry: CapsuleInstanceRegistry = new Map()
643
849
 
@@ -658,7 +864,8 @@ export function CapsuleSpineContract({ freezeCapsule, resolve, importCapsule, sp
658
864
  instanceRegistry,
659
865
  extendedCapsuleInstance,
660
866
  runtimeSpineContracts,
661
- capsuleInstance
867
+ capsuleInstance,
868
+ timing
662
869
  })
663
870
  },
664
871
  hydrate: ({ capsuleSnapshot }: { capsuleSnapshot: any }): any => {